Skip to main content

systemprompt_cli/commands/plugins/mcp/
call_client.rs

1//! MCP client for `plugins mcp call`.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use rmcp::ServiceExt;
8use rmcp::model::{
9    CallToolRequestParams, ClientCapabilities, ClientInfo, ContentBlock, Implementation,
10};
11use rmcp::transport::streamable_http_client::{
12    StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
13};
14use std::time::Duration;
15use systemprompt_identifiers::{AgentName, McpServerId, McpToolName};
16use systemprompt_mcp::services::client::HttpClientWithContext;
17use systemprompt_models::ai::tools::CallToolResult;
18use tokio::time::timeout;
19
20use super::types::McpToolContent;
21use crate::session::CliSessionContext;
22
23#[derive(Debug)]
24pub struct ToolCallParams<'a> {
25    pub server_name: &'a McpServerId,
26    pub url: &'a str,
27    pub tool_name: &'a McpToolName,
28    // JSON: MCP tool-call arguments are the tool's own JSON object.
29    pub arguments: Option<serde_json::Value>,
30    pub session_ctx: &'a CliSessionContext,
31    pub timeout_secs: u64,
32}
33
34pub async fn execute_tool_call(params: ToolCallParams<'_>) -> Result<CallToolResult> {
35    let ToolCallParams {
36        server_name,
37        url,
38        tool_name,
39        arguments,
40        session_ctx,
41        timeout_secs,
42    } = params;
43
44    let agent_name = AgentName::try_new(format!("cli-{server_name}"))
45        .context("MCP server name does not form a valid agent name")?;
46    let request_context = session_ctx.to_request_context(agent_name);
47    let http_client = HttpClientWithContext::new(request_context)?;
48    let config = StreamableHttpClientTransportConfig::with_uri(url)
49        .auth_header(session_ctx.session_token().as_str().to_owned());
50    let transport = StreamableHttpClientTransport::with_client(http_client, config);
51
52    let client_info = ClientInfo::new(
53        ClientCapabilities::default(),
54        Implementation::new(format!("systemprompt-cli-{}", server_name), "1.0.0"),
55    );
56
57    let client = timeout(
58        Duration::from_secs(timeout_secs),
59        client_info.serve(transport),
60    )
61    .await
62    .context("Connection timeout")?
63    .context("Failed to connect to MCP server")?;
64
65    let mut params = CallToolRequestParams::new(String::from(tool_name));
66    params.arguments = arguments.and_then(|v| v.as_object().cloned());
67
68    let result = client.call_tool(params).await.map_err(|e| {
69        anyhow::anyhow!(
70            "MCP tool '{}' on '{}' rejected the call: {}",
71            tool_name,
72            server_name,
73            e
74        )
75    })?;
76
77    client.cancel().await?;
78    Ok(result)
79}
80
81pub async fn list_available_tools(
82    server_name: &McpServerId,
83    url: &str,
84    session_ctx: &CliSessionContext,
85    timeout_secs: u64,
86) -> Result<Vec<String>> {
87    let agent_name = AgentName::try_new(format!("cli-{server_name}"))
88        .context("MCP server name does not form a valid agent name")?;
89    let request_context = session_ctx.to_request_context(agent_name);
90    let http_client = HttpClientWithContext::new(request_context)?;
91    let config = StreamableHttpClientTransportConfig::with_uri(url)
92        .auth_header(session_ctx.session_token().as_str().to_owned());
93    let transport = StreamableHttpClientTransport::with_client(http_client, config);
94
95    let client_info = ClientInfo::new(
96        ClientCapabilities::default(),
97        Implementation::new(format!("systemprompt-cli-{}", server_name), "1.0.0"),
98    );
99
100    let client = timeout(
101        Duration::from_secs(timeout_secs),
102        client_info.serve(transport),
103    )
104    .await
105    .context("Connection timeout")?
106    .context("Failed to connect to MCP server")?;
107
108    let tools_response = client
109        // Why: rmcp serialises a `None` params as `"params": null`, which a
110        // strict server (Google's MCP) refuses with -32602; send `{}`.
111        .list_tools(Some(rmcp::model::PaginatedRequestParams::default()))
112        .await
113        .context("Failed to list tools")?;
114
115    let tool_names: Vec<String> = tools_response
116        .tools
117        .into_iter()
118        .map(|t| t.name.to_string())
119        .collect();
120
121    client.cancel().await?;
122    Ok(tool_names)
123}
124
125pub fn convert_content(content: &ContentBlock) -> McpToolContent {
126    match content {
127        ContentBlock::Text(text) => McpToolContent {
128            kind: "text".to_owned(),
129            text: Some(text.text.clone()),
130            mime_type: None,
131            data: None,
132        },
133        ContentBlock::Image(image) => McpToolContent {
134            kind: "image".to_owned(),
135            text: None,
136            mime_type: Some(image.mime_type.clone()),
137            data: Some(image.data.clone()),
138        },
139        ContentBlock::Resource(resource) => McpToolContent {
140            kind: "resource".to_owned(),
141            text: Some(format!("{:?}", resource.resource)),
142            mime_type: None,
143            data: None,
144        },
145        ContentBlock::Audio(audio) => McpToolContent {
146            kind: "audio".to_owned(),
147            text: None,
148            mime_type: Some(audio.mime_type.clone()),
149            data: Some(audio.data.clone()),
150        },
151        ContentBlock::ResourceLink(link) => McpToolContent {
152            kind: "resource_link".to_owned(),
153            text: Some(link.uri.clone()),
154            mime_type: link.mime_type.clone(),
155            data: None,
156        },
157        _ => McpToolContent {
158            kind: "unknown".to_owned(),
159            text: None,
160            mime_type: None,
161            data: None,
162        },
163    }
164}