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 port: u16,
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        port,
37        tool_name,
38        arguments,
39        session_ctx,
40        timeout_secs,
41    } = params;
42    let url = format!("http://127.0.0.1:{}/mcp", port);
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.as_str())
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(tool_name.to_owned());
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: &str,
83    port: u16,
84    session_ctx: &CliSessionContext,
85    timeout_secs: u64,
86) -> Result<Vec<String>> {
87    let url = format!("http://127.0.0.1:{}/mcp", port);
88
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.as_str())
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 = ClientInfo::new(
98        ClientCapabilities::default(),
99        Implementation::new(format!("systemprompt-cli-{}", server_name), "1.0.0"),
100    );
101
102    let client = timeout(
103        Duration::from_secs(timeout_secs),
104        client_info.serve(transport),
105    )
106    .await
107    .context("Connection timeout")?
108    .context("Failed to connect to MCP server")?;
109
110    let tools_response = client
111        .list_tools(None)
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}