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