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:
Yeachan-Heo
2026-04-03 14:31:25 +00:00
parent 8805386bea
commit b3fe057559
6 changed files with 1155 additions and 105 deletions

View File

@@ -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| {