Skip to main content

hh_record/
mcp_proxy.rs

1//! MCP stdio proxy (FR-2): a newline-delimited JSON-RPC middleman between an
2//! MCP client (the host on `hh`'s stdin/stdout) and an MCP server (the child
3//! process). It forwards every line **verbatim** (wire correctness preserved),
4//! and as a side effect records each message as an Halfhand event:
5//!
6//! - client → server: a request (`id` + `method`) → [`EventKind::McpRequest`]
7//!   with `correlate_key = <id>`; a notification (`method`, no `id`) →
8//!   [`EventKind::McpNotification`].
9//! - server → client: a response (`id`, no `method`) → [`EventKind::McpResponse`]
10//!   with `correlates` pointing at the matching request's event id and
11//!   `latency_ms` in the body; a notification → [`EventKind::McpNotification`].
12//! - a JSON-array batch (MCP 2025-03-26) is forwarded verbatim and recorded as
13//!   one [`EventKind::McpNotification`] (v0.1 simplification: no per-element
14//!   correlation, flagged in the plan).
15//!
16//! Sessions (FR-2.3): if `HH_SESSION_ID` is set, the proxy **attaches** to that
17//! session — it records events into it but leaves its lifecycle (`status`,
18//! `ended_at`) untouched, because the parent `hh run` owns finalize. A missing
19//! id is an actionable error (no orphan session). With no env var, the proxy
20//! creates a standalone `mcp-only` session and finalizes it with the server's
21//! exit code.
22//!
23//! Concurrency (ADR-0001): two OS threads (upstream/downstream) share an
24//! `Arc<Mutex<EventWriter>>` and an `Arc<Mutex<HashMap<…>>>` correlation map.
25//! Lock ordering is deadlock-free: acquire the map → copy/insert → release →
26//! acquire the writer → append → release. The two locks are **never held
27//! together** (CLAUDE.md single-writer rule; the `Mutex` serializes appends).
28
29use std::collections::HashMap;
30use std::io::{BufRead, BufReader, Read, Write};
31use std::path::PathBuf;
32use std::process::{Child, Command, Stdio};
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::{Arc, Mutex};
35
36use hh_core::blob::BlobStore;
37use hh_core::event::{AdapterStatus, AgentKind, Event, EventKind, NewSession, SessionStatus};
38use hh_core::store::{EventWriter, Store};
39
40use crate::git::GitMeta;
41
42/// Blob spillover threshold (matches the adapter and PTY capture): payloads
43/// whose serialized JSON is ≥ this many bytes go to the blob store.
44const SPILL_BYTES: usize = 256 * 1024;
45
46/// Options for [`run_mcp_proxy`], built by the binary from `McpProxyArgs`.
47#[derive(Debug, Clone)]
48pub struct McpProxyOptions {
49    /// The MCP server argv (program + args).
50    pub command: Vec<String>,
51    /// Working directory for the server process.
52    pub cwd: PathBuf,
53    /// Human-readable hint stored as the session `command` label for a
54    /// standalone `mcp-only` session (unused when attaching).
55    pub session_hint: Option<String>,
56    /// `hh` version string (FR-1.2).
57    pub hh_version: String,
58    /// Record-time redactor (`[redaction] at_record`, docs/redaction-design.md):
59    /// when set, event summaries/bodies are redacted by the writer task and
60    /// blob content by the redacting blob store, before anything hits disk.
61    pub redactor: Option<Arc<hh_core::redact::Detectors>>,
62}
63
64/// The outcome of a finished proxy run (FR-2).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct McpProxyOutcome {
67    /// Full session UUID string (the attached parent's, or the new standalone
68    /// session's).
69    pub session_id: String,
70    /// 6-hex-char short id (the standalone session's; empty-ish for attached,
71    /// resolved best-effort from the parent row).
72    pub short_id: String,
73    /// `true` if the proxy attached to an existing session via `HH_SESSION_ID`.
74    pub attached: bool,
75    /// Server exit code, if it could be determined.
76    pub exit_code: Option<i32>,
77}
78
79/// Run the MCP stdio proxy (FR-2). See the module docs for the session model
80/// and the forwarding/recording contract.
81///
82/// # Errors
83///
84/// Returns [`crate::RecordError`] for setup failures (server spawn, store
85/// errors, an unresolvable `HH_SESSION_ID`). A server that exits nonzero is
86/// *not* an error here — it's reflected in `McpProxyOutcome.exit_code`.
87#[allow(clippy::too_many_lines)] // the proxy loop is inherently linear
88pub fn run_mcp_proxy(store: &Store, opts: &McpProxyOptions) -> crate::Result<McpProxyOutcome> {
89    let now = now_unix_ms();
90
91    // --- Session (FR-2.3) ------------------------------------------------
92    // Attached: HH_SESSION_ID present → resolve + read the parent's started_at
93    // so MCP events share the parent's timeline. Standalone: create an mcp-only
94    // session we own and finalize at the end.
95    let attached_id = std::env::var_os("HH_SESSION_ID").and_then(|v| v.into_string().ok());
96    let (session_id, short_id, base_started_at, attached) = if let Some(id) = attached_id.as_deref()
97    {
98        let resolved = store.resolve_session(id).map_err(|e| {
99            crate::RecordError::McpSession(format!(
100                "session `{id}` not found ({e}); run `hh mcp-proxy -- …` standalone, \
101                 or re-run inside `hh run` so the proxy can attach to a live session"
102            ))
103        })?;
104        let parent_started = store.session_started_at(&resolved).map_err(|e| {
105            crate::RecordError::McpSession(format!(
106                "could not read start time of session `{resolved}`: {e}"
107            ))
108        })?;
109        let short = store.session_short_id(&resolved).unwrap_or_default();
110        (resolved, short, parent_started, true)
111    } else {
112        let session_uuid = hh_core::event::now_v7();
113        let git = GitMeta::capture(&opts.cwd);
114        // The session `command` records the proxy wrapping the server argv
115        // (`mcp-proxy` prefix + server command) so `hh list` shows what was
116        // proxied. `session_hint` is a forward-compat label (no name column
117        // yet); it is not persisted in v0.1.
118        let mut command = vec!["mcp-proxy".to_string()];
119        command.extend_from_slice(&opts.command);
120        let new_session = NewSession {
121            id: session_uuid,
122            started_at: now,
123            agent_kind: AgentKind::McpOnly,
124            adapter_status: AdapterStatus::None,
125            command,
126            cwd: opts.cwd.clone(),
127            hostname: hostname(),
128            hh_version: opts.hh_version.clone(),
129            model: None,
130            git_branch: git.branch,
131            git_sha: git.sha,
132            git_dirty: git.dirty,
133        };
134        let created = store.create_session(&new_session)?;
135        (created.id, created.short_id, now, false)
136    };
137
138    let writer = Arc::new(Mutex::new(
139        store
140            .event_writer_with_redactor(opts.redactor.clone())
141            .map_err(|e| crate::RecordError::Mcp(format!("open event writer: {e}")))?,
142    ));
143    let blobs = Arc::new(crate::make_blob_store(store, opts.redactor.clone()));
144    // JSON-RPC id (string repr) → (request event id, request unix-ms ts).
145    let pending: Arc<Mutex<HashMap<String, (i64, i64)>>> = Arc::new(Mutex::new(HashMap::new()));
146    let stop = Arc::new(AtomicBool::new(false));
147
148    // --- Spawn the server (FR-2.1) --------------------------------------
149    let mut cmd = Command::new(&opts.command[0]);
150    cmd.args(&opts.command[1..])
151        .current_dir(&opts.cwd)
152        .stdin(Stdio::piped())
153        .stdout(Stdio::piped())
154        .stderr(Stdio::inherit());
155    let mut child = cmd
156        .spawn()
157        .map_err(|e| crate::RecordError::Mcp(format!("spawn server `{}`: {e}", opts.command[0])))?;
158    let server_stdin = child
159        .stdin
160        .take()
161        .ok_or_else(|| crate::RecordError::Mcp("server stdin not piped".into()))?;
162    let server_stdout = child
163        .stdout
164        .take()
165        .ok_or_else(|| crate::RecordError::Mcp("server stdout not piped".into()))?;
166
167    // --- Two threads: upstream (hh stdin → server) + downstream (server → hh stdout)
168    // Shared recording state (writer + blobs + correlation map + session clock)
169    // is bundled in `ProxyCtx` so the thread entrypoints stay under the
170    // pedantic argument limit and the lock ordering lives in one place.
171    let ctx = ProxyCtx {
172        writer: Arc::clone(&writer),
173        blobs: Arc::clone(&blobs),
174        pending: Arc::clone(&pending),
175        session_id: session_id.clone(),
176        base_started_at,
177    };
178    let up_stop = Arc::clone(&stop);
179    let upstream = std::thread::Builder::new()
180        .name("hh-mcp-upstream".into())
181        .spawn({
182            let ctx = ctx.clone();
183            move || run_upstream(server_stdin, ctx, up_stop)
184        })
185        .map_err(|e| crate::RecordError::Mcp(format!("spawn upstream thread: {e}")))?;
186
187    let down_stop = Arc::clone(&stop);
188    let mut stdout = std::io::stdout();
189    let downstream = std::thread::Builder::new()
190        .name("hh-mcp-downstream".into())
191        .spawn({
192            let ctx = ctx.clone();
193            move || run_downstream(server_stdout, &mut stdout, ctx, down_stop)
194        })
195        .map_err(|e| crate::RecordError::Mcp(format!("spawn downstream thread: {e}")))?;
196
197    // Wait for both threads. The upstream thread ends when hh's stdin EOFs; it
198    // closes the server's stdin, which makes the server exit, which EOFs the
199    // downstream reader. `stop` is a backstop for a server that never exits.
200    let _ = upstream.join();
201    let _ = downstream.join();
202    stop.store(true, Ordering::Release);
203
204    let exit_code = wait_for_child(&mut child);
205
206    // --- Finalize (standalone only) -------------------------------------
207    // Flush + close the writer first (no intra-process contention: the threads
208    // have joined), then assign step ordinals + finalize the session row.
209    // Recovers from poisoning (see `ProxyCtx::append`) rather than failing
210    // finalize outright — a panic in either proxy thread must not also abort it.
211    {
212        let mut w = writer
213            .lock()
214            .unwrap_or_else(std::sync::PoisonError::into_inner);
215        w.flush()
216            .map_err(|e| crate::RecordError::Mcp(format!("flush writer: {e}")))?;
217        w.close()
218            .map_err(|e| crate::RecordError::Mcp(format!("close writer: {e}")))?;
219    }
220    if attached {
221        // Attached: leave the parent's lifecycle untouched. Self-heal steps so
222        // the late MCP events get ordinals without waiting for the next `hh`
223        // invocation (best-effort; a cross-process race is still possible).
224        let _ = store.assign_steps(&session_id);
225    } else {
226        let ended_at = now_unix_ms();
227        let status = match exit_code {
228            Some(0) => SessionStatus::Ok,
229            Some(_) => SessionStatus::Error,
230            None => SessionStatus::Interrupted,
231        };
232        store
233            .assign_steps(&session_id)
234            .map_err(|e| crate::RecordError::Mcp(format!("assign steps: {e}")))?;
235        store
236            .finalize_session(&session_id, ended_at, exit_code, status)
237            .map_err(|e| crate::RecordError::Mcp(format!("finalize session: {e}")))?;
238    }
239
240    Ok(McpProxyOutcome {
241        session_id,
242        short_id,
243        attached,
244        exit_code,
245    })
246}
247
248/// Shared recording state for the two proxy threads. Bundling the writer, blob
249/// store, correlation map, and session clock here keeps the thread entrypoints
250/// under the pedantic argument limit and centralizes the lock-ordering rule
251/// (map → release → writer → release; the two locks are never held together).
252#[derive(Clone)]
253struct ProxyCtx {
254    /// Single-writer event sink (CLAUDE.md intra-process rule; the `Mutex`
255    /// serializes appends from both threads).
256    writer: Arc<Mutex<EventWriter>>,
257    /// Blob store for >256 KiB spillover.
258    blobs: Arc<BlobStore>,
259    /// JSON-RPC id (string repr) → (request event id, request absolute unix-ms
260    /// ts). Populated by upstream; consumed by downstream for `correlates` +
261    /// `latency_ms`.
262    pending: Arc<Mutex<HashMap<String, (i64, i64)>>>,
263    /// Session id events are recorded into (the attached parent's, or the
264    /// standalone mcp-only session's).
265    session_id: String,
266    /// `started_at` of the session whose timeline these events share. Event
267    /// `ts_ms` is `now_unix_ms() - base_started_at` so attached MCP events
268    /// interleave correctly on the parent's clock (FR-2.3).
269    base_started_at: i64,
270}
271
272impl ProxyCtx {
273    /// Append `event` under the shared writer lock, returning the assigned row
274    /// id on success. A poisoned lock is recovered rather than treated as a
275    /// failure (a panic in the sibling thread must not blind this one too);
276    /// store errors are still swallowed (best-effort recording; forwarding
277    /// always continues).
278    fn append(&self, event: Event) -> Option<i64> {
279        let w = self
280            .writer
281            .lock()
282            .unwrap_or_else(std::sync::PoisonError::into_inner);
283        w.append_event(event).ok()
284    }
285
286    /// Look up a pending request by correlation key, returning
287    /// `(correlates_event_id, request_absolute_ts)`. Lock ordering: acquire the
288    /// map, copy out, release — never nest with the writer lock.
289    fn take_pending(&self, key: &str) -> Option<(i64, i64)> {
290        self.pending.lock().ok()?.get(key).copied()
291    }
292
293    /// Register a pending request (id → (event id, absolute ts)). Lock ordering:
294    /// the writer lock is already released by the caller before this is called.
295    fn register_pending(&self, key: String, event_id: i64, ts_abs: i64) {
296        if let Ok(mut m) = self.pending.lock() {
297            m.insert(key, (event_id, ts_abs));
298        }
299    }
300
301    /// Build an event with the common fields filled, spilling `body` to a blob
302    /// if it is ≥ [`SPILL_BYTES`].
303    fn event(
304        &self,
305        kind: EventKind,
306        ts_ms: i64,
307        summary: String,
308        body: serde_json::Value,
309    ) -> Event {
310        let (body_json, blob_hash, blob_size) = maybe_spill(body, &self.blobs);
311        Event {
312            session_id: self.session_id.clone(),
313            ts_ms,
314            kind,
315            step: None,
316            summary,
317            body_json,
318            blob_hash,
319            blob_size,
320            correlates: None,
321        }
322    }
323}
324
325/// Upstream: read NDJSON from `hh`'s stdin, classify each line, record it, and
326/// forward the raw bytes verbatim to the server's stdin. Closes the server's
327/// stdin on EOF, which is how the server learns the client is done.
328#[allow(clippy::needless_pass_by_value)] // ctx + stop are moved into the spawned thread; only refs are used inside, but ownership transfer is the point (no 'static ref can cross the thread boundary).
329fn run_upstream(mut server_stdin: impl Write + Send, ctx: ProxyCtx, stop: Arc<AtomicBool>) {
330    let stdin = std::io::stdin();
331    let mut reader = BufReader::new(stdin.lock());
332    let mut line = Vec::with_capacity(8 * 1024);
333    while !stop.load(Ordering::Acquire) {
334        line.clear();
335        let n = match reader.read_until(b'\n', &mut line) {
336            Ok(0) => break, // stdin EOF
337            Ok(n) => n,
338            Err(e) => {
339                if e.kind() != std::io::ErrorKind::Interrupted {
340                    break;
341                }
342                continue;
343            }
344        };
345        // Classify + record (best-effort), then forward the raw bytes verbatim.
346        record_upstream_line(line.as_slice(), &ctx);
347        if server_stdin.write_all(&line[..n]).is_err() {
348            break; // server closed its stdin
349        }
350        let _ = server_stdin.flush();
351    }
352    // Closing the server's stdin signals end-of-input; the server then exits.
353    let _ = server_stdin.flush();
354}
355
356/// Downstream: read NDJSON from the server's stdout, classify each line, record
357/// it (resolving `correlates` + `latency_ms` for responses), and forward the
358/// raw bytes verbatim to `hh`'s stdout.
359#[allow(clippy::needless_pass_by_value)] // ctx + stop are moved into the spawned thread; only refs are used inside, but ownership transfer is the point (no 'static ref can cross the thread boundary).
360fn run_downstream(
361    server_stdout: impl Read + Send,
362    stdout: &mut (impl Write + Send),
363    ctx: ProxyCtx,
364    stop: Arc<AtomicBool>,
365) {
366    let mut reader = BufReader::new(server_stdout);
367    let mut line = Vec::with_capacity(8 * 1024);
368    while !stop.load(Ordering::Acquire) {
369        line.clear();
370        let n = match reader.read_until(b'\n', &mut line) {
371            Ok(0) => break, // server stdout EOF (server exited)
372            Ok(n) => n,
373            Err(e) => {
374                if e.kind() != std::io::ErrorKind::Interrupted {
375                    break;
376                }
377                continue;
378            }
379        };
380        record_downstream_line(line.as_slice(), &ctx);
381        if stdout.write_all(&line[..n]).is_err() {
382            break; // client closed its stdout
383        }
384        let _ = stdout.flush();
385    }
386}
387
388/// Classify a client→server line and append an event. A request (`id` +
389/// `method`) registers its id → (event id, ts) in `pending` for the matching
390/// response; a notification (`method`, no `id`) records an `mcp_notification`.
391/// Batches (JSON arrays) record one `mcp_notification`. Unparseable lines are
392/// forwarded but not recorded (wire correctness > recording completeness).
393fn record_upstream_line(line: &[u8], ctx: &ProxyCtx) {
394    let Some(parsed) = parse_json_line(line) else {
395        return;
396    };
397    let now_abs = now_unix_ms();
398    let ts_ms = now_abs - ctx.base_started_at;
399    match &parsed {
400        serde_json::Value::Object(obj) => {
401            // Clone the small classifier fields out of the borrow scope so
402            // `parsed` can be moved into the event body below without E0505.
403            let id = obj.get("id").cloned();
404            let method = obj
405                .get("method")
406                .and_then(|v| v.as_str())
407                .map(str::to_string);
408            match (id.as_ref(), method.as_deref()) {
409                (Some(id_val), Some(method)) => {
410                    // Request: record + register the correlation key.
411                    let key = id_key(id_val);
412                    let summary = truncate_summary(&format!("mcp request: {method}"));
413                    let body = serde_json::to_value(&parsed).unwrap_or_else(|_| parsed.clone());
414                    let mut event = ctx.event(EventKind::McpRequest, ts_ms, summary, body);
415                    if let Some(k) = &key {
416                        if let Some(b) = event.body_json.as_mut() {
417                            if let Some(o) = b.as_object_mut() {
418                                o.insert("correlate_key".into(), serde_json::json!(k));
419                            }
420                        }
421                    }
422                    let event_id = ctx.append(event);
423                    if let (Some(k), Some(eid)) = (key, event_id) {
424                        // Lock ordering: the writer lock is already released
425                        // (append returned) before the map lock is taken — never
426                        // nested.
427                        ctx.register_pending(k, eid, now_abs);
428                    }
429                }
430                (None, Some(method)) => record_notification(ctx, ts_ms, method, parsed),
431                // Object with neither id nor method, or id without method:
432                // malformed MCP; forward only.
433                _ => {}
434            }
435        }
436        serde_json::Value::Array(arr) => record_batch(ctx, ts_ms, arr.len()),
437        // Scalars: forward only.
438        _ => {}
439    }
440}
441
442/// Classify a server→client line and append an event. A response (`id`, no
443/// `method`) resolves `correlates` from `pending` and records `latency_ms`;
444/// a notification records an `mcp_notification`. Batches and unparseable lines
445/// are handled as in [`record_upstream_line`].
446fn record_downstream_line(line: &[u8], ctx: &ProxyCtx) {
447    let Some(parsed) = parse_json_line(line) else {
448        return;
449    };
450    let ts_ms = now_unix_ms() - ctx.base_started_at;
451    match &parsed {
452        serde_json::Value::Object(obj) => {
453            // Clone the small classifier fields out of the borrow scope so
454            // `parsed` can be moved into the event body below without E0505.
455            let id = obj.get("id").cloned();
456            let method = obj
457                .get("method")
458                .and_then(|v| v.as_str())
459                .map(str::to_string);
460            match (id.as_ref(), method.as_deref()) {
461                (Some(id_val), None) => record_response(ctx, ts_ms, id_val, &parsed),
462                (Some(_), Some(method)) => record_server_request(ctx, ts_ms, method, parsed),
463                (None, Some(method)) => record_notification(ctx, ts_ms, method, parsed),
464                // Object with neither id nor method: malformed MCP; forward only.
465                _ => {}
466            }
467        }
468        serde_json::Value::Array(arr) => record_batch(ctx, ts_ms, arr.len()),
469        _ => {}
470    }
471}
472
473/// Record a server→client response: resolve `correlates` + `latency_ms` from
474/// the pending request, and inject `latency_ms` into the body.
475fn record_response(
476    ctx: &ProxyCtx,
477    ts_ms: i64,
478    id_val: &serde_json::Value,
479    parsed: &serde_json::Value,
480) {
481    let key = id_key(id_val);
482    let (correlates, request_ts) = match &key {
483        Some(k) => ctx
484            .take_pending(k)
485            .map_or((None, None), |(eid, ts)| (Some(eid), Some(ts))),
486        None => (None, None),
487    };
488    let latency_ms = request_ts.map(|t| now_unix_ms() - t);
489    let id_disp = id_key(id_val).unwrap_or_else(|| "?".into());
490    let summary = truncate_summary(&format!("mcp response: id {id_disp}"));
491    let mut body = serde_json::to_value(parsed).unwrap_or_else(|_| parsed.clone());
492    if let Some(lat) = latency_ms {
493        if let Some(b) = body.as_object_mut() {
494            b.insert("latency_ms".into(), serde_json::json!(lat));
495        }
496    }
497    let mut event = ctx.event(EventKind::McpResponse, ts_ms, summary, body);
498    event.correlates = correlates;
499    ctx.append(event);
500}
501
502/// Record a server-side request (a server → client message that itself has
503/// `id` + `method`, e.g. sampling/roots elicitation). No correlation target
504/// exists on this side, so `correlates` stays `None`.
505fn record_server_request(ctx: &ProxyCtx, ts_ms: i64, method: &str, parsed: serde_json::Value) {
506    let summary = truncate_summary(&format!("mcp request: {method}"));
507    let body = serde_json::to_value(&parsed).unwrap_or(parsed);
508    ctx.append(ctx.event(EventKind::McpRequest, ts_ms, summary, body));
509}
510
511/// Record an `mcp_notification` for a `method`-only message.
512fn record_notification(ctx: &ProxyCtx, ts_ms: i64, method: &str, parsed: serde_json::Value) {
513    let summary = truncate_summary(&format!("mcp notification: {method}"));
514    let body = serde_json::to_value(&parsed).unwrap_or(parsed);
515    ctx.append(ctx.event(EventKind::McpNotification, ts_ms, summary, body));
516}
517
518/// Record one `mcp_notification` for a JSON-array batch (MCP 2025-03-26). v0.1
519/// simplification: the raw line is forwarded verbatim (wire correctness holds),
520/// but no per-element correlation is recorded.
521fn record_batch(ctx: &ProxyCtx, ts_ms: i64, len: usize) {
522    let summary = truncate_summary(&format!("mcp batch: {len} messages"));
523    let body = serde_json::json!({ "batch": true, "count": len });
524    ctx.append(ctx.event(EventKind::McpNotification, ts_ms, summary, body));
525}
526
527/// Parse a single NDJSON line (trailing `\n` trimmed) as a JSON value. Returns
528/// `None` on any parse error (caller forwards the raw line verbatim regardless).
529fn parse_json_line(line: &[u8]) -> Option<serde_json::Value> {
530    let trimmed = line.strip_suffix(b"\n").unwrap_or(line);
531    let trimmed = trimmed.strip_suffix(b"\r").unwrap_or(trimmed);
532    serde_json::from_slice(trimmed).ok()
533}
534
535/// The JSON-RPC `id` as a correlation key: numbers and strings use their
536/// string repr; `null` (not usable for correlation per the spec) → `None`.
537fn id_key(v: &serde_json::Value) -> Option<String> {
538    match v {
539        serde_json::Value::Number(n) => Some(n.to_string()),
540        serde_json::Value::String(s) => Some(s.clone()),
541        _ => None,
542    }
543}
544
545/// If `body` serializes to ≥ [`SPILL_BYTES`], store it as a blob and return an
546/// overflow envelope; otherwise return the body inline. Mirrors the adapter's
547/// `maybe_spill` (FR-1.5 / FR-2 spillover contract).
548fn maybe_spill(
549    body: serde_json::Value,
550    blobs: &BlobStore,
551) -> (Option<serde_json::Value>, Option<String>, Option<u64>) {
552    let serialized = serde_json::to_vec(&body).unwrap_or_default();
553    if serialized.len() >= SPILL_BYTES {
554        if let Ok(outcome) = blobs.put(&serialized) {
555            let envelope = serde_json::json!({
556                "overflow": true,
557                "size": outcome.size,
558                "blob_hash": outcome.hash,
559                "encoding": "blob",
560            });
561            return (Some(envelope), Some(outcome.hash), Some(outcome.size));
562        }
563    }
564    (Some(body), None, None)
565}
566
567/// Truncate a summary to the SRS §4.1 limit of 120 chars (matches the runner).
568fn truncate_summary(s: &str) -> String {
569    const LIMIT: usize = 120;
570    if s.chars().count() <= LIMIT {
571        return s.to_string();
572    }
573    let truncated: String = s.chars().take(LIMIT - 1).collect();
574    format!("{truncated}…")
575}
576
577/// Current unix-ms UTC timestamp.
578fn now_unix_ms() -> i64 {
579    std::time::SystemTime::now()
580        .duration_since(std::time::UNIX_EPOCH)
581        .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
582}
583
584/// Best-effort hostname (FR-1.2). Shells out to `hostname` (no extra dep).
585fn hostname() -> Option<String> {
586    std::process::Command::new("hostname")
587        .output()
588        .ok()
589        .filter(|o| o.status.success())
590        .and_then(|o| String::from_utf8(o.stdout).ok())
591        .map(|s| s.trim().to_string())
592        .filter(|s| !s.is_empty())
593}
594
595/// Wait for the server child to exit and return its exit code (best-effort).
596fn wait_for_child(child: &mut Child) -> Option<i32> {
597    match child.wait() {
598        Ok(status) => status.code(),
599        Err(_) => None,
600    }
601}
602
603/// Fuzz-only entry points into the MCP JSON-RPC line classifier (`cargo fuzz`
604/// target `mcp_frame`). Gated behind the `fuzzing` feature so it never widens
605/// the crate's normal public API.
606#[cfg(feature = "fuzzing")]
607pub mod fuzzing {
608    use super::{record_downstream_line, record_upstream_line, ProxyCtx};
609    use hh_core::blob::BlobStore;
610    use hh_core::event::{AdapterStatus, AgentKind, NewSession};
611    use hh_core::store::Store;
612    use std::collections::HashMap;
613    use std::sync::{Arc, Mutex, OnceLock};
614
615    /// A `ProxyCtx` backed by a real (process-unique temp dir) store, built
616    /// once and reused across fuzz iterations — the interesting logic under
617    /// fuzz is the NDJSON classification in `record_*_line`, not store setup.
618    fn ctx() -> &'static ProxyCtx {
619        static CTX: OnceLock<ProxyCtx> = OnceLock::new();
620        CTX.get_or_init(|| {
621            let dir = std::env::temp_dir().join(format!("hh-fuzz-mcp-{}", std::process::id()));
622            let store =
623                Store::open(&dir.join("hh.db"), &dir.join("blobs")).expect("open fuzz store");
624            let new_session = NewSession {
625                id: hh_core::event::now_v7(),
626                started_at: 0,
627                agent_kind: AgentKind::McpOnly,
628                adapter_status: AdapterStatus::None,
629                command: vec!["fuzz".to_string()],
630                cwd: std::env::temp_dir(),
631                hostname: None,
632                hh_version: "fuzz".to_string(),
633                model: None,
634                git_branch: None,
635                git_sha: None,
636                git_dirty: None,
637            };
638            let created = store
639                .create_session(&new_session)
640                .expect("create fuzz session");
641            let writer = Arc::new(Mutex::new(store.event_writer().expect("fuzz writer")));
642            let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
643            ProxyCtx {
644                writer,
645                blobs,
646                pending: Arc::new(Mutex::new(HashMap::new())),
647                session_id: created.id,
648                base_started_at: 0,
649            }
650        })
651    }
652
653    /// Fuzz a client→server line exactly as the upstream thread processes it.
654    pub fn fuzz_upstream_line(line: &[u8]) {
655        record_upstream_line(line, ctx());
656    }
657
658    /// Fuzz a server→client line exactly as the downstream thread processes it.
659    pub fn fuzz_downstream_line(line: &[u8]) {
660        record_downstream_line(line, ctx());
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn id_key_handles_string_number_null() {
670        assert_eq!(id_key(&serde_json::json!("abc")), Some("abc".into()));
671        assert_eq!(id_key(&serde_json::json!(42)), Some("42".into()));
672        assert_eq!(id_key(&serde_json::json!(null)), None);
673        assert_eq!(id_key(&serde_json::json!(true)), None);
674    }
675
676    #[test]
677    fn parse_json_line_handles_trailing_newline_and_cr() {
678        assert!(parse_json_line(b"{\"a\":1}\n").is_some());
679        assert!(parse_json_line(b"{\"a\":1}\r\n").is_some());
680        assert!(parse_json_line(b"not json\n").is_none());
681        assert!(parse_json_line(b"").is_none());
682    }
683
684    #[test]
685    fn maybe_spill_inline_below_threshold_and_spills_above() {
686        // Real in-temp-dir blob store (CLAUDE.md: no mocks).
687        let dir = std::env::temp_dir().join(format!("hh-mcp-spill-{}", std::process::id()));
688        std::fs::create_dir_all(&dir).unwrap();
689        let blobs = BlobStore::new(dir.clone());
690
691        // A small body stays inline.
692        let small = serde_json::json!({"method": "tools/call"});
693        let (body, hash, size) = maybe_spill(small.clone(), &blobs);
694        assert_eq!(body.as_ref().unwrap(), &small);
695        assert!(hash.is_none() && size.is_none());
696
697        // A body that serializes ≥ 256 KiB spills to a blob with an envelope.
698        let big = serde_json::json!({"data": "x".repeat(SPILL_BYTES)});
699        let (body, hash, size) = maybe_spill(big, &blobs);
700        let envelope = body.expect("large body must spill");
701        assert_eq!(envelope["overflow"], serde_json::json!(true));
702        assert_eq!(envelope["encoding"], serde_json::json!("blob"));
703        let hash = hash.expect("blob hash set");
704        assert!(size.unwrap() >= SPILL_BYTES as u64);
705        // The spilled bytes round-trip from the blob store.
706        let stored = blobs.get(&hash).unwrap();
707        assert!(stored.len() >= SPILL_BYTES);
708
709        std::fs::remove_dir_all(&dir).ok();
710    }
711}