1use 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
42const SPILL_BYTES: usize = 256 * 1024;
45
46#[derive(Debug, Clone)]
48pub struct McpProxyOptions {
49 pub command: Vec<String>,
51 pub cwd: PathBuf,
53 pub session_hint: Option<String>,
56 pub hh_version: String,
58 pub redactor: Option<Arc<hh_core::redact::Detectors>>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct McpProxyOutcome {
67 pub session_id: String,
70 pub short_id: String,
73 pub attached: bool,
75 pub exit_code: Option<i32>,
77}
78
79#[allow(clippy::too_many_lines)] pub fn run_mcp_proxy(store: &Store, opts: &McpProxyOptions) -> crate::Result<McpProxyOutcome> {
89 let now = now_unix_ms();
90
91 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 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 let pending: Arc<Mutex<HashMap<String, (i64, i64)>>> = Arc::new(Mutex::new(HashMap::new()));
146 let stop = Arc::new(AtomicBool::new(false));
147
148 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 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 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 {
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 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#[derive(Clone)]
253struct ProxyCtx {
254 writer: Arc<Mutex<EventWriter>>,
257 blobs: Arc<BlobStore>,
259 pending: Arc<Mutex<HashMap<String, (i64, i64)>>>,
263 session_id: String,
266 base_started_at: i64,
270}
271
272impl ProxyCtx {
273 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 fn take_pending(&self, key: &str) -> Option<(i64, i64)> {
290 self.pending.lock().ok()?.get(key).copied()
291 }
292
293 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 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#[allow(clippy::needless_pass_by_value)] fn 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, Ok(n) => n,
338 Err(e) => {
339 if e.kind() != std::io::ErrorKind::Interrupted {
340 break;
341 }
342 continue;
343 }
344 };
345 record_upstream_line(line.as_slice(), &ctx);
347 if server_stdin.write_all(&line[..n]).is_err() {
348 break; }
350 let _ = server_stdin.flush();
351 }
352 let _ = server_stdin.flush();
354}
355
356#[allow(clippy::needless_pass_by_value)] fn 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, 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; }
384 let _ = stdout.flush();
385 }
386}
387
388fn 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 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 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 ctx.register_pending(k, eid, now_abs);
428 }
429 }
430 (None, Some(method)) => record_notification(ctx, ts_ms, method, parsed),
431 _ => {}
434 }
435 }
436 serde_json::Value::Array(arr) => record_batch(ctx, ts_ms, arr.len()),
437 _ => {}
439 }
440}
441
442fn 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 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 _ => {}
466 }
467 }
468 serde_json::Value::Array(arr) => record_batch(ctx, ts_ms, arr.len()),
469 _ => {}
470 }
471}
472
473fn 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
502fn 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
511fn 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
518fn 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
527fn 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
535fn 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
545fn 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
567fn 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
577fn 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
584fn 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
595fn wait_for_child(child: &mut Child) -> Option<i32> {
597 match child.wait() {
598 Ok(status) => status.code(),
599 Err(_) => None,
600 }
601}
602
603#[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 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 pub fn fuzz_upstream_line(line: &[u8]) {
655 record_upstream_line(line, ctx());
656 }
657
658 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 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 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 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 let stored = blobs.get(&hash).unwrap();
707 assert!(stored.len() >= SPILL_BYTES);
708
709 std::fs::remove_dir_all(&dir).ok();
710 }
711}