Skip to main content

typesec_agent/interop/
mcp.rs

1//! Model Context Protocol dialect: JSON-RPC `tools/call` requests in,
2//! `isError` tool results out.
3//!
4//! MCP is the common tool bus emerging across agent hosts (Claude, IDEs,
5//! OpenAI-compatible runtimes). Guarding it guards every server behind it —
6//! including tools you don't control. The `typesec mcp-gate` CLI subcommand
7//! builds a full stdio proxy on this codec.
8
9use serde_json::{Value, json};
10
11use super::call::{GuardedToolCall, InteropError, ToolCallRequest};
12use super::wire;
13
14/// Dialect name used in error messages and the Python bindings.
15pub const DIALECT: &str = "mcp";
16
17/// Parse `tools/call` invocations from a single JSON-RPC request object or a
18/// bare array of requests. Requests with other methods (initialize,
19/// tools/list, notifications) yield no calls — they are not errors.
20pub fn parse_tool_calls(payload: &Value) -> Result<Vec<ToolCallRequest>, InteropError> {
21    let requests: &[Value] = match payload {
22        Value::Array(items) => items,
23        Value::Object(_) => std::slice::from_ref(payload),
24        _ => {
25            return Err(InteropError::malformed(
26                DIALECT,
27                "expected a JSON-RPC request object or an array of them",
28            ));
29        }
30    };
31    requests
32        .iter()
33        .filter(|request| is_tools_call(request))
34        .map(parse_request)
35        .collect()
36}
37
38/// `true` if this JSON-RPC message is a `tools/call` request.
39pub fn is_tools_call(message: &Value) -> bool {
40    message.get("method").and_then(Value::as_str) == Some("tools/call")
41}
42
43fn parse_request(request: &Value) -> Result<ToolCallRequest, InteropError> {
44    let params = request
45        .get("params")
46        .ok_or_else(|| InteropError::malformed(DIALECT, "tools/call request has no 'params'"))?;
47    let name = wire::required_str(params, "name", DIALECT)?;
48    let arguments = wire::parse_arguments(params.get("arguments"), DIALECT, name)?;
49    let mut call = ToolCallRequest::new(name, arguments);
50    if let Some(id) = request.get("id").filter(|id| !id.is_null()) {
51        // JSON-RPC ids may be numbers or strings; normalize to the string form
52        // (`denial` restores numeric ids, `denial_with_id` echoes exactly).
53        call = call.with_call_id(match id {
54            Value::String(s) => s.clone(),
55            other => other.to_string(),
56        });
57    }
58    Ok(call)
59}
60
61/// Filter a `tools/list` result (`{"tools": [...]}` or a bare array) down to
62/// the definitions `keep` accepts, returning the same shape it was given.
63pub fn filter_tools(payload: &Value, keep: &dyn Fn(&str) -> bool) -> Value {
64    let name_of = |item: &Value| wire::optional_str(item, "name");
65    match payload.get("tools") {
66        Some(tools) => {
67            let mut result = payload.clone();
68            result["tools"] = wire::filter_tool_array(tools, name_of, keep);
69            result
70        }
71        None => wire::filter_tool_array(payload, name_of, keep),
72    }
73}
74
75/// Render a denied/undecided call as a complete JSON-RPC *response* carrying
76/// an MCP tool result with `isError: true`, so the client receives the
77/// refusal as tool output. A `call_id` that parses as an integer is restored
78/// to a numeric id; use [`denial_with_id`] to echo the original id exactly.
79/// Returns `None` for allowed calls.
80pub fn denial(call: &GuardedToolCall) -> Option<Value> {
81    let id = match &call.request.call_id {
82        Some(raw) => raw
83            .parse::<i64>()
84            .map_or_else(|_| json!(raw), |number| json!(number)),
85        None => Value::Null,
86    };
87    denial_with_id(call, id)
88}
89
90/// Like [`denial`], but echoing the JSON-RPC `id` verbatim — the form a proxy
91/// that still holds the original request should use.
92pub fn denial_with_id(call: &GuardedToolCall, id: Value) -> Option<Value> {
93    call.denial_message().map(|text| {
94        json!({
95            "jsonrpc": "2.0",
96            "id": id,
97            "result": {
98                "content": [{"type": "text", "text": text}],
99                "isError": true,
100            },
101        })
102    })
103}