fix(code-mode): improve tool signatures for LLM discovery (#6177)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2025-12-19 12:03:41 +08:00
committed by GitHub
parent d4814042e6
commit 1ccc579dac
5 changed files with 200 additions and 19 deletions
Generated
+13
View File
@@ -3141,6 +3141,7 @@ dependencies = [
"tracing", "tracing",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"unbinder",
"unicode-normalization", "unicode-normalization",
"url", "url",
"urlencoding", "urlencoding",
@@ -8485,6 +8486,18 @@ dependencies = [
"zip 2.5.0", "zip 2.5.0",
] ]
[[package]]
name = "unbinder"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911624cb458604f41ac58db43ab8c2ccc9ad530791c1cd5d0ff8d824a38eeaa7"
dependencies = [
"cfg-if",
"rustc-hash 2.1.1",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.8.1" version = "2.8.1"
@@ -44,7 +44,7 @@ pub enum SaveAsFormat {
pub struct WebScrapeParams { pub struct WebScrapeParams {
/// The URL to fetch content from /// The URL to fetch content from
pub url: String, pub url: String,
/// How to interpret and save the content /// Format of the response.
#[serde(default)] #[serde(default)]
pub save_as: SaveAsFormat, pub save_as: SaveAsFormat,
} }
@@ -479,8 +479,7 @@ impl ComputerControllerServer {
- text (for HTML pages) - text (for HTML pages)
- json (for API responses) - json (for API responses)
- binary (for images and other files) - binary (for images and other files)
The content is cached locally and can be accessed later using the cache_path Returns 'Content saved to: <path>'. Use cache to read the content.
returned in the response.
" "
)] )]
pub async fn web_scrape( pub async fn web_scrape(
+1
View File
@@ -115,6 +115,7 @@ ignore = { workspace = true }
which = { workspace = true} which = { workspace = true}
boa_engine = "0.21.0" boa_engine = "0.21.0"
boa_gc = "0.21" boa_gc = "0.21"
unbinder = "0.1.7"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] } winapi = { version = "0.3", features = ["wincred"] }
@@ -69,12 +69,86 @@ struct InputSchema {
required: Vec<String>, required: Vec<String>,
} }
fn quote_join(vals: &[&str]) -> String {
format!("\"{}\"", vals.join("\" | \""))
}
fn infer_type(schema: &Value) -> Option<String> {
if schema.get("properties").is_some() {
Some("object".to_string())
} else if schema.get("items").is_some() {
Some("array".to_string())
} else {
None
}
}
fn extract_type_from_schema(schema: &Value) -> Option<String> {
// enum array (github-mcp style)
if let Some(arr) = schema.get("enum").and_then(|e| e.as_array()) {
let vals: Vec<_> = arr.iter().filter_map(|v| v.as_str()).collect();
if !vals.is_empty() {
return Some(quote_join(&vals));
}
}
// oneOf with const (schemars enums)
if let Some(arr) = schema.get("oneOf").and_then(|o| o.as_array()) {
let vals: Vec<_> = arr
.iter()
.filter_map(|v| v.get("const")?.as_str())
.collect();
if !vals.is_empty() {
return Some(quote_join(&vals));
}
}
// anyOf (Option<T> or unions)
if let Some(arr) = schema.get("anyOf").and_then(|o| o.as_array()) {
let non_null: Vec<_> = arr
.iter()
.filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
.collect();
if non_null.len() == 1 {
return extract_type_from_schema(non_null[0]).or_else(|| infer_type(non_null[0]));
}
if non_null.len() > 1 {
let types: Vec<_> = non_null
.iter()
.filter_map(|v| extract_type_from_schema(v).or_else(|| infer_type(v)))
.collect();
if !types.is_empty() {
return Some(types.join(" | "));
}
}
}
// type field (string or array)
match schema.get("type") {
Some(Value::String(s)) => Some(s.clone()),
Some(Value::Array(arr)) => {
let non_null: Vec<_> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| *s != "null")
.collect();
match non_null.len() {
0 => None,
1 => Some(non_null[0].to_string()),
_ => Some(non_null.join(" | ")),
}
}
_ => None,
}
}
struct ToolInfo { struct ToolInfo {
server_name: String, server_name: String,
tool_name: String, tool_name: String,
full_name: String, full_name: String,
description: String, description: String,
params: Vec<(String, String, bool)>, params: Vec<(String, String, bool)>,
return_type: String,
} }
impl ToolInfo { impl ToolInfo {
@@ -82,9 +156,9 @@ impl ToolInfo {
let (server_name, tool_name) = tool.name.as_ref().split_once("__")?; let (server_name, tool_name) = tool.name.as_ref().split_once("__")?;
let param_names = get_parameter_names(tool); let param_names = get_parameter_names(tool);
let schema: InputSchema = let mut schema_value = Value::Object(tool.input_schema.as_ref().clone());
serde_json::from_value(Value::Object(tool.input_schema.as_ref().clone())) let _ = unbinder::dereference_schema(&mut schema_value, unbinder::Options::default());
.unwrap_or_default(); let schema: InputSchema = serde_json::from_value(schema_value).unwrap_or_default();
let params = param_names let params = param_names
.iter() .iter()
@@ -92,14 +166,24 @@ impl ToolInfo {
let ty = schema let ty = schema
.properties .properties
.get(name) .get(name)
.and_then(|p| p.get("type")) .and_then(extract_type_from_schema)
.and_then(|t| t.as_str()) .unwrap_or_else(|| "any".to_string());
.unwrap_or("any");
let required = schema.required.contains(name); let required = schema.required.contains(name);
(name.clone(), ty.to_string(), required) (name.clone(), ty, required)
}) })
.collect(); .collect();
let return_type = tool
.output_schema
.as_ref()
.and_then(|schema| {
let mut schema_value = Value::Object(schema.as_ref().clone());
let _ =
unbinder::dereference_schema(&mut schema_value, unbinder::Options::default());
extract_type_from_schema(&schema_value)
})
.unwrap_or_else(|| "string".to_string());
Some(Self { Some(Self {
server_name: server_name.to_string(), server_name: server_name.to_string(),
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
@@ -110,6 +194,7 @@ impl ToolInfo {
.map(|d| d.as_ref().to_string()) .map(|d| d.as_ref().to_string())
.unwrap_or_default(), .unwrap_or_default(),
params, params,
return_type,
}) })
} }
@@ -121,7 +206,10 @@ impl ToolInfo {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let desc = self.description.lines().next().unwrap_or(""); let desc = self.description.lines().next().unwrap_or("");
format!("{}({{ {params} }}): string - {desc}", self.tool_name) format!(
"{}({{ {params} }}): {} - {desc}",
self.tool_name, self.return_type
)
} }
} }
@@ -306,7 +394,7 @@ impl CodeExecutionClient {
- RIGHT: One execute_code call with a script that calls all needed tools - RIGHT: One execute_code call with a script that calls all needed tools
Workflow: Workflow:
1. Use read_module("server") to discover tools and signatures 1. Use the read_module tool to discover tools and signatures
2. Write ONE script that imports and calls ALL tools needed for the task 2. Write ONE script that imports and calls ALL tools needed for the task
3. Chain results: use output from one tool as input to the next 3. Chain results: use output from one tool as input to the next
"#}.to_string()), "#}.to_string()),
@@ -498,6 +586,7 @@ impl CodeExecutionClient {
if !matching_tools.is_empty() { if !matching_tools.is_empty() {
output.push_str("## Matching Tools\n"); output.push_str("## Matching Tools\n");
output.push_str("Use the read_module tool for full signature and import syntax\n\n");
for tool in &matching_tools { for tool in &matching_tools {
output.push_str(&format!( output.push_str(&format!(
"- {}/{}: {}\n", "- {}/{}: {}\n",
@@ -611,7 +700,7 @@ impl McpClientTrait for CodeExecutionClient {
- Last expression is the result - Last expression is the result
- No comments in code - No comments in code
BEFORE CALLING: Use read_module("server") to check required parameters. BEFORE CALLING: Use the read_module tool to check required parameters.
"#} "#}
.to_string(), .to_string(),
schema::<ExecuteCodeParams>(), schema::<ExecuteCodeParams>(),
@@ -656,9 +745,9 @@ impl McpClientTrait for CodeExecutionClient {
Search for tools by name or description across all available modules. Search for tools by name or description across all available modules.
USAGE: USAGE:
- Single term: search_modules({ terms: "file" }) - Single term: search_modules with terms="file"
- Multiple terms: search_modules({ terms: ["git", "shell"] }) - Multiple terms: search_modules with terms=["git", "shell"]
- Regex patterns: search_modules({ terms: "sh.*", regex: true }) - Regex patterns: search_modules with terms="sh.*", regex=true
Returns matching servers and tools with descriptions. Returns matching servers and tools with descriptions.
Use this when you don't know which module contains the tool you need. Use this when you don't know which module contains the tool you need.
@@ -745,7 +834,7 @@ impl McpClientTrait for CodeExecutionClient {
Modules: {} Modules: {}
Use read_module("name") to see tool signatures before calling unfamiliar tools. Use the read_module tool to see signatures before calling unfamiliar tools.
"#}, "#},
server_list.join(", ") server_list.join(", ")
)) ))
@@ -755,6 +844,8 @@ impl McpClientTrait for CodeExecutionClient {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Arc;
use test_case::test_case;
#[tokio::test] #[tokio::test]
async fn test_execute_code_simple() { async fn test_execute_code_simple() {
@@ -809,6 +900,7 @@ mod tests {
full_name: "developer__shell".to_string(), full_name: "developer__shell".to_string(),
description: "Execute shell commands".to_string(), description: "Execute shell commands".to_string(),
params: vec![("command".to_string(), "string".to_string(), true)], params: vec![("command".to_string(), "string".to_string(), true)],
return_type: "string".to_string(),
}, },
ToolInfo { ToolInfo {
server_name: "developer".to_string(), server_name: "developer".to_string(),
@@ -816,6 +908,7 @@ mod tests {
full_name: "developer__text_editor".to_string(), full_name: "developer__text_editor".to_string(),
description: "Edit text files".to_string(), description: "Edit text files".to_string(),
params: vec![("path".to_string(), "string".to_string(), true)], params: vec![("path".to_string(), "string".to_string(), true)],
return_type: "string".to_string(),
}, },
ToolInfo { ToolInfo {
server_name: "git".to_string(), server_name: "git".to_string(),
@@ -823,6 +916,7 @@ mod tests {
full_name: "git__commit".to_string(), full_name: "git__commit".to_string(),
description: "Commit changes to git".to_string(), description: "Commit changes to git".to_string(),
params: vec![("message".to_string(), "string".to_string(), true)], params: vec![("message".to_string(), "string".to_string(), true)],
return_type: "string".to_string(),
}, },
]; ];
@@ -883,6 +977,7 @@ mod tests {
full_name: "developer__shell".to_string(), full_name: "developer__shell".to_string(),
description: "Execute shell commands".to_string(), description: "Execute shell commands".to_string(),
params: vec![], params: vec![],
return_type: "string".to_string(),
}, },
ToolInfo { ToolInfo {
server_name: "developer".to_string(), server_name: "developer".to_string(),
@@ -890,6 +985,7 @@ mod tests {
full_name: "developer__text_editor".to_string(), full_name: "developer__text_editor".to_string(),
description: "Edit text files".to_string(), description: "Edit text files".to_string(),
params: vec![], params: vec![],
return_type: "string".to_string(),
}, },
]; ];
@@ -916,4 +1012,73 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid regex")); assert!(result.unwrap_err().contains("Invalid regex"));
} }
#[test_case(
"github__get_me",
serde_json::json!({"type": "object", "properties": {}}),
None,
"get_me({ }): string - Get details of the authenticated user";
"no params, no output schema"
)]
#[test_case(
"filesystem__read_file",
serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
Some(serde_json::json!({"type": "object"})),
"read_file({ path: string }): object - Read the complete contents of a file";
"string param, object output"
)]
#[test_case(
"memory__create_entities",
serde_json::json!({"type": "object", "properties": {"entities": {"type": "array"}}, "required": ["entities"]}),
Some(serde_json::json!({"type": "object"})),
"create_entities({ entities: array }): object - Create multiple new entities";
"array param, object output"
)]
#[test_case(
"github__dismiss_notification",
serde_json::json!({"type": "object", "properties": {
"threadID": {"type": "string"},
"state": {"type": "string", "enum": ["read", "done"]}
}, "required": ["threadID", "state"]}),
None,
"dismiss_notification({ state: \"read\" | \"done\", threadID: string }): string - Dismiss a notification";
"enum param, no output schema"
)]
#[test_case(
"computercontroller__web_scrape",
serde_json::json!({"type": "object", "properties": {
"url": {"type": "string"},
"save_as": {"oneOf": [{"const": "text"}, {"const": "json"}, {"const": "binary"}]}
}, "required": ["url"]}),
None,
"web_scrape({ save_as?: \"text\" | \"json\" | \"binary\", url: string }): string - Scrape content from URL";
"oneOf const param (schemars), no output schema"
)]
fn test_mcp_tool_signature(
name: &str,
input: serde_json::Value,
output: Option<serde_json::Value>,
expected: &str,
) {
let input_schema: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(input).unwrap();
let output_schema = output.map(|v| {
Arc::new(
serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(v).unwrap(),
)
});
let desc = expected.split(" - ").nth(1).unwrap_or("").to_string();
let tool = McpTool {
name: name.to_string().into(),
title: None,
description: Some(desc.into()),
input_schema: Arc::new(input_schema),
output_schema,
annotations: None,
icons: None,
meta: None,
};
let info = ToolInfo::from_mcp_tool(&tool).unwrap();
assert_eq!(info.to_signature(), expected);
}
} }
+5 -2
View File
@@ -174,11 +174,14 @@ fn require_str_parameter<'a>(v: &'a serde_json::Value, name: &str) -> Result<&'a
} }
pub fn get_parameter_names(tool: &Tool) -> Vec<String> { pub fn get_parameter_names(tool: &Tool) -> Vec<String> {
tool.input_schema let mut names: Vec<String> = tool
.input_schema
.get("properties") .get("properties")
.and_then(|props| props.as_object()) .and_then(|props| props.as_object())
.map(|props| props.keys().cloned().collect()) .map(|props| props.keys().cloned().collect())
.unwrap_or_default() .unwrap_or_default();
names.sort();
names
} }
impl Default for ExtensionManager { impl Default for ExtensionManager {