mcp: execute stdio tool calls
This commit is contained in:
@@ -99,6 +99,51 @@ pub struct ListToolsResult {
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolRequest {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
impl CallToolRequest {
|
||||
pub fn new(name: impl Into<String>, arguments: Value) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolResult {
|
||||
#[serde(default)]
|
||||
pub content: Vec<McpContentBlock>,
|
||||
#[serde(default)]
|
||||
pub structured_content: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub is_error: bool,
|
||||
#[serde(default, rename = "_meta")]
|
||||
pub meta: Option<Value>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
/// One untrusted MCP `tools/call` content block.
|
||||
///
|
||||
/// The `type` discriminator is kept explicit and all server-owned fields stay
|
||||
/// data in `fields`; this crate does not turn rich MCP content into hidden host
|
||||
/// context.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
#[serde(flatten)]
|
||||
pub fields: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
/// A resolved, explicit local stdio MCP server process specification.
|
||||
#[derive(Clone)]
|
||||
pub struct McpStdioServerSpec {
|
||||
@@ -426,6 +471,25 @@ impl McpStdioClient {
|
||||
self.request(McpPhase::Running, "tools/list", params).await
|
||||
}
|
||||
|
||||
/// Execute an initialized MCP `tools/call` request.
|
||||
///
|
||||
/// The caller is responsible for applying Yoi tool permissions before this
|
||||
/// method is reached and for bounding/serializing the untrusted result before
|
||||
/// it is exposed to model-visible tool history.
|
||||
pub async fn call_tool(
|
||||
&mut self,
|
||||
request: CallToolRequest,
|
||||
) -> Result<CallToolResult, McpClientError> {
|
||||
let params = serde_json::to_value(request).map_err(|err| {
|
||||
McpClientError::new(
|
||||
&self.server_name,
|
||||
McpPhase::Running,
|
||||
McpErrorKind::Protocol(format!("failed to serialize tools/call request: {err}")),
|
||||
)
|
||||
})?;
|
||||
self.request(McpPhase::Running, "tools/call", params).await
|
||||
}
|
||||
|
||||
/// Request pages from `tools/list` up to a host-supplied page/tool bound.
|
||||
///
|
||||
/// Bounds are enforced by the host so a server cannot make startup discovery
|
||||
|
||||
+92
-1
@@ -10,7 +10,10 @@ fn main() {
|
||||
match mode.as_str() {
|
||||
"success" => success(),
|
||||
"tools" => tools_list(),
|
||||
"tools-call-forbidden" => tools_list(),
|
||||
"tools-call-normal" => tools_call_normal(),
|
||||
"tools-call-is-error" => tools_call_is_error(),
|
||||
"tools-call-protocol-error" => tools_call_protocol_error(),
|
||||
"tools-call-forbidden" => tools_call_forbidden(),
|
||||
"fail-init" => fail_init(),
|
||||
"sampling" => sampling_request(),
|
||||
"shutdown-hang" => shutdown_hang(),
|
||||
@@ -96,6 +99,94 @@ fn tools_list() {
|
||||
}
|
||||
}
|
||||
|
||||
fn tools_call_normal() {
|
||||
tools_call(|request| {
|
||||
assert_eq!(request["params"]["name"], "search-files");
|
||||
assert_eq!(request["params"]["arguments"]["query"], "needle");
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": "found needle"}],
|
||||
"structuredContent": {"matches": ["needle.rs"]},
|
||||
"_meta": {"server": "mock"}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn tools_call_is_error() {
|
||||
tools_call(|request| {
|
||||
assert_eq!(request["params"]["name"], "search-files");
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"result": {
|
||||
"isError": true,
|
||||
"content": [{"type": "text", "text": "tool-level failure"}]
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn tools_call_protocol_error() {
|
||||
tools_call(|request| {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"error": {"code": -32010, "message": "server refused tools/call"}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn tools_call_forbidden() {
|
||||
let init = read_json();
|
||||
assert_eq!(init["method"], "initialize");
|
||||
write_json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": init["id"],
|
||||
"result": initialize_result(),
|
||||
}));
|
||||
let initialized = read_json();
|
||||
assert_eq!(initialized["method"], "notifications/initialized");
|
||||
|
||||
loop {
|
||||
let request = read_json();
|
||||
assert_ne!(
|
||||
request["method"], "tools/call",
|
||||
"permission denial path must not send MCP tools/call"
|
||||
);
|
||||
if request["method"] == "shutdown" {
|
||||
write_json(json!({"jsonrpc":"2.0", "id": request["id"], "result": {}}));
|
||||
let notification = read_json();
|
||||
assert_eq!(notification["method"], "exit");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tools_call(response: impl FnOnce(&Value) -> Value) {
|
||||
let init = read_json();
|
||||
assert_eq!(init["method"], "initialize");
|
||||
write_json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": init["id"],
|
||||
"result": initialize_result(),
|
||||
}));
|
||||
let initialized = read_json();
|
||||
assert_eq!(initialized["method"], "notifications/initialized");
|
||||
|
||||
let call = read_json();
|
||||
assert_eq!(call["method"], "tools/call");
|
||||
write_json(response(&call));
|
||||
|
||||
let shutdown = read_json();
|
||||
assert_eq!(shutdown["method"], "shutdown");
|
||||
write_json(json!({"jsonrpc":"2.0", "id": shutdown["id"], "result": {}}));
|
||||
let notification = read_json();
|
||||
assert_eq!(notification["method"], "exit");
|
||||
}
|
||||
|
||||
fn fail_init() {
|
||||
let secret = env::var("MCP_TEST_SECRET").unwrap_or_default();
|
||||
for idx in 0..5 {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use mcp::stdio::{
|
||||
McpErrorKind, McpPhase, McpStdioClient, McpStdioLimits, McpStdioServerSpec, McpToolListLimits,
|
||||
CallToolRequest, McpErrorKind, McpPhase, McpStdioClient, McpStdioLimits, McpStdioServerSpec,
|
||||
McpToolListLimits,
|
||||
};
|
||||
|
||||
fn mock_server(mode: &str) -> McpStdioServerSpec {
|
||||
@@ -161,6 +162,74 @@ async fn initialize_failure_reports_server_phase_and_redacted_bounded_stderr() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_tool_returns_normal_result() {
|
||||
let mut client = McpStdioClient::connect(mock_server("tools-call-normal"), tight_limits())
|
||||
.await
|
||||
.expect("connect");
|
||||
let result = client
|
||||
.call_tool(CallToolRequest::new(
|
||||
"search-files",
|
||||
serde_json::json!({"query": "needle"}),
|
||||
))
|
||||
.await
|
||||
.expect("call tool");
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.content.len(), 1);
|
||||
assert_eq!(result.content[0].kind, "text");
|
||||
assert_eq!(result.content[0].fields["text"], "found needle");
|
||||
assert_eq!(
|
||||
result.structured_content.as_ref().unwrap()["matches"][0],
|
||||
"needle.rs"
|
||||
);
|
||||
assert_eq!(result.meta.as_ref().unwrap()["server"], "mock");
|
||||
client.shutdown().await.expect("shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_tool_preserves_mcp_is_error_result() {
|
||||
let mut client = McpStdioClient::connect(mock_server("tools-call-is-error"), tight_limits())
|
||||
.await
|
||||
.expect("connect");
|
||||
let result = client
|
||||
.call_tool(CallToolRequest::new(
|
||||
"search-files",
|
||||
serde_json::json!({"query": "needle"}),
|
||||
))
|
||||
.await
|
||||
.expect("call tool");
|
||||
assert!(result.is_error);
|
||||
assert_eq!(result.content[0].fields["text"], "tool-level failure");
|
||||
client.shutdown().await.expect("shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_tool_reports_json_rpc_protocol_error_distinctly() {
|
||||
let mut client =
|
||||
McpStdioClient::connect(mock_server("tools-call-protocol-error"), tight_limits())
|
||||
.await
|
||||
.expect("connect");
|
||||
let err = client
|
||||
.call_tool(CallToolRequest::new(
|
||||
"search-files",
|
||||
serde_json::json!({"query": "needle"}),
|
||||
))
|
||||
.await
|
||||
.expect_err("protocol error");
|
||||
assert!(matches!(err.kind, McpErrorKind::JsonRpcError { .. }));
|
||||
client.shutdown().await.expect("shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_denial_style_shutdown_sends_no_tools_call() {
|
||||
let mut client = McpStdioClient::connect(mock_server("tools-call-forbidden"), tight_limits())
|
||||
.await
|
||||
.expect("connect");
|
||||
// This mirrors Worker pre-tool-call denial: the ordinary Tool execution body
|
||||
// is never entered, so the MCP server sees lifecycle shutdown but no call.
|
||||
client.shutdown().await.expect("shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_terminates_or_kills_uncooperative_server() {
|
||||
let mut client = McpStdioClient::connect(mock_server("shutdown-hang"), tight_limits())
|
||||
|
||||
Reference in New Issue
Block a user