mirror of
https://github.com/instructkr/claude-code.git
synced 2026-04-06 19:28:49 +03:00
Close the MCP lifecycle gap from config to runtime tool execution
This wires configured MCP servers into the CLI/runtime path so discovered MCP tools, resource wrappers, search visibility, shutdown handling, and best-effort discovery all work together instead of living as isolated runtime primitives. Constraint: Keep non-MCP startup flows working without new required config Constraint: Preserve partial availability when one configured MCP server fails discovery Rejected: Fail runtime startup on any MCP discovery error | too brittle for mixed healthy/broken server configs Rejected: Keep MCP support runtime-only without registry wiring | left discovery and invocation unreachable from the CLI tool lane Confidence: high Scope-risk: moderate Reversibility: clean Directive: Runtime MCP tools are registry-backed but executed through CliToolExecutor state; keep future tool-registry changes aligned with that split Tested: cargo test -p runtime mcp -- --nocapture; cargo test -p tools -- --nocapture; cargo test -p rusty-claude-cli -- --nocapture; cargo test --workspace -- --nocapture Not-tested: Live remote MCP transports (http/sse/ws/sdk) remain unsupported in the CLI execution path
This commit is contained in:
@@ -59,6 +59,15 @@ pub struct ToolSpec {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GlobalToolRegistry {
|
||||
plugin_tools: Vec<PluginTool>,
|
||||
runtime_tools: Vec<RuntimeToolDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RuntimeToolDefinition {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
pub required_permission: PermissionMode,
|
||||
}
|
||||
|
||||
impl GlobalToolRegistry {
|
||||
@@ -66,6 +75,7 @@ impl GlobalToolRegistry {
|
||||
pub fn builtin() -> Self {
|
||||
Self {
|
||||
plugin_tools: Vec::new(),
|
||||
runtime_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +98,37 @@ impl GlobalToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { plugin_tools })
|
||||
Ok(Self {
|
||||
plugin_tools,
|
||||
runtime_tools: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_runtime_tools(
|
||||
mut self,
|
||||
runtime_tools: Vec<RuntimeToolDefinition>,
|
||||
) -> Result<Self, String> {
|
||||
let mut seen_names = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.map(|spec| spec.name.to_string())
|
||||
.chain(
|
||||
self.plugin_tools
|
||||
.iter()
|
||||
.map(|tool| tool.definition().name.clone()),
|
||||
)
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
for tool in &runtime_tools {
|
||||
if !seen_names.insert(tool.name.clone()) {
|
||||
return Err(format!(
|
||||
"runtime tool `{}` conflicts with an existing tool name",
|
||||
tool.name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.runtime_tools = runtime_tools;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn normalize_allowed_tools(
|
||||
@@ -108,6 +148,7 @@ impl GlobalToolRegistry {
|
||||
.iter()
|
||||
.map(|tool| tool.definition().name.clone()),
|
||||
)
|
||||
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut name_map = canonical_names
|
||||
.iter()
|
||||
@@ -154,6 +195,15 @@ impl GlobalToolRegistry {
|
||||
description: Some(spec.description.to_string()),
|
||||
input_schema: spec.input_schema,
|
||||
});
|
||||
let runtime = self
|
||||
.runtime_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: tool.input_schema.clone(),
|
||||
});
|
||||
let plugin = self
|
||||
.plugin_tools
|
||||
.iter()
|
||||
@@ -166,7 +216,7 @@ impl GlobalToolRegistry {
|
||||
description: tool.definition().description.clone(),
|
||||
input_schema: tool.definition().input_schema.clone(),
|
||||
});
|
||||
builtin.chain(plugin).collect()
|
||||
builtin.chain(runtime).chain(plugin).collect()
|
||||
}
|
||||
|
||||
pub fn permission_specs(
|
||||
@@ -177,6 +227,11 @@ impl GlobalToolRegistry {
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.map(|spec| (spec.name.to_string(), spec.required_permission));
|
||||
let runtime = self
|
||||
.runtime_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
||||
.map(|tool| (tool.name.clone(), tool.required_permission));
|
||||
let plugin = self
|
||||
.plugin_tools
|
||||
.iter()
|
||||
@@ -189,7 +244,32 @@ impl GlobalToolRegistry {
|
||||
.map(|permission| (tool.definition().name.clone(), permission))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(builtin.chain(plugin).collect())
|
||||
Ok(builtin.chain(runtime).chain(plugin).collect())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_runtime_tool(&self, name: &str) -> bool {
|
||||
self.runtime_tools.iter().any(|tool| tool.name == name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
pending_mcp_servers: Option<Vec<String>>,
|
||||
) -> ToolSearchOutput {
|
||||
let query = query.trim().to_string();
|
||||
let normalized_query = normalize_tool_search_query(&query);
|
||||
let matches = search_tool_specs(&query, max_results.max(1), &self.searchable_tool_specs());
|
||||
|
||||
ToolSearchOutput {
|
||||
matches,
|
||||
query,
|
||||
normalized_query,
|
||||
total_deferred_tools: self.searchable_tool_specs().len(),
|
||||
pending_mcp_servers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
|
||||
@@ -203,6 +283,24 @@ impl GlobalToolRegistry {
|
||||
.execute(input)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn searchable_tool_specs(&self) -> Vec<SearchableToolSpec> {
|
||||
let builtin = deferred_tool_specs()
|
||||
.into_iter()
|
||||
.map(|spec| SearchableToolSpec {
|
||||
name: spec.name.to_string(),
|
||||
description: spec.description.to_string(),
|
||||
});
|
||||
let runtime = self.runtime_tools.iter().map(|tool| SearchableToolSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone().unwrap_or_default(),
|
||||
});
|
||||
let plugin = self.plugin_tools.iter().map(|tool| SearchableToolSpec {
|
||||
name: tool.definition().name.clone(),
|
||||
description: tool.definition().description.clone().unwrap_or_default(),
|
||||
});
|
||||
builtin.chain(runtime).chain(plugin).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_tool_name(value: &str) -> String {
|
||||
@@ -946,8 +1044,8 @@ struct AgentJob {
|
||||
allowed_tools: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolSearchOutput {
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ToolSearchOutput {
|
||||
matches: Vec<String>,
|
||||
query: String,
|
||||
normalized_query: String,
|
||||
@@ -1031,6 +1129,12 @@ struct PlanModeOutput {
|
||||
current_local_mode: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchableToolSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct StructuredOutputResult {
|
||||
data: String,
|
||||
@@ -2163,19 +2267,7 @@ fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
|
||||
let deferred = deferred_tool_specs();
|
||||
let max_results = input.max_results.unwrap_or(5).max(1);
|
||||
let query = input.query.trim().to_string();
|
||||
let normalized_query = normalize_tool_search_query(&query);
|
||||
let matches = search_tool_specs(&query, max_results, &deferred);
|
||||
|
||||
ToolSearchOutput {
|
||||
matches,
|
||||
query,
|
||||
normalized_query,
|
||||
total_deferred_tools: deferred.len(),
|
||||
pending_mcp_servers: None,
|
||||
}
|
||||
GlobalToolRegistry::builtin().search(&input.query, input.max_results.unwrap_or(5), None)
|
||||
}
|
||||
|
||||
fn deferred_tool_specs() -> Vec<ToolSpec> {
|
||||
@@ -2190,7 +2282,7 @@ fn deferred_tool_specs() -> Vec<ToolSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
|
||||
fn search_tool_specs(query: &str, max_results: usize, specs: &[SearchableToolSpec]) -> Vec<String> {
|
||||
let lowered = query.to_lowercase();
|
||||
if let Some(selection) = lowered.strip_prefix("select:") {
|
||||
return selection
|
||||
@@ -2201,8 +2293,8 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
||||
let wanted = canonical_tool_token(wanted);
|
||||
specs
|
||||
.iter()
|
||||
.find(|spec| canonical_tool_token(spec.name) == wanted)
|
||||
.map(|spec| spec.name.to_string())
|
||||
.find(|spec| canonical_tool_token(&spec.name) == wanted)
|
||||
.map(|spec| spec.name.clone())
|
||||
})
|
||||
.take(max_results)
|
||||
.collect();
|
||||
@@ -2229,8 +2321,8 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
||||
.iter()
|
||||
.filter_map(|spec| {
|
||||
let name = spec.name.to_lowercase();
|
||||
let canonical_name = canonical_tool_token(spec.name);
|
||||
let normalized_description = normalize_tool_search_query(spec.description);
|
||||
let canonical_name = canonical_tool_token(&spec.name);
|
||||
let normalized_description = normalize_tool_search_query(&spec.description);
|
||||
let haystack = format!(
|
||||
"{name} {} {canonical_name}",
|
||||
spec.description.to_lowercase()
|
||||
@@ -2263,7 +2355,7 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
||||
if score == 0 && !lowered.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((score, spec.name.to_string()))
|
||||
Some((score, spec.name.clone()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -3424,7 +3516,7 @@ mod tests {
|
||||
use super::{
|
||||
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
|
||||
execute_tool, final_assistant_text, mvp_tool_specs, permission_mode_from_plugin,
|
||||
persist_agent_terminal_state, push_output_block, AgentInput, AgentJob,
|
||||
persist_agent_terminal_state, push_output_block, AgentInput, AgentJob, GlobalToolRegistry,
|
||||
SubagentToolExecutor,
|
||||
};
|
||||
use api::OutputContentBlock;
|
||||
@@ -3486,6 +3578,48 @@ mod tests {
|
||||
assert!(empty_permission.contains("unsupported plugin permission: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
||||
let registry = GlobalToolRegistry::builtin()
|
||||
.with_runtime_tools(vec![super::RuntimeToolDefinition {
|
||||
name: "mcp__demo__echo".to_string(),
|
||||
description: Some("Echo text from the demo MCP server".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": { "text": { "type": "string" } },
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: runtime::PermissionMode::ReadOnly,
|
||||
}])
|
||||
.expect("runtime tools should register");
|
||||
|
||||
let allowed = registry
|
||||
.normalize_allowed_tools(&["mcp__demo__echo".to_string()])
|
||||
.expect("runtime tool should be allow-listable")
|
||||
.expect("allow-list should be populated");
|
||||
assert!(allowed.contains("mcp__demo__echo"));
|
||||
|
||||
let definitions = registry.definitions(Some(&allowed));
|
||||
assert_eq!(definitions.len(), 1);
|
||||
assert_eq!(definitions[0].name, "mcp__demo__echo");
|
||||
|
||||
let permissions = registry
|
||||
.permission_specs(Some(&allowed))
|
||||
.expect("runtime tool permissions should resolve");
|
||||
assert_eq!(
|
||||
permissions,
|
||||
vec![(
|
||||
"mcp__demo__echo".to_string(),
|
||||
runtime::PermissionMode::ReadOnly
|
||||
)]
|
||||
);
|
||||
|
||||
let search = registry.search("demo echo", 5, Some(vec!["pending-server".to_string()]));
|
||||
let output = serde_json::to_value(search).expect("search output should serialize");
|
||||
assert_eq!(output["matches"][0], "mcp__demo__echo");
|
||||
assert_eq!(output["pending_mcp_servers"][0], "pending-server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_fetch_returns_prompt_aware_summary() {
|
||||
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||
|
||||
Reference in New Issue
Block a user