Skip to main content

assay_core/mcp/
parser.rs

1use crate::mcp::era::{
2    classify_message, correlation_id, fold_envelope, id_is_acceptable, observe_client_capabilities,
3    observe_header, observe_request_metadata, observe_result, resolve_era, CapabilityObservation,
4    EnvelopeObservation, McpEraContext, MessageKind, ParsedMcpEvent, RequestMetadata,
5};
6use crate::mcp::era::{CorrelationId, DuplicateAwareSink, EraResolution, SeenMembers, UniqueValue};
7use crate::mcp::json_depth;
8use crate::mcp::types::*;
9use anyhow::{bail, Context, Result};
10use serde::Deserialize;
11
12/// Parse MCP transcript file contents into normalized McpEvents.
13/// Public surface, unchanged. Projects the events out of the detailed parse and drops the era
14/// sidecar, so downstream `McpEvent` consumers see exactly what they saw before this slice.
15pub fn parse_mcp_transcript(text: &str, format: McpInputFormat) -> Result<Vec<McpEvent>> {
16    Ok(parse_mcp_transcript_detailed(text, format)?
17        .into_iter()
18        .map(|parsed| parsed.event)
19        .collect())
20}
21
22/// Internal parse that keeps what the public event shape cannot carry.
23///
24/// The envelope is folded per entry, from the transcript-level slots each entry starts with plus
25/// its own, so a later deviant entry cannot reach back and contaminate an earlier correct one. The
26/// request and result signals are per message and are read from the payload's retained raw JSON,
27/// before any projection can lose them.
28pub(crate) fn parse_mcp_transcript_detailed(
29    text: &str,
30    format: McpInputFormat,
31) -> Result<Vec<ParsedMcpEvent>> {
32    parse_mcp_transcript_detailed_internal(text, format, None)
33}
34
35/// Bounded internal parse. The outer transcript has already passed its depth scan; this limit is
36/// carried to JSON stored inside SSE `data` strings, which is a separate document whose structure
37/// is invisible to the outer scan.
38pub(crate) fn parse_mcp_transcript_detailed_with_depth(
39    text: &str,
40    format: McpInputFormat,
41    max_json_depth: usize,
42) -> Result<Vec<ParsedMcpEvent>> {
43    parse_mcp_transcript_detailed_internal(text, format, Some(max_json_depth))
44}
45
46fn parse_mcp_transcript_detailed_internal(
47    text: &str,
48    format: McpInputFormat,
49    max_embedded_json_depth: Option<usize>,
50) -> Result<Vec<ParsedMcpEvent>> {
51    let (events, envelopes) = parse_events_with_envelopes(text, format, max_embedded_json_depth)?;
52    let framed = is_framed(format);
53    let parsed: Vec<ParsedMcpEvent> = events
54        .into_iter()
55        .map(|event| {
56            // Observations are taken by reference before the event moves, so no payload is cloned.
57            //
58            // One classification decides both axes, so neither can be reached from the other's
59            // arm. Reading `result` unconditionally gave a hybrid message, a valid string `method`
60            // alongside a `result`, both request metadata and a result observation: a result
61            // conclusion about an event the parser had already called a request. And a notification
62            // carries `method` like a request while its `_meta` is optional and a different type,
63            // so holding it to the request requirement invents a fault in the other direction.
64            //
65            // The capability set travels on the same arm as the request metadata and for the same
66            // reason: it is stated by a request and nowhere else. A response gets it by correlation
67            // below, never by reading its own bytes.
68            let (request_metadata, result_observation, capability_observation, is_error_response) =
69                match payload_raw(&event.payload) {
70                    // The same classifier the parser used. A shape it rejects never reaches here,
71                    // because `parse_events_with_envelopes` runs first and refuses it, so the error
72                    // arm is a state this pass cannot observe rather than one it tolerates.
73                    Some(raw) => match classify_message(raw) {
74                        Ok(MessageKind::Request { .. }) => (
75                            Some(observe_request_metadata(raw)),
76                            None,
77                            observe_client_capabilities(raw),
78                            false,
79                        ),
80                        Ok(MessageKind::Notification { .. }) => (None, None, None, false),
81                        Ok(MessageKind::Response) => {
82                            (None, observe_result(raw), None, raw.get("error").is_some())
83                        }
84                        Err(_) => (None, None, None, false),
85                    },
86                    None => (None, None, None, false),
87                };
88            // Unframed formats have one whole-input observation and no entries to index. A framed
89            // input that does not resolve to an entry is a mapping the parser cannot vouch for,
90            // and a plausible-but-wrong attribution is worse than an unusable one.
91            let envelope = if framed {
92                envelopes
93                    .get(event.source_line.saturating_sub(1) as usize)
94                    .cloned()
95                    .unwrap_or(EnvelopeObservation::Malformed)
96            } else {
97                EnvelopeObservation::NotApplicable
98            };
99            let era = resolve_era(
100                &envelope,
101                request_metadata
102                    .as_ref()
103                    .unwrap_or(&RequestMetadata::Absent),
104            );
105            // The typed key, taken from the same reader `correlate_calls` keys on, so the sidecar
106            // and the correlation cannot disagree about which call this is.
107            let correlation = payload_raw(&event.payload).and_then(correlation_id);
108            ParsedMcpEvent {
109                event,
110                context: McpEraContext {
111                    envelope,
112                    era,
113                    correlation,
114                    request_metadata,
115                    result_observation,
116                    capability_observation,
117                },
118                is_error_response,
119            }
120        })
121        .collect();
122    correlate_calls(parsed)
123}
124
125/// What one outstanding call carries forward to its own response.
126struct CallSignals {
127    era: EraResolution,
128    capability: Option<CapabilityObservation>,
129}
130
131/// Give a response the era and capability set its own call resolved to.
132///
133/// The era resolves from two signals that both live on a request: the transport header and
134/// `params._meta`. A response carries neither, so it fell back to the header alone. A request whose
135/// header and body disagree is `Conflicting`, while its response resolved to `Known` from the header
136/// and a missing `resultType` under a legacy era is `Terminal` — so a contradicted call could still
137/// conclude that the action completed. The contradiction has to travel to the result.
138///
139/// The capability set travels the same way and for a sharper reason: it is stated per request and
140/// MUST NOT be inferred from a prior one, so reading it off anything but this call's own request
141/// would be the inference the revision forbids.
142///
143/// Correlation is by JSON-RPC id within one transcript, which the parser already establishes and
144/// validates for duplicates. A response with no matching request keeps the era it resolved on its
145/// own, so this adds authority rather than removing it. Multi-hop calls spread across separate
146/// records stay out of scope: that needs a call-scoped identity this slice does not define.
147fn correlate_calls(mut parsed: Vec<ParsedMcpEvent>) -> Result<Vec<ParsedMcpEvent>> {
148    // Source order, not a map built up front. A global map is last-wins, so a response could take
149    // the era of a request that had not happened yet: with an id reused after the response, the
150    // contradiction on the call being answered was replaced by the clean era of the next call.
151    // Requests are outstanding until a response consumes them.
152    //
153    // Era and capability travel together on one entry rather than in two maps. They are two facts
154    // about the same call, and two maps could be removed at different moments and give a response
155    // one call's era with another call's capabilities.
156    let mut outstanding: std::collections::HashMap<CorrelationId, CallSignals> =
157        std::collections::HashMap::new();
158    for p in &mut parsed {
159        // The typed key, not the public `String` rendering: that renders JSON `1` and `"1"`
160        // identically, and they are different ids.
161        let Some(raw) = payload_raw(&p.event.payload) else {
162            continue;
163        };
164        let Some(id) = correlation_id(raw) else {
165            continue;
166        };
167        // The shared classifier is the authority, not the presence of an observation. An error
168        // response deliberately has no result observation, since the `resultType` requirement is
169        // about `result`, so keying off that observation made an error response invisible here: it
170        // inherited nothing and consumed nothing, and a legal sequential reuse of its id then
171        // tripped the two-outstanding refusal.
172        let kind = classify_message(raw).ok();
173        match kind {
174            Some(MessageKind::Request { .. }) => {
175                // Two calls outstanding on one id makes the correlation ambiguous, and choosing
176                // either is a silent choice between two calls. Reuse after a response is legal and
177                // is what the removal below permits.
178                let signals = CallSignals {
179                    era: p.context.era.clone(),
180                    capability: p.context.capability_observation.clone(),
181                };
182                if outstanding.insert(id.clone(), signals).is_some() {
183                    bail!(
184                        "two outstanding JSON-RPC requests share an id at source line {}",
185                        p.event.source_line
186                    );
187                }
188            }
189            Some(MessageKind::Response) => {
190                // An orphan response keeps the era it resolved on its own, so correlation adds
191                // authority rather than removing it. Its capability observation stays `None`: no
192                // request was seen, and borrowing a neighbouring call's set is exactly the
193                // inference the revision forbids.
194                if let Some(signals) = outstanding.remove(&id) {
195                    p.context.era = signals.era;
196                    p.context.capability_observation = signals.capability;
197                }
198            }
199            Some(MessageKind::Notification { .. }) | None => {}
200        }
201    }
202    Ok(parsed)
203}
204
205/// Read the header slot inside a `transport_context` as an observation.
206///
207/// A container that is present and not an object is a signal that arrived and failed, not silence.
208/// `Value::get` answers `None` for a scalar, an array or a null, which made a deviant container
209/// indistinguishable from no container at all: at transcript level the whole transcript read as
210/// `Absent`, and at entry level the entry silently inherited whatever valid default the transcript
211/// had set. Both are a fold toward "nothing was wrong" on the evidence that something was.
212fn observe_transport_context(ctx: Option<&serde_json::Value>) -> Option<EnvelopeObservation> {
213    let ctx = ctx?;
214    let Some(map) = ctx.as_object() else {
215        return Some(EnvelopeObservation::Malformed);
216    };
217    // A readable container with no `headers` key is silence rather than a defect: it arrived, it
218    // was legible, and it carried no header slot. Only an unreadable one is a finding.
219    observe_header(map.get("headers"))
220}
221
222/// Which formats carry transport framing at all. `Inspector` reads an events array straight into
223/// `parse_jsonrpc_message` and never reaches `parse_transport_transcript`, so it has no envelope.
224fn is_framed(format: McpInputFormat) -> bool {
225    matches!(
226        format,
227        McpInputFormat::StreamableHttp | McpInputFormat::HttpSse
228    )
229}
230
231fn payload_raw(payload: &McpPayload) -> Option<&serde_json::Value> {
232    match payload {
233        McpPayload::SessionStart { raw }
234        | McpPayload::ToolsListRequest { raw }
235        | McpPayload::ToolsListResponse { raw, .. }
236        | McpPayload::ToolCallRequest { raw, .. }
237        | McpPayload::ToolCallResponse { raw, .. }
238        | McpPayload::SessionEnd { raw, .. }
239        | McpPayload::Other { raw, .. } => Some(raw),
240    }
241}
242
243fn parse_events_with_envelopes(
244    text: &str,
245    format: McpInputFormat,
246    max_embedded_json_depth: Option<usize>,
247) -> Result<(Vec<McpEvent>, Vec<EnvelopeObservation>)> {
248    let (events, envelopes) = match format {
249        McpInputFormat::JsonRpc => (parse_jsonrpc_jsonl(text)?, Vec::new()),
250        McpInputFormat::Inspector => (parse_inspector_best_effort(text)?, Vec::new()),
251        McpInputFormat::StreamableHttp => parse_transport_transcript_detailed(
252            text,
253            "streamable-http",
254            "streamable-http transcript",
255            false,
256            max_embedded_json_depth,
257        )?,
258        McpInputFormat::HttpSse => parse_transport_transcript_detailed(
259            text,
260            "http-sse",
261            "http-sse transcript",
262            true,
263            max_embedded_json_depth,
264        )?,
265    };
266    // No global id gate here. The old one keyed on the public `String` rendering, so a number `1`
267    // and a string `"1"` collided; it refused any reuse anywhere in the transcript, so a legal reuse
268    // after a response was rejected; and it only saw `ToolCallRequest`. It also ran before
269    // correlation, which made it the de facto lifetime authority without owning the typed key.
270    // `correlate_calls` is that authority now, on the typed outstanding map.
271    Ok((events, envelopes))
272}
273
274fn parse_jsonrpc_jsonl(text: &str) -> Result<Vec<McpEvent>> {
275    let mut out = Vec::new();
276
277    for (lineno, line) in text.lines().enumerate() {
278        let line = line.trim();
279        if line.is_empty() {
280            continue;
281        }
282
283        let UniqueValue(v) = serde_json::from_str::<UniqueValue>(line)
284            .with_context(|| format!("invalid JSON on line {}", lineno + 1))?;
285
286        let event = parse_jsonrpc_message(
287            v,
288            (lineno + 1) as u64,
289            None,
290            McpAuthorizationDiscovery::default(),
291        )?;
292        out.push(event);
293    }
294
295    Ok(out)
296}
297
298fn parse_inspector_best_effort(text: &str) -> Result<Vec<McpEvent>> {
299    let UniqueValue(v) =
300        serde_json::from_str::<UniqueValue>(text).context("invalid inspector JSON")?;
301
302    // Handle Inspector export variations:
303    // 1. Array of events
304    // 2. Object with "events" array
305    let arr = v
306        .get("events")
307        .cloned()
308        .or_else(|| v.as_array().cloned().map(serde_json::Value::Array))
309        .and_then(|x| x.as_array().cloned())
310        .unwrap_or_default();
311
312    let mut out = Vec::new();
313    for (idx, item) in arr.into_iter().enumerate() {
314        // Use array index as source_line for sorting stability
315        let event = parse_jsonrpc_message(
316            item,
317            (idx + 1) as u64,
318            None,
319            McpAuthorizationDiscovery::default(),
320        )?;
321        out.push(event);
322    }
323
324    Ok(out)
325}
326
327/// The transport parse, returning the per-entry envelope observations it already had in hand.
328///
329/// Deserializing the text a second time to read the headers would double the peak memory a hostile
330/// transcript can cost, on the one path that exists to read untrusted input, so the observations
331/// are taken from the same `TransportTranscript` the events come from.
332fn parse_transport_transcript_detailed(
333    text: &str,
334    expected_transport: &str,
335    source_label: &str,
336    allow_endpoint_event: bool,
337    max_embedded_json_depth: Option<usize>,
338) -> Result<(Vec<McpEvent>, Vec<EnvelopeObservation>)> {
339    let transcript: TransportTranscript =
340        serde_json::from_str(text).with_context(|| format!("invalid {}", source_label))?;
341
342    let actual_transport = transcript.transport.as_deref().unwrap_or("missing");
343    if actual_transport != expected_transport {
344        bail!(
345            "{} transport must be {:?}, found {:?}",
346            source_label,
347            expected_transport,
348            actual_transport
349        );
350    }
351
352    let mut transcript_slots = Vec::new();
353    if let Some(o) = observe_transport_context(transcript.transport_context.as_ref().map(|u| &u.0))
354    {
355        transcript_slots.push(o);
356    }
357    if let Some(o) = observe_header(transcript.headers.as_ref().map(|u| &u.0)) {
358        transcript_slots.push(o);
359    }
360
361    let mut envelopes = Vec::new();
362    let mut out = Vec::new();
363    for (idx, entry) in transcript.entries.into_iter().enumerate() {
364        let mut slots = transcript_slots.clone();
365        if let Some(o) = observe_transport_context(entry.transport_context.as_ref().map(|u| &u.0)) {
366            slots.push(o);
367        }
368        if let Some(o) = observe_header(entry.headers.as_ref().map(|u| &u.0)) {
369            slots.push(o);
370        }
371        envelopes.push(fold_envelope(slots, true));
372        let source_line = (idx + 1) as u64;
373        let present = usize::from(entry.request.is_some())
374            + usize::from(entry.response.is_some())
375            + usize::from(entry.sse.is_some());
376
377        if present != 1 {
378            bail!(
379                "{} entry {} must contain exactly one of request, response, or sse",
380                source_label,
381                source_line
382            );
383        }
384
385        if let Some(UniqueValue(request)) = entry.request {
386            out.push(parse_jsonrpc_message(
387                request,
388                source_line,
389                entry.timestamp_ms,
390                McpAuthorizationDiscovery::default(),
391            )?);
392            continue;
393        }
394
395        let auth_discovery = parse_transport_auth_discovery(&entry);
396
397        if let Some(UniqueValue(response)) = entry.response {
398            out.push(parse_jsonrpc_message(
399                response,
400                source_line,
401                entry.timestamp_ms,
402                auth_discovery,
403            )?);
404            continue;
405        }
406
407        if let Some(sse) = entry.sse {
408            if let Some(jsonrpc) =
409                extract_jsonrpc_from_sse(&sse, allow_endpoint_event, max_embedded_json_depth)?
410            {
411                out.push(parse_jsonrpc_message(
412                    jsonrpc,
413                    source_line,
414                    entry.timestamp_ms,
415                    McpAuthorizationDiscovery::default(),
416                )?);
417            }
418        }
419    }
420
421    Ok((out, envelopes))
422}
423
424fn parse_jsonrpc_message(
425    v: serde_json::Value,
426    source_line: u64,
427    timestamp_ms_override: Option<u64>,
428    auth_discovery: McpAuthorizationDiscovery,
429) -> Result<McpEvent> {
430    if !v.is_object() {
431        bail!(
432            "MCP event at source line {} must be a JSON object",
433            source_line
434        );
435    }
436
437    if v.get("jsonrpc").and_then(serde_json::Value::as_str) != Some("2.0") {
438        bail!(
439            "MCP event at source line {} must carry JSON-RPC version 2.0",
440            source_line
441        );
442    }
443
444    let ts_ms = timestamp_ms_override.or_else(|| extract_ts_ms(&v));
445
446    // One classification for the whole crate. A present non-string `method` is a malformed message
447    // shape, not a message of another kind, and it is refused here: that is a JSON-RPC shape
448    // refusal rather than an era-state refusal, so it does not weaken the rule that every era
449    // observation parses. The message is value-free.
450    let kind = classify_message(&v)
451        .map_err(|e| anyhow::anyhow!("MCP event at source line {}: {}", source_line, e))?;
452
453    // Classification first, then the id its kind requires. The shapes differ: a notification has
454    // no id by definition, a request and a success response must name the call they are part of,
455    // and an error response is how a peer reports a request it could not parse, so it may have no
456    // usable id at all and simply correlates with nothing. Normalizing before classifying cannot
457    // apply any of that, because it does not yet know what it is looking at.
458    let raw_id = v.get("id");
459    let id_str = match kind {
460        MessageKind::Notification { .. } => None,
461        MessageKind::Request { .. } => Some(require_acceptable_id(raw_id, source_line)?),
462        MessageKind::Response => {
463            let is_error = v.get("error").is_some();
464            match raw_id {
465                // An invalid-request error response may carry no usable id.
466                None | Some(serde_json::Value::Null) if is_error => None,
467                _ => Some(require_acceptable_id(raw_id, source_line)?),
468            }
469        }
470    };
471    let payload =
472        if let MessageKind::Request { method } | MessageKind::Notification { method } = kind {
473            match method {
474                "tools/list" => McpPayload::ToolsListRequest { raw: v.clone() },
475                "tools/call" => {
476                    let params = v.get("params").cloned().unwrap_or(serde_json::Value::Null);
477                    let name = params
478                        .get("name")
479                        .and_then(|x| x.as_str())
480                        .unwrap_or("unknown_tool")
481                        .to_string();
482                    let arguments = params
483                        .get("arguments")
484                        .cloned()
485                        .unwrap_or(serde_json::Value::Null);
486                    McpPayload::ToolCallRequest {
487                        name,
488                        arguments,
489                        raw: v.clone(),
490                    }
491                }
492                // Add other standard MCP methods mapping here if needed
493                _ => McpPayload::Other { raw: v.clone() },
494            }
495        } else {
496            // Response (result or error)
497            if v.get("result").is_some() {
498                if looks_like_tools_list_result(&v) {
499                    let tools = parse_tools_list_result(&v)?;
500                    McpPayload::ToolsListResponse {
501                        tools,
502                        raw: v.clone(),
503                    }
504                } else {
505                    McpPayload::ToolCallResponse {
506                        result: v.get("result").cloned().unwrap_or(serde_json::Value::Null),
507                        is_error: false,
508                        raw: v.clone(),
509                    }
510                }
511            } else if v.get("error").is_some() {
512                McpPayload::ToolCallResponse {
513                    result: v.get("error").cloned().unwrap_or(serde_json::Value::Null),
514                    is_error: true,
515                    raw: v.clone(),
516                }
517            } else {
518                // Maybe it's not JSON-RPC, or it's a notification/special event
519                // Check for known "Session" markers if any (ad-hoc)
520                McpPayload::Other { raw: v.clone() }
521            }
522        };
523
524    Ok(McpEvent {
525        source_line,
526        timestamp_ms: ts_ms,
527        jsonrpc_id: id_str,
528        auth_discovery,
529        payload,
530    })
531}
532
533fn parse_transport_auth_discovery(entry: &TransportTranscriptEntry) -> McpAuthorizationDiscovery {
534    let Some(status) = extract_http_status(entry) else {
535        return McpAuthorizationDiscovery::default();
536    };
537
538    if status != 401 {
539        return McpAuthorizationDiscovery::default();
540    }
541
542    let header_value = entry
543        .transport_context
544        .as_ref()
545        .and_then(|value| find_header_case_insensitive(&value.0, "www-authenticate"))
546        .or_else(|| {
547            entry
548                .headers
549                .as_ref()
550                .and_then(|value| find_header_case_insensitive(&value.0, "www-authenticate"))
551        });
552
553    let Some(www_authenticate) = header_value else {
554        return McpAuthorizationDiscovery::default();
555    };
556
557    let resource_metadata_visible = auth_param_visible(&www_authenticate, "resource_metadata");
558    let scope_challenge_visible = auth_param_visible(&www_authenticate, "scope");
559
560    if !resource_metadata_visible && !scope_challenge_visible {
561        return McpAuthorizationDiscovery::default();
562    }
563
564    McpAuthorizationDiscovery {
565        visible: true,
566        source_kind: McpAuthorizationDiscoverySourceKind::WwwAuthenticate,
567        resource_metadata_visible,
568        authorization_servers_visible: false,
569        scope_challenge_visible,
570    }
571}
572
573fn extract_http_status(entry: &TransportTranscriptEntry) -> Option<u16> {
574    entry
575        .transport_context
576        .as_ref()
577        .and_then(|v| extract_http_status_from_value(&v.0))
578        .or_else(|| {
579            entry
580                .headers
581                .as_ref()
582                .and_then(|v| extract_http_status_from_value(&v.0))
583        })
584}
585
586fn extract_http_status_from_value(value: &serde_json::Value) -> Option<u16> {
587    match value {
588        serde_json::Value::Object(map) => {
589            for key in ["status", "status_code", "http_status"] {
590                if let Some(status) = map.get(key).and_then(json_value_to_u16) {
591                    return Some(status);
592                }
593            }
594
595            map.get("response").and_then(extract_http_status_from_value)
596        }
597        _ => None,
598    }
599}
600
601fn json_value_to_u16(value: &serde_json::Value) -> Option<u16> {
602    match value {
603        serde_json::Value::Number(n) => n.as_u64().and_then(|n| u16::try_from(n).ok()),
604        serde_json::Value::String(s) => s.parse::<u16>().ok(),
605        _ => None,
606    }
607}
608
609fn find_header_case_insensitive(value: &serde_json::Value, header_name: &str) -> Option<String> {
610    match value {
611        serde_json::Value::Object(map) => {
612            if let Some(headers) = map.get("headers") {
613                if let Some(found) = find_header_case_insensitive(headers, header_name) {
614                    return Some(found);
615                }
616            }
617
618            if let Some(response) = map.get("response") {
619                if let Some(found) = find_header_case_insensitive(response, header_name) {
620                    return Some(found);
621                }
622            }
623
624            map.iter().find_map(|(key, value)| {
625                if key.eq_ignore_ascii_case(header_name) {
626                    value.as_str().map(ToString::to_string)
627                } else {
628                    None
629                }
630            })
631        }
632        _ => None,
633    }
634}
635
636fn auth_param_visible(header_value: &str, param_name: &str) -> bool {
637    let lower = header_value.to_ascii_lowercase();
638    let needle = format!("{param_name}=");
639
640    lower
641        .match_indices(&needle)
642        .any(|(idx, _)| idx == 0 || matches!(lower.as_bytes()[idx - 1], b' ' | b',' | b'\t'))
643}
644
645/// Accept the id a request or a success response must carry, or refuse value-free.
646///
647/// The existing type diagnostics for booleans, arrays and objects are kept: they are more specific
648/// than "not a string or a number" and downstream tests pin them.
649fn require_acceptable_id(raw_id: Option<&serde_json::Value>, source_line: u64) -> Result<String> {
650    // Acceptance and correlation are different questions, so this asks only the first. One clone,
651    // on the accepting path; the refusing path copies nothing.
652    reject_unusable_id_shape(raw_id, source_line)?;
653    // Acceptance is broader than correlation and is decided here: the schema allows a string or any
654    // number, so any of those is an acceptable id even when `correlation_id` declines to key it.
655    // Re-deciding the *correlation* rule here is what once made its mutation stop biting, so this
656    // asks only about acceptance and leaves keying to the one function that owns it.
657    match raw_id.filter(|v| id_is_acceptable(v)) {
658        Some(serde_json::Value::String(id)) => Ok(id.clone()),
659        Some(serde_json::Value::Number(id)) => Ok(id.to_string()),
660        _ => bail!(
661            "JSON-RPC id on source line {} must be a string or a number",
662            source_line
663        ),
664    }
665}
666
667/// The more specific type diagnostics, kept because they name the fault better than "not a string or
668/// an integer" and downstream tests pin them. Returns nothing: the accepted value is produced by the
669/// single match in [`require_acceptable_id`], so this no longer clones one to throw it away.
670fn reject_unusable_id_shape(raw_id: Option<&serde_json::Value>, source_line: u64) -> Result<()> {
671    match raw_id {
672        None
673        | Some(serde_json::Value::Null)
674        | Some(serde_json::Value::String(_))
675        | Some(serde_json::Value::Number(_)) => Ok(()),
676        Some(serde_json::Value::Bool(_)) => {
677            bail!(
678                "JSON-RPC id on source line {} must not be a boolean",
679                source_line
680            )
681        }
682        Some(serde_json::Value::Array(_)) => {
683            bail!(
684                "JSON-RPC id on source line {} must not be an array",
685                source_line
686            )
687        }
688        Some(serde_json::Value::Object(_)) => {
689            bail!(
690                "JSON-RPC id on source line {} must not be an object",
691                source_line
692            )
693        }
694    }
695}
696
697fn extract_jsonrpc_from_sse(
698    sse: &TransportSseEnvelope,
699    allow_endpoint_event: bool,
700    max_embedded_json_depth: Option<usize>,
701) -> Result<Option<serde_json::Value>> {
702    let event_name = sse.event.as_deref().unwrap_or("message");
703    if event_name == "endpoint" && allow_endpoint_event {
704        return Ok(None);
705    }
706
707    if event_name != "message" {
708        return Ok(None);
709    }
710
711    extract_jsonrpc_like_value(&sse.data.0, max_embedded_json_depth)
712}
713
714#[derive(Debug, thiserror::Error)]
715#[error("embedded SSE JSON exceeds its depth limit")]
716pub(crate) struct EmbeddedJsonDepthExceeded;
717
718/// Pull a JSON-RPC-looking value out of an SSE `data` payload.
719///
720/// `Ok(None)` means the payload is not JSON-RPC-shaped, which is tolerated: an SSE stream carries
721/// keepalives and endpoint frames alongside messages. An `Err` means the payload *is* JSON and
722/// carries a duplicate member, which is refused. The two are told apart by
723/// `serde_json::error::Category`, not by reading the message: a visitor's `Error::custom` classifies
724/// as `Data` while malformed bytes classify as `Syntax`, so the distinction is typed.
725fn extract_jsonrpc_like_value(
726    value: &serde_json::Value,
727    max_embedded_json_depth: Option<usize>,
728) -> Result<Option<serde_json::Value>> {
729    match value {
730        serde_json::Value::Object(map)
731            if map.contains_key("method")
732                || map.contains_key("result")
733                || map.contains_key("error")
734                || map.contains_key("jsonrpc") =>
735        {
736            Ok(Some(value.clone()))
737        }
738        // The embedded string is a *different* input from the transcript, not a second pass over the
739        // same one, so it goes through the same duplicate-aware boundary rather than a plain
740        // `Value`. Without this an SSE frame carrying its payload as a string was the one path that
741        // kept a duplicate member.
742        serde_json::Value::String(text) => {
743            if max_embedded_json_depth
744                .is_some_and(|limit| json_depth::exceeds_limit(text.as_bytes(), limit, false))
745            {
746                return Err(EmbeddedJsonDepthExceeded.into());
747            }
748            match serde_json::from_str::<UniqueValue>(text) {
749                Ok(UniqueValue(parsed)) => {
750                    extract_jsonrpc_like_value(&parsed, max_embedded_json_depth)
751                }
752                Err(e) if e.classify() == serde_json::error::Category::Data => {
753                    Err(anyhow::Error::new(e).context("invalid SSE data payload"))
754                }
755                Err(_) => Ok(None),
756            }
757        }
758        _ => Ok(None),
759    }
760}
761
762fn extract_ts_ms(v: &serde_json::Value) -> Option<u64> {
763    // Try standard keys.
764    if let Some(t) = v.get("timestamp_ms").and_then(|t| t.as_u64()) {
765        return Some(t);
766    }
767    if let Some(t) = v.get("timestamp").and_then(|t| t.as_u64()) {
768        return Some(t); // Assume ms if big integer, otherwise might be seconds?
769                        // For P0, assume ms or handled by caller if not.
770    }
771    None
772}
773
774fn looks_like_tools_list_result(v: &serde_json::Value) -> bool {
775    v.get("result")
776        .and_then(|r| r.get("tools"))
777        .and_then(|t| t.as_array())
778        .is_some()
779}
780
781fn parse_tools_list_result(v: &serde_json::Value) -> Result<Vec<McpToolDef>> {
782    let tools = v
783        .get("result")
784        .and_then(|r| r.get("tools"))
785        .and_then(|t| t.as_array())
786        .cloned()
787        .unwrap_or_default();
788
789    let mut out = Vec::new();
790    for tool in tools {
791        let name = tool
792            .get("name")
793            .and_then(|x| x.as_str())
794            .unwrap_or("unknown")
795            .to_string();
796        let description = tool
797            .get("description")
798            .and_then(|x| x.as_str())
799            .map(|s| s.to_string());
800        // Handle inputSchema (camelCase) or input_schema (snake_case)
801        let input_schema = tool
802            .get("inputSchema")
803            .cloned()
804            .or_else(|| tool.get("input_schema").cloned());
805        out.push(McpToolDef {
806            name,
807            description,
808            input_schema,
809            tool_identity: None,
810        });
811    }
812    Ok(out)
813}
814
815#[derive(Debug, Default)]
816struct TransportTranscript {
817    transport: Option<String>,
818    #[allow(dead_code)]
819    transport_context: Option<UniqueValue>,
820    #[allow(dead_code)]
821    headers: Option<UniqueValue>,
822    entries: Vec<TransportTranscriptEntry>,
823}
824
825/// Hand-written so every member name passes through [`SeenMembers`], including the unknown ones the
826/// derive discards without looking at. Assigning `Some(..)` on each arm keeps the property the
827/// deleted `present_slot` helper carried: an explicitly written `null` stays present rather than
828/// folding to absent, which is what let a broken entry inherit a valid transcript default.
829impl<'de> Deserialize<'de> for TransportTranscript {
830    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
831        struct V;
832        impl<'de> serde::de::Visitor<'de> for V {
833            type Value = TransportTranscript;
834            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
835                f.write_str("a transport transcript with unique members")
836            }
837            fn visit_map<A: serde::de::MapAccess<'de>>(
838                self,
839                mut map: A,
840            ) -> Result<TransportTranscript, A::Error> {
841                let mut out = TransportTranscript::default();
842                let mut seen = SeenMembers::default();
843                while let Some(key) = map.next_key::<String>()? {
844                    seen.insert::<A::Error>(&key)?;
845                    match key.as_str() {
846                        "transport" => out.transport = map.next_value()?,
847                        "transport_context" => out.transport_context = Some(map.next_value()?),
848                        "headers" => out.headers = Some(map.next_value()?),
849                        "entries" => out.entries = map.next_value()?,
850                        _ => {
851                            map.next_value::<DuplicateAwareSink>()?;
852                        }
853                    }
854                }
855                Ok(out)
856            }
857        }
858        d.deserialize_map(V)
859    }
860}
861
862#[derive(Debug, Default)]
863struct TransportTranscriptEntry {
864    timestamp_ms: Option<u64>,
865    #[allow(dead_code)]
866    transport_context: Option<UniqueValue>,
867    #[allow(dead_code)]
868    headers: Option<UniqueValue>,
869    request: Option<UniqueValue>,
870    response: Option<UniqueValue>,
871    sse: Option<TransportSseEnvelope>,
872}
873
874/// Same reason as the transcript above.
875impl<'de> Deserialize<'de> for TransportTranscriptEntry {
876    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
877        struct V;
878        impl<'de> serde::de::Visitor<'de> for V {
879            type Value = TransportTranscriptEntry;
880            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
881                f.write_str("a transport transcript entry with unique members")
882            }
883            fn visit_map<A: serde::de::MapAccess<'de>>(
884                self,
885                mut map: A,
886            ) -> Result<TransportTranscriptEntry, A::Error> {
887                let mut out = TransportTranscriptEntry::default();
888                let mut seen = SeenMembers::default();
889                while let Some(key) = map.next_key::<String>()? {
890                    seen.insert::<A::Error>(&key)?;
891                    match key.as_str() {
892                        "timestamp_ms" => out.timestamp_ms = map.next_value()?,
893                        "transport_context" => out.transport_context = Some(map.next_value()?),
894                        "headers" => out.headers = Some(map.next_value()?),
895                        "request" => out.request = Some(map.next_value()?),
896                        "response" => out.response = Some(map.next_value()?),
897                        // Symmetric with the other slots: an explicitly written null is a slot
898                        // that arrived and failed, not an absent one. Folding it to absent let it
899                        // vanish silently beside a valid request.
900                        "sse" => match map.next_value::<Option<TransportSseEnvelope>>()? {
901                            Some(envelope) => out.sse = Some(envelope),
902                            None => {
903                                return Err(serde::de::Error::custom(
904                                    "sse slot is present and null",
905                                ))
906                            }
907                        },
908                        _ => {
909                            map.next_value::<DuplicateAwareSink>()?;
910                        }
911                    }
912                }
913                Ok(out)
914            }
915        }
916        d.deserialize_map(V)
917    }
918}
919
920#[derive(Debug, Default)]
921struct TransportSseEnvelope {
922    event: Option<String>,
923    #[allow(dead_code)]
924    id: Option<String>,
925    data: UniqueValue,
926}
927
928/// Duplicate-aware like the transcript and the entry. The derive would have let a repeated unknown
929/// member through, and `data` is where an SSE frame carries its whole JSON-RPC message.
930impl<'de> Deserialize<'de> for TransportSseEnvelope {
931    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
932        struct V;
933        impl<'de> serde::de::Visitor<'de> for V {
934            type Value = TransportSseEnvelope;
935            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936                f.write_str("an SSE envelope with unique members")
937            }
938            fn visit_map<A: serde::de::MapAccess<'de>>(
939                self,
940                mut map: A,
941            ) -> Result<TransportSseEnvelope, A::Error> {
942                let mut out = TransportSseEnvelope::default();
943                let mut seen = SeenMembers::default();
944                // A hand-written `Deserialize` inherits none of the derive's field obligations. The
945                // derive made `data` required; initializing from `Default` and never checking let a
946                // frame with no data produce zero events instead of a refusal, which is a malformed
947                // frame disappearing silently.
948                let mut saw_data = false;
949                while let Some(key) = map.next_key::<String>()? {
950                    seen.insert::<A::Error>(&key)?;
951                    match key.as_str() {
952                        "event" => out.event = map.next_value()?,
953                        "id" => out.id = map.next_value()?,
954                        "data" => {
955                            out.data = map.next_value()?;
956                            saw_data = true;
957                        }
958                        _ => {
959                            map.next_value::<DuplicateAwareSink>()?;
960                        }
961                    }
962                }
963                if !saw_data {
964                    return Err(serde::de::Error::missing_field("data"));
965                }
966                Ok(out)
967            }
968        }
969        d.deserialize_map(V)
970    }
971}
972
973#[cfg(test)]
974mod era_wiring_tests;