mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08: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:
@@ -42,12 +42,14 @@ use runtime::{
|
||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||
parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, ApiClient,
|
||||
ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||||
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
|
||||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent,
|
||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
ConversationMessage, ConversationRuntime, McpServerManager, McpTool, MessageRole,
|
||||
OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode,
|
||||
PermissionPolicy, ProjectContext, PromptCacheEvent, RuntimeError, Session, TokenUsage,
|
||||
ToolError, ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tools::GlobalToolRegistry;
|
||||
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
||||
|
||||
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||
fn max_tokens_for_model(model: &str) -> u32 {
|
||||
@@ -576,11 +578,17 @@ fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
|
||||
let cwd = env::current_dir().map_err(|error| error.to_string())?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let runtime_config = loader.load().map_err(|error| error.to_string())?;
|
||||
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||
let plugin_tools = plugin_manager
|
||||
.aggregated_tools()
|
||||
let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
|
||||
.map_err(|error| error.to_string())?;
|
||||
GlobalToolRegistry::with_plugin_tools(plugin_tools)
|
||||
let registry = state.tool_registry.clone();
|
||||
if let Some(mcp_state) = state.mcp_state {
|
||||
mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.shutdown()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
|
||||
@@ -1491,23 +1499,35 @@ struct RuntimePluginState {
|
||||
feature_config: runtime::RuntimeFeatureConfig,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
plugin_registry: PluginRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
}
|
||||
|
||||
struct RuntimeMcpState {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
manager: McpServerManager,
|
||||
pending_servers: Vec<String>,
|
||||
}
|
||||
|
||||
struct BuiltRuntime {
|
||||
runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
|
||||
plugin_registry: PluginRegistry,
|
||||
plugins_active: bool,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
mcp_active: bool,
|
||||
}
|
||||
|
||||
impl BuiltRuntime {
|
||||
fn new(
|
||||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||
plugin_registry: PluginRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime: Some(runtime),
|
||||
plugin_registry,
|
||||
plugins_active: true,
|
||||
mcp_state,
|
||||
mcp_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1527,6 +1547,19 @@ impl BuiltRuntime {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shutdown_mcp(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.mcp_active {
|
||||
if let Some(mcp_state) = &self.mcp_state {
|
||||
mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.shutdown()?;
|
||||
}
|
||||
self.mcp_active = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for BuiltRuntime {
|
||||
@@ -1549,10 +1582,284 @@ impl DerefMut for BuiltRuntime {
|
||||
|
||||
impl Drop for BuiltRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_mcp();
|
||||
let _ = self.shutdown_plugins();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolSearchRequest {
|
||||
query: String,
|
||||
max_results: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct McpToolRequest {
|
||||
#[serde(rename = "qualifiedName")]
|
||||
qualified_name: Option<String>,
|
||||
tool: Option<String>,
|
||||
arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListMcpResourcesRequest {
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReadMcpResourceRequest {
|
||||
server: String,
|
||||
uri: String,
|
||||
}
|
||||
|
||||
impl RuntimeMcpState {
|
||||
fn new(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<Option<(Self, runtime::McpToolDiscoveryReport)>, Box<dyn std::error::Error>> {
|
||||
let mut manager = McpServerManager::from_runtime_config(runtime_config);
|
||||
if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let discovery = runtime.block_on(manager.discover_tools_best_effort());
|
||||
let pending_servers = discovery
|
||||
.failed_servers
|
||||
.iter()
|
||||
.map(|failure| failure.server_name.clone())
|
||||
.chain(
|
||||
discovery
|
||||
.unsupported_servers
|
||||
.iter()
|
||||
.map(|server| server.server_name.clone()),
|
||||
)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Some((
|
||||
Self {
|
||||
runtime,
|
||||
manager,
|
||||
pending_servers,
|
||||
},
|
||||
discovery,
|
||||
)))
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.block_on(self.manager.shutdown())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pending_servers(&self) -> Option<Vec<String>> {
|
||||
(!self.pending_servers.is_empty()).then(|| self.pending_servers.clone())
|
||||
}
|
||||
|
||||
fn server_names(&self) -> Vec<String> {
|
||||
self.manager.server_names()
|
||||
}
|
||||
|
||||
fn call_tool(
|
||||
&mut self,
|
||||
qualified_tool_name: &str,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> Result<String, ToolError> {
|
||||
let response = self
|
||||
.runtime
|
||||
.block_on(self.manager.call_tool(qualified_tool_name, arguments))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(ToolError::new(format!(
|
||||
"MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})",
|
||||
error.message, error.code
|
||||
)));
|
||||
}
|
||||
|
||||
let result = response.result.ok_or_else(|| {
|
||||
ToolError::new(format!(
|
||||
"MCP tool `{qualified_tool_name}` returned no result payload"
|
||||
))
|
||||
})?;
|
||||
serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
fn list_resources_for_server(&mut self, server_name: &str) -> Result<String, ToolError> {
|
||||
let result = self
|
||||
.runtime
|
||||
.block_on(self.manager.list_resources(server_name))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"server": server_name,
|
||||
"resources": result.resources,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
fn list_resources_for_all_servers(&mut self) -> Result<String, ToolError> {
|
||||
let mut resources = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for server_name in self.server_names() {
|
||||
match self
|
||||
.runtime
|
||||
.block_on(self.manager.list_resources(&server_name))
|
||||
{
|
||||
Ok(result) => resources.push(json!({
|
||||
"server": server_name,
|
||||
"resources": result.resources,
|
||||
})),
|
||||
Err(error) => failures.push(json!({
|
||||
"server": server_name,
|
||||
"error": error.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
if resources.is_empty() && !failures.is_empty() {
|
||||
let message = failures
|
||||
.iter()
|
||||
.filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(ToolError::new(message));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"resources": resources,
|
||||
"failures": failures,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
fn read_resource(&mut self, server_name: &str, uri: &str) -> Result<String, ToolError> {
|
||||
let result = self
|
||||
.runtime
|
||||
.block_on(self.manager.read_resource(server_name, uri))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"server": server_name,
|
||||
"contents": result.contents,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_mcp_state(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<
|
||||
(
|
||||
Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
Vec<RuntimeToolDefinition>,
|
||||
),
|
||||
Box<dyn std::error::Error>,
|
||||
> {
|
||||
let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else {
|
||||
return Ok((None, Vec::new()));
|
||||
};
|
||||
|
||||
let mut runtime_tools = discovery
|
||||
.tools
|
||||
.iter()
|
||||
.map(mcp_runtime_tool_definition)
|
||||
.collect::<Vec<_>>();
|
||||
if !mcp_state.server_names().is_empty() {
|
||||
runtime_tools.extend(mcp_wrapper_tool_definitions());
|
||||
}
|
||||
|
||||
Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools))
|
||||
}
|
||||
|
||||
fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition {
|
||||
RuntimeToolDefinition {
|
||||
name: tool.qualified_name.clone(),
|
||||
description: Some(
|
||||
tool.tool
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)),
|
||||
),
|
||||
input_schema: tool
|
||||
.tool
|
||||
.input_schema
|
||||
.clone()
|
||||
.unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })),
|
||||
required_permission: permission_mode_for_mcp_tool(&tool.tool),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_wrapper_tool_definitions() -> Vec<RuntimeToolDefinition> {
|
||||
vec![
|
||||
RuntimeToolDefinition {
|
||||
name: "MCPTool".to_string(),
|
||||
description: Some(
|
||||
"Call a configured MCP tool by its qualified name and JSON arguments.".to_string(),
|
||||
),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"qualifiedName": { "type": "string" },
|
||||
"arguments": {}
|
||||
},
|
||||
"required": ["qualifiedName"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::DangerFullAccess,
|
||||
},
|
||||
RuntimeToolDefinition {
|
||||
name: "ListMcpResourcesTool".to_string(),
|
||||
description: Some(
|
||||
"List MCP resources from one configured server or from every connected server."
|
||||
.to_string(),
|
||||
),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
RuntimeToolDefinition {
|
||||
name: "ReadMcpResourceTool".to_string(),
|
||||
description: Some("Read a specific MCP resource from a configured server.".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"uri": { "type": "string" }
|
||||
},
|
||||
"required": ["server", "uri"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode {
|
||||
let read_only = mcp_annotation_flag(tool, "readOnlyHint");
|
||||
let destructive = mcp_annotation_flag(tool, "destructiveHint");
|
||||
let open_world = mcp_annotation_flag(tool, "openWorldHint");
|
||||
|
||||
if read_only && !destructive && !open_world {
|
||||
PermissionMode::ReadOnly
|
||||
} else if destructive || open_world {
|
||||
PermissionMode::DangerFullAccess
|
||||
} else {
|
||||
PermissionMode::WorkspaceWrite
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool {
|
||||
tool.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.get(key))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
struct HookAbortMonitor {
|
||||
stop_tx: Option<Sender<()>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
@@ -3375,11 +3682,14 @@ fn build_runtime_plugin_state_with_loader(
|
||||
.feature_config()
|
||||
.clone()
|
||||
.with_hooks(runtime_config.hooks().merged(&plugin_hook_config));
|
||||
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?;
|
||||
let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?;
|
||||
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?
|
||||
.with_runtime_tools(runtime_tools)?;
|
||||
Ok(RuntimePluginState {
|
||||
feature_config,
|
||||
tool_registry,
|
||||
plugin_registry,
|
||||
mcp_state,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3801,6 +4111,7 @@ fn build_runtime_with_plugin_state(
|
||||
feature_config,
|
||||
tool_registry,
|
||||
plugin_registry,
|
||||
mcp_state,
|
||||
} = runtime_plugin_state;
|
||||
plugin_registry.initialize()?;
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
@@ -3814,7 +4125,12 @@ fn build_runtime_with_plugin_state(
|
||||
tool_registry.clone(),
|
||||
progress_reporter,
|
||||
)?,
|
||||
CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
|
||||
CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
emit_output,
|
||||
tool_registry.clone(),
|
||||
mcp_state.clone(),
|
||||
),
|
||||
permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?,
|
||||
system_prompt,
|
||||
@@ -3823,7 +4139,7 @@ fn build_runtime_with_plugin_state(
|
||||
if emit_output {
|
||||
runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
|
||||
}
|
||||
Ok(BuiltRuntime::new(runtime, plugin_registry))
|
||||
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
|
||||
}
|
||||
|
||||
struct CliHookProgressReporter;
|
||||
@@ -4758,6 +5074,7 @@ struct CliToolExecutor {
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
}
|
||||
|
||||
impl CliToolExecutor {
|
||||
@@ -4765,12 +5082,72 @@ impl CliToolExecutor {
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
emit_output: bool,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer: TerminalRenderer::new(),
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
mcp_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_search_tool(&self, value: serde_json::Value) -> Result<String, ToolError> {
|
||||
let input: ToolSearchRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
let pending_mcp_servers = self.mcp_state.as_ref().and_then(|state| {
|
||||
state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.pending_servers()
|
||||
});
|
||||
serde_json::to_string_pretty(&self.tool_registry.search(
|
||||
&input.query,
|
||||
input.max_results.unwrap_or(5),
|
||||
pending_mcp_servers,
|
||||
))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
fn execute_runtime_tool(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<String, ToolError> {
|
||||
let Some(mcp_state) = &self.mcp_state else {
|
||||
return Err(ToolError::new(format!(
|
||||
"runtime tool `{tool_name}` is unavailable without configured MCP servers"
|
||||
)));
|
||||
};
|
||||
let mut mcp_state = mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
|
||||
match tool_name {
|
||||
"MCPTool" => {
|
||||
let input: McpToolRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
let qualified_name = input
|
||||
.qualified_name
|
||||
.or(input.tool)
|
||||
.ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?;
|
||||
mcp_state.call_tool(&qualified_name, input.arguments)
|
||||
}
|
||||
"ListMcpResourcesTool" => {
|
||||
let input: ListMcpResourcesRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
match input.server {
|
||||
Some(server_name) => mcp_state.list_resources_for_server(&server_name),
|
||||
None => mcp_state.list_resources_for_all_servers(),
|
||||
}
|
||||
}
|
||||
"ReadMcpResourceTool" => {
|
||||
let input: ReadMcpResourceRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
mcp_state.read_resource(&input.server, &input.uri)
|
||||
}
|
||||
_ => mcp_state.call_tool(tool_name, Some(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4788,7 +5165,16 @@ impl ToolExecutor for CliToolExecutor {
|
||||
}
|
||||
let value = serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
match self.tool_registry.execute(tool_name, &value) {
|
||||
let result = if tool_name == "ToolSearch" {
|
||||
self.execute_search_tool(value)
|
||||
} else if self.tool_registry.has_runtime_tool(tool_name) {
|
||||
self.execute_runtime_tool(tool_name, value)
|
||||
} else {
|
||||
self.tool_registry
|
||||
.execute(tool_name, &value)
|
||||
.map_err(ToolError::new)
|
||||
};
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &output, false);
|
||||
@@ -4800,12 +5186,12 @@ impl ToolExecutor for CliToolExecutor {
|
||||
}
|
||||
Err(error) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &error, true);
|
||||
let markdown = format_tool_result(tool_name, &error.to_string(), true);
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||||
}
|
||||
Err(ToolError::new(error))
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5006,8 +5392,9 @@ mod tests {
|
||||
resolve_model_alias, resolve_session_reference, response_to_events,
|
||||
resume_supported_slash_commands, run_resume_command,
|
||||
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
||||
CliAction, CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent,
|
||||
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand,
|
||||
StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -5015,7 +5402,7 @@ mod tests {
|
||||
};
|
||||
use runtime::{
|
||||
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
|
||||
PermissionMode, Session,
|
||||
PermissionMode, Session, ToolExecutor,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
@@ -6226,7 +6613,11 @@ UU conflicted.rs",
|
||||
|
||||
#[test]
|
||||
fn init_template_mentions_detected_rust_workspace() {
|
||||
let rendered = crate::init::render_init_claude_md(std::path::Path::new("."));
|
||||
let _guard = cwd_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let rendered = crate::init::render_init_claude_md(&workspace_root);
|
||||
assert!(rendered.contains("# CLAUDE.md"));
|
||||
assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
|
||||
}
|
||||
@@ -6617,6 +7008,111 @@ UU conflicted.rs",
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
let script_path = workspace.join("fixture-mcp.py");
|
||||
write_mcp_server_fixture(&script_path);
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"alpha": {{
|
||||
"command": "python3",
|
||||
"args": ["{}"]
|
||||
}},
|
||||
"broken": {{
|
||||
"command": "python3",
|
||||
"args": ["-c", "import sys; sys.exit(0)"]
|
||||
}}
|
||||
}}
|
||||
}}"#,
|
||||
script_path.to_string_lossy()
|
||||
),
|
||||
)
|
||||
.expect("write mcp settings");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("runtime plugin state should load");
|
||||
|
||||
let allowed = state
|
||||
.tool_registry
|
||||
.normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()])
|
||||
.expect("mcp tools should be allow-listable")
|
||||
.expect("allow-list should exist");
|
||||
assert!(allowed.contains("mcp__alpha__echo"));
|
||||
assert!(allowed.contains("MCPTool"));
|
||||
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
|
||||
let tool_output = executor
|
||||
.execute("mcp__alpha__echo", r#"{"text":"hello"}"#)
|
||||
.expect("discovered mcp tool should execute");
|
||||
let tool_json: serde_json::Value =
|
||||
serde_json::from_str(&tool_output).expect("tool output should be json");
|
||||
assert_eq!(tool_json["structuredContent"]["echoed"], "hello");
|
||||
|
||||
let wrapped_output = executor
|
||||
.execute(
|
||||
"MCPTool",
|
||||
r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#,
|
||||
)
|
||||
.expect("generic mcp wrapper should execute");
|
||||
let wrapped_json: serde_json::Value =
|
||||
serde_json::from_str(&wrapped_output).expect("wrapped output should be json");
|
||||
assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped");
|
||||
|
||||
let search_output = executor
|
||||
.execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#)
|
||||
.expect("tool search should execute");
|
||||
let search_json: serde_json::Value =
|
||||
serde_json::from_str(&search_output).expect("search output should be json");
|
||||
assert_eq!(search_json["matches"][0], "mcp__alpha__echo");
|
||||
assert_eq!(search_json["pending_mcp_servers"][0], "broken");
|
||||
|
||||
let listed = executor
|
||||
.execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#)
|
||||
.expect("resources should list");
|
||||
let listed_json: serde_json::Value =
|
||||
serde_json::from_str(&listed).expect("resource output should be json");
|
||||
assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt");
|
||||
|
||||
let read = executor
|
||||
.execute(
|
||||
"ReadMcpResourceTool",
|
||||
r#"{"server":"alpha","uri":"file://guide.txt"}"#,
|
||||
)
|
||||
.expect("resource should read");
|
||||
let read_json: serde_json::Value =
|
||||
serde_json::from_str(&read).expect("resource read output should be json");
|
||||
assert_eq!(
|
||||
read_json["contents"][0]["text"],
|
||||
"contents for file://guide.txt"
|
||||
);
|
||||
|
||||
if let Some(mcp_state) = state.mcp_state {
|
||||
mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.shutdown()
|
||||
.expect("mcp shutdown should succeed");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
|
||||
let config_home = temp_dir();
|
||||
@@ -6671,6 +7167,105 @@ UU conflicted.rs",
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mcp_server_fixture(script_path: &Path) {
|
||||
let script = [
|
||||
"#!/usr/bin/env python3",
|
||||
"import json, sys",
|
||||
"",
|
||||
"def read_message():",
|
||||
" header = b''",
|
||||
r" while not header.endswith(b'\r\n\r\n'):",
|
||||
" chunk = sys.stdin.buffer.read(1)",
|
||||
" if not chunk:",
|
||||
" return None",
|
||||
" header += chunk",
|
||||
" length = 0",
|
||||
r" for line in header.decode().split('\r\n'):",
|
||||
r" if line.lower().startswith('content-length:'):",
|
||||
" length = int(line.split(':', 1)[1].strip())",
|
||||
" payload = sys.stdin.buffer.read(length)",
|
||||
" return json.loads(payload.decode())",
|
||||
"",
|
||||
"def send_message(message):",
|
||||
" payload = json.dumps(message).encode()",
|
||||
r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
|
||||
" sys.stdout.buffer.flush()",
|
||||
"",
|
||||
"while True:",
|
||||
" request = read_message()",
|
||||
" if request is None:",
|
||||
" break",
|
||||
" method = request['method']",
|
||||
" if method == 'initialize':",
|
||||
" send_message({",
|
||||
" 'jsonrpc': '2.0',",
|
||||
" 'id': request['id'],",
|
||||
" 'result': {",
|
||||
" 'protocolVersion': request['params']['protocolVersion'],",
|
||||
" 'capabilities': {'tools': {}, 'resources': {}},",
|
||||
" 'serverInfo': {'name': 'fixture', 'version': '1.0.0'}",
|
||||
" }",
|
||||
" })",
|
||||
" elif method == 'tools/list':",
|
||||
" send_message({",
|
||||
" 'jsonrpc': '2.0',",
|
||||
" 'id': request['id'],",
|
||||
" 'result': {",
|
||||
" 'tools': [",
|
||||
" {",
|
||||
" 'name': 'echo',",
|
||||
" 'description': 'Echo from MCP fixture',",
|
||||
" 'inputSchema': {",
|
||||
" 'type': 'object',",
|
||||
" 'properties': {'text': {'type': 'string'}},",
|
||||
" 'required': ['text'],",
|
||||
" 'additionalProperties': False",
|
||||
" },",
|
||||
" 'annotations': {'readOnlyHint': True}",
|
||||
" }",
|
||||
" ]",
|
||||
" }",
|
||||
" })",
|
||||
" elif method == 'tools/call':",
|
||||
" args = request['params'].get('arguments') or {}",
|
||||
" send_message({",
|
||||
" 'jsonrpc': '2.0',",
|
||||
" 'id': request['id'],",
|
||||
" 'result': {",
|
||||
" 'content': [{'type': 'text', 'text': f\"echo:{args.get('text', '')}\"}],",
|
||||
" 'structuredContent': {'echoed': args.get('text', '')},",
|
||||
" 'isError': False",
|
||||
" }",
|
||||
" })",
|
||||
" elif method == 'resources/list':",
|
||||
" send_message({",
|
||||
" 'jsonrpc': '2.0',",
|
||||
" 'id': request['id'],",
|
||||
" 'result': {",
|
||||
" 'resources': [{'uri': 'file://guide.txt', 'name': 'guide', 'mimeType': 'text/plain'}]",
|
||||
" }",
|
||||
" })",
|
||||
" elif method == 'resources/read':",
|
||||
" uri = request['params']['uri']",
|
||||
" send_message({",
|
||||
" 'jsonrpc': '2.0',",
|
||||
" 'id': request['id'],",
|
||||
" 'result': {",
|
||||
" 'contents': [{'uri': uri, 'mimeType': 'text/plain', 'text': f'contents for {uri}'}]",
|
||||
" }",
|
||||
" })",
|
||||
" else:",
|
||||
" send_message({",
|
||||
" 'jsonrpc': '2.0',",
|
||||
" 'id': request['id'],",
|
||||
" 'error': {'code': -32601, 'message': method}",
|
||||
" })",
|
||||
"",
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(script_path, script).expect("mcp fixture script should write");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sandbox_report_tests {
|
||||
use super::{format_sandbox_report, HookAbortMonitor};
|
||||
|
||||
Reference in New Issue
Block a user