systemprompt_cli/commands/plugins/mcp/
call.rs1use std::sync::Arc;
7
8use crate::commands::shared::mcp_tools::direct_url;
9use anyhow::{Context, Result, anyhow};
10use clap::Args;
11use systemprompt_identifiers::{McpServerId, McpToolName};
12use systemprompt_loader::ConfigLoader;
13use systemprompt_mcp::services::McpOrchestrator;
14use systemprompt_models::ai::tools::CallToolResult;
15
16use super::call_client::{
17 ToolCallParams, convert_content, execute_tool_call, list_available_tools,
18};
19use super::types::{McpCallOutput, McpToolContent};
20use crate::context::CommandContext;
21use crate::interactive::{Prompter, resolve_required};
22use crate::session::{CliSessionContext, get_or_create_session};
23use crate::shared::{CommandOutput, render_result};
24
25#[derive(Debug, Args)]
26#[command(
27 after_help = "Examples:\n systemprompt plugins mcp call systemprompt systemprompt \\\n \
28 --args '{\"command\":\"core skills list\"}'\n\n systemprompt plugins mcp call \
29 <server> <tool> -a '{\"key\":\"value\"}'"
30)]
31pub struct CallArgs {
32 #[arg(
33 help = "MCP server name (required in non-interactive mode)",
34 value_parser = crate::shared::parse_mcp_server_id
35 )]
36 pub server: Option<McpServerId>,
37
38 #[arg(
39 help = "Tool name to execute (required in non-interactive mode)",
40 value_parser = crate::shared::parse_mcp_tool_name
41 )]
42 pub tool: Option<McpToolName>,
43
44 #[arg(short = 'a', long, help = "Tool arguments as JSON string")]
45 pub args: Option<String>,
46
47 #[arg(long, default_value = "30", help = "Timeout in seconds")]
48 pub timeout: u64,
49}
50
51pub(super) async fn execute(args: CallArgs, ctx: &CommandContext) -> Result<CommandOutput> {
52 let config = &ctx.cli;
53 let prompter = ctx.prompter();
54 let services_config = ConfigLoader::load().context("Failed to load services configuration")?;
55 let session_ctx = get_or_create_session(ctx).await?;
56
57 let server_arg = args.server.clone();
58 let tool_arg = args.tool.clone();
59 let timeout_secs = args.timeout;
60
61 let server_name = resolve_required(server_arg, "server", config, || {
62 prompt_server_selection(prompter, &services_config)
63 })?;
64
65 let _server_config = services_config
66 .mcp_servers
67 .get(server_name.as_str())
68 .ok_or_else(|| anyhow!("MCP server '{}' not found in configuration", server_name))?;
69
70 let url = resolve_running_url(&server_name, ctx).await?;
71
72 let tool_name = resolve_required(tool_arg, "tool", config, || {
73 prompt_tool_selection(prompter, &server_name, &url, &session_ctx, timeout_secs)
74 })?;
75
76 let tool_args: Option<serde_json::Value> = args
77 .args
78 .as_ref()
79 .map(|s| serde_json::from_str(s))
80 .transpose()
81 .context("Invalid JSON in --args")?;
82
83 let start_time = std::time::Instant::now();
84
85 let result = execute_tool_call(ToolCallParams {
86 server_name: &server_name,
87 url: &url,
88 tool_name: &tool_name,
89 arguments: tool_args,
90 session_ctx: &session_ctx,
91 timeout_secs,
92 })
93 .await;
94
95 let execution_time_ms = start_time.elapsed().as_millis() as u64;
96
97 let (output, failure) = match result {
98 Ok(tool_result) => {
99 success_outcome(&tool_result, &server_name, &tool_name, execution_time_ms)
100 },
101 Err(e) => failure_outcome(e.to_string(), &server_name, &tool_name, execution_time_ms),
102 };
103
104 let card = CommandOutput::card_value(format!("Tool Execution: {}", tool_name), &output);
105
106 if let Some(msg) = failure {
107 render_result(&card, config);
108 return Err(anyhow!(msg));
109 }
110
111 Ok(card)
112}
113
114async fn resolve_running_url(server_name: &McpServerId, ctx: &CommandContext) -> Result<String> {
115 let app = ctx
116 .app_context()
117 .await
118 .context("Failed to initialize application context")?;
119
120 let manager = McpOrchestrator::new(
121 (**app.service_repository()).clone(),
122 Arc::clone(app.app_paths_arc()),
123 app.mcp_registry().clone(),
124 )
125 .context("Failed to initialize MCP manager")?;
126 let running_servers = manager
127 .get_running_servers()
128 .await
129 .context("Failed to get running servers")?;
130
131 let server = running_servers
132 .iter()
133 .find(|s| s.name == server_name.as_str())
134 .ok_or_else(|| anyhow!("MCP server '{}' is not running", server_name))?;
135 direct_url(server)
136}
137
138fn success_outcome(
139 tool_result: &CallToolResult,
140 server_name: &McpServerId,
141 tool_name: &McpToolName,
142 execution_time_ms: u64,
143) -> (McpCallOutput, Option<String>) {
144 let content: Vec<McpToolContent> = tool_result.content.iter().map(convert_content).collect();
145 let is_error = tool_result.is_error.unwrap_or(false);
146 let failure = is_error.then(|| {
147 let detail = content
148 .iter()
149 .filter_map(|c| c.text.as_deref())
150 .collect::<Vec<_>>()
151 .join("\n");
152 if detail.is_empty() {
153 format!(
154 "MCP tool '{}' on '{}' reported is_error=true with no message",
155 tool_name, server_name
156 )
157 } else {
158 format!(
159 "MCP tool '{}' on '{}' reported is_error=true: {}",
160 tool_name, server_name, detail
161 )
162 }
163 });
164
165 (
166 McpCallOutput {
167 server: server_name.clone(),
168 tool: tool_name.clone(),
169 success: !is_error,
170 content,
171 execution_time_ms,
172 error: failure.clone(),
173 },
174 failure,
175 )
176}
177
178fn failure_outcome(
179 message: String,
180 server_name: &McpServerId,
181 tool_name: &McpToolName,
182 execution_time_ms: u64,
183) -> (McpCallOutput, Option<String>) {
184 (
185 McpCallOutput {
186 server: server_name.clone(),
187 tool: tool_name.clone(),
188 success: false,
189 content: vec![],
190 execution_time_ms,
191 error: Some(message.clone()),
192 },
193 Some(message),
194 )
195}
196
197pub fn prompt_server_selection(
198 prompter: &dyn Prompter,
199 config: &systemprompt_manifest::ServicesConfig,
200) -> Result<McpServerId> {
201 let mut servers: Vec<String> = config.mcp_servers.keys().cloned().collect();
202 servers.sort();
203
204 if servers.is_empty() {
205 return Err(anyhow!("No MCP servers configured"));
206 }
207
208 let selection = prompter.select("Select MCP server", &servers)?;
209 Ok(McpServerId::new(servers[selection].clone()))
210}
211
212fn prompt_tool_selection(
213 prompter: &dyn Prompter,
214 server_name: &McpServerId,
215 url: &str,
216 session_ctx: &CliSessionContext,
217 timeout_secs: u64,
218) -> Result<McpToolName> {
219 let rt = tokio::runtime::Handle::current();
220 let tools = rt.block_on(async {
221 list_available_tools(server_name, url, session_ctx, timeout_secs).await
222 })?;
223
224 if tools.is_empty() {
225 return Err(anyhow!("No tools available on server '{}'", server_name));
226 }
227
228 let selection = prompter.select("Select tool to execute", &tools)?;
229 McpToolName::try_new(tools[selection].clone())
230 .context("MCP server advertised an invalid tool name")
231}