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