Skip to main content

systemprompt_cli/commands/shared/
mcp_tools.rs

1//! MCP tool probing shared by `admin agents tools` and `plugins mcp tools`.
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::{ClientCapabilities, ClientInfo, Implementation};
9use rmcp::transport::streamable_http_client::{
10    StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
11};
12use std::time::Duration;
13use systemprompt_identifiers::{AgentName, ContextId, SessionId, SessionToken, TraceId};
14use systemprompt_mcp::McpServerConfig;
15use systemprompt_mcp::services::SpawnTarget;
16use systemprompt_mcp::services::client::HttpClientWithContext;
17use systemprompt_models::execution::context::RequestContext;
18use tokio::time::timeout;
19use tracing::debug;
20
21fn probe_context(server_name: &str) -> RequestContext {
22    RequestContext::new(
23        SessionId::new(format!("cli-{server_name}")),
24        TraceId::generate(),
25        ContextId::derived_from_cli_probe(server_name),
26        AgentName::system(),
27    )
28}
29
30#[derive(Debug)]
31pub struct ToolInfo {
32    pub name: String,
33    pub description: Option<String>,
34    pub parameters_count: usize,
35    pub input_schema: Option<serde_json::Value>,
36    pub output_schema: Option<serde_json::Value>,
37}
38
39// Why: an internal server is spawned on a local port and reached there
40// directly; an external one is never spawned and is reached at its endpoint.
41pub fn direct_url(server: &McpServerConfig) -> Result<String> {
42    if server.is_external() {
43        return Ok(server.remote_endpoint.clone());
44    }
45    Ok(format!("http://127.0.0.1:{}/mcp", server.spawn_port()?))
46}
47
48pub async fn list_tools_unauthenticated(
49    server_name: &str,
50    url: &str,
51    timeout_secs: u64,
52) -> Result<Vec<ToolInfo>> {
53    let config = StreamableHttpClientTransportConfig::with_uri(url);
54    let transport = StreamableHttpClientTransport::with_client(
55        HttpClientWithContext::new(probe_context(server_name))?,
56        config,
57    );
58
59    let client_info = ClientInfo::new(
60        ClientCapabilities::default(),
61        Implementation::new(format!("systemprompt-cli-{}", server_name), "1.0.0"),
62    );
63
64    let client = timeout(
65        Duration::from_secs(timeout_secs),
66        client_info.serve(transport),
67    )
68    .await
69    .context("Connection timeout")?
70    .context("Failed to connect to MCP server")?;
71
72    let tools_response = client
73        // Why: rmcp serialises a `None` params as `"params": null`, which a
74        // strict server (Google's MCP) refuses with -32602; send `{}`.
75        .list_tools(Some(rmcp::model::PaginatedRequestParams::default()))
76        .await
77        .context("Failed to list tools")?;
78
79    let tools: Vec<ToolInfo> = tools_response
80        .tools
81        .into_iter()
82        .map(convert_tool_info)
83        .collect();
84
85    client.cancel().await?;
86    Ok(tools)
87}
88
89pub async fn list_tools_authenticated(
90    server_name: &str,
91    url: &str,
92    token: &SessionToken,
93    timeout_secs: u64,
94) -> Result<Vec<ToolInfo>> {
95    let config =
96        StreamableHttpClientTransportConfig::with_uri(url).auth_header(token.as_str().to_owned());
97    let context = probe_context(server_name).with_auth_token(token.as_str());
98    let transport =
99        StreamableHttpClientTransport::with_client(HttpClientWithContext::new(context)?, config);
100
101    let client_info = ClientInfo::new(
102        ClientCapabilities::default(),
103        Implementation::new(format!("systemprompt-cli-{}", server_name), "1.0.0"),
104    );
105
106    let client = timeout(
107        Duration::from_secs(timeout_secs),
108        client_info.serve(transport),
109    )
110    .await
111    .context("Connection timeout")?
112    .context("Failed to connect to MCP server")?;
113
114    let tools_response = client
115        // Why: rmcp serialises a `None` params as `"params": null`, which a
116        // strict server (Google's MCP) refuses with -32602; send `{}`.
117        .list_tools(Some(rmcp::model::PaginatedRequestParams::default()))
118        .await
119        .context("Failed to list tools")?;
120
121    let tools: Vec<ToolInfo> = tools_response
122        .tools
123        .into_iter()
124        .map(convert_tool_info)
125        .collect();
126
127    client.cancel().await?;
128    Ok(tools)
129}
130
131fn convert_tool_info(tool: rmcp::model::Tool) -> ToolInfo {
132    let input_schema = match serde_json::to_value(&tool.input_schema) {
133        Ok(schema) => Some(schema),
134        Err(e) => {
135            debug!(tool = %tool.name, error = %e, "Failed to serialize input schema");
136            None
137        },
138    };
139    let output_schema =
140        tool.output_schema
141            .as_ref()
142            .and_then(|s| match serde_json::to_value(s.as_ref()) {
143                Ok(schema) => Some(schema),
144                Err(e) => {
145                    debug!(tool = %tool.name, error = %e, "Failed to serialize output schema");
146                    None
147                },
148            });
149    let parameters_count = input_schema
150        .as_ref()
151        .and_then(|s| s.get("properties"))
152        .and_then(|p| p.as_object())
153        .map_or(0, serde_json::Map::len);
154
155    ToolInfo {
156        name: tool.name.to_string(),
157        description: tool.description.map(|d| d.to_string()),
158        parameters_count,
159        input_schema,
160        output_schema,
161    }
162}