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