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.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use serde::Deserialize;
16use serde_json::{Value, json};
17use systemprompt_identifiers::{McpExecutionId, McpToolName};
18use systemprompt_models::artifacts::EXECUTION_META_KEY;
19
20const TOOLS_CALL_METHOD: &str = "tools/call";
21
22#[derive(Deserialize)]
23struct RequestFrame {
24    // JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
25    #[serde(default)]
26    id: Option<Value>,
27    method: String,
28    #[serde(default)]
29    params: Option<ToolCallParams>,
30}
31
32#[derive(Deserialize)]
33struct ToolCallParams {
34    // JSON: MCP JSON-RPC — the client-supplied tool name, validated below so a
35    // malformed one is classified rather than dropped as "not a tool call".
36    #[serde(default)]
37    name: Option<Value>,
38    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
39    #[serde(default)]
40    arguments: Option<Value>,
41}
42
43/// What a forwarded request frame is, as far as tool-call governance and
44/// audit are concerned.
45#[derive(Debug)]
46pub enum ToolCallFrame {
47    NotToolCall,
48    Call(ToolCallInvocation),
49    InvalidName { raw_name: Option<String> },
50}
51
52#[derive(Debug)]
53pub struct ToolCallInvocation {
54    // JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
55    pub id: Value,
56    pub tool_name: McpToolName,
57    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
58    pub arguments: Value,
59}
60
61pub fn parse_tool_call(body: &[u8]) -> Option<ToolCallInvocation> {
62    match classify_tool_call(body) {
63        ToolCallFrame::Call(invocation) => Some(invocation),
64        ToolCallFrame::NotToolCall | ToolCallFrame::InvalidName { .. } => None,
65    }
66}
67
68pub fn classify_tool_call(body: &[u8]) -> ToolCallFrame {
69    let Ok(frame) = serde_json::from_slice::<RequestFrame>(body) else {
70        return ToolCallFrame::NotToolCall;
71    };
72    if frame.method != TOOLS_CALL_METHOD {
73        return ToolCallFrame::NotToolCall;
74    }
75    let Some(params) = frame.params else {
76        return ToolCallFrame::InvalidName { raw_name: None };
77    };
78    let raw_name = match params.name {
79        Some(Value::String(name)) => name,
80        Some(other) => {
81            return ToolCallFrame::InvalidName {
82                raw_name: Some(other.to_string()),
83            };
84        },
85        None => return ToolCallFrame::InvalidName { raw_name: None },
86    };
87    match McpToolName::try_new(raw_name.as_str()) {
88        Ok(tool_name) => ToolCallFrame::Call(ToolCallInvocation {
89            id: frame.id.unwrap_or(Value::Null),
90            tool_name,
91            arguments: params.arguments.unwrap_or(Value::Null),
92        }),
93        Err(_) => ToolCallFrame::InvalidName {
94            raw_name: Some(raw_name),
95        },
96    }
97}
98
99#[derive(Deserialize)]
100struct ResponseFrame {
101    #[serde(default)]
102    result: Option<ToolCallResult>,
103    // JSON: MCP JSON-RPC `error` object — `data` is server-defined.
104    #[serde(default)]
105    error: Option<Value>,
106}
107
108#[derive(Deserialize)]
109struct ToolCallResult {
110    #[serde(default, rename = "isError")]
111    is_error: bool,
112    #[serde(default, rename = "structuredContent")]
113    structured_content: Option<Value>,
114    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
115    #[serde(default)]
116    content: Option<Value>,
117}
118
119#[derive(Debug)]
120pub struct ToolCallOutcome {
121    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
122    pub output: Option<Value>,
123    pub error_message: Option<String>,
124    // JSON: MCP JSON-RPC — open-shaped `tools/call` payload per the MCP spec.
125    pub result: Option<Value>,
126}
127
128// JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
129pub fn parse_response_frame(data: &str, request_id: &Value) -> Option<ToolCallOutcome> {
130    let frame: Value = serde_json::from_str(data).ok()?;
131    if frame.get("id") != Some(request_id) {
132        return None;
133    }
134    let parsed: ResponseFrame = serde_json::from_value(frame.clone()).ok()?;
135    if let Some(error) = parsed.error {
136        return Some(ToolCallOutcome {
137            error_message: Some(error.to_string()),
138            output: Some(error),
139            result: None,
140        });
141    }
142    let result = parsed.result?;
143    let output = result.structured_content.or(result.content);
144    let error_message = result
145        .is_error
146        .then(|| "MCP tool call returned isError".to_owned());
147    Some(ToolCallOutcome {
148        output,
149        error_message,
150        result: frame.get("result").cloned(),
151    })
152}
153
154// JSON: MCP JSON-RPC `id` — string or number per JSON-RPC 2.0.
155pub fn frame_matches(data: &str, request_id: &Value) -> bool {
156    serde_json::from_str::<Value>(data)
157        .ok()
158        .is_some_and(|frame| frame.get("id") == Some(request_id))
159}
160
161pub fn stamp_execution(data: &str, mcp_execution_id: &McpExecutionId) -> Option<String> {
162    let mut frame: Value = serde_json::from_str(data).ok()?;
163    let result = frame.get_mut("result")?.as_object_mut()?;
164    let meta = result
165        .entry("_meta")
166        .or_insert_with(|| json!({}))
167        .as_object_mut()?;
168    let execution = meta
169        .entry(EXECUTION_META_KEY)
170        .or_insert_with(|| json!({}))
171        .as_object_mut()?;
172    execution
173        .entry("mcp_execution_id")
174        .or_insert_with(|| Value::String(mcp_execution_id.to_string()));
175    serde_json::to_string(&frame).ok()
176}
177
178pub fn extract_sse_data(frame: &str) -> Option<String> {
179    let mut data = String::new();
180    for line in frame.lines() {
181        if let Some(rest) = line.strip_prefix("data:") {
182            if !data.is_empty() {
183                data.push('\n');
184            }
185            data.push_str(rest.trim_start());
186        }
187    }
188    (!data.is_empty()).then_some(data)
189}
190
191pub fn replace_sse_data(frame: &str, data: &str) -> String {
192    let mut out = String::with_capacity(frame.len() + data.len());
193    let mut wrote = false;
194    for line in frame.trim_end_matches('\n').lines() {
195        if line.starts_with("data:") {
196            if !wrote {
197                out.push_str("data: ");
198                out.push_str(data);
199                out.push('\n');
200                wrote = true;
201            }
202            continue;
203        }
204        out.push_str(line);
205        out.push('\n');
206    }
207    if !wrote {
208        out.push_str("data: ");
209        out.push_str(data);
210        out.push('\n');
211    }
212    out.push('\n');
213    out
214}