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 anyhow::{Context, Result, anyhow};
9use clap::Args;
10use systemprompt_loader::ConfigLoader;
11use systemprompt_mcp::services::McpOrchestrator;
12use systemprompt_models::ai::tools::CallToolResult;
13use systemprompt_runtime::AppContext;
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 port = resolve_running_port(&server_name).await?;
64
65    let tool_name = resolve_required(tool_arg, "tool", config, || {
66        prompt_tool_selection(prompter, &server_name, port, &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        port,
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_port(server_name: &str) -> Result<u16> {
108    let ctx = AppContext::new()
109        .await
110        .context("Failed to initialize application context")?;
111
112    let manager = McpOrchestrator::new(
113        Arc::clone(ctx.db_pool()),
114        Arc::clone(ctx.app_paths_arc()),
115        ctx.mcp_registry().clone(),
116    )
117    .context("Failed to initialize MCP manager")?;
118    let running_servers = manager
119        .get_running_servers()
120        .await
121        .context("Failed to get running servers")?;
122
123    running_servers
124        .iter()
125        .find(|s| s.name == server_name)
126        .map(|s| s.port)
127        .ok_or_else(|| anyhow!("MCP server '{}' is not running", server_name))
128}
129
130fn success_outcome(
131    tool_result: &CallToolResult,
132    server_name: &str,
133    tool_name: &str,
134    execution_time_ms: u64,
135) -> (McpCallOutput, Option<String>) {
136    let content: Vec<McpToolContent> = tool_result.content.iter().map(convert_content).collect();
137    let is_error = tool_result.is_error.unwrap_or(false);
138    let failure = is_error.then(|| {
139        let detail = content
140            .iter()
141            .filter_map(|c| c.text.as_deref())
142            .collect::<Vec<_>>()
143            .join("\n");
144        if detail.is_empty() {
145            format!(
146                "MCP tool '{}' on '{}' reported is_error=true with no message",
147                tool_name, server_name
148            )
149        } else {
150            format!(
151                "MCP tool '{}' on '{}' reported is_error=true: {}",
152                tool_name, server_name, detail
153            )
154        }
155    });
156
157    (
158        McpCallOutput {
159            server: server_name.to_owned(),
160            tool: tool_name.to_owned(),
161            success: !is_error,
162            content,
163            execution_time_ms,
164            error: failure.clone(),
165        },
166        failure,
167    )
168}
169
170fn failure_outcome(
171    message: String,
172    server_name: &str,
173    tool_name: &str,
174    execution_time_ms: u64,
175) -> (McpCallOutput, Option<String>) {
176    (
177        McpCallOutput {
178            server: server_name.to_owned(),
179            tool: tool_name.to_owned(),
180            success: false,
181            content: vec![],
182            execution_time_ms,
183            error: Some(message.clone()),
184        },
185        Some(message),
186    )
187}
188
189pub fn prompt_server_selection(
190    prompter: &dyn Prompter,
191    config: &systemprompt_models::ServicesConfig,
192) -> Result<String> {
193    let mut servers: Vec<String> = config.mcp_servers.keys().cloned().collect();
194    servers.sort();
195
196    if servers.is_empty() {
197        return Err(anyhow!("No MCP servers configured"));
198    }
199
200    let selection = prompter.select("Select MCP server", &servers)?;
201    Ok(servers[selection].clone())
202}
203
204fn prompt_tool_selection(
205    prompter: &dyn Prompter,
206    server_name: &str,
207    port: u16,
208    session_ctx: &CliSessionContext,
209    timeout_secs: u64,
210) -> Result<String> {
211    let rt = tokio::runtime::Handle::current();
212    let tools = rt.block_on(async {
213        list_available_tools(server_name, port, session_ctx, timeout_secs).await
214    })?;
215
216    if tools.is_empty() {
217        return Err(anyhow!("No tools available on server '{}'", server_name));
218    }
219
220    let selection = prompter.select("Select tool to execute", &tools)?;
221    Ok(tools[selection].clone())
222}