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.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use serde::Deserialize;
13use serde_json::Value;
14
15const TOOLS_CALL_METHOD: &str = "tools/call";
16
17#[derive(Deserialize)]
18struct RequestFrame {
19    #[serde(default)]
20    id: Option<Value>,
21    method: String,
22    #[serde(default)]
23    params: Option<ToolCallParams>,
24}
25
26#[derive(Deserialize)]
27struct ToolCallParams {
28    name: String,
29    #[serde(default)]
30    arguments: Option<Value>,
31}
32
33#[derive(Debug)]
34pub struct ToolCallInvocation {
35    pub id: Value,
36    pub tool_name: String,
37    pub arguments: Value,
38}
39
40pub fn parse_tool_call(body: &[u8]) -> Option<ToolCallInvocation> {
41    let frame: RequestFrame = serde_json::from_slice(body).ok()?;
42    if frame.method != TOOLS_CALL_METHOD {
43        return None;
44    }
45    let params = frame.params?;
46    Some(ToolCallInvocation {
47        id: frame.id.unwrap_or(Value::Null),
48        tool_name: params.name,
49        arguments: params.arguments.unwrap_or(Value::Null),
50    })
51}
52
53#[derive(Deserialize)]
54struct ResponseFrame {
55    #[serde(default)]
56    id: Option<Value>,
57    #[serde(default)]
58    result: Option<ToolCallResult>,
59    #[serde(default)]
60    error: Option<Value>,
61}
62
63#[derive(Deserialize)]
64struct ToolCallResult {
65    #[serde(default, rename = "isError")]
66    is_error: bool,
67    #[serde(default, rename = "structuredContent")]
68    structured_content: Option<Value>,
69    #[serde(default)]
70    content: Option<Value>,
71}
72
73#[derive(Debug)]
74pub struct ToolCallOutcome {
75    pub output: Option<Value>,
76    pub error_message: Option<String>,
77}
78
79pub fn parse_response_frame(data: &str, request_id: &Value) -> Option<ToolCallOutcome> {
80    let frame: ResponseFrame = serde_json::from_str(data).ok()?;
81    if frame.id.as_ref() != Some(request_id) {
82        return None;
83    }
84    if let Some(error) = frame.error {
85        return Some(ToolCallOutcome {
86            error_message: Some(error.to_string()),
87            output: Some(error),
88        });
89    }
90    let result = frame.result?;
91    let output = result.structured_content.or(result.content);
92    let error_message = result
93        .is_error
94        .then(|| "MCP tool call returned isError".to_owned());
95    Some(ToolCallOutcome {
96        output,
97        error_message,
98    })
99}
100
101pub fn extract_sse_data(frame: &str) -> Option<String> {
102    let mut data = String::new();
103    for line in frame.lines() {
104        if let Some(rest) = line.strip_prefix("data:") {
105            if !data.is_empty() {
106                data.push('\n');
107            }
108            data.push_str(rest.trim_start());
109        }
110    }
111    (!data.is_empty()).then_some(data)
112}