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