Skip to main content

systemprompt_api/services/proxy/audit/
jsonrpc.rs

1//! Minimal JSON-RPC / MCP frame parsing for the tool-call audit tap.
2//!
3//! The gateway forwards MCP frames verbatim; to audit a `tools/call` it parses
4//! the tool name and arguments from the request and the result from the
5//! response, matching them by JSON-RPC id. The `arguments`, `result`, and
6//! `content` payloads are `serde_json::Value` because MCP defines them as
7//! open-shaped at the wire boundary. The matching response frame is also
8//! stamped with the execution id the tap minted, under the systemprompt
9//! `_meta` key, so a client that reports the result later carries the exact
10//! server key. A result counts as failed when it is a JSON-RPC error, sets
11//! `isError`, or carries a top-level `error` key in its body (structured
12//! content, or a text block holding a JSON object): hosted servers report
13//! tool-level failures that way without the protocol flag.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use serde::Deserialize;
19use serde_json::{Value, json};
20use systemprompt_identifiers::{McpExecutionId, McpToolName};
21use systemprompt_models::artifacts::EXECUTION_META_KEY;
22
23const TOOLS_CALL_METHOD: &str = "tools/call";
24
25#[derive(Deserialize)]
26struct RequestFrame {
27    // JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
28    #[serde(default)]
29    id: Option<Value>,
30    method: String,
31    #[serde(default)]
32    params: Option<ToolCallParams>,
33}
34
35#[derive(Deserialize)]
36struct ToolCallParams {
37    // JSON: MCP JSON-RPC — the client-supplied tool name, validated below so a
38    // malformed one is classified rather than dropped as "not a tool call".
39    #[serde(default)]
40    name: Option<Value>,
41    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
42    #[serde(default)]
43    arguments: Option<Value>,
44}
45
46/// What a forwarded request frame is, as far as tool-call governance and
47/// audit are concerned.
48#[derive(Debug)]
49pub enum ToolCallFrame {
50    NotToolCall,
51    Call(ToolCallInvocation),
52    InvalidName { raw_name: Option<String> },
53}
54
55#[derive(Debug)]
56pub struct ToolCallInvocation {
57    // JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
58    pub id: Value,
59    pub tool_name: McpToolName,
60    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
61    pub arguments: Value,
62}
63
64pub fn parse_tool_call(body: &[u8]) -> Option<ToolCallInvocation> {
65    match classify_tool_call(body) {
66        ToolCallFrame::Call(invocation) => Some(invocation),
67        ToolCallFrame::NotToolCall | ToolCallFrame::InvalidName { .. } => None,
68    }
69}
70
71pub fn classify_tool_call(body: &[u8]) -> ToolCallFrame {
72    let Ok(frame) = serde_json::from_slice::<RequestFrame>(body) else {
73        return ToolCallFrame::NotToolCall;
74    };
75    if frame.method != TOOLS_CALL_METHOD {
76        return ToolCallFrame::NotToolCall;
77    }
78    let Some(params) = frame.params else {
79        return ToolCallFrame::InvalidName { raw_name: None };
80    };
81    let raw_name = match params.name {
82        Some(Value::String(name)) => name,
83        Some(other) => {
84            return ToolCallFrame::InvalidName {
85                raw_name: Some(other.to_string()),
86            };
87        },
88        None => return ToolCallFrame::InvalidName { raw_name: None },
89    };
90    match McpToolName::try_new(raw_name.as_str()) {
91        Ok(tool_name) => ToolCallFrame::Call(ToolCallInvocation {
92            id: frame.id.unwrap_or(Value::Null),
93            tool_name,
94            arguments: params.arguments.unwrap_or(Value::Null),
95        }),
96        Err(_) => ToolCallFrame::InvalidName {
97            raw_name: Some(raw_name),
98        },
99    }
100}
101
102#[derive(Deserialize)]
103struct ResponseFrame {
104    #[serde(default)]
105    result: Option<ToolCallResult>,
106    // JSON: MCP JSON-RPC `error` object — `data` is server-defined.
107    #[serde(default)]
108    error: Option<Value>,
109}
110
111#[derive(Deserialize)]
112struct ToolCallResult {
113    #[serde(default, rename = "isError")]
114    is_error: bool,
115    #[serde(default, rename = "structuredContent")]
116    structured_content: Option<Value>,
117    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
118    #[serde(default)]
119    content: Option<Value>,
120}
121
122#[derive(Debug)]
123pub struct ToolCallOutcome {
124    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
125    pub output: Option<Value>,
126    pub error_message: Option<String>,
127    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
128    pub result: Option<Value>,
129}
130
131// JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
132pub fn parse_response_frame(data: &str, request_id: &Value) -> Option<ToolCallOutcome> {
133    let frame: Value = serde_json::from_str(data).ok()?;
134    if frame.get("id") != Some(request_id) {
135        return None;
136    }
137    let parsed: ResponseFrame = serde_json::from_value(frame.clone()).ok()?;
138    if let Some(error) = parsed.error {
139        return Some(ToolCallOutcome {
140            error_message: Some(error.to_string()),
141            output: Some(error),
142            result: None,
143        });
144    }
145    let result = parsed.result?;
146    let output = result.structured_content.or(result.content);
147    let error_message = if result.is_error {
148        Some("MCP tool call returned isError".to_owned())
149    } else {
150        output
151            .as_ref()
152            .and_then(body_error)
153            .map(|error| format!("MCP tool result reported an error: {error}"))
154    };
155    Some(ToolCallOutcome {
156        output,
157        error_message,
158        result: frame.get("result").cloned(),
159    })
160}
161
162// JSON: MCP `tools/call` output — structured content or the content blocks.
163fn body_error(output: &Value) -> Option<String> {
164    let reported = |body: &Value| -> Option<String> {
165        match body.as_object()?.get("error")? {
166            Value::Null | Value::Bool(false) => None,
167            Value::String(text) => Some(text.clone()),
168            other => Some(other.to_string()),
169        }
170    };
171    match output {
172        Value::Array(blocks) => blocks.iter().find_map(|block| {
173            let text = block.get("text")?.as_str()?;
174            reported(&serde_json::from_str::<Value>(text).ok()?)
175        }),
176        body => reported(body),
177    }
178}
179
180// JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
181pub fn frame_matches(data: &str, request_id: &Value) -> bool {
182    serde_json::from_str::<Value>(data)
183        .ok()
184        .is_some_and(|frame| frame.get("id") == Some(request_id))
185}
186
187pub fn stamp_execution(data: &str, mcp_execution_id: &McpExecutionId) -> Option<String> {
188    let mut frame: Value = serde_json::from_str(data).ok()?;
189    let result = frame.get_mut("result")?.as_object_mut()?;
190    let meta = result
191        .entry("_meta")
192        .or_insert_with(|| json!({}))
193        .as_object_mut()?;
194    let execution = meta
195        .entry(EXECUTION_META_KEY)
196        .or_insert_with(|| json!({}))
197        .as_object_mut()?;
198    execution
199        .entry("mcp_execution_id")
200        .or_insert_with(|| Value::String(mcp_execution_id.to_string()));
201    serde_json::to_string(&frame).ok()
202}
203
204pub fn extract_sse_data(frame: &str) -> Option<String> {
205    let mut data = String::new();
206    for line in frame.lines() {
207        if let Some(rest) = line.strip_prefix("data:") {
208            if !data.is_empty() {
209                data.push('\n');
210            }
211            data.push_str(rest.trim_start());
212        }
213    }
214    (!data.is_empty()).then_some(data)
215}
216
217pub fn replace_sse_data(frame: &str, data: &str) -> String {
218    let mut out = String::with_capacity(frame.len() + data.len());
219    let mut wrote = false;
220    for line in frame.trim_end_matches('\n').lines() {
221        if line.starts_with("data:") {
222            if !wrote {
223                out.push_str("data: ");
224                out.push_str(data);
225                out.push('\n');
226                wrote = true;
227            }
228            continue;
229        }
230        out.push_str(line);
231        out.push('\n');
232    }
233    if !wrote {
234        out.push_str("data: ");
235        out.push_str(data);
236        out.push('\n');
237    }
238    out.push('\n');
239    out
240}