Skip to main content

systemprompt_cli/commands/plugins/mcp/
call.rs

1//! `plugins mcp call` command invoking one tool on a running server.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}