use std::sync::Arc;
use rig::completion::ToolDefinition;
use rig::tool::{ToolDyn, ToolError};
use rig::wasm_compat::WasmBoxedFuture;
use serde_json::Value;
use crate::error::Result;
use outrig::{McpClient, McpToolResult};
#[derive(Debug, Clone)]
pub struct McpToolAdapter {
pub openai_name: String,
pub mcp_tool_name: String,
pub description: String,
pub input_schema: Value,
pub result_cap_bytes: usize,
pub client: Arc<McpClient>,
}
impl McpToolAdapter {
pub async fn from_client_tools(
client: Arc<McpClient>,
result_cap_bytes: usize,
) -> Result<Vec<McpToolAdapter>> {
let tools = client.list_tools().await?;
let server_name = client.name().to_string();
Ok(tools
.into_iter()
.map(|t| McpToolAdapter {
openai_name: outrig::sanitize_tool_name(&server_name, &t.name),
mcp_tool_name: t.name,
description: t.description.unwrap_or_default(),
input_schema: t.input_schema,
result_cap_bytes,
client: client.clone(),
})
.collect())
}
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct McpAdapterError(String);
pub fn truncate_for_llm(result: &str, max: usize) -> String {
if result.is_empty() || result.len() <= max {
return result.to_string();
}
if max == 0 {
return String::new();
}
let original_len = result.len();
let mut cut = max.saturating_sub(truncation_marker(original_len, max, 0).len());
loop {
cut = floor_char_boundary(result, cut.min(result.len()));
let marker = truncation_marker(original_len, max, cut);
if marker.len() >= max {
return truncate_marker(&marker, max);
}
let content_budget = max - marker.len();
if cut <= content_budget {
let mut truncated = String::with_capacity(cut + marker.len());
truncated.push_str(&result[..cut]);
truncated.push_str(&marker);
debug_assert!(truncated.len() <= max);
return truncated;
}
cut = content_budget;
}
}
fn adapt_tool_result(result: McpToolResult, max: usize) -> std::result::Result<String, ToolError> {
let content_text = truncate_for_llm(&result.content_text, max);
if result.is_error {
Err(ToolError::ToolCallError(Box::new(McpAdapterError(
content_text,
))))
} else {
Ok(content_text)
}
}
fn truncation_marker(original_len: usize, max: usize, kept: usize) -> String {
let dropped = original_len.saturating_sub(kept);
format!(
concat!(
"\n\n[outrig: tool result truncated]\n",
" original size: {original_len} bytes\n",
" max: {max} bytes\n",
" kept: first {kept} bytes; trailing {dropped} bytes dropped.\n\n",
" This tool result was larger than the configured max. Your next call\n",
" should narrow the query: use head/tail/grep/--max-count, scope a\n",
" directory or line range, or call a more specific tool. Re-running\n",
" the same call will produce the same truncation.",
),
original_len = original_len,
max = max,
kept = kept,
dropped = dropped,
)
}
fn truncate_marker(marker: &str, max: usize) -> String {
let cut = floor_char_boundary(marker, max.min(marker.len()));
marker[..cut].to_string()
}
fn floor_char_boundary(s: &str, mut index: usize) -> usize {
while !s.is_char_boundary(index) {
index -= 1;
}
index
}
impl ToolDyn for McpToolAdapter {
fn name(&self) -> String {
self.openai_name.clone()
}
fn definition(&self, _prompt: String) -> WasmBoxedFuture<'_, ToolDefinition> {
Box::pin(async move {
ToolDefinition {
name: self.openai_name.clone(),
description: self.description.clone(),
parameters: self.input_schema.clone(),
}
})
}
fn call(&self, args: String) -> WasmBoxedFuture<'_, std::result::Result<String, ToolError>> {
Box::pin(async move {
let parsed: Value = if args.is_empty() {
Value::Null
} else {
serde_json::from_str(&args)?
};
let result = self
.client
.call_tool(&self.mcp_tool_name, parsed)
.await
.map_err(|e| ToolError::ToolCallError(Box::new(McpAdapterError(e.to_string()))))?;
adapt_tool_result(result, self.result_cap_bytes)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAX: usize = 1024;
#[test]
fn truncate_for_llm_leaves_empty_and_under_cap_results_unchanged() {
assert_eq!(truncate_for_llm("", MAX), "");
assert_eq!(truncate_for_llm("short", MAX), "short");
}
#[test]
fn truncate_for_llm_leaves_exact_cap_result_unchanged() {
let input = "a".repeat(MAX);
let output = truncate_for_llm(&input, MAX);
assert_eq!(output, input);
}
#[test]
fn truncate_for_llm_caps_one_byte_over_with_marker() {
let input = "a".repeat(MAX + 1);
let output = truncate_for_llm(&input, MAX);
assert!(output.len() <= MAX, "output len: {}", output.len());
assert!(output.contains("[outrig: tool result truncated]"));
assert!(output.contains("original size: 1025 bytes"));
assert!(output.contains("max: 1024 bytes"));
assert!(output.ends_with("produce the same truncation."));
}
#[test]
fn truncate_for_llm_caps_large_result_with_original_size_and_hint() {
let input = "x".repeat(5 * 1024 * 1024);
let output = truncate_for_llm(&input, 4096);
assert!(output.len() <= 4096, "output len: {}", output.len());
assert!(output.contains("original size: 5242880 bytes"));
assert!(output.contains("max: 4096 bytes"));
assert!(output.contains("should narrow the query"));
}
#[test]
fn truncate_for_llm_keeps_valid_utf8_at_boundary() {
let input = format!("{}{}", "a".repeat(900), "🙂".repeat(200));
let output = truncate_for_llm(&input, MAX);
assert!(output.len() <= MAX, "output len: {}", output.len());
assert!(output.is_char_boundary(output.len()));
assert!(output.contains("[outrig: tool result truncated]"));
}
#[test]
fn adapt_tool_result_truncates_success_content() {
let result = McpToolResult {
content_text: "a".repeat(MAX + 1),
is_error: false,
};
let output = adapt_tool_result(result, MAX).expect("success result");
assert!(output.len() <= MAX, "output len: {}", output.len());
assert!(output.contains("[outrig: tool result truncated]"));
}
#[test]
fn adapt_tool_result_truncates_error_content() {
let result = McpToolResult {
content_text: "e".repeat(MAX + 1),
is_error: true,
};
let err = adapt_tool_result(result, MAX).expect_err("error result");
let ToolError::ToolCallError(source) = err else {
panic!("expected tool-call error");
};
let msg = source.to_string();
assert!(msg.len() <= MAX, "error len: {}", msg.len());
assert!(msg.contains("[outrig: tool result truncated]"));
}
}