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