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