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;
13
14use super::call_client::{
15    ToolCallParams, convert_content, execute_tool_call, list_available_tools,
16};
17use super::types::{McpCallOutput, McpToolContent};
18use crate::context::CommandContext;
19use crate::interactive::{Prompter, resolve_required};
20use crate::session::{CliSessionContext, get_or_create_session};
21use crate::shared::{CommandOutput, render_result};
22
23#[derive(Debug, Args)]
24#[command(
25    after_help = "Examples:\n  systemprompt plugins mcp call systemprompt systemprompt \\\n      \
26                  --args '{\"command\":\"core skills list\"}'\n\n  systemprompt plugins mcp call \
27                  <server> <tool> -a '{\"key\":\"value\"}'"
28)]
29pub struct CallArgs {
30    #[arg(help = "MCP server name (required in non-interactive mode)")]
31    pub server: Option<String>,
32
33    #[arg(help = "Tool name to execute (required in non-interactive mode)")]
34    pub tool: Option<String>,
35
36    #[arg(short = 'a', long, help = "Tool arguments as JSON string")]
37    pub args: Option<String>,
38
39    #[arg(long, default_value = "30", help = "Timeout in seconds")]
40    pub timeout: u64,
41}
42
43pub(super) async fn execute(args: CallArgs, ctx: &CommandContext) -> Result<CommandOutput> {
44    let config = &ctx.cli;
45    let prompter = ctx.prompter();
46    let services_config = ConfigLoader::load().context("Failed to load services configuration")?;
47    let session_ctx = get_or_create_session(ctx).await?;
48
49    let server_arg = args.server.clone();
50    let tool_arg = args.tool.clone();
51    let timeout_secs = args.timeout;
52
53    let server_name = resolve_required(server_arg, "server", config, || {
54        prompt_server_selection(prompter, &services_config)
55    })?;
56
57    let _server_config = services_config
58        .mcp_servers
59        .get(&server_name)
60        .ok_or_else(|| anyhow!("MCP server '{}' not found in configuration", server_name))?;
61
62    let port = resolve_running_port(&server_name, ctx).await?;
63
64    let tool_name = resolve_required(tool_arg, "tool", config, || {
65        prompt_tool_selection(prompter, &server_name, port, &session_ctx, timeout_secs)
66    })?;
67
68    let tool_args: Option<serde_json::Value> = args
69        .args
70        .as_ref()
71        .map(|s| serde_json::from_str(s))
72        .transpose()
73        .context("Invalid JSON in --args")?;
74
75    let start_time = std::time::Instant::now();
76
77    let result = execute_tool_call(ToolCallParams {
78        server_name: &server_name,
79        port,
80        tool_name: &tool_name,
81        arguments: tool_args,
82        session_ctx: &session_ctx,
83        timeout_secs,
84    })
85    .await;
86
87    let execution_time_ms = start_time.elapsed().as_millis() as u64;
88
89    let (output, failure) = match result {
90        Ok(tool_result) => {
91            success_outcome(&tool_result, &server_name, &tool_name, execution_time_ms)
92        },
93        Err(e) => failure_outcome(e.to_string(), &server_name, &tool_name, execution_time_ms),
94    };
95
96    let card = CommandOutput::card_value(format!("Tool Execution: {}", tool_name), &output);
97
98    if let Some(msg) = failure {
99        render_result(&card, config);
100        return Err(anyhow!(msg));
101    }
102
103    Ok(card)
104}
105
106async fn resolve_running_port(server_name: &str, ctx: &CommandContext) -> Result<u16> {
107    let app = ctx
108        .app_context()
109        .await
110        .context("Failed to initialize application context")?;
111
112    let manager = McpOrchestrator::new(
113        Arc::clone(app.db_pool()),
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    running_servers
125        .iter()
126        .find(|s| s.name == server_name)
127        .map(|s| s.port)
128        .ok_or_else(|| anyhow!("MCP server '{}' is not running", server_name))
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    port: u16,
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, port, 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}