supercode_harness/mcp.rs
1//! P5-2 (COMPOSABLE-HARNESS-DESIGN.md §2 module 15 `mcp.client`, D7 rows
2//! 1-8): full [Model Context Protocol](https://modelcontextprotocol.io)
3//! client support — stdio (P5-1 baseline, GROWN not rewritten), remote
4//! HTTP/SSE transports, resources + templates, prompts-as-commands,
5//! server instructions, and elicitation — plus the [`handle_request`] /
6//! [`serve_stdio`] harness-as-MCP-server direction (module 16).
7//!
8//! **Transport model.** [`McpClient`] hides three wire shapes behind one
9//! `request()`/`list_tools()`/`call_tool()`/... API:
10//! - [`McpClient::connect`] — stdio (newline-delimited JSON-RPC over a
11//! spawned child process's stdin/stdout). Pre-existing (P5-1 baseline).
12//! - [`McpClient::connect_http`] — a single POST per request ("Streamable
13//! HTTP", non-streaming case): the response body is either a bare
14//! `application/json` object or a `text/event-stream` body carrying the
15//! one response event. A `Mcp-Session-Id` response header, if the server
16//! sends one, is captured and replayed on every subsequent request.
17//! - [`McpClient::connect_sse`] — the legacy (2024-11-05) HTTP+SSE
18//! transport: a persistent `GET` stream whose first event names the POST
19//! endpoint for client→server messages; a background reader task
20//! forwards every subsequent server→client frame into an in-process
21//! channel.
22//!
23//! **Server-initiated requests and notifications.** A real MCP session is
24//! bidirectional: while a client request is in flight, the server may push
25//! a notification (`resources/updated`, …) or even issue its OWN request
26//! back to the client (`elicitation/create`). [`McpClient`]'s read loop
27//! (`McpClient::handle_incoming_message`) recognizes all three shapes on
28//! both the stdio and SSE transports (persistent, bidirectional
29//! connections) and dispatches server-initiated requests to the installed
30//! [`McpElicitationHandler`] — see that trait's doc comment for the
31//! HEADLESS-DENY default and the tui-deferred interactive part. The HTTP
32//! transport is a single non-streaming request/response cycle with no
33//! return channel for a reply; a server-initiated request arriving on it
34//! is a documented, tested, fail-CLOSED error (`Error::tool("mcp", ...)`),
35//! never a silent drop or a hang — see `McpClient::http_roundtrip`'s doc
36//! comment.
37//!
38//! **Security posture (this module's own scope; see also
39//! `crate::configfile`'s project-sanitization for the config-file side).**
40//! MCP OAuth tokens are credentials, the same trust class as
41//! `Config::api_key` (§3.2 S13) — [`crate::mcp_oauth`] handles only the
42//! wire PROTOCOL (device-code grant, refresh); persistence to disk with
43//! trust-grade (owner-only, user/global-directory-only) permissions is a
44//! CLI-layer concern (`crates/cli/src/userconfig.rs`'s
45//! `save_mcp_oauth_tokens`/`load_mcp_oauth_tokens`, mirroring
46//! `save_api_key`'s existing 0600-perms precedent) — this crate never
47//! writes a token to disk itself. Remote connects honor an active
48//! [`crate::tools::NetworkPolicy`] (module 12's SSRF/domain-allowlist
49//! floor) via `crate::tools::check_network_policy`, the exact function
50//! [`crate::tools::ToolContext::check_network`] itself calls — one
51//! enforcement point, not a second parallel one.
52
53use std::collections::BTreeMap;
54use std::sync::Arc;
55use std::time::Duration;
56
57use async_trait::async_trait;
58use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
59use serde_json::{json, Value};
60use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
61use tokio::sync::{mpsc, Mutex};
62
63use crate::error::{Error, Result};
64use crate::tools::{NetworkPolicy, Tool, ToolContext, ToolRegistry};
65#[cfg(feature = "adapter-mcp")]
66use crate::{
67 FrontendAttachment, FrontendResponse, HarnessSessionService, SdkError, SdkOperation,
68 SdkRequest, SdkRuntime, SdkService,
69};
70
71const PROTOCOL_VERSION: &str = "2025-06-18";
72
73/// Default per-request timeout (connect handshake + every subsequent
74/// `request()`) for the network transports — stdio has no analogous
75/// "hung server" risk distinct from a hung read, so it is NOT subject to
76/// this timeout (a misbehaving stdio child can still be killed by the
77/// caller; `kill_on_drop` already covers process cleanup).
78pub const DEFAULT_MCP_TIMEOUT: Duration = Duration::from_secs(30);
79
80/// Hardening cap (Fable-5 review, memory-DoS-from-a-hostile-configured-
81/// server finding): the maximum size of a single non-streaming HTTP
82/// response body (`McpClient::http_roundtrip`) this client will buffer
83/// before erroring out. 16 MiB is generous for real tool-call/initialize
84/// responses (the actual payloads this transport carries) while bounding
85/// how much memory a misbehaving or malicious configured MCP server can
86/// force this process to allocate for one response.
87pub const MCP_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
88
89/// Hardening cap (same review finding as [`MCP_MAX_RESPONSE_BYTES`]): the
90/// maximum size a single un-terminated SSE frame may grow to inside
91/// `SseLineAccumulator` before it's treated as malformed/hostile and the
92/// connection is torn down, rather than the accumulator buffer growing
93/// without bound while waiting forever for a blank-line terminator that
94/// never arrives.
95pub const MCP_MAX_SSE_FRAME_BYTES: usize = 16 * 1024 * 1024;
96
97/// Hardening cap (same review finding): the maximum joined size of
98/// `resources/read`'s concatenated text contents [`McpClient::read_resource`]
99/// will return before erroring out instead of buffering an unbounded string.
100pub const MCP_MAX_RESOURCE_BYTES: usize = 16 * 1024 * 1024;
101
102/// Hardening cap (same review finding): the SSE background reader task's
103/// outbound channel capacity — bounds how many unconsumed server-pushed
104/// frames (responses + notifications) can queue up before the reader task
105/// blocks on `send` (applying backpressure to the socket read, never
106/// growing an unbounded queue) rather than being fed by an
107/// `unbounded_channel`. Large enough that ordinary notification bursts
108/// don't get throttled; a `request()` in flight (or the next one issued)
109/// drains it, so a reader task paused on a full channel is not a deadlock
110/// — see [`sse_reader_task`]'s doc comment.
111const MCP_SSE_CHANNEL_CAPACITY: usize = 256;
112
113// ============================================================================
114// ---- transport plumbing ----------------------------------------------------
115// ============================================================================
116
117/// One connected transport's read/write mechanics. Kept private —
118/// [`McpClient`] is the only thing that touches this; every public method
119/// (`list_tools`, `call_tool`, `list_resources`, …) is transport-agnostic.
120enum Conn {
121 Stdio {
122 #[allow(dead_code)] // kept alive for `kill_on_drop`
123 child: tokio::process::Child,
124 stdin: tokio::process::ChildStdin,
125 stdout: BufReader<tokio::process::ChildStdout>,
126 },
127 Http {
128 client: reqwest::Client,
129 url: String,
130 headers: HeaderMap,
131 /// Captured from a `Mcp-Session-Id` response header, if the server
132 /// sends one, and replayed on every subsequent request — some
133 /// Streamable HTTP servers require it after the first exchange.
134 session_id: Option<String>,
135 },
136 Sse {
137 client: reqwest::Client,
138 post_url: String,
139 headers: HeaderMap,
140 inbox: mpsc::Receiver<SseInboxMsg>,
141 #[allow(dead_code)] // kept alive so the background reader isn't dropped
142 reader: tokio::task::JoinHandle<()>,
143 },
144}
145
146/// One item the SSE background reader task ([`sse_reader_task`]) hands to
147/// [`McpClient::sse_roundtrip`] over the (bounded, see
148/// [`MCP_SSE_CHANNEL_CAPACITY`]) inbox channel: either a decoded JSON-RPC
149/// frame, or a fatal reason the reader task is about to exit for (e.g. the
150/// [`MCP_MAX_SSE_FRAME_BYTES`] cap being hit) — the latter lets a request
151/// waiting on the channel fail with a NAMED error instead of the generic
152/// "sse stream closed" it would otherwise see once the sender drops.
153enum SseInboxMsg {
154 Frame(Value),
155 Error(String),
156}
157
158/// Build a [`HeaderMap`] from a plain string map — used by both
159/// [`McpClient::connect_http`] and [`McpClient::connect_sse`]. An entry
160/// whose key/value isn't valid header syntax is skipped rather than
161/// failing the whole connect (a single malformed custom header shouldn't
162/// block an otherwise-valid connection); this mirrors the "best effort,
163/// never silently privilege-escalate" posture elsewhere in this crate —
164/// skipping is safe here because the effect is "header absent", never
165/// "wrong value sent".
166fn build_header_map(headers: &BTreeMap<String, String>) -> HeaderMap {
167 let mut map = HeaderMap::new();
168 for (k, v) in headers {
169 let (Ok(name), Ok(value)) = (
170 HeaderName::from_bytes(k.as_bytes()),
171 HeaderValue::from_str(v),
172 ) else {
173 continue;
174 };
175 map.insert(name, value);
176 }
177 map
178}
179
180/// How an [`McpClient`] was connected — kept on the client so
181/// [`McpClient::reconnect`] can rebuild an equivalent connection without
182/// the caller having to remember its own parameters.
183#[derive(Debug, Clone)]
184pub enum McpConnectParams {
185 /// Spawned-process transport.
186 Stdio {
187 /// The command that was spawned.
188 command: String,
189 /// Its arguments.
190 args: Vec<String>,
191 /// Extra environment variables set on top of the inherited environment.
192 env: BTreeMap<String, String>,
193 },
194 /// Streamable-HTTP (non-streaming) transport.
195 Http {
196 /// The server endpoint URL.
197 url: String,
198 /// Extra request headers.
199 headers: BTreeMap<String, String>,
200 },
201 /// Legacy HTTP+SSE transport.
202 Sse {
203 /// The SSE stream URL.
204 url: String,
205 /// Extra request headers.
206 headers: BTreeMap<String, String>,
207 },
208}
209
210// ============================================================================
211// ---- elicitation ------------------------------------------------------------
212// ============================================================================
213
214/// P5-2 (§2.1 dep "elicitation → `tools.question` surface"; §2 module 6's
215/// own row: "⚡ headless print mode (deny-default like OC, oc§1)"): a
216/// server→client `elicitation/create` request, mid-`tools/call`, asking the
217/// user for structured input.
218#[derive(Debug, Clone)]
219pub struct ElicitationRequest {
220 /// The server's human-readable prompt.
221 pub message: String,
222 /// JSON Schema for the requested input shape.
223 pub requested_schema: Value,
224}
225
226/// The outcome an [`McpElicitationHandler`] returns — the three actions the
227/// MCP elicitation spec defines.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum ElicitationAction {
230 /// The user supplied the requested data (see [`ElicitationResponse::content`]).
231 Accept,
232 /// The user was asked and declined.
233 Decline,
234 /// The interaction was cancelled/dismissed without a decision.
235 Cancel,
236}
237
238/// What an [`McpElicitationHandler`] returns for one [`ElicitationRequest`].
239#[derive(Debug, Clone)]
240pub struct ElicitationResponse {
241 /// Which of the three MCP elicitation outcomes this is.
242 pub action: ElicitationAction,
243 /// Present only when `action == Accept`.
244 pub content: Option<Value>,
245}
246
247impl ElicitationResponse {
248 fn decline() -> Self {
249 ElicitationResponse {
250 action: ElicitationAction::Decline,
251 content: None,
252 }
253 }
254
255 fn to_json_rpc_result(&self) -> Value {
256 match self.action {
257 ElicitationAction::Accept => json!({
258 "action": "accept",
259 "content": self.content.clone().unwrap_or(json!({})),
260 }),
261 ElicitationAction::Decline => json!({"action": "decline"}),
262 ElicitationAction::Cancel => json!({"action": "cancel"}),
263 }
264 }
265}
266
267/// Handles a server-initiated `elicitation/create` request — the
268/// `tools.question` surface's PROTOCOL side (§2.1 dep). The real
269/// interactive prompt UI is `tui`'s job (P5 item #4, not yet built);
270/// pending that, [`HeadlessElicitationHandler`] is the honest default —
271/// DENY (decline), matching module 6's own "headless print mode:
272/// deny-default like OC" row rather than hanging the tool call or silently
273/// fabricating an answer. An embedder (or a future `tui` integration) can
274/// install a real interactive handler via
275/// [`McpClient::set_elicitation_handler`].
276#[async_trait]
277pub trait McpElicitationHandler: Send + Sync {
278 /// Decide how to respond to one elicitation request.
279 async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse;
280}
281
282/// The default: every elicitation request is declined. Correct for
283/// non-interactive/print-mode runs (the only mode this crate's CLI
284/// embedder — `crates/cli` — runs in today); a TUI-backed handler is a
285/// tui-deferred follow-up, not built here.
286pub struct HeadlessElicitationHandler;
287
288#[async_trait]
289impl McpElicitationHandler for HeadlessElicitationHandler {
290 async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
291 ElicitationResponse::decline()
292 }
293}
294
295fn parse_elicitation_request(params: &Value) -> ElicitationRequest {
296 ElicitationRequest {
297 message: params
298 .get("message")
299 .and_then(Value::as_str)
300 .unwrap_or_default()
301 .to_string(),
302 requested_schema: params
303 .get("requestedSchema")
304 .cloned()
305 .unwrap_or_else(|| json!({})),
306 }
307}
308
309// ============================================================================
310// ---- client -----------------------------------------------------------------
311// ============================================================================
312
313/// A tool exposed by a remote MCP server.
314#[derive(Debug, Clone)]
315pub struct McpToolDef {
316 /// Tool name (unqualified — the remote server's own name for it).
317 pub name: String,
318 /// Human description.
319 pub description: String,
320 /// JSON Schema for the tool's input.
321 pub input_schema: Value,
322}
323
324/// A resource exposed by a remote MCP server (`resources/list`).
325#[derive(Debug, Clone, Default)]
326pub struct McpResourceDef {
327 /// The resource's URI.
328 pub uri: String,
329 /// Human-readable name.
330 pub name: String,
331 /// Human description.
332 pub description: String,
333 /// MIME type, if the server declared one.
334 pub mime_type: Option<String>,
335}
336
337/// A resource TEMPLATE exposed by a remote MCP server (`resources/templates/list`).
338#[derive(Debug, Clone, Default)]
339pub struct McpResourceTemplateDef {
340 /// The RFC 6570 URI template.
341 pub uri_template: String,
342 /// Human-readable name.
343 pub name: String,
344 /// Human description.
345 pub description: String,
346}
347
348/// A prompt exposed by a remote MCP server (`prompts/list`).
349#[derive(Debug, Clone, Default)]
350pub struct McpPromptDef {
351 /// Prompt name (unqualified — the remote server's own name for it).
352 pub name: String,
353 /// Human description.
354 pub description: String,
355 /// The arguments this prompt accepts.
356 pub arguments: Vec<McpPromptArgDef>,
357}
358
359/// One argument a [`McpPromptDef`] accepts.
360#[derive(Debug, Clone, Default)]
361pub struct McpPromptArgDef {
362 /// Argument name.
363 pub name: String,
364 /// Whether the server requires this argument.
365 pub required: bool,
366}
367
368/// A client connected to an MCP server over stdio, HTTP, or SSE — see the
369/// module doc comment for the transport model.
370pub struct McpClient {
371 conn: Conn,
372 next_id: i64,
373 params: McpConnectParams,
374 /// The [`NetworkPolicy`] this client was connected under (`None` for
375 /// stdio, or when no policy was passed to `connect_http`/`connect_sse`)
376 /// — remembered so [`Self::reconnect`] re-applies the SAME policy to
377 /// the rebuilt connection instead of silently reconnecting unchecked
378 /// (Fable-5 review: `reconnect` used to pass `None` regardless of what
379 /// the original connect used, reintroducing the redirect-SSRF class
380 /// `connect_http`/`connect_sse` otherwise close).
381 network_policy: Option<NetworkPolicy>,
382 timeout: Duration,
383 elicitation_handler: Arc<dyn McpElicitationHandler>,
384 /// The `instructions` field from the server's `initialize` response, if
385 /// any (§2 module 15 D7 row 5 "instructions"). `None` when the server
386 /// didn't send one.
387 pub instructions: Option<String>,
388 /// Notifications this client has received but no caller has consumed
389 /// yet (e.g. `notifications/resources/updated`) — a simple in-memory
390 /// log, since this crate has no live-push channel to the model mid-turn
391 /// (§2 module 15's resources row is request/response tool-shaped, see
392 /// `McpResourceSubscribeTool`'s doc comment).
393 pending_notifications: std::sync::Mutex<Vec<Value>>,
394}
395
396impl McpClient {
397 /// Spawn `command args...` as an MCP server and perform the `initialize`
398 /// handshake (stdio transport). `env` holds extra environment variables
399 /// for the spawned process (from the server's config `env` block, e.g.
400 /// an API token an MCP server needs) — they're set ON TOP OF supercode's
401 /// own inherited environment, never replacing it: `tokio::process::Command`
402 /// inherits the parent's environment by default (no `.env_clear()` here),
403 /// and `.envs(env)` only adds/overrides the specific named vars. This
404 /// matches Claude Code / Codex's own `env` semantics for MCP servers.
405 pub async fn connect(
406 command: &str,
407 args: &[&str],
408 env: &BTreeMap<String, String>,
409 ) -> Result<Self> {
410 let mut child = tokio::process::Command::new(command)
411 .args(args)
412 .envs(env)
413 .stdin(std::process::Stdio::piped())
414 .stdout(std::process::Stdio::piped())
415 .stderr(std::process::Stdio::null())
416 // Reap the server if the client is dropped, rather than relying on
417 // it noticing stdin EOF — a server that ignores stdin would linger.
418 .kill_on_drop(true)
419 .spawn()
420 .map_err(|e| Error::tool("mcp", format!("spawn {command}: {e}")))?;
421 let stdin = child
422 .stdin
423 .take()
424 .ok_or_else(|| Error::tool("mcp", "no stdin"))?;
425 let stdout = BufReader::new(
426 child
427 .stdout
428 .take()
429 .ok_or_else(|| Error::tool("mcp", "no stdout"))?,
430 );
431 let params = McpConnectParams::Stdio {
432 command: command.to_string(),
433 args: args.iter().map(|s| s.to_string()).collect(),
434 env: env.clone(),
435 };
436 let mut client = McpClient {
437 conn: Conn::Stdio {
438 child,
439 stdin,
440 stdout,
441 },
442 next_id: 0,
443 params,
444 // Stdio has no network policy to remember — nothing to reconnect
445 // a stdio child process against (see `NetworkPolicy`'s doc
446 // comment: it's an HTTP/SSRF floor).
447 network_policy: None,
448 timeout: DEFAULT_MCP_TIMEOUT,
449 elicitation_handler: Arc::new(HeadlessElicitationHandler),
450 instructions: None,
451 pending_notifications: std::sync::Mutex::new(Vec::new()),
452 };
453 client.initialize().await?;
454 Ok(client)
455 }
456
457 /// P5-2 (§2 module 15 D7 row 2 "remote HTTP"): connect over a single
458 /// POST-per-request "Streamable HTTP" transport (the non-streaming
459 /// case — see the module doc comment for what that scopes out).
460 /// `network_policy`, if `Some` and enabled, is enforced against `url`
461 /// BEFORE any connection is attempted (SSRF/domain-allowlist floor,
462 /// same enforcement point `ToolContext::check_network` uses).
463 pub async fn connect_http(
464 url: &str,
465 headers: &BTreeMap<String, String>,
466 network_policy: Option<&NetworkPolicy>,
467 ) -> Result<Self> {
468 crate::tools::check_network_policy(network_policy, url)?;
469 let client = reqwest::Client::builder()
470 .timeout(DEFAULT_MCP_TIMEOUT)
471 .redirect(crate::tools::network_checked_redirect_policy(
472 network_policy.cloned(),
473 ))
474 .build()
475 .map_err(|e| Error::tool("mcp", format!("building http client: {e}")))?;
476 let params = McpConnectParams::Http {
477 url: url.to_string(),
478 headers: headers.clone(),
479 };
480 let mut mcp_client = McpClient {
481 conn: Conn::Http {
482 client,
483 url: url.to_string(),
484 headers: build_header_map(headers),
485 session_id: None,
486 },
487 next_id: 0,
488 params,
489 // Remembered so `reconnect` re-enforces the SAME policy on the
490 // rebuilt connection rather than reconnecting unchecked.
491 network_policy: network_policy.cloned(),
492 timeout: DEFAULT_MCP_TIMEOUT,
493 elicitation_handler: Arc::new(HeadlessElicitationHandler),
494 instructions: None,
495 pending_notifications: std::sync::Mutex::new(Vec::new()),
496 };
497 mcp_client.initialize().await?;
498 Ok(mcp_client)
499 }
500
501 /// P5-2 (§2 module 15 D7 row 2 "remote SSE"): connect over the legacy
502 /// (2024-11-05) HTTP+SSE transport — a persistent `GET url` stream whose
503 /// first event names the client→server POST endpoint. Same
504 /// [`NetworkPolicy`] enforcement as [`Self::connect_http`].
505 pub async fn connect_sse(
506 url: &str,
507 headers: &BTreeMap<String, String>,
508 network_policy: Option<&NetworkPolicy>,
509 ) -> Result<Self> {
510 crate::tools::check_network_policy(network_policy, url)?;
511 // No client-level `.timeout()`: the GET stream is intentionally
512 // long-lived (it stays open for the connection's whole lifetime),
513 // and this same client also issues the client->server POSTs — a
514 // blanket per-request timeout would apply to (and could truncate)
515 // the persistent GET just as much as a POST. `Self::timeout`
516 // (default [`DEFAULT_MCP_TIMEOUT`]) bounds each `request()`'s WAIT
517 // for its matching response instead — see `sse_roundtrip`.
518 let client = reqwest::Client::builder()
519 .redirect(crate::tools::network_checked_redirect_policy(
520 network_policy.cloned(),
521 ))
522 .build()
523 .map_err(|e| Error::tool("mcp", format!("building sse client: {e}")))?;
524 let header_map = build_header_map(headers);
525 let mut req = client.get(url);
526 req = req.header(reqwest::header::ACCEPT, "text/event-stream");
527 req = req.headers(header_map.clone());
528 let resp = req
529 .send()
530 .await
531 .map_err(|e| Error::tool("mcp", format!("sse connect failed: {e}")))?;
532 if !resp.status().is_success() {
533 return Err(Error::tool(
534 "mcp",
535 format!("sse connect: http status {}", resp.status()),
536 ));
537 }
538 let base_url = url.to_string();
539 let (endpoint_tx, endpoint_rx) = tokio::sync::oneshot::channel();
540 // Bounded (not `unbounded_channel`): see `MCP_SSE_CHANNEL_CAPACITY`'s
541 // doc comment for why a flooding server should apply backpressure to
542 // the reader task rather than growing an unbounded in-memory queue.
543 let (msg_tx, msg_rx) = mpsc::channel(MCP_SSE_CHANNEL_CAPACITY);
544 let reader = tokio::spawn(sse_reader_task(resp, base_url, endpoint_tx, msg_tx));
545 let post_url = tokio::time::timeout(DEFAULT_MCP_TIMEOUT, endpoint_rx)
546 .await
547 .map_err(|_| Error::tool("mcp", "timed out waiting for sse endpoint event"))?
548 .map_err(|_| Error::tool("mcp", "sse stream closed before an endpoint event"))?;
549 let params = McpConnectParams::Sse {
550 url: url.to_string(),
551 headers: headers.clone(),
552 };
553 let mut mcp_client = McpClient {
554 conn: Conn::Sse {
555 client,
556 post_url,
557 headers: header_map,
558 inbox: msg_rx,
559 reader,
560 },
561 next_id: 0,
562 params,
563 // Remembered so `reconnect` re-enforces the SAME policy on the
564 // rebuilt connection rather than reconnecting unchecked.
565 network_policy: network_policy.cloned(),
566 timeout: DEFAULT_MCP_TIMEOUT,
567 elicitation_handler: Arc::new(HeadlessElicitationHandler),
568 instructions: None,
569 pending_notifications: std::sync::Mutex::new(Vec::new()),
570 };
571 mcp_client.initialize().await?;
572 Ok(mcp_client)
573 }
574
575 /// Re-establish this client's connection from its own remembered
576 /// [`McpConnectParams`] AND its own remembered [`NetworkPolicy`] (see
577 /// `Self::network_policy`'s field doc comment) — the "reconnect" half
578 /// of "connection lifecycle, reconnect, timeouts" (§2 module 15 D7 row
579 /// 1/2). Does NOT mutate `self`; the caller swaps in the returned
580 /// client (and its tools/resources need re-wrapping, since a
581 /// [`crate::tools::Tool`] closes over a specific
582 /// `Arc<Mutex<McpClient>>`).
583 ///
584 /// **Security note (Fable-5 review, latent-SSRF-landmine finding):**
585 /// this method has no callers today (unwired public API) — but a
586 /// future caller wiring it up gets the SAME [`NetworkPolicy`]
587 /// enforcement the original `connect_http`/`connect_sse` applied for
588 /// free, because the http/sse arms below pass `self.network_policy`
589 /// (not `None`) through to `connect_http`/`connect_sse`, which run the
590 /// exact same pre-connect host check + per-hop redirect re-check as
591 /// the original connect. Passing `None` here would silently reconnect
592 /// with no policy at all — the exact redirect-SSRF class those two
593 /// constructors otherwise close (`mcp_remote.rs`'s
594 /// `reconnect_reuses_the_original_network_policy` test fails on that
595 /// revert).
596 pub async fn reconnect(&self) -> Result<Self> {
597 match &self.params {
598 McpConnectParams::Stdio { command, args, env } => {
599 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
600 Self::connect(command, &args_ref, env).await
601 }
602 McpConnectParams::Http { url, headers } => {
603 Self::connect_http(url, headers, self.network_policy.as_ref()).await
604 }
605 McpConnectParams::Sse { url, headers } => {
606 Self::connect_sse(url, headers, self.network_policy.as_ref()).await
607 }
608 }
609 }
610
611 /// Install a non-default elicitation handler (e.g. a `tui` integration).
612 pub fn set_elicitation_handler(&mut self, handler: Arc<dyn McpElicitationHandler>) {
613 self.elicitation_handler = handler;
614 }
615
616 /// Per-request timeout for the network transports (stdio is unaffected
617 /// — see [`DEFAULT_MCP_TIMEOUT`]'s doc comment). Default 30s.
618 pub fn set_timeout(&mut self, timeout: Duration) {
619 self.timeout = timeout;
620 }
621
622 /// Notifications received but not yet consumed by a caller (see
623 /// `Self::pending_notifications`'s field doc comment). Draining
624 /// (`std::mem::take`) rather than cloning — a caller that wants to peek
625 /// without consuming should not call this.
626 pub fn take_pending_notifications(&self) -> Vec<Value> {
627 self.pending_notifications
628 .lock()
629 .map(|mut v| std::mem::take(&mut *v))
630 .unwrap_or_default()
631 }
632
633 async fn initialize(&mut self) -> Result<()> {
634 let result = self
635 .request(
636 "initialize",
637 json!({
638 "protocolVersion": PROTOCOL_VERSION,
639 "capabilities": {
640 // Advertise elicitation support: this client CAN
641 // receive `elicitation/create` (even though the
642 // headless-default handler always declines it) — a
643 // server that gates the elicitation capability
644 // behind the client's own advertised capability
645 // still gets a real (if headless-conservative)
646 // answer instead of never being offered the chance
647 // to ask.
648 "elicitation": {}
649 },
650 "clientInfo": {"name": "supercode", "version": env!("CARGO_PKG_VERSION")}
651 }),
652 )
653 .await?;
654 self.instructions = result
655 .get("instructions")
656 .and_then(Value::as_str)
657 .map(str::to_string);
658 // Per the MCP spec, the client sends an `initialized` notification
659 // once the handshake completes. Best-effort: a server that doesn't
660 // require it (most don't gate on it) is unaffected either way.
661 let _ = self.notify("notifications/initialized", json!({})).await;
662 Ok(())
663 }
664
665 /// Send a JSON-RPC NOTIFICATION (no reply expected). Errors are the
666 /// caller's to decide whether to propagate — `initialize`'s own call
667 /// above deliberately ignores them (best-effort).
668 async fn notify(&mut self, method: &str, params: Value) -> Result<()> {
669 let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
670 self.send_raw(&msg).await
671 }
672
673 /// Write one JSON-RPC message to the wire — the write half every
674 /// transport needs (a client request, a reply to a server-initiated
675 /// request, or a notification). The HTTP transport has no persistent
676 /// connection to write an unsolicited message on; see
677 /// [`Self::http_roundtrip`] for how it round-trips instead.
678 async fn send_raw(&mut self, msg: &Value) -> Result<()> {
679 match &mut self.conn {
680 Conn::Stdio { stdin, .. } => {
681 stdin
682 .write_all(format!("{msg}\n").as_bytes())
683 .await
684 .map_err(|e| Error::tool("mcp", format!("write: {e}")))?;
685 stdin
686 .flush()
687 .await
688 .map_err(|e| Error::tool("mcp", format!("flush: {e}")))?;
689 Ok(())
690 }
691 Conn::Sse {
692 client,
693 post_url,
694 headers,
695 ..
696 } => {
697 let resp = client
698 .post(post_url.as_str())
699 .headers(headers.clone())
700 .json(msg)
701 .send()
702 .await
703 .map_err(|e| Error::tool("mcp", format!("sse post: {e}")))?;
704 if !resp.status().is_success() {
705 return Err(Error::tool(
706 "mcp",
707 format!("sse post: http status {}", resp.status()),
708 ));
709 }
710 Ok(())
711 }
712 Conn::Http { .. } => Err(Error::tool(
713 "mcp",
714 "cannot send an unsolicited message over the http (non-streaming) transport",
715 )),
716 }
717 }
718
719 /// Dispatch one incoming JSON-RPC message while waiting for `waiting_id`'s
720 /// response. Returns `Some(result)` when `msg` IS that response
721 /// (success or error, folded to `Result` here so the caller's loop just
722 /// returns); `None` means "keep waiting" (a notification was logged, a
723 /// server-initiated request was answered, or `msg` was some other
724 /// stale/irrelevant frame).
725 async fn handle_incoming_message(
726 &mut self,
727 waiting_id: i64,
728 msg: Value,
729 ) -> Result<Option<Value>> {
730 let id = msg.get("id").and_then(Value::as_i64);
731 let has_method = msg.get("method").and_then(Value::as_str);
732
733 if id == Some(waiting_id) && has_method.is_none() {
734 if let Some(err) = msg.get("error") {
735 return Err(Error::tool("mcp", format!("rpc error: {err}")));
736 }
737 return Ok(Some(msg.get("result").cloned().unwrap_or(Value::Null)));
738 }
739
740 match (id, has_method) {
741 // A server-initiated REQUEST (has both an id and a method) —
742 // today only `elicitation/create` is understood; anything else
743 // gets a clean JSON-RPC "method not found" reply rather than
744 // silently hanging the server waiting for a response we'll
745 // never send.
746 (Some(req_id), Some(method)) => {
747 let reply = if method == "elicitation/create" {
748 let params = msg.get("params").cloned().unwrap_or(Value::Null);
749 let request = parse_elicitation_request(¶ms);
750 let handler = self.elicitation_handler.clone();
751 let response = handler.handle(&request).await;
752 json!({"jsonrpc": "2.0", "id": req_id, "result": response.to_json_rpc_result()})
753 } else {
754 json!({
755 "jsonrpc": "2.0", "id": req_id,
756 "error": {"code": -32601, "message": format!("supercode does not handle server-initiated `{method}`")}
757 })
758 };
759 self.send_raw(&reply).await?;
760 Ok(None)
761 }
762 // A notification (method, no id) — log it and keep waiting.
763 (None, Some(_)) => {
764 if let Ok(mut log) = self.pending_notifications.lock() {
765 log.push(msg);
766 }
767 Ok(None)
768 }
769 // A response to some OTHER (stale) request id, or an
770 // unparseable/irrelevant frame — ignore and keep waiting.
771 _ => Ok(None),
772 }
773 }
774
775 async fn request(&mut self, method: &str, params: Value) -> Result<Value> {
776 self.next_id += 1;
777 let id = self.next_id;
778 let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
779 match &self.conn {
780 Conn::Stdio { .. } => self.stdio_roundtrip(id, &msg).await,
781 Conn::Sse { .. } => self.sse_roundtrip(id, &msg).await,
782 Conn::Http { .. } => self.http_roundtrip(id, &msg).await,
783 }
784 }
785
786 async fn stdio_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
787 self.send_raw(msg).await?;
788 loop {
789 let Conn::Stdio { stdout, .. } = &mut self.conn else {
790 unreachable!("stdio_roundtrip called on a non-stdio connection")
791 };
792 let mut buf = String::new();
793 let n = stdout
794 .read_line(&mut buf)
795 .await
796 .map_err(|e| Error::tool("mcp", format!("read: {e}")))?;
797 if n == 0 {
798 return Err(Error::tool("mcp", "server closed the connection"));
799 }
800 let Ok(incoming) = serde_json::from_str::<Value>(buf.trim()) else {
801 continue;
802 };
803 if let Some(result) = self.handle_incoming_message(id, incoming).await? {
804 return Ok(result);
805 }
806 }
807 }
808
809 async fn sse_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
810 self.send_raw(msg).await?;
811 loop {
812 let inbox_msg = {
813 let Conn::Sse { inbox, .. } = &mut self.conn else {
814 unreachable!("sse_roundtrip called on a non-sse connection")
815 };
816 tokio::time::timeout(self.timeout, inbox.recv())
817 .await
818 .map_err(|_| Error::tool("mcp", "timed out waiting for an sse response"))?
819 .ok_or_else(|| Error::tool("mcp", "sse stream closed"))?
820 };
821 // A fatal reason the reader task sent instead of a frame (e.g.
822 // MCP_MAX_SSE_FRAME_BYTES exceeded) — surface it as a named
823 // error immediately rather than looping on it.
824 let incoming = match inbox_msg {
825 SseInboxMsg::Frame(v) => v,
826 SseInboxMsg::Error(reason) => return Err(Error::tool("mcp", reason)),
827 };
828 if let Some(result) = self.handle_incoming_message(id, incoming).await? {
829 return Ok(result);
830 }
831 }
832 }
833
834 /// P5-2: the http (Streamable-HTTP, non-streaming) round-trip. Named
835 /// limitation (see the module doc comment): a server-initiated request
836 /// embedded in the response body — e.g. an elicitation mid-call — has
837 /// no channel for this client to reply on within a single POST/response
838 /// cycle, so it is a clean, tested error rather than a silent drop or a
839 /// hang. A `text/event-stream` response body IS still supported for the
840 /// common single-event non-streaming case many Streamable HTTP servers
841 /// use to answer a `tools/call`.
842 async fn http_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
843 let (client, url, headers, session_id) = match &self.conn {
844 Conn::Http {
845 client,
846 url,
847 headers,
848 session_id,
849 } => (
850 client.clone(),
851 url.clone(),
852 headers.clone(),
853 session_id.clone(),
854 ),
855 _ => unreachable!("http_roundtrip called on a non-http connection"),
856 };
857 let mut req = client.post(&url).headers(headers).json(msg);
858 if let Some(sid) = &session_id {
859 req = req.header("Mcp-Session-Id", sid.as_str());
860 }
861 let resp = req
862 .send()
863 .await
864 .map_err(|e| Error::tool("mcp", format!("http request failed: {e}")))?;
865 if !resp.status().is_success() {
866 return Err(Error::tool("mcp", format!("http status {}", resp.status())));
867 }
868 if let Some(new_sid) = resp
869 .headers()
870 .get("mcp-session-id")
871 .and_then(|v| v.to_str().ok())
872 .map(str::to_string)
873 {
874 if let Conn::Http { session_id, .. } = &mut self.conn {
875 *session_id = Some(new_sid);
876 }
877 }
878 let content_type = resp
879 .headers()
880 .get(reqwest::header::CONTENT_TYPE)
881 .and_then(|v| v.to_str().ok())
882 .unwrap_or("")
883 .to_string();
884 // Hardening (Fable-5 review, memory-DoS finding): bounded read, not
885 // a bare `resp.bytes()` — see MCP_MAX_RESPONSE_BYTES's doc comment.
886 let body = read_capped_body(resp, MCP_MAX_RESPONSE_BYTES, "http response body").await?;
887 let frames: Vec<Value> = if content_type.starts_with("text/event-stream") {
888 parse_sse_body(&body)
889 } else {
890 vec![serde_json::from_slice::<Value>(&body)
891 .map_err(|e| Error::tool("mcp", format!("decoding http response: {e}")))?]
892 };
893 for frame in frames {
894 let frame_id = frame.get("id").and_then(Value::as_i64);
895 let has_method = frame.get("method").is_some();
896 if frame_id == Some(id) && !has_method {
897 if let Some(err) = frame.get("error") {
898 return Err(Error::tool("mcp", format!("rpc error: {err}")));
899 }
900 return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
901 }
902 if has_method {
903 // A server-initiated request/notification embedded in a
904 // non-streaming http response — see this method's doc
905 // comment for why this is a fail-closed error, not silently
906 // dropped or hung on.
907 return Err(Error::tool(
908 "mcp",
909 "server sent a server-initiated request/notification over the http \
910 (non-streaming) transport — elicitation and live notifications need \
911 stdio or sse",
912 ));
913 }
914 }
915 Err(Error::tool(
916 "mcp",
917 "http response never contained this request's result",
918 ))
919 }
920
921 // ---- tools --------------------------------------------------------
922
923 /// List the tools the server offers.
924 pub async fn list_tools(&mut self) -> Result<Vec<McpToolDef>> {
925 let result = self.request("tools/list", json!({})).await?;
926 let tools = result
927 .get("tools")
928 .and_then(Value::as_array)
929 .cloned()
930 .unwrap_or_default();
931 Ok(tools
932 .into_iter()
933 .map(|t| McpToolDef {
934 name: t
935 .get("name")
936 .and_then(Value::as_str)
937 .unwrap_or_default()
938 .to_string(),
939 description: t
940 .get("description")
941 .and_then(Value::as_str)
942 .unwrap_or_default()
943 .to_string(),
944 input_schema: t
945 .get("inputSchema")
946 .cloned()
947 .unwrap_or_else(|| json!({"type": "object"})),
948 })
949 .collect())
950 }
951
952 /// Call a tool and return its text content.
953 pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<String> {
954 let result = self
955 .request("tools/call", json!({"name": name, "arguments": arguments}))
956 .await?;
957 Ok(extract_content_text(&result))
958 }
959
960 // ---- resources + templates (§2 module 15 D7 row 3) -----------------
961
962 /// List the resources the server offers.
963 pub async fn list_resources(&mut self) -> Result<Vec<McpResourceDef>> {
964 let result = self.request("resources/list", json!({})).await?;
965 Ok(result
966 .get("resources")
967 .and_then(Value::as_array)
968 .cloned()
969 .unwrap_or_default()
970 .into_iter()
971 .map(|r| McpResourceDef {
972 uri: str_field(&r, "uri"),
973 name: str_field(&r, "name"),
974 description: str_field(&r, "description"),
975 mime_type: r
976 .get("mimeType")
977 .and_then(Value::as_str)
978 .map(str::to_string),
979 })
980 .collect())
981 }
982
983 /// List the resource templates the server offers.
984 pub async fn list_resource_templates(&mut self) -> Result<Vec<McpResourceTemplateDef>> {
985 let result = self.request("resources/templates/list", json!({})).await?;
986 Ok(result
987 .get("resourceTemplates")
988 .and_then(Value::as_array)
989 .cloned()
990 .unwrap_or_default()
991 .into_iter()
992 .map(|r| McpResourceTemplateDef {
993 uri_template: str_field(&r, "uriTemplate"),
994 name: str_field(&r, "name"),
995 description: str_field(&r, "description"),
996 })
997 .collect())
998 }
999
1000 /// Read one resource's content by URI. Errors (fail-closed, named —
1001 /// hardening, see [`MCP_MAX_RESOURCE_BYTES`]'s doc comment) if the
1002 /// joined text exceeds the cap, rather than returning/buffering an
1003 /// unbounded string.
1004 pub async fn read_resource(&mut self, uri: &str) -> Result<String> {
1005 let result = self.request("resources/read", json!({"uri": uri})).await?;
1006 let joined = result
1007 .get("contents")
1008 .and_then(Value::as_array)
1009 .map(|items| {
1010 items
1011 .iter()
1012 .filter_map(|i| {
1013 i.get("text")
1014 .and_then(Value::as_str)
1015 .map(str::to_string)
1016 .or_else(|| {
1017 i.get("blob")
1018 .and_then(Value::as_str)
1019 .map(|b| format!("[base64 blob, {} bytes encoded]", b.len()))
1020 })
1021 })
1022 .collect::<Vec<_>>()
1023 .join("\n")
1024 })
1025 .unwrap_or_default();
1026 if joined.len() > MCP_MAX_RESOURCE_BYTES {
1027 return Err(Error::tool(
1028 "mcp",
1029 format!(
1030 "resource {uri}: joined contents exceeded max {MCP_MAX_RESOURCE_BYTES} bytes"
1031 ),
1032 ));
1033 }
1034 Ok(joined)
1035 }
1036
1037 /// Subscribe to update notifications for one resource by URI — updates
1038 /// arrive as `notifications/resources/updated` frames, logged in
1039 /// [`McpClient::take_pending_notifications`].
1040 pub async fn subscribe_resource(&mut self, uri: &str) -> Result<()> {
1041 self.request("resources/subscribe", json!({"uri": uri}))
1042 .await?;
1043 Ok(())
1044 }
1045
1046 // ---- prompts-as-commands (§2 module 15 D7 row 4) --------------------
1047
1048 /// List the prompts the server offers.
1049 pub async fn list_prompts(&mut self) -> Result<Vec<McpPromptDef>> {
1050 let result = self.request("prompts/list", json!({})).await?;
1051 Ok(result
1052 .get("prompts")
1053 .and_then(Value::as_array)
1054 .cloned()
1055 .unwrap_or_default()
1056 .into_iter()
1057 .map(|p| McpPromptDef {
1058 name: str_field(&p, "name"),
1059 description: str_field(&p, "description"),
1060 arguments: p
1061 .get("arguments")
1062 .and_then(Value::as_array)
1063 .cloned()
1064 .unwrap_or_default()
1065 .into_iter()
1066 .map(|a| McpPromptArgDef {
1067 name: str_field(&a, "name"),
1068 required: a.get("required").and_then(Value::as_bool).unwrap_or(false),
1069 })
1070 .collect(),
1071 })
1072 .collect())
1073 }
1074
1075 /// Render a server prompt with `args` (a flat string->string map — the
1076 /// MCP spec's `prompts/get` `arguments` shape) into the concatenated
1077 /// text of every returned message — this crate's `Config.prompts`
1078 /// entries are likewise a single flat rendered string
1079 /// ([`crate::agent::Agent::expand_prompt`]'s local-template shape), so
1080 /// the two surfaces stay uniform to a caller.
1081 pub async fn get_prompt(
1082 &mut self,
1083 name: &str,
1084 args: BTreeMap<String, String>,
1085 ) -> Result<String> {
1086 let result = self
1087 .request("prompts/get", json!({"name": name, "arguments": args}))
1088 .await?;
1089 Ok(result
1090 .get("messages")
1091 .and_then(Value::as_array)
1092 .map(|msgs| {
1093 msgs.iter()
1094 .filter_map(|m| {
1095 m.get("content")
1096 .and_then(|c| c.get("text"))
1097 .and_then(Value::as_str)
1098 })
1099 .collect::<Vec<_>>()
1100 .join("\n\n")
1101 })
1102 .unwrap_or_default())
1103 }
1104}
1105
1106fn str_field(v: &Value, key: &str) -> String {
1107 v.get(key)
1108 .and_then(Value::as_str)
1109 .unwrap_or_default()
1110 .to_string()
1111}
1112
1113/// Pull the concatenated text out of an MCP `content` array.
1114fn extract_content_text(result: &Value) -> String {
1115 result
1116 .get("content")
1117 .and_then(Value::as_array)
1118 .map(|items| {
1119 items
1120 .iter()
1121 .filter_map(|i| i.get("text").and_then(Value::as_str))
1122 .collect::<Vec<_>>()
1123 .join("\n")
1124 })
1125 .unwrap_or_default()
1126}
1127
1128/// Read `resp`'s body up to `cap` bytes, erroring (fail-closed, named error
1129/// naming `what`) rather than buffering further — the bounded replacement
1130/// for a bare `resp.bytes()` (`McpClient::http_roundtrip`'s hardening;
1131/// see [`MCP_MAX_RESPONSE_BYTES`]'s doc comment for why). Checks
1132/// `Content-Length` first as a fast reject when the server declares a
1133/// too-large body up front, then streams chunk-by-chunk (a hostile server
1134/// can lie about `Content-Length` or omit it and stream forever) so actual
1135/// memory use never exceeds `cap` before this errors out.
1136async fn read_capped_body(resp: reqwest::Response, cap: usize, what: &str) -> Result<Vec<u8>> {
1137 use futures::StreamExt;
1138 if let Some(len) = resp.content_length() {
1139 if len as usize > cap {
1140 return Err(Error::tool(
1141 "mcp",
1142 format!("{what}: declared content-length {len} bytes exceeds max {cap} bytes"),
1143 ));
1144 }
1145 }
1146 let mut buf: Vec<u8> = Vec::new();
1147 let mut stream = resp.bytes_stream();
1148 while let Some(chunk) = stream.next().await {
1149 let chunk = chunk.map_err(|e| Error::tool("mcp", format!("reading {what}: {e}")))?;
1150 buf.extend_from_slice(&chunk);
1151 if buf.len() > cap {
1152 return Err(Error::tool(
1153 "mcp",
1154 format!("{what}: exceeded max {cap} bytes"),
1155 ));
1156 }
1157 }
1158 Ok(buf)
1159}
1160
1161/// Parse a `text/event-stream` byte body into its JSON `data:` payloads —
1162/// used both by the http transport's single-response-body case
1163/// (`McpClient::http_roundtrip`) and by the sse reader task's per-chunk
1164/// incremental parser (`SseLineAccumulator`) sharing the same per-event
1165/// field syntax. Multiple `data:` lines within one event are joined with
1166/// `\n` per the SSE spec before JSON-parsing; an event whose joined data
1167/// doesn't parse as JSON is skipped (never fatal — matches this crate's
1168/// existing stdio precedent of skipping an unparseable line).
1169fn parse_sse_body(body: &[u8]) -> Vec<Value> {
1170 let text = String::from_utf8_lossy(body);
1171 let mut out = Vec::new();
1172 for event in text.split("\n\n") {
1173 let mut data_lines = Vec::new();
1174 for line in event.lines() {
1175 if let Some(d) = line.strip_prefix("data:") {
1176 data_lines.push(d.trim_start());
1177 }
1178 }
1179 if data_lines.is_empty() {
1180 continue;
1181 }
1182 if let Ok(v) = serde_json::from_str::<Value>(&data_lines.join("\n")) {
1183 out.push(v);
1184 }
1185 }
1186 out
1187}
1188
1189/// Incremental SSE event parser for the persistent SSE reader task —
1190/// accumulates raw bytes across chunk boundaries (a `data:` line can be
1191/// split across two TCP reads) and yields one `(event_name, data)` pair per
1192/// complete (blank-line-terminated) event.
1193#[derive(Default)]
1194struct SseLineAccumulator {
1195 buf: String,
1196}
1197
1198impl SseLineAccumulator {
1199 /// Feed `chunk` in and return every complete event it produced. Errors
1200 /// (fail-closed, hardening — see [`MCP_MAX_SSE_FRAME_BYTES`]'s doc
1201 /// comment) when the trailing, still-incomplete tail left after
1202 /// draining every complete event exceeds the cap — i.e. a single frame
1203 /// that never sends its terminating blank line. The buffer is cleared
1204 /// on that error, so a caller that (today, none do) chose to keep
1205 /// pushing after an error wouldn't keep growing it either.
1206 fn push(&mut self, chunk: &[u8]) -> Result<Vec<(Option<String>, String)>> {
1207 self.buf.push_str(&String::from_utf8_lossy(chunk));
1208 let mut out = Vec::new();
1209 // Process every COMPLETE event (terminated by a blank line) currently
1210 // in the buffer; leave any trailing partial event for the next push.
1211 while let Some(pos) = self.buf.find("\n\n") {
1212 let event_text: String = self.buf.drain(..pos + 2).collect();
1213 let mut event_name = None;
1214 let mut data_lines = Vec::new();
1215 for line in event_text.lines() {
1216 if let Some(v) = line.strip_prefix("event:") {
1217 event_name = Some(v.trim_start().to_string());
1218 } else if let Some(v) = line.strip_prefix("data:") {
1219 data_lines.push(v.trim_start().to_string());
1220 }
1221 }
1222 if !data_lines.is_empty() || event_name.is_some() {
1223 out.push((event_name, data_lines.join("\n")));
1224 }
1225 }
1226 if self.buf.len() > MCP_MAX_SSE_FRAME_BYTES {
1227 self.buf.clear();
1228 return Err(Error::tool(
1229 "mcp",
1230 format!(
1231 "sse frame exceeded max {MCP_MAX_SSE_FRAME_BYTES} bytes without a \
1232 terminating blank line"
1233 ),
1234 ));
1235 }
1236 Ok(out)
1237 }
1238}
1239
1240/// Background task for [`McpClient::connect_sse`]: reads `resp`'s byte
1241/// stream, sends the discovered POST endpoint URL (from the first
1242/// `event: endpoint` frame) on `endpoint_tx` exactly once, and forwards
1243/// every subsequent JSON-parseable `event: message` frame's `data:` payload
1244/// into `msg_tx`. Exits quietly (dropping both channels) when the stream
1245/// ends — a `request()` waiting on `msg_tx`'s receiver then sees a closed
1246/// channel and reports "sse stream closed" rather than hanging forever.
1247///
1248/// `msg_tx` is bounded ([`MCP_SSE_CHANNEL_CAPACITY`]) — `.send(..).await`
1249/// below therefore applies backpressure (this task simply stops draining
1250/// the socket) when nothing has called `request()`/drained `inbox` in a
1251/// while, rather than this task buffering an unbounded queue of unconsumed
1252/// frames. That's not a deadlock: this task is the only thing that can
1253/// ever fill the channel, and the next `McpClient::request()` (or the one
1254/// already in flight) is the thing that drains it — there's no cycle where
1255/// this task itself needs to make progress for that drain to happen. On an
1256/// `SseLineAccumulator` error (an oversized, un-terminated frame — see
1257/// [`MCP_MAX_SSE_FRAME_BYTES`]) this task sends a named
1258/// [`SseInboxMsg::Error`] and exits, so a pending `request()` fails fast
1259/// with a clear reason instead of just seeing a closed channel.
1260async fn sse_reader_task(
1261 resp: reqwest::Response,
1262 base_url: String,
1263 endpoint_tx: tokio::sync::oneshot::Sender<String>,
1264 msg_tx: mpsc::Sender<SseInboxMsg>,
1265) {
1266 use futures::StreamExt;
1267 let mut stream = resp.bytes_stream();
1268 let mut acc = SseLineAccumulator::default();
1269 let mut endpoint_tx = Some(endpoint_tx);
1270 while let Some(chunk) = stream.next().await {
1271 let Ok(bytes) = chunk else { break };
1272 let events = match acc.push(&bytes) {
1273 Ok(events) => events,
1274 Err(e) => {
1275 let _ = msg_tx.send(SseInboxMsg::Error(e.to_string())).await;
1276 return;
1277 }
1278 };
1279 for (event_name, data) in events {
1280 match event_name.as_deref() {
1281 Some("endpoint") => {
1282 if let Some(tx) = endpoint_tx.take() {
1283 let resolved = resolve_endpoint_url(&base_url, data.trim());
1284 let _ = tx.send(resolved);
1285 }
1286 }
1287 _ => {
1288 // "message" (the spec name) or an unnamed event — any
1289 // frame with `data:` that isn't the endpoint discovery
1290 // event is a JSON-RPC message.
1291 if let Ok(v) = serde_json::from_str::<Value>(&data) {
1292 if msg_tx.send(SseInboxMsg::Frame(v)).await.is_err() {
1293 return; // no one is listening anymore
1294 }
1295 }
1296 }
1297 }
1298 }
1299 }
1300}
1301
1302/// Resolve the `endpoint` event's `data:` payload (which the spec allows to
1303/// be a bare path, e.g. `/messages?session=abc`) against the SSE stream's
1304/// own origin — an absolute URL passes through unchanged.
1305fn resolve_endpoint_url(base_url: &str, endpoint: &str) -> String {
1306 if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
1307 return endpoint.to_string();
1308 }
1309 let Some(scheme_end) = base_url.find("://") else {
1310 return endpoint.to_string();
1311 };
1312 let after_scheme = &base_url[scheme_end + 3..];
1313 let origin_end = after_scheme.find('/').map(|i| scheme_end + 3 + i);
1314 let origin = match origin_end {
1315 Some(end) => &base_url[..end],
1316 None => base_url,
1317 };
1318 if endpoint.starts_with('/') {
1319 format!("{origin}{endpoint}")
1320 } else {
1321 format!("{origin}/{endpoint}")
1322 }
1323}
1324
1325// ============================================================================
1326// ---- server-handle: shared client + everything a server attach produces ---
1327// ============================================================================
1328
1329/// P5-2: one connected server, wrapping the `Arc<Mutex<McpClient>>` every
1330/// derived [`Tool`]/prompt-source shares — the single point that produces
1331/// tools ([`McpTool`]), resource tools, prompt names, and the server's
1332/// folded-in instructions, so a caller (`crates/cli`'s `attach_mcp`) only
1333/// has to connect once and ask this handle for everything else.
1334#[derive(Clone)]
1335pub struct McpServerHandle {
1336 /// This server's name (the `mcp__<server>__…` namespace prefix).
1337 pub server: String,
1338 client: Arc<Mutex<McpClient>>,
1339}
1340
1341impl McpServerHandle {
1342 /// Wrap an already-connected `client` under `server`'s name.
1343 pub fn new(server: impl Into<String>, client: McpClient) -> Self {
1344 McpServerHandle {
1345 server: server.into(),
1346 client: Arc::new(Mutex::new(client)),
1347 }
1348 }
1349
1350 /// The server's `initialize`-time instructions, if any.
1351 pub async fn instructions(&self) -> Option<String> {
1352 self.client.lock().await.instructions.clone()
1353 }
1354
1355 /// This server's tools, namespaced `mcp__<server>__<tool>`.
1356 pub async fn tools(&self) -> Result<Vec<McpTool>> {
1357 let defs = self.client.lock().await.list_tools().await?;
1358 Ok(defs
1359 .into_iter()
1360 .map(|d| McpTool {
1361 name: format!("mcp__{}__{}", self.server, d.name),
1362 description: d.description,
1363 parameters: d.input_schema,
1364 remote_name: d.name,
1365 client: self.client.clone(),
1366 })
1367 .collect())
1368 }
1369
1370 /// This server's resource-access tools (`resources_list`/`_read`/
1371 /// `_subscribe`), always offered regardless of whether the server
1372 /// actually advertised a `resources` capability — a server that
1373 /// doesn't support resources simply errors clearly on the underlying
1374 /// `resources/list` call (the same "let the remote error surface"
1375 /// posture [`McpTool::execute`] already has for `tools/call`), rather
1376 /// than this client trying to pre-negotiate capabilities perfectly.
1377 pub fn resource_tools(&self) -> Vec<Box<dyn Tool>> {
1378 vec![
1379 Box::new(McpResourcesListTool {
1380 name: format!("mcp__{}__resources_list", self.server),
1381 client: self.client.clone(),
1382 }),
1383 Box::new(McpResourceReadTool {
1384 name: format!("mcp__{}__resources_read", self.server),
1385 client: self.client.clone(),
1386 }),
1387 Box::new(McpResourceSubscribeTool {
1388 name: format!("mcp__{}__resources_subscribe", self.server),
1389 client: self.client.clone(),
1390 }),
1391 ]
1392 }
1393
1394 /// This server's prompts, namespaced `mcp__<server>__<prompt>` — see
1395 /// [`McpPromptSource`]'s doc comment for why namespacing (not a bare
1396 /// name) is the mechanism that keeps an untrusted/remote server from
1397 /// ever being able to collide with a trusted command name.
1398 pub async fn prompts(&self) -> Result<Vec<(String, McpPromptSource)>> {
1399 let defs = self.client.lock().await.list_prompts().await?;
1400 Ok(defs
1401 .into_iter()
1402 .map(|d| {
1403 (
1404 format!("mcp__{}__{}", self.server, d.name),
1405 McpPromptSource {
1406 client: self.client.clone(),
1407 remote_name: d.name,
1408 arg_names: d.arguments.into_iter().map(|a| a.name).collect(),
1409 },
1410 )
1411 })
1412 .collect())
1413 }
1414
1415 /// The shared client — for callers that need lower-level access (e.g.
1416 /// installing an elicitation handler, or OAuth token refresh wiring).
1417 pub fn client(&self) -> Arc<Mutex<McpClient>> {
1418 self.client.clone()
1419 }
1420}
1421
1422/// A supercode [`Tool`] backed by a remote MCP tool. The name is namespaced
1423/// `mcp__<server>__<tool>` to match the convention seen in the corpus.
1424pub struct McpTool {
1425 name: String,
1426 description: String,
1427 parameters: Value,
1428 remote_name: String,
1429 client: Arc<Mutex<McpClient>>,
1430}
1431
1432impl McpTool {
1433 /// Wrap every tool from `client` (already connected) under `server`
1434 /// prefix. Kept for API/test back-compat (P5-1 baseline signature); new
1435 /// callers that also want resources/prompts/instructions should use
1436 /// [`McpServerHandle`] directly.
1437 pub async fn from_client(server: &str, client: McpClient) -> Result<Vec<McpTool>> {
1438 McpServerHandle::new(server, client).tools().await
1439 }
1440}
1441
1442#[async_trait]
1443impl Tool for McpTool {
1444 fn name(&self) -> &str {
1445 &self.name
1446 }
1447 fn description(&self) -> &str {
1448 &self.description
1449 }
1450 fn parameters(&self) -> Value {
1451 self.parameters.clone()
1452 }
1453 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1454 self.client
1455 .lock()
1456 .await
1457 .call_tool(&self.remote_name, args)
1458 .await
1459 }
1460}
1461
1462/// A prompt this crate can render via `prompts/get` — what
1463/// [`McpServerHandle::prompts`] hands back for a caller to register as a
1464/// slash-command source (`crate::agent::Agent::register_mcp_prompt`).
1465///
1466/// **P4d-class security lesson, closed BY CONSTRUCTION (§2 module 15 D7 row
1467/// 4's own security note, "an MCP-provided prompt from an untrusted/
1468/// project-scoped server must not silently override a trusted command
1469/// name"):** every prompt this crate surfaces is namespaced
1470/// `mcp__<server>__<prompt>` — never the bare remote name. Since no
1471/// built-in or user-authored `[core.prompts]` command name is EVER
1472/// `mcp__`-prefixed (that prefix is reserved by this module), a remote
1473/// server — however untrusted, however maliciously named its prompts are —
1474/// cannot construct a colliding key: `mcp__evil__code-review` and
1475/// `code-review` are simply different map keys. This is the same
1476/// "namespace instead of trust-flag" treatment [`McpTool`] already applies
1477/// to tool names; a test in `crates/harness/tests/mcp_prompts.rs` pins it
1478/// (`untrusted_mcp_prompt_cannot_override_a_trusted_command_name`).
1479#[derive(Clone)]
1480pub struct McpPromptSource {
1481 client: Arc<Mutex<McpClient>>,
1482 remote_name: String,
1483 /// The server-declared argument names, in `prompts/list` order — used
1484 /// by `crate::agent::Agent::expand_prompt_async` to map a slash
1485 /// command's trailing free text onto this prompt's named arguments
1486 /// (single-argument prompts get the whole trailing text; multi-argument
1487 /// prompts expect `key=value` pairs — see that method's doc comment).
1488 arg_names: Vec<String>,
1489}
1490
1491impl McpPromptSource {
1492 /// Render this prompt with `args` (see [`McpClient::get_prompt`]).
1493 pub async fn render(&self, args: BTreeMap<String, String>) -> Result<String> {
1494 self.client
1495 .lock()
1496 .await
1497 .get_prompt(&self.remote_name, args)
1498 .await
1499 }
1500
1501 /// This prompt's declared argument names, in order.
1502 pub fn arg_names(&self) -> &[String] {
1503 &self.arg_names
1504 }
1505}
1506
1507#[async_trait]
1508impl crate::sdk::SdkPromptSource for McpPromptSource {
1509 async fn render(&self, args: BTreeMap<String, String>) -> Result<String> {
1510 McpPromptSource::render(self, args).await
1511 }
1512
1513 fn arg_names(&self) -> &[String] {
1514 McpPromptSource::arg_names(self)
1515 }
1516}
1517
1518// ---- resource tools ---------------------------------------------------
1519
1520struct McpResourcesListTool {
1521 name: String,
1522 client: Arc<Mutex<McpClient>>,
1523}
1524
1525#[async_trait]
1526impl Tool for McpResourcesListTool {
1527 fn name(&self) -> &str {
1528 &self.name
1529 }
1530 fn description(&self) -> &str {
1531 "List this MCP server's available resources and resource templates."
1532 }
1533 fn parameters(&self) -> Value {
1534 json!({"type": "object", "properties": {}, "additionalProperties": false})
1535 }
1536 async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
1537 let mut client = self.client.lock().await;
1538 let resources = client.list_resources().await?;
1539 let templates = client.list_resource_templates().await?;
1540 let mut out = String::new();
1541 for r in &resources {
1542 out.push_str(&format!("- {} ({})\n", r.uri, r.name));
1543 }
1544 for t in &templates {
1545 out.push_str(&format!("- template: {} ({})\n", t.uri_template, t.name));
1546 }
1547 if out.is_empty() {
1548 out.push_str("(no resources or templates)\n");
1549 }
1550 Ok(out)
1551 }
1552}
1553
1554#[derive(serde::Deserialize)]
1555struct ResourceUriArgs {
1556 uri: String,
1557}
1558
1559struct McpResourceReadTool {
1560 name: String,
1561 client: Arc<Mutex<McpClient>>,
1562}
1563
1564#[async_trait]
1565impl Tool for McpResourceReadTool {
1566 fn name(&self) -> &str {
1567 &self.name
1568 }
1569 fn description(&self) -> &str {
1570 "Read one resource from this MCP server by URI."
1571 }
1572 fn parameters(&self) -> Value {
1573 json!({
1574 "type": "object",
1575 "properties": {"uri": {"type": "string"}},
1576 "required": ["uri"],
1577 "additionalProperties": false
1578 })
1579 }
1580 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1581 let a: ResourceUriArgs =
1582 serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
1583 tool: self.name.clone(),
1584 message: e.to_string(),
1585 })?;
1586 self.client.lock().await.read_resource(&a.uri).await
1587 }
1588}
1589
1590struct McpResourceSubscribeTool {
1591 name: String,
1592 client: Arc<Mutex<McpClient>>,
1593}
1594
1595#[async_trait]
1596impl Tool for McpResourceSubscribeTool {
1597 fn name(&self) -> &str {
1598 &self.name
1599 }
1600 fn description(&self) -> &str {
1601 "Subscribe to update notifications for one resource on this MCP server by URI. \
1602 Updates surface as this server's pending-notifications log (no live push into the \
1603 conversation) — call resources_list/resources_read again to see the latest content."
1604 }
1605 fn parameters(&self) -> Value {
1606 json!({
1607 "type": "object",
1608 "properties": {"uri": {"type": "string"}},
1609 "required": ["uri"],
1610 "additionalProperties": false
1611 })
1612 }
1613 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1614 let a: ResourceUriArgs =
1615 serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
1616 tool: self.name.clone(),
1617 message: e.to_string(),
1618 })?;
1619 self.client.lock().await.subscribe_resource(&a.uri).await?;
1620 Ok(format!("subscribed to {}", a.uri))
1621 }
1622}
1623
1624// ============================================================================
1625// ---- C2 cache-invalidation signal (§2.2 C2) ---------------------------------
1626// ============================================================================
1627
1628/// P5-2 (§2.2 C2 "connect invalidates cache prefix"; §2 module 25 `cache`
1629/// is the referee): the churn notice a caller (`crates/cli`'s `attach_mcp`)
1630/// emits when connecting a server under an active `CachePlan::ImportedPrefix`
1631/// — a pure, independently-testable function so the wording/threshold logic
1632/// isn't buried in CLI plumbing. "At minimum emit the churn signal" (P5-2
1633/// build brief) — this is that signal; it does not itself reset any cache
1634/// bookkeeping (see `Agent::register_tool`'s own C2 note for the runtime
1635/// half: any tool registered after the agent's first turn resets
1636/// `cache_established`, MCP-sourced or not).
1637pub fn cache_churn_notice(server: &str, tool_count: usize) -> String {
1638 format!(
1639 "mcp: connecting `{server}` added {tool_count} tool(s) to the prompt prefix — with \
1640 an imported-prefix cache plan active, this likely invalidates the cache hit on the \
1641 next turn (C2)"
1642 )
1643}
1644
1645// ============================================================================
1646// ---- server side ------------------------------------------------------------
1647// ============================================================================
1648
1649/// MCP tool projection of the versioned SDK facade. The MCP envelope and
1650/// tool-call id never enter the SDK request or its canonical session data.
1651#[cfg(feature = "adapter-mcp")]
1652pub struct SdkMcpTool {
1653 service: Arc<Mutex<HarnessSessionService>>,
1654 runtime: Option<Arc<dyn SdkRuntime>>,
1655 attachment: Arc<Mutex<Option<FrontendAttachment>>>,
1656}
1657
1658#[cfg(feature = "adapter-mcp")]
1659impl Default for SdkMcpTool {
1660 fn default() -> Self {
1661 Self::new()
1662 }
1663}
1664
1665#[cfg(feature = "adapter-mcp")]
1666impl SdkMcpTool {
1667 /// Create an independent stateful SDK projection for one MCP server.
1668 pub fn new() -> Self {
1669 Self {
1670 service: Arc::new(Mutex::new(HarnessSessionService::new())),
1671 runtime: None,
1672 attachment: Arc::new(Mutex::new(None)),
1673 }
1674 }
1675
1676 /// Project an already-owned SDK runtime into MCP without granting MCP
1677 /// process-launch, persistence, or shutdown authority.
1678 pub async fn attached(runtime: Arc<dyn SdkRuntime>) -> std::result::Result<Self, SdkError> {
1679 let attachment = runtime.attach(200).await?;
1680 Ok(Self {
1681 service: Arc::new(Mutex::new(HarnessSessionService::new())),
1682 runtime: Some(runtime),
1683 attachment: Arc::new(Mutex::new(Some(attachment))),
1684 })
1685 }
1686
1687 async fn execute_attached(
1688 &self,
1689 runtime: &Arc<dyn SdkRuntime>,
1690 operation: SdkOperation,
1691 params: Value,
1692 ) -> std::result::Result<Value, SdkError> {
1693 let descriptor = runtime.describe().await?;
1694 let session_id = descriptor.session_id;
1695 match operation {
1696 SdkOperation::Input => {
1697 let prompt = params
1698 .get("prompt")
1699 .or_else(|| params.get("text"))
1700 .and_then(Value::as_str)
1701 .ok_or_else(|| {
1702 SdkError::new(
1703 crate::SdkErrorCode::InvalidArgument,
1704 operation,
1705 "input requires string `prompt` or `text`",
1706 )
1707 })?;
1708 let image_urls = match params.get("image_urls") {
1709 None => Vec::new(),
1710 Some(Value::Array(values)) => values
1711 .iter()
1712 .map(|value| {
1713 value.as_str().map(str::to_owned).ok_or_else(|| {
1714 SdkError::new(
1715 crate::SdkErrorCode::InvalidArgument,
1716 operation,
1717 "input requires string entries in `image_urls`",
1718 )
1719 })
1720 })
1721 .collect::<std::result::Result<Vec<_>, _>>()?,
1722 Some(_) => {
1723 return Err(SdkError::new(
1724 crate::SdkErrorCode::InvalidArgument,
1725 operation,
1726 "input requires array `image_urls`",
1727 ))
1728 }
1729 };
1730 let reply = runtime
1731 .submit_with_images(prompt.to_string(), image_urls)
1732 .await?;
1733 Ok(json!({"session_id":session_id, "reply":reply}))
1734 }
1735 SdkOperation::Events => {
1736 let mut attachment = self.attachment.lock().await;
1737 if attachment.is_none() {
1738 *attachment = Some(runtime.attach(200).await?);
1739 }
1740 let event = attachment
1741 .as_mut()
1742 .expect("attachment initialized")
1743 .next_event()
1744 .await?;
1745 Ok(json!({"session_id":session_id, "event":event}))
1746 }
1747 SdkOperation::Interrupt => Ok(json!({
1748 "session_id":session_id,
1749 "interrupted":runtime.interrupt().await?,
1750 })),
1751 SdkOperation::Steer => {
1752 let prompt = params
1753 .get("prompt")
1754 .or_else(|| params.get("text"))
1755 .and_then(Value::as_str)
1756 .ok_or_else(|| {
1757 SdkError::new(
1758 crate::SdkErrorCode::InvalidArgument,
1759 operation,
1760 "steer requires string `prompt` or `text`",
1761 )
1762 })?;
1763 runtime.steer(prompt.to_string()).await?;
1764 Ok(json!({"session_id":session_id}))
1765 }
1766 SdkOperation::Respond => {
1767 let response = serde_json::from_value::<FrontendResponse>(
1768 params.get("response").cloned().unwrap_or(Value::Null),
1769 )
1770 .map_err(|error| {
1771 SdkError::new(
1772 crate::SdkErrorCode::InvalidArgument,
1773 operation,
1774 error.to_string(),
1775 )
1776 })?;
1777 runtime.respond(response).await?;
1778 Ok(json!({"session_id":session_id}))
1779 }
1780 _ => Err(SdkError::unsupported(operation)),
1781 }
1782 }
1783}
1784
1785#[async_trait]
1786#[cfg(feature = "adapter-mcp")]
1787impl Tool for SdkMcpTool {
1788 fn name(&self) -> &str {
1789 "supercode_sdk"
1790 }
1791
1792 fn description(&self) -> &str {
1793 "Invoke one operation on Supercode's versioned session/runtime SDK facade."
1794 }
1795
1796 fn parameters(&self) -> Value {
1797 json!({
1798 "type": "object",
1799 "properties": {
1800 "operation": {
1801 "type": "string",
1802 "enum": ["discover", "load", "start", "resume", "input", "events", "interrupt", "steer", "respond", "export", "close"]
1803 },
1804 "params": {"type": "object"}
1805 },
1806 "required": ["operation"],
1807 "additionalProperties": false
1808 })
1809 }
1810
1811 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1812 let operation = serde_json::from_value::<SdkOperation>(
1813 args.get("operation").cloned().unwrap_or(Value::Null),
1814 )
1815 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1816 if let Some(runtime) = &self.runtime {
1817 return self
1818 .execute_attached(
1819 runtime,
1820 operation,
1821 args.get("params").cloned().unwrap_or_else(|| json!({})),
1822 )
1823 .await
1824 .and_then(|value| {
1825 serde_json::to_string(&value)
1826 .map_err(|error| SdkError::Transport(error.to_string()))
1827 })
1828 .map_err(|error| sdk_mcp_error(self.name(), &error));
1829 }
1830 if !matches!(
1831 operation,
1832 SdkOperation::Discover | SdkOperation::Load | SdkOperation::Export
1833 ) {
1834 return Err(Error::tool(
1835 self.name(),
1836 format!(
1837 "SUPERCODE_SDK_ERROR:{}",
1838 json!({
1839 "name":"unsupported_action",
1840 "operation":operation,
1841 "message":"the MCP SDK adapter is read-only; runtime control requires an owner surface",
1842 })
1843 ),
1844 ));
1845 }
1846 let mut params = args.get("params").cloned().unwrap_or_else(|| json!({}));
1847 confine_sdk_mcp_params(operation, &mut params, ctx)?;
1848 let result = self
1849 .service
1850 .lock()
1851 .await
1852 .execute(SdkRequest { operation, params })
1853 .await
1854 .map_err(|error| sdk_mcp_error(self.name(), &error))?;
1855 serde_json::to_string(&result).map_err(|error| Error::tool(self.name(), error.to_string()))
1856 }
1857}
1858
1859#[cfg(feature = "adapter-mcp")]
1860fn sdk_mcp_error(tool: &str, error: &SdkError) -> Error {
1861 Error::tool(
1862 tool,
1863 format!(
1864 "SUPERCODE_SDK_ERROR:{}",
1865 json!({
1866 "name":error.code(),
1867 "operation":error.operation(),
1868 "message":error.to_string(),
1869 })
1870 ),
1871 )
1872}
1873
1874#[cfg(feature = "adapter-mcp")]
1875fn confine_sdk_mcp_params(
1876 operation: SdkOperation,
1877 params: &mut Value,
1878 ctx: &ToolContext,
1879) -> Result<()> {
1880 if operation == SdkOperation::Discover {
1881 if params.get("homes").is_some() {
1882 return Err(Error::tool(
1883 "supercode_sdk",
1884 "MCP discovery cannot override harness homes",
1885 ));
1886 }
1887 params["workspace"] = json!(ctx.cwd);
1888 return Ok(());
1889 }
1890 let path = params
1891 .pointer("/locator/storage/path")
1892 .and_then(Value::as_str)
1893 .ok_or_else(|| Error::tool("supercode_sdk", "load/export requires locator.storage.path"))?;
1894 let path = ctx.resolve(path);
1895 if !crate::safe_path::contained(&ctx.cwd, &path) {
1896 return Err(Error::tool(
1897 "supercode_sdk",
1898 "session locator escapes the MCP workspace",
1899 ));
1900 }
1901 params["locator"]["storage"]["path"] = json!(path);
1902 Ok(())
1903}
1904
1905/// Register the SDK adapter alongside ordinary MCP coding tools.
1906#[cfg(feature = "adapter-mcp")]
1907pub fn register_sdk_tool(registry: &mut ToolRegistry) {
1908 registry.register(SdkMcpTool::new());
1909}
1910
1911/// Handle one JSON-RPC request against a [`ToolRegistry`], returning the
1912/// JSON-RPC response (or `None` for notifications that need no reply).
1913pub async fn handle_request(
1914 registry: &ToolRegistry,
1915 ctx: &ToolContext,
1916 request: &Value,
1917) -> Option<Value> {
1918 let id = request.get("id").cloned();
1919 let method = request.get("method").and_then(Value::as_str).unwrap_or("");
1920 let reply = |result: Value| Some(json!({"jsonrpc": "2.0", "id": id, "result": result}));
1921
1922 match method {
1923 "initialize" => reply(json!({
1924 "protocolVersion": PROTOCOL_VERSION,
1925 "capabilities": {"tools": {}},
1926 "serverInfo": {"name": "supercode", "version": env!("CARGO_PKG_VERSION")}
1927 })),
1928 "tools/list" => {
1929 let tools: Vec<Value> = registry
1930 .iter()
1931 .map(|t| {
1932 json!({
1933 "name": t.name(),
1934 "description": t.description(),
1935 "inputSchema": t.parameters(),
1936 })
1937 })
1938 .collect();
1939 reply(json!({"tools": tools}))
1940 }
1941 "tools/call" => {
1942 let params = request.get("params").cloned().unwrap_or(Value::Null);
1943 let name = params.get("name").and_then(Value::as_str).unwrap_or("");
1944 let args = params.get("arguments").cloned().unwrap_or(json!({}));
1945 match registry.get(name) {
1946 None => Some(json!({
1947 "jsonrpc": "2.0", "id": id,
1948 "error": {"code": -32601, "message": format!("unknown tool `{name}`")}
1949 })),
1950 Some(tool) => {
1951 let (text, is_error, structured) = match tool.execute(args, ctx).await {
1952 Ok(t) => (t, false, None),
1953 Err(e) => {
1954 let text = e.to_string();
1955 let structured = text
1956 .split_once("SUPERCODE_SDK_ERROR:")
1957 .and_then(|(_, value)| serde_json::from_str::<Value>(value).ok())
1958 .map(|error| json!({"error":error}));
1959 (format!("Error: {text}"), true, structured)
1960 }
1961 };
1962 reply(json!({
1963 "content": [{"type": "text", "text": text}],
1964 "isError": is_error,
1965 "structuredContent": structured,
1966 }))
1967 }
1968 }
1969 }
1970 // Notifications (no id) and unknown methods.
1971 _ if id.is_none() => None,
1972 _ => Some(json!({
1973 "jsonrpc": "2.0", "id": id,
1974 "error": {"code": -32601, "message": format!("unknown method `{method}`")}
1975 })),
1976 }
1977}
1978
1979/// Run a blocking stdio MCP server exposing `registry`, reading requests from
1980/// stdin and writing responses to stdout until EOF.
1981pub async fn serve_stdio(registry: &ToolRegistry, ctx: &ToolContext) -> Result<()> {
1982 let mut stdin = BufReader::new(tokio::io::stdin());
1983 let mut stdout = tokio::io::stdout();
1984 let mut line = String::new();
1985 loop {
1986 line.clear();
1987 if stdin.read_line(&mut line).await? == 0 {
1988 break;
1989 }
1990 let Ok(req) = serde_json::from_str::<Value>(line.trim()) else {
1991 continue;
1992 };
1993 if let Some(resp) = handle_request(registry, ctx, &req).await {
1994 stdout.write_all(format!("{resp}\n").as_bytes()).await?;
1995 stdout.flush().await?;
1996 }
1997 }
1998 Ok(())
1999}
2000
2001#[cfg(test)]
2002mod tests {
2003 use super::*;
2004
2005 #[tokio::test]
2006 async fn mcp_sdk_tool_is_a_thin_named_error_projection() {
2007 let mut registry = ToolRegistry::new();
2008 register_sdk_tool(&mut registry);
2009 let ctx = ToolContext::new(std::env::temp_dir());
2010
2011 let listed = handle_request(
2012 ®istry,
2013 &ctx,
2014 &json!({"jsonrpc":"2.0", "id":1, "method":"tools/list"}),
2015 )
2016 .await
2017 .unwrap();
2018 assert_eq!(listed["result"]["tools"][0]["name"], "supercode_sdk");
2019
2020 let response = handle_request(
2021 ®istry,
2022 &ctx,
2023 &json!({
2024 "jsonrpc":"2.0",
2025 "id":2,
2026 "method":"tools/call",
2027 "params": {
2028 "name":"supercode_sdk",
2029 "arguments":{"operation":"steer", "params":{}}
2030 }
2031 }),
2032 )
2033 .await
2034 .unwrap();
2035 assert_eq!(response["result"]["isError"], true);
2036 assert_eq!(
2037 response["result"]["structuredContent"]["error"]["name"],
2038 "unsupported_action"
2039 );
2040 assert_eq!(
2041 response["result"]["structuredContent"]["error"]["operation"],
2042 "steer"
2043 );
2044 }
2045
2046 #[test]
2047 fn cache_churn_notice_names_server_and_count() {
2048 let msg = cache_churn_notice("github", 12);
2049 assert!(msg.contains("github"));
2050 assert!(msg.contains("12"));
2051 assert!(msg.contains("C2"));
2052 }
2053
2054 #[test]
2055 fn resolve_endpoint_url_passes_through_absolute_urls() {
2056 assert_eq!(
2057 resolve_endpoint_url("http://localhost:1234/sse", "https://other/msg"),
2058 "https://other/msg"
2059 );
2060 }
2061
2062 #[test]
2063 fn resolve_endpoint_url_resolves_relative_path_against_origin() {
2064 assert_eq!(
2065 resolve_endpoint_url("http://localhost:1234/sse", "/messages?session=abc"),
2066 "http://localhost:1234/messages?session=abc"
2067 );
2068 }
2069
2070 #[test]
2071 fn parse_sse_body_extracts_multiple_events() {
2072 let body = b"event: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"a\":2}\n\n";
2073 let out = parse_sse_body(body);
2074 assert_eq!(out.len(), 2);
2075 assert_eq!(out[0]["a"], 1);
2076 assert_eq!(out[1]["a"], 2);
2077 }
2078
2079 #[test]
2080 fn sse_line_accumulator_handles_a_split_chunk() {
2081 let mut acc = SseLineAccumulator::default();
2082 let first = acc.push(b"event: message\ndata: {\"a\":").unwrap();
2083 assert!(first.is_empty(), "no complete event yet");
2084 let second = acc.push(b"1}\n\n").unwrap();
2085 assert_eq!(second.len(), 1);
2086 assert_eq!(second[0].0.as_deref(), Some("message"));
2087 assert_eq!(second[0].1, "{\"a\":1}");
2088 }
2089
2090 #[test]
2091 fn sse_line_accumulator_errors_and_resets_on_an_oversized_unterminated_frame() {
2092 // Fable-5 review hardening: an SSE frame that never sends its
2093 // terminating blank line must not grow the accumulator without
2094 // bound — it must error (fail-closed) once it exceeds
2095 // MCP_MAX_SSE_FRAME_BYTES, and the buffer must be reset (not left
2096 // holding the oversized data) rather than growing on every push.
2097 let mut acc = SseLineAccumulator::default();
2098 let chunk = vec![b'x'; MCP_MAX_SSE_FRAME_BYTES + 1];
2099 let err = acc.push(&chunk).unwrap_err();
2100 assert!(
2101 err.to_string().contains("exceeded max"),
2102 "error should name the cap: {err}"
2103 );
2104 assert_eq!(
2105 acc.buf.len(),
2106 0,
2107 "buffer must be reset on overflow, not left growing"
2108 );
2109 }
2110
2111 #[test]
2112 fn sse_line_accumulator_stays_under_cap_for_legit_small_events() {
2113 // No-over-block confirmation: an ordinary small event (well under
2114 // the cap) still parses normally.
2115 let mut acc = SseLineAccumulator::default();
2116 let events = acc
2117 .push(b"event: message\ndata: {\"ok\":true}\n\n")
2118 .unwrap();
2119 assert_eq!(events.len(), 1);
2120 assert_eq!(events[0].1, "{\"ok\":true}");
2121 }
2122
2123 #[test]
2124 fn elicitation_response_decline_serializes_without_content() {
2125 let r = ElicitationResponse::decline();
2126 assert_eq!(r.to_json_rpc_result(), json!({"action": "decline"}));
2127 }
2128
2129 #[test]
2130 fn elicitation_response_accept_carries_content() {
2131 let r = ElicitationResponse {
2132 action: ElicitationAction::Accept,
2133 content: Some(json!({"name": "value"})),
2134 };
2135 assert_eq!(
2136 r.to_json_rpc_result(),
2137 json!({"action": "accept", "content": {"name": "value"}})
2138 );
2139 }
2140
2141 /// Fable-5 review, latent-SSRF-landmine finding: `reconnect` used to
2142 /// call `connect_http`/`connect_sse` with `None` for the network
2143 /// policy regardless of what the original connect used, silently
2144 /// skipping BOTH the pre-connect host check and the per-hop redirect
2145 /// re-check on every reconnect. This is a FAIL-ON-REVERT test: it
2146 /// builds an already-"connected" `McpClient` by hand (private-field
2147 /// access — this `tests` module is a child of `mcp`, so normal Rust
2148 /// visibility rules give it that) whose remembered `network_policy`
2149 /// DENIES the very host its `params` would reconnect to. A real
2150 /// `connect_http` call under a denying policy can never produce a
2151 /// connected client in the first place (see
2152 /// `mcp_remote.rs::network_policy_denies_a_disallowed_http_host_before_connecting`),
2153 /// which is why this can't be expressed as a pure public-API
2154 /// integration test — the point under test is specifically whether
2155 /// `reconnect` reuses `self.network_policy` (this test) instead of
2156 /// `None` (what a revert would reintroduce, and what this test would
2157 /// then fail to catch as an error).
2158 #[tokio::test]
2159 async fn reconnect_denies_a_disallowed_host_before_reconnecting() {
2160 use std::sync::atomic::{AtomicBool, Ordering};
2161
2162 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2163 let addr = listener.local_addr().unwrap();
2164 let connected = Arc::new(AtomicBool::new(false));
2165 let connected2 = connected.clone();
2166 tokio::spawn(async move {
2167 if let Ok((mut sock, _)) = listener.accept().await {
2168 connected2.store(true, Ordering::SeqCst);
2169 let mut buf = [0u8; 1024];
2170 use tokio::io::AsyncReadExt;
2171 let _ = sock.read(&mut buf).await;
2172 }
2173 });
2174 let url = format!("http://127.0.0.1:{}/mcp", addr.port());
2175 let deny_policy = NetworkPolicy {
2176 enabled: true,
2177 allow_domains: vec![],
2178 deny_domains: vec!["127.0.0.1".to_string()],
2179 };
2180 let client = McpClient {
2181 conn: Conn::Http {
2182 client: reqwest::Client::new(),
2183 url: url.clone(),
2184 headers: HeaderMap::new(),
2185 session_id: None,
2186 },
2187 next_id: 0,
2188 params: McpConnectParams::Http {
2189 url: url.clone(),
2190 headers: BTreeMap::new(),
2191 },
2192 network_policy: Some(deny_policy),
2193 timeout: DEFAULT_MCP_TIMEOUT,
2194 elicitation_handler: Arc::new(HeadlessElicitationHandler),
2195 instructions: None,
2196 pending_notifications: std::sync::Mutex::new(Vec::new()),
2197 };
2198
2199 let result = client.reconnect().await;
2200 assert!(
2201 result.is_err(),
2202 "reconnect must refuse to reconnect to a host its own remembered policy denies"
2203 );
2204 tokio::time::sleep(Duration::from_millis(50)).await;
2205 assert!(
2206 !connected.load(Ordering::SeqCst),
2207 "the denied host must never even be contacted on reconnect"
2208 );
2209 }
2210}