Skip to main content

systemprompt_cli/commands/plugins/mcp/
call.rs

1use 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}