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        .list_tools(None)
74        .await
75        .context("Failed to list tools")?;
76
77    let tools: Vec<ToolInfo> = tools_response
78        .tools
79        .into_iter()
80        .map(convert_tool_info)
81        .collect();
82
83    client.cancel().await?;
84    Ok(tools)
85}
86
87pub async fn list_tools_authenticated(
88    server_name: &str,
89    url: &str,
90    token: &SessionToken,
91    timeout_secs: u64,
92) -> Result<Vec<ToolInfo>> {
93    let config =
94        StreamableHttpClientTransportConfig::with_uri(url).auth_header(token.as_str().to_owned());
95    let context = probe_context(server_name).with_auth_token(token.as_str());
96    let transport =
97        StreamableHttpClientTransport::with_client(HttpClientWithContext::new(context)?, config);
98
99    let client_info = ClientInfo::new(
100        ClientCapabilities::default(),
101        Implementation::new(format!("systemprompt-cli-{}", server_name), "1.0.0"),
102    );
103
104    let client = timeout(
105        Duration::from_secs(timeout_secs),
106        client_info.serve(transport),
107    )
108    .await
109    .context("Connection timeout")?
110    .context("Failed to connect to MCP server")?;
111
112    let tools_response = client
113        .list_tools(None)
114        .await
115        .context("Failed to list tools")?;
116
117    let tools: Vec<ToolInfo> = tools_response
118        .tools
119        .into_iter()
120        .map(convert_tool_info)
121        .collect();
122
123    client.cancel().await?;
124    Ok(tools)
125}
126
127fn convert_tool_info(tool: rmcp::model::Tool) -> ToolInfo {
128    let input_schema = match serde_json::to_value(&tool.input_schema) {
129        Ok(schema) => Some(schema),
130        Err(e) => {
131            debug!(tool = %tool.name, error = %e, "Failed to serialize input schema");
132            None
133        },
134    };
135    let output_schema =
136        tool.output_schema
137            .as_ref()
138            .and_then(|s| match serde_json::to_value(s.as_ref()) {
139                Ok(schema) => Some(schema),
140                Err(e) => {
141                    debug!(tool = %tool.name, error = %e, "Failed to serialize output schema");
142                    None
143                },
144            });
145    let parameters_count = input_schema
146        .as_ref()
147        .and_then(|s| s.get("properties"))
148        .and_then(|p| p.as_object())
149        .map_or(0, serde_json::Map::len);
150
151    ToolInfo {
152        name: tool.name.to_string(),
153        description: tool.description.map(|d| d.to_string()),
154        parameters_count,
155        input_schema,
156        output_schema,
157    }
158}