Skip to main content

assay_core/mcp/
parser.rs

1use crate::mcp::types::*;
2use anyhow::{Context, Result};
3
4/// Parse MCP transcript file contents into normalized McpEvents.
5pub fn parse_mcp_transcript(text: &str, format: McpInputFormat) -> Result<Vec<McpEvent>> {
6    match format {
7        McpInputFormat::JsonRpc => parse_jsonrpc_jsonl(text),
8        McpInputFormat::Inspector => parse_inspector_best_effort(text),
9    }
10}
11
12fn parse_jsonrpc_jsonl(text: &str) -> Result<Vec<McpEvent>> {
13    let mut out = Vec::new();
14
15    for (lineno, line) in text.lines().enumerate() {
16        let line = line.trim();
17        if line.is_empty() {
18            continue;
19        }
20
21        let v: serde_json::Value = serde_json::from_str(line)
22            .with_context(|| format!("invalid JSON on line {}", lineno + 1))?;
23
24        let event = parse_single_event(v, (lineno + 1) as u64)?;
25        out.push(event);
26    }
27
28    Ok(out)
29}
30
31fn parse_inspector_best_effort(text: &str) -> Result<Vec<McpEvent>> {
32    let v: serde_json::Value = serde_json::from_str(text).context("invalid inspector JSON")?;
33
34    // Handle Inspector export variations:
35    // 1. Array of events
36    // 2. Object with "events" array
37    let arr = v
38        .get("events")
39        .cloned()
40        .or_else(|| v.as_array().cloned().map(serde_json::Value::Array))
41        .and_then(|x| x.as_array().cloned())
42        .unwrap_or_default();
43
44    let mut out = Vec::new();
45    for (idx, item) in arr.into_iter().enumerate() {
46        // Use array index as source_line for sorting stability
47        let event = parse_single_event(item, (idx + 1) as u64)?;
48        out.push(event);
49    }
50
51    Ok(out)
52}
53
54fn parse_single_event(v: serde_json::Value, source_line: u64) -> Result<McpEvent> {
55    let ts_ms = extract_ts_ms(&v);
56
57    // JSON-RPC ID extraction
58    let id_str = v
59        .get("id")
60        .map(|x| x.to_string().trim_matches('"').to_string());
61
62    // Check for JSON-RPC Request (has method)
63    let method = v
64        .get("method")
65        .and_then(|m| m.as_str())
66        .map(|s| s.to_string());
67
68    let payload = if let Some(method) = method {
69        match method.as_str() {
70            "tools/list" => McpPayload::ToolsListRequest { raw: v.clone() },
71            "tools/call" => {
72                let params = v.get("params").cloned().unwrap_or(serde_json::Value::Null);
73                let name = params
74                    .get("name")
75                    .and_then(|x| x.as_str())
76                    .unwrap_or("unknown_tool")
77                    .to_string();
78                let arguments = params
79                    .get("arguments")
80                    .cloned()
81                    .unwrap_or(serde_json::Value::Null);
82                McpPayload::ToolCallRequest {
83                    name,
84                    arguments,
85                    raw: v.clone(),
86                }
87            }
88            // Add other standard MCP methods mapping here if needed
89            _ => McpPayload::Other { raw: v.clone() },
90        }
91    } else {
92        // Response (result or error)
93        if v.get("result").is_some() {
94            if looks_like_tools_list_result(&v) {
95                let tools = parse_tools_list_result(&v)?;
96                McpPayload::ToolsListResponse {
97                    tools,
98                    raw: v.clone(),
99                }
100            } else {
101                McpPayload::ToolCallResponse {
102                    result: v.get("result").cloned().unwrap_or(serde_json::Value::Null),
103                    is_error: false,
104                    raw: v.clone(),
105                }
106            }
107        } else if v.get("error").is_some() {
108            McpPayload::ToolCallResponse {
109                result: v.get("error").cloned().unwrap_or(serde_json::Value::Null),
110                is_error: true,
111                raw: v.clone(),
112            }
113        } else {
114            // Maybe it's not JSON-RPC, or it's a notification/special event
115            // Check for known "Session" markers if any (ad-hoc)
116            McpPayload::Other { raw: v.clone() }
117        }
118    };
119
120    Ok(McpEvent {
121        source_line,
122        timestamp_ms: ts_ms,
123        jsonrpc_id: id_str,
124        payload,
125    })
126}
127
128fn extract_ts_ms(v: &serde_json::Value) -> Option<u64> {
129    // Try standard keys.
130    if let Some(t) = v.get("timestamp_ms").and_then(|t| t.as_u64()) {
131        return Some(t);
132    }
133    if let Some(t) = v.get("timestamp").and_then(|t| t.as_u64()) {
134        return Some(t); // Assume ms if big integer, otherwise might be seconds?
135                        // For P0, assume ms or handled by caller if not.
136    }
137    None
138}
139
140fn looks_like_tools_list_result(v: &serde_json::Value) -> bool {
141    v.get("result")
142        .and_then(|r| r.get("tools"))
143        .and_then(|t| t.as_array())
144        .is_some()
145}
146
147fn parse_tools_list_result(v: &serde_json::Value) -> Result<Vec<McpToolDef>> {
148    let tools = v
149        .get("result")
150        .and_then(|r| r.get("tools"))
151        .and_then(|t| t.as_array())
152        .cloned()
153        .unwrap_or_default();
154
155    let mut out = Vec::new();
156    for tool in tools {
157        let name = tool
158            .get("name")
159            .and_then(|x| x.as_str())
160            .unwrap_or("unknown")
161            .to_string();
162        let description = tool
163            .get("description")
164            .and_then(|x| x.as_str())
165            .map(|s| s.to_string());
166        // Handle inputSchema (camelCase) or input_schema (snake_case)
167        let input_schema = tool
168            .get("inputSchema")
169            .cloned()
170            .or_else(|| tool.get("input_schema").cloned());
171        out.push(McpToolDef {
172            name,
173            description,
174            input_schema,
175            tool_identity: None,
176        });
177    }
178    Ok(out)
179}