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, None, None, 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 None,
474 ))
475 .build()
476 .map_err(|e| Error::tool("mcp", format!("building http client: {e}")))?;
477 let params = McpConnectParams::Http {
478 url: url.to_string(),
479 headers: headers.clone(),
480 };
481 let mut mcp_client = McpClient {
482 conn: Conn::Http {
483 client,
484 url: url.to_string(),
485 headers: build_header_map(headers),
486 session_id: None,
487 },
488 next_id: 0,
489 params,
490 // Remembered so `reconnect` re-enforces the SAME policy on the
491 // rebuilt connection rather than reconnecting unchecked.
492 network_policy: network_policy.cloned(),
493 timeout: DEFAULT_MCP_TIMEOUT,
494 elicitation_handler: Arc::new(HeadlessElicitationHandler),
495 instructions: None,
496 pending_notifications: std::sync::Mutex::new(Vec::new()),
497 };
498 mcp_client.initialize().await?;
499 Ok(mcp_client)
500 }
501
502 /// P5-2 (§2 module 15 D7 row 2 "remote SSE"): connect over the legacy
503 /// (2024-11-05) HTTP+SSE transport — a persistent `GET url` stream whose
504 /// first event names the client→server POST endpoint. Same
505 /// [`NetworkPolicy`] enforcement as [`Self::connect_http`].
506 pub async fn connect_sse(
507 url: &str,
508 headers: &BTreeMap<String, String>,
509 network_policy: Option<&NetworkPolicy>,
510 ) -> Result<Self> {
511 crate::tools::check_network_policy(network_policy, None, None, url)?;
512 // No client-level `.timeout()`: the GET stream is intentionally
513 // long-lived (it stays open for the connection's whole lifetime),
514 // and this same client also issues the client->server POSTs — a
515 // blanket per-request timeout would apply to (and could truncate)
516 // the persistent GET just as much as a POST. `Self::timeout`
517 // (default [`DEFAULT_MCP_TIMEOUT`]) bounds each `request()`'s WAIT
518 // for its matching response instead — see `sse_roundtrip`.
519 let client = reqwest::Client::builder()
520 .redirect(crate::tools::network_checked_redirect_policy(
521 network_policy.cloned(),
522 None,
523 ))
524 .build()
525 .map_err(|e| Error::tool("mcp", format!("building sse client: {e}")))?;
526 let header_map = build_header_map(headers);
527 let mut req = client.get(url);
528 req = req.header(reqwest::header::ACCEPT, "text/event-stream");
529 req = req.headers(header_map.clone());
530 let resp = req
531 .send()
532 .await
533 .map_err(|e| Error::tool("mcp", format!("sse connect failed: {e}")))?;
534 if !resp.status().is_success() {
535 return Err(Error::tool(
536 "mcp",
537 format!("sse connect: http status {}", resp.status()),
538 ));
539 }
540 let base_url = url.to_string();
541 let (endpoint_tx, endpoint_rx) = tokio::sync::oneshot::channel();
542 // Bounded (not `unbounded_channel`): see `MCP_SSE_CHANNEL_CAPACITY`'s
543 // doc comment for why a flooding server should apply backpressure to
544 // the reader task rather than growing an unbounded in-memory queue.
545 let (msg_tx, msg_rx) = mpsc::channel(MCP_SSE_CHANNEL_CAPACITY);
546 let reader = tokio::spawn(sse_reader_task(resp, base_url, endpoint_tx, msg_tx));
547 let post_url = tokio::time::timeout(DEFAULT_MCP_TIMEOUT, endpoint_rx)
548 .await
549 .map_err(|_| Error::tool("mcp", "timed out waiting for sse endpoint event"))?
550 .map_err(|_| Error::tool("mcp", "sse stream closed before an endpoint event"))?;
551 let params = McpConnectParams::Sse {
552 url: url.to_string(),
553 headers: headers.clone(),
554 };
555 let mut mcp_client = McpClient {
556 conn: Conn::Sse {
557 client,
558 post_url,
559 headers: header_map,
560 inbox: msg_rx,
561 reader,
562 },
563 next_id: 0,
564 params,
565 // Remembered so `reconnect` re-enforces the SAME policy on the
566 // rebuilt connection rather than reconnecting unchecked.
567 network_policy: network_policy.cloned(),
568 timeout: DEFAULT_MCP_TIMEOUT,
569 elicitation_handler: Arc::new(HeadlessElicitationHandler),
570 instructions: None,
571 pending_notifications: std::sync::Mutex::new(Vec::new()),
572 };
573 mcp_client.initialize().await?;
574 Ok(mcp_client)
575 }
576
577 /// Re-establish this client's connection from its own remembered
578 /// [`McpConnectParams`] AND its own remembered [`NetworkPolicy`] (see
579 /// `Self::network_policy`'s field doc comment) — the "reconnect" half
580 /// of "connection lifecycle, reconnect, timeouts" (§2 module 15 D7 row
581 /// 1/2). Does NOT mutate `self`; the caller swaps in the returned
582 /// client (and its tools/resources need re-wrapping, since a
583 /// [`crate::tools::Tool`] closes over a specific
584 /// `Arc<Mutex<McpClient>>`).
585 ///
586 /// **Security note (Fable-5 review, latent-SSRF-landmine finding):**
587 /// this method has no callers today (unwired public API) — but a
588 /// future caller wiring it up gets the SAME [`NetworkPolicy`]
589 /// enforcement the original `connect_http`/`connect_sse` applied for
590 /// free, because the http/sse arms below pass `self.network_policy`
591 /// (not `None`) through to `connect_http`/`connect_sse`, which run the
592 /// exact same pre-connect host check + per-hop redirect re-check as
593 /// the original connect. Passing `None` here would silently reconnect
594 /// with no policy at all — the exact redirect-SSRF class those two
595 /// constructors otherwise close (`mcp_remote.rs`'s
596 /// `reconnect_reuses_the_original_network_policy` test fails on that
597 /// revert).
598 pub async fn reconnect(&self) -> Result<Self> {
599 match &self.params {
600 McpConnectParams::Stdio { command, args, env } => {
601 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
602 Self::connect(command, &args_ref, env).await
603 }
604 McpConnectParams::Http { url, headers } => {
605 Self::connect_http(url, headers, self.network_policy.as_ref()).await
606 }
607 McpConnectParams::Sse { url, headers } => {
608 Self::connect_sse(url, headers, self.network_policy.as_ref()).await
609 }
610 }
611 }
612
613 /// Install a non-default elicitation handler (e.g. a `tui` integration).
614 pub fn set_elicitation_handler(&mut self, handler: Arc<dyn McpElicitationHandler>) {
615 self.elicitation_handler = handler;
616 }
617
618 /// Per-request timeout for the network transports (stdio is unaffected
619 /// — see [`DEFAULT_MCP_TIMEOUT`]'s doc comment). Default 30s.
620 pub fn set_timeout(&mut self, timeout: Duration) {
621 self.timeout = timeout;
622 }
623
624 /// Notifications received but not yet consumed by a caller (see
625 /// `Self::pending_notifications`'s field doc comment). Draining
626 /// (`std::mem::take`) rather than cloning — a caller that wants to peek
627 /// without consuming should not call this.
628 pub fn take_pending_notifications(&self) -> Vec<Value> {
629 self.pending_notifications
630 .lock()
631 .map(|mut v| std::mem::take(&mut *v))
632 .unwrap_or_default()
633 }
634
635 async fn initialize(&mut self) -> Result<()> {
636 let result = self
637 .request(
638 "initialize",
639 json!({
640 "protocolVersion": PROTOCOL_VERSION,
641 "capabilities": {
642 // Advertise elicitation support: this client CAN
643 // receive `elicitation/create` (even though the
644 // headless-default handler always declines it) — a
645 // server that gates the elicitation capability
646 // behind the client's own advertised capability
647 // still gets a real (if headless-conservative)
648 // answer instead of never being offered the chance
649 // to ask.
650 "elicitation": {}
651 },
652 "clientInfo": {"name": "supercode", "version": env!("CARGO_PKG_VERSION")}
653 }),
654 )
655 .await?;
656 self.instructions = result
657 .get("instructions")
658 .and_then(Value::as_str)
659 .map(str::to_string);
660 // Per the MCP spec, the client sends an `initialized` notification
661 // once the handshake completes. Best-effort: a server that doesn't
662 // require it (most don't gate on it) is unaffected either way.
663 let _ = self.notify("notifications/initialized", json!({})).await;
664 Ok(())
665 }
666
667 /// Send a JSON-RPC NOTIFICATION (no reply expected). Errors are the
668 /// caller's to decide whether to propagate — `initialize`'s own call
669 /// above deliberately ignores them (best-effort).
670 async fn notify(&mut self, method: &str, params: Value) -> Result<()> {
671 let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
672 self.send_raw(&msg).await
673 }
674
675 /// Write one JSON-RPC message to the wire — the write half every
676 /// transport needs (a client request, a reply to a server-initiated
677 /// request, or a notification). The HTTP transport has no persistent
678 /// connection to write an unsolicited message on; see
679 /// [`Self::http_roundtrip`] for how it round-trips instead.
680 async fn send_raw(&mut self, msg: &Value) -> Result<()> {
681 match &mut self.conn {
682 Conn::Stdio { stdin, .. } => {
683 stdin
684 .write_all(format!("{msg}\n").as_bytes())
685 .await
686 .map_err(|e| Error::tool("mcp", format!("write: {e}")))?;
687 stdin
688 .flush()
689 .await
690 .map_err(|e| Error::tool("mcp", format!("flush: {e}")))?;
691 Ok(())
692 }
693 Conn::Sse {
694 client,
695 post_url,
696 headers,
697 ..
698 } => {
699 let resp = client
700 .post(post_url.as_str())
701 .headers(headers.clone())
702 .json(msg)
703 .send()
704 .await
705 .map_err(|e| Error::tool("mcp", format!("sse post: {e}")))?;
706 if !resp.status().is_success() {
707 return Err(Error::tool(
708 "mcp",
709 format!("sse post: http status {}", resp.status()),
710 ));
711 }
712 Ok(())
713 }
714 Conn::Http { .. } => Err(Error::tool(
715 "mcp",
716 "cannot send an unsolicited message over the http (non-streaming) transport",
717 )),
718 }
719 }
720
721 /// Dispatch one incoming JSON-RPC message while waiting for `waiting_id`'s
722 /// response. Returns `Some(result)` when `msg` IS that response
723 /// (success or error, folded to `Result` here so the caller's loop just
724 /// returns); `None` means "keep waiting" (a notification was logged, a
725 /// server-initiated request was answered, or `msg` was some other
726 /// stale/irrelevant frame).
727 async fn handle_incoming_message(
728 &mut self,
729 waiting_id: i64,
730 msg: Value,
731 ) -> Result<Option<Value>> {
732 let id = msg.get("id").and_then(Value::as_i64);
733 let has_method = msg.get("method").and_then(Value::as_str);
734
735 if id == Some(waiting_id) && has_method.is_none() {
736 if let Some(err) = msg.get("error") {
737 return Err(Error::tool("mcp", format!("rpc error: {err}")));
738 }
739 return Ok(Some(msg.get("result").cloned().unwrap_or(Value::Null)));
740 }
741
742 match (id, has_method) {
743 // A server-initiated REQUEST (has both an id and a method) —
744 // today only `elicitation/create` is understood; anything else
745 // gets a clean JSON-RPC "method not found" reply rather than
746 // silently hanging the server waiting for a response we'll
747 // never send.
748 (Some(req_id), Some(method)) => {
749 let reply = if method == "elicitation/create" {
750 let params = msg.get("params").cloned().unwrap_or(Value::Null);
751 let request = parse_elicitation_request(¶ms);
752 let handler = self.elicitation_handler.clone();
753 let response = handler.handle(&request).await;
754 json!({"jsonrpc": "2.0", "id": req_id, "result": response.to_json_rpc_result()})
755 } else {
756 json!({
757 "jsonrpc": "2.0", "id": req_id,
758 "error": {"code": -32601, "message": format!("supercode does not handle server-initiated `{method}`")}
759 })
760 };
761 self.send_raw(&reply).await?;
762 Ok(None)
763 }
764 // A notification (method, no id) — log it and keep waiting.
765 (None, Some(_)) => {
766 if let Ok(mut log) = self.pending_notifications.lock() {
767 log.push(msg);
768 }
769 Ok(None)
770 }
771 // A response to some OTHER (stale) request id, or an
772 // unparseable/irrelevant frame — ignore and keep waiting.
773 _ => Ok(None),
774 }
775 }
776
777 async fn request(&mut self, method: &str, params: Value) -> Result<Value> {
778 self.next_id += 1;
779 let id = self.next_id;
780 let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
781 match &self.conn {
782 Conn::Stdio { .. } => self.stdio_roundtrip(id, &msg).await,
783 Conn::Sse { .. } => self.sse_roundtrip(id, &msg).await,
784 Conn::Http { .. } => self.http_roundtrip(id, &msg).await,
785 }
786 }
787
788 async fn stdio_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
789 self.send_raw(msg).await?;
790 loop {
791 let Conn::Stdio { stdout, .. } = &mut self.conn else {
792 unreachable!("stdio_roundtrip called on a non-stdio connection")
793 };
794 let mut buf = String::new();
795 let n = stdout
796 .read_line(&mut buf)
797 .await
798 .map_err(|e| Error::tool("mcp", format!("read: {e}")))?;
799 if n == 0 {
800 return Err(Error::tool("mcp", "server closed the connection"));
801 }
802 let Ok(incoming) = serde_json::from_str::<Value>(buf.trim()) else {
803 continue;
804 };
805 if let Some(result) = self.handle_incoming_message(id, incoming).await? {
806 return Ok(result);
807 }
808 }
809 }
810
811 async fn sse_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
812 self.send_raw(msg).await?;
813 loop {
814 let inbox_msg = {
815 let Conn::Sse { inbox, .. } = &mut self.conn else {
816 unreachable!("sse_roundtrip called on a non-sse connection")
817 };
818 tokio::time::timeout(self.timeout, inbox.recv())
819 .await
820 .map_err(|_| Error::tool("mcp", "timed out waiting for an sse response"))?
821 .ok_or_else(|| Error::tool("mcp", "sse stream closed"))?
822 };
823 // A fatal reason the reader task sent instead of a frame (e.g.
824 // MCP_MAX_SSE_FRAME_BYTES exceeded) — surface it as a named
825 // error immediately rather than looping on it.
826 let incoming = match inbox_msg {
827 SseInboxMsg::Frame(v) => v,
828 SseInboxMsg::Error(reason) => return Err(Error::tool("mcp", reason)),
829 };
830 if let Some(result) = self.handle_incoming_message(id, incoming).await? {
831 return Ok(result);
832 }
833 }
834 }
835
836 /// P5-2: the http (Streamable-HTTP, non-streaming) round-trip. Named
837 /// limitation (see the module doc comment): a server-initiated request
838 /// embedded in the response body — e.g. an elicitation mid-call — has
839 /// no channel for this client to reply on within a single POST/response
840 /// cycle, so it is a clean, tested error rather than a silent drop or a
841 /// hang. A `text/event-stream` response body IS still supported for the
842 /// common single-event non-streaming case many Streamable HTTP servers
843 /// use to answer a `tools/call`.
844 async fn http_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
845 let (client, url, headers, session_id) = match &self.conn {
846 Conn::Http {
847 client,
848 url,
849 headers,
850 session_id,
851 } => (
852 client.clone(),
853 url.clone(),
854 headers.clone(),
855 session_id.clone(),
856 ),
857 _ => unreachable!("http_roundtrip called on a non-http connection"),
858 };
859 let mut req = client.post(&url).headers(headers).json(msg);
860 if let Some(sid) = &session_id {
861 req = req.header("Mcp-Session-Id", sid.as_str());
862 }
863 let resp = req
864 .send()
865 .await
866 .map_err(|e| Error::tool("mcp", format!("http request failed: {e}")))?;
867 if !resp.status().is_success() {
868 return Err(Error::tool("mcp", format!("http status {}", resp.status())));
869 }
870 if let Some(new_sid) = resp
871 .headers()
872 .get("mcp-session-id")
873 .and_then(|v| v.to_str().ok())
874 .map(str::to_string)
875 {
876 if let Conn::Http { session_id, .. } = &mut self.conn {
877 *session_id = Some(new_sid);
878 }
879 }
880 let content_type = resp
881 .headers()
882 .get(reqwest::header::CONTENT_TYPE)
883 .and_then(|v| v.to_str().ok())
884 .unwrap_or("")
885 .to_string();
886 // Hardening (Fable-5 review, memory-DoS finding): bounded read, not
887 // a bare `resp.bytes()` — see MCP_MAX_RESPONSE_BYTES's doc comment.
888 let body = read_capped_body(resp, MCP_MAX_RESPONSE_BYTES, "http response body").await?;
889 let frames: Vec<Value> = if content_type.starts_with("text/event-stream") {
890 parse_sse_body(&body)
891 } else {
892 vec![serde_json::from_slice::<Value>(&body)
893 .map_err(|e| Error::tool("mcp", format!("decoding http response: {e}")))?]
894 };
895 for frame in frames {
896 let frame_id = frame.get("id").and_then(Value::as_i64);
897 let has_method = frame.get("method").is_some();
898 if frame_id == Some(id) && !has_method {
899 if let Some(err) = frame.get("error") {
900 return Err(Error::tool("mcp", format!("rpc error: {err}")));
901 }
902 return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
903 }
904 if has_method {
905 // A server-initiated request/notification embedded in a
906 // non-streaming http response — see this method's doc
907 // comment for why this is a fail-closed error, not silently
908 // dropped or hung on.
909 return Err(Error::tool(
910 "mcp",
911 "server sent a server-initiated request/notification over the http \
912 (non-streaming) transport — elicitation and live notifications need \
913 stdio or sse",
914 ));
915 }
916 }
917 Err(Error::tool(
918 "mcp",
919 "http response never contained this request's result",
920 ))
921 }
922
923 // ---- tools --------------------------------------------------------
924
925 /// List the tools the server offers.
926 pub async fn list_tools(&mut self) -> Result<Vec<McpToolDef>> {
927 let result = self.request("tools/list", json!({})).await?;
928 let tools = result
929 .get("tools")
930 .and_then(Value::as_array)
931 .cloned()
932 .unwrap_or_default();
933 Ok(tools
934 .into_iter()
935 .map(|t| McpToolDef {
936 name: t
937 .get("name")
938 .and_then(Value::as_str)
939 .unwrap_or_default()
940 .to_string(),
941 description: t
942 .get("description")
943 .and_then(Value::as_str)
944 .unwrap_or_default()
945 .to_string(),
946 input_schema: t
947 .get("inputSchema")
948 .cloned()
949 .unwrap_or_else(|| json!({"type": "object"})),
950 })
951 .collect())
952 }
953
954 /// Call a tool and return its text content.
955 pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<String> {
956 let result = self
957 .request("tools/call", json!({"name": name, "arguments": arguments}))
958 .await?;
959 Ok(extract_content_text(&result))
960 }
961
962 // ---- resources + templates (§2 module 15 D7 row 3) -----------------
963
964 /// List the resources the server offers.
965 pub async fn list_resources(&mut self) -> Result<Vec<McpResourceDef>> {
966 let result = self.request("resources/list", json!({})).await?;
967 Ok(result
968 .get("resources")
969 .and_then(Value::as_array)
970 .cloned()
971 .unwrap_or_default()
972 .into_iter()
973 .map(|r| McpResourceDef {
974 uri: str_field(&r, "uri"),
975 name: str_field(&r, "name"),
976 description: str_field(&r, "description"),
977 mime_type: r
978 .get("mimeType")
979 .and_then(Value::as_str)
980 .map(str::to_string),
981 })
982 .collect())
983 }
984
985 /// List the resource templates the server offers.
986 pub async fn list_resource_templates(&mut self) -> Result<Vec<McpResourceTemplateDef>> {
987 let result = self.request("resources/templates/list", json!({})).await?;
988 Ok(result
989 .get("resourceTemplates")
990 .and_then(Value::as_array)
991 .cloned()
992 .unwrap_or_default()
993 .into_iter()
994 .map(|r| McpResourceTemplateDef {
995 uri_template: str_field(&r, "uriTemplate"),
996 name: str_field(&r, "name"),
997 description: str_field(&r, "description"),
998 })
999 .collect())
1000 }
1001
1002 /// Read one resource's content by URI. Errors (fail-closed, named —
1003 /// hardening, see [`MCP_MAX_RESOURCE_BYTES`]'s doc comment) if the
1004 /// joined text exceeds the cap, rather than returning/buffering an
1005 /// unbounded string.
1006 pub async fn read_resource(&mut self, uri: &str) -> Result<String> {
1007 let result = self.request("resources/read", json!({"uri": uri})).await?;
1008 let joined = result
1009 .get("contents")
1010 .and_then(Value::as_array)
1011 .map(|items| {
1012 items
1013 .iter()
1014 .filter_map(|i| {
1015 i.get("text")
1016 .and_then(Value::as_str)
1017 .map(str::to_string)
1018 .or_else(|| {
1019 i.get("blob")
1020 .and_then(Value::as_str)
1021 .map(|b| format!("[base64 blob, {} bytes encoded]", b.len()))
1022 })
1023 })
1024 .collect::<Vec<_>>()
1025 .join("\n")
1026 })
1027 .unwrap_or_default();
1028 if joined.len() > MCP_MAX_RESOURCE_BYTES {
1029 return Err(Error::tool(
1030 "mcp",
1031 format!(
1032 "resource {uri}: joined contents exceeded max {MCP_MAX_RESOURCE_BYTES} bytes"
1033 ),
1034 ));
1035 }
1036 Ok(joined)
1037 }
1038
1039 /// Subscribe to update notifications for one resource by URI — updates
1040 /// arrive as `notifications/resources/updated` frames, logged in
1041 /// [`McpClient::take_pending_notifications`].
1042 pub async fn subscribe_resource(&mut self, uri: &str) -> Result<()> {
1043 self.request("resources/subscribe", json!({"uri": uri}))
1044 .await?;
1045 Ok(())
1046 }
1047
1048 // ---- prompts-as-commands (§2 module 15 D7 row 4) --------------------
1049
1050 /// List the prompts the server offers.
1051 pub async fn list_prompts(&mut self) -> Result<Vec<McpPromptDef>> {
1052 let result = self.request("prompts/list", json!({})).await?;
1053 Ok(result
1054 .get("prompts")
1055 .and_then(Value::as_array)
1056 .cloned()
1057 .unwrap_or_default()
1058 .into_iter()
1059 .map(|p| McpPromptDef {
1060 name: str_field(&p, "name"),
1061 description: str_field(&p, "description"),
1062 arguments: p
1063 .get("arguments")
1064 .and_then(Value::as_array)
1065 .cloned()
1066 .unwrap_or_default()
1067 .into_iter()
1068 .map(|a| McpPromptArgDef {
1069 name: str_field(&a, "name"),
1070 required: a.get("required").and_then(Value::as_bool).unwrap_or(false),
1071 })
1072 .collect(),
1073 })
1074 .collect())
1075 }
1076
1077 /// Render a server prompt with `args` (a flat string->string map — the
1078 /// MCP spec's `prompts/get` `arguments` shape) into the concatenated
1079 /// text of every returned message — this crate's `Config.prompts`
1080 /// entries are likewise a single flat rendered string
1081 /// ([`crate::agent::Agent::expand_prompt`]'s local-template shape), so
1082 /// the two surfaces stay uniform to a caller.
1083 pub async fn get_prompt(
1084 &mut self,
1085 name: &str,
1086 args: BTreeMap<String, String>,
1087 ) -> Result<String> {
1088 let result = self
1089 .request("prompts/get", json!({"name": name, "arguments": args}))
1090 .await?;
1091 Ok(result
1092 .get("messages")
1093 .and_then(Value::as_array)
1094 .map(|msgs| {
1095 msgs.iter()
1096 .filter_map(|m| {
1097 m.get("content")
1098 .and_then(|c| c.get("text"))
1099 .and_then(Value::as_str)
1100 })
1101 .collect::<Vec<_>>()
1102 .join("\n\n")
1103 })
1104 .unwrap_or_default())
1105 }
1106}
1107
1108fn str_field(v: &Value, key: &str) -> String {
1109 v.get(key)
1110 .and_then(Value::as_str)
1111 .unwrap_or_default()
1112 .to_string()
1113}
1114
1115/// Pull the concatenated text out of an MCP `content` array.
1116fn extract_content_text(result: &Value) -> String {
1117 result
1118 .get("content")
1119 .and_then(Value::as_array)
1120 .map(|items| {
1121 items
1122 .iter()
1123 .filter_map(|i| i.get("text").and_then(Value::as_str))
1124 .collect::<Vec<_>>()
1125 .join("\n")
1126 })
1127 .unwrap_or_default()
1128}
1129
1130/// Read `resp`'s body up to `cap` bytes, erroring (fail-closed, named error
1131/// naming `what`) rather than buffering further — the bounded replacement
1132/// for a bare `resp.bytes()` (`McpClient::http_roundtrip`'s hardening;
1133/// see [`MCP_MAX_RESPONSE_BYTES`]'s doc comment for why). Checks
1134/// `Content-Length` first as a fast reject when the server declares a
1135/// too-large body up front, then streams chunk-by-chunk (a hostile server
1136/// can lie about `Content-Length` or omit it and stream forever) so actual
1137/// memory use never exceeds `cap` before this errors out.
1138async fn read_capped_body(resp: reqwest::Response, cap: usize, what: &str) -> Result<Vec<u8>> {
1139 use futures::StreamExt;
1140 if let Some(len) = resp.content_length() {
1141 if len as usize > cap {
1142 return Err(Error::tool(
1143 "mcp",
1144 format!("{what}: declared content-length {len} bytes exceeds max {cap} bytes"),
1145 ));
1146 }
1147 }
1148 let mut buf: Vec<u8> = Vec::new();
1149 let mut stream = resp.bytes_stream();
1150 while let Some(chunk) = stream.next().await {
1151 let chunk = chunk.map_err(|e| Error::tool("mcp", format!("reading {what}: {e}")))?;
1152 buf.extend_from_slice(&chunk);
1153 if buf.len() > cap {
1154 return Err(Error::tool(
1155 "mcp",
1156 format!("{what}: exceeded max {cap} bytes"),
1157 ));
1158 }
1159 }
1160 Ok(buf)
1161}
1162
1163/// Parse a `text/event-stream` byte body into its JSON `data:` payloads —
1164/// used both by the http transport's single-response-body case
1165/// (`McpClient::http_roundtrip`) and by the sse reader task's per-chunk
1166/// incremental parser (`SseLineAccumulator`) sharing the same per-event
1167/// field syntax. Multiple `data:` lines within one event are joined with
1168/// `\n` per the SSE spec before JSON-parsing; an event whose joined data
1169/// doesn't parse as JSON is skipped (never fatal — matches this crate's
1170/// existing stdio precedent of skipping an unparseable line).
1171fn parse_sse_body(body: &[u8]) -> Vec<Value> {
1172 let text = String::from_utf8_lossy(body);
1173 let mut out = Vec::new();
1174 for event in text.split("\n\n") {
1175 let mut data_lines = Vec::new();
1176 for line in event.lines() {
1177 if let Some(d) = line.strip_prefix("data:") {
1178 data_lines.push(d.trim_start());
1179 }
1180 }
1181 if data_lines.is_empty() {
1182 continue;
1183 }
1184 if let Ok(v) = serde_json::from_str::<Value>(&data_lines.join("\n")) {
1185 out.push(v);
1186 }
1187 }
1188 out
1189}
1190
1191/// Incremental SSE event parser for the persistent SSE reader task —
1192/// accumulates raw bytes across chunk boundaries (a `data:` line can be
1193/// split across two TCP reads) and yields one `(event_name, data)` pair per
1194/// complete (blank-line-terminated) event.
1195#[derive(Default)]
1196struct SseLineAccumulator {
1197 buf: String,
1198}
1199
1200impl SseLineAccumulator {
1201 /// Feed `chunk` in and return every complete event it produced. Errors
1202 /// (fail-closed, hardening — see [`MCP_MAX_SSE_FRAME_BYTES`]'s doc
1203 /// comment) when the trailing, still-incomplete tail left after
1204 /// draining every complete event exceeds the cap — i.e. a single frame
1205 /// that never sends its terminating blank line. The buffer is cleared
1206 /// on that error, so a caller that (today, none do) chose to keep
1207 /// pushing after an error wouldn't keep growing it either.
1208 fn push(&mut self, chunk: &[u8]) -> Result<Vec<(Option<String>, String)>> {
1209 self.buf.push_str(&String::from_utf8_lossy(chunk));
1210 let mut out = Vec::new();
1211 // Process every COMPLETE event (terminated by a blank line) currently
1212 // in the buffer; leave any trailing partial event for the next push.
1213 while let Some(pos) = self.buf.find("\n\n") {
1214 let event_text: String = self.buf.drain(..pos + 2).collect();
1215 let mut event_name = None;
1216 let mut data_lines = Vec::new();
1217 for line in event_text.lines() {
1218 if let Some(v) = line.strip_prefix("event:") {
1219 event_name = Some(v.trim_start().to_string());
1220 } else if let Some(v) = line.strip_prefix("data:") {
1221 data_lines.push(v.trim_start().to_string());
1222 }
1223 }
1224 if !data_lines.is_empty() || event_name.is_some() {
1225 out.push((event_name, data_lines.join("\n")));
1226 }
1227 }
1228 if self.buf.len() > MCP_MAX_SSE_FRAME_BYTES {
1229 self.buf.clear();
1230 return Err(Error::tool(
1231 "mcp",
1232 format!(
1233 "sse frame exceeded max {MCP_MAX_SSE_FRAME_BYTES} bytes without a \
1234 terminating blank line"
1235 ),
1236 ));
1237 }
1238 Ok(out)
1239 }
1240}
1241
1242/// Background task for [`McpClient::connect_sse`]: reads `resp`'s byte
1243/// stream, sends the discovered POST endpoint URL (from the first
1244/// `event: endpoint` frame) on `endpoint_tx` exactly once, and forwards
1245/// every subsequent JSON-parseable `event: message` frame's `data:` payload
1246/// into `msg_tx`. Exits quietly (dropping both channels) when the stream
1247/// ends — a `request()` waiting on `msg_tx`'s receiver then sees a closed
1248/// channel and reports "sse stream closed" rather than hanging forever.
1249///
1250/// `msg_tx` is bounded ([`MCP_SSE_CHANNEL_CAPACITY`]) — `.send(..).await`
1251/// below therefore applies backpressure (this task simply stops draining
1252/// the socket) when nothing has called `request()`/drained `inbox` in a
1253/// while, rather than this task buffering an unbounded queue of unconsumed
1254/// frames. That's not a deadlock: this task is the only thing that can
1255/// ever fill the channel, and the next `McpClient::request()` (or the one
1256/// already in flight) is the thing that drains it — there's no cycle where
1257/// this task itself needs to make progress for that drain to happen. On an
1258/// `SseLineAccumulator` error (an oversized, un-terminated frame — see
1259/// [`MCP_MAX_SSE_FRAME_BYTES`]) this task sends a named
1260/// [`SseInboxMsg::Error`] and exits, so a pending `request()` fails fast
1261/// with a clear reason instead of just seeing a closed channel.
1262async fn sse_reader_task(
1263 resp: reqwest::Response,
1264 base_url: String,
1265 endpoint_tx: tokio::sync::oneshot::Sender<String>,
1266 msg_tx: mpsc::Sender<SseInboxMsg>,
1267) {
1268 use futures::StreamExt;
1269 let mut stream = resp.bytes_stream();
1270 let mut acc = SseLineAccumulator::default();
1271 let mut endpoint_tx = Some(endpoint_tx);
1272 while let Some(chunk) = stream.next().await {
1273 let Ok(bytes) = chunk else { break };
1274 let events = match acc.push(&bytes) {
1275 Ok(events) => events,
1276 Err(e) => {
1277 let _ = msg_tx.send(SseInboxMsg::Error(e.to_string())).await;
1278 return;
1279 }
1280 };
1281 for (event_name, data) in events {
1282 match event_name.as_deref() {
1283 Some("endpoint") => {
1284 if let Some(tx) = endpoint_tx.take() {
1285 let resolved = resolve_endpoint_url(&base_url, data.trim());
1286 let _ = tx.send(resolved);
1287 }
1288 }
1289 _ => {
1290 // "message" (the spec name) or an unnamed event — any
1291 // frame with `data:` that isn't the endpoint discovery
1292 // event is a JSON-RPC message.
1293 if let Ok(v) = serde_json::from_str::<Value>(&data) {
1294 if msg_tx.send(SseInboxMsg::Frame(v)).await.is_err() {
1295 return; // no one is listening anymore
1296 }
1297 }
1298 }
1299 }
1300 }
1301 }
1302}
1303
1304/// Resolve the `endpoint` event's `data:` payload (which the spec allows to
1305/// be a bare path, e.g. `/messages?session=abc`) against the SSE stream's
1306/// own origin — an absolute URL passes through unchanged.
1307fn resolve_endpoint_url(base_url: &str, endpoint: &str) -> String {
1308 if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
1309 return endpoint.to_string();
1310 }
1311 let Some(scheme_end) = base_url.find("://") else {
1312 return endpoint.to_string();
1313 };
1314 let after_scheme = &base_url[scheme_end + 3..];
1315 let origin_end = after_scheme.find('/').map(|i| scheme_end + 3 + i);
1316 let origin = match origin_end {
1317 Some(end) => &base_url[..end],
1318 None => base_url,
1319 };
1320 if endpoint.starts_with('/') {
1321 format!("{origin}{endpoint}")
1322 } else {
1323 format!("{origin}/{endpoint}")
1324 }
1325}
1326
1327// ============================================================================
1328// ---- server-handle: shared client + everything a server attach produces ---
1329// ============================================================================
1330
1331/// P5-2: one connected server, wrapping the `Arc<Mutex<McpClient>>` every
1332/// derived [`Tool`]/prompt-source shares — the single point that produces
1333/// tools ([`McpTool`]), resource tools, prompt names, and the server's
1334/// folded-in instructions, so a caller (`crates/cli`'s `attach_mcp`) only
1335/// has to connect once and ask this handle for everything else.
1336#[derive(Clone)]
1337pub struct McpServerHandle {
1338 /// This server's name (the `mcp__<server>__…` namespace prefix).
1339 pub server: String,
1340 client: Arc<Mutex<McpClient>>,
1341}
1342
1343impl McpServerHandle {
1344 /// Wrap an already-connected `client` under `server`'s name.
1345 pub fn new(server: impl Into<String>, client: McpClient) -> Self {
1346 McpServerHandle {
1347 server: server.into(),
1348 client: Arc::new(Mutex::new(client)),
1349 }
1350 }
1351
1352 /// The server's `initialize`-time instructions, if any.
1353 pub async fn instructions(&self) -> Option<String> {
1354 self.client.lock().await.instructions.clone()
1355 }
1356
1357 /// This server's tools, namespaced `mcp__<server>__<tool>`.
1358 pub async fn tools(&self) -> Result<Vec<McpTool>> {
1359 let defs = self.client.lock().await.list_tools().await?;
1360 Ok(defs
1361 .into_iter()
1362 .map(|d| McpTool {
1363 name: format!("mcp__{}__{}", self.server, d.name),
1364 description: d.description,
1365 parameters: d.input_schema,
1366 remote_name: d.name,
1367 client: self.client.clone(),
1368 })
1369 .collect())
1370 }
1371
1372 /// This server's resource-access tools (`resources_list`/`_read`/
1373 /// `_subscribe`), always offered regardless of whether the server
1374 /// actually advertised a `resources` capability — a server that
1375 /// doesn't support resources simply errors clearly on the underlying
1376 /// `resources/list` call (the same "let the remote error surface"
1377 /// posture [`McpTool::execute`] already has for `tools/call`), rather
1378 /// than this client trying to pre-negotiate capabilities perfectly.
1379 pub fn resource_tools(&self) -> Vec<Box<dyn Tool>> {
1380 vec![
1381 Box::new(McpResourcesListTool {
1382 name: format!("mcp__{}__resources_list", self.server),
1383 client: self.client.clone(),
1384 }),
1385 Box::new(McpResourceReadTool {
1386 name: format!("mcp__{}__resources_read", self.server),
1387 client: self.client.clone(),
1388 }),
1389 Box::new(McpResourceSubscribeTool {
1390 name: format!("mcp__{}__resources_subscribe", self.server),
1391 client: self.client.clone(),
1392 }),
1393 ]
1394 }
1395
1396 /// This server's prompts, namespaced `mcp__<server>__<prompt>` — see
1397 /// [`McpPromptSource`]'s doc comment for why namespacing (not a bare
1398 /// name) is the mechanism that keeps an untrusted/remote server from
1399 /// ever being able to collide with a trusted command name.
1400 pub async fn prompts(&self) -> Result<Vec<(String, McpPromptSource)>> {
1401 let defs = self.client.lock().await.list_prompts().await?;
1402 Ok(defs
1403 .into_iter()
1404 .map(|d| {
1405 (
1406 format!("mcp__{}__{}", self.server, d.name),
1407 McpPromptSource {
1408 client: self.client.clone(),
1409 remote_name: d.name,
1410 arg_names: d.arguments.into_iter().map(|a| a.name).collect(),
1411 },
1412 )
1413 })
1414 .collect())
1415 }
1416
1417 /// The shared client — for callers that need lower-level access (e.g.
1418 /// installing an elicitation handler, or OAuth token refresh wiring).
1419 pub fn client(&self) -> Arc<Mutex<McpClient>> {
1420 self.client.clone()
1421 }
1422}
1423
1424/// A supercode [`Tool`] backed by a remote MCP tool. The name is namespaced
1425/// `mcp__<server>__<tool>` to match the convention seen in the corpus.
1426pub struct McpTool {
1427 name: String,
1428 description: String,
1429 parameters: Value,
1430 remote_name: String,
1431 client: Arc<Mutex<McpClient>>,
1432}
1433
1434impl McpTool {
1435 /// Wrap every tool from `client` (already connected) under `server`
1436 /// prefix. Kept for API/test back-compat (P5-1 baseline signature); new
1437 /// callers that also want resources/prompts/instructions should use
1438 /// [`McpServerHandle`] directly.
1439 pub async fn from_client(server: &str, client: McpClient) -> Result<Vec<McpTool>> {
1440 McpServerHandle::new(server, client).tools().await
1441 }
1442}
1443
1444#[async_trait]
1445impl Tool for McpTool {
1446 fn name(&self) -> &str {
1447 &self.name
1448 }
1449 fn description(&self) -> &str {
1450 &self.description
1451 }
1452 fn parameters(&self) -> Value {
1453 self.parameters.clone()
1454 }
1455 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1456 self.client
1457 .lock()
1458 .await
1459 .call_tool(&self.remote_name, args)
1460 .await
1461 }
1462}
1463
1464/// A prompt this crate can render via `prompts/get` — what
1465/// [`McpServerHandle::prompts`] hands back for a caller to register as a
1466/// slash-command source (`crate::agent::Agent::register_mcp_prompt`).
1467///
1468/// **P4d-class security lesson, closed BY CONSTRUCTION (§2 module 15 D7 row
1469/// 4's own security note, "an MCP-provided prompt from an untrusted/
1470/// project-scoped server must not silently override a trusted command
1471/// name"):** every prompt this crate surfaces is namespaced
1472/// `mcp__<server>__<prompt>` — never the bare remote name. Since no
1473/// built-in or user-authored `[core.prompts]` command name is EVER
1474/// `mcp__`-prefixed (that prefix is reserved by this module), a remote
1475/// server — however untrusted, however maliciously named its prompts are —
1476/// cannot construct a colliding key: `mcp__evil__code-review` and
1477/// `code-review` are simply different map keys. This is the same
1478/// "namespace instead of trust-flag" treatment [`McpTool`] already applies
1479/// to tool names; a test in `crates/harness/tests/mcp_prompts.rs` pins it
1480/// (`untrusted_mcp_prompt_cannot_override_a_trusted_command_name`).
1481#[derive(Clone)]
1482pub struct McpPromptSource {
1483 client: Arc<Mutex<McpClient>>,
1484 remote_name: String,
1485 /// The server-declared argument names, in `prompts/list` order — used
1486 /// by `crate::agent::Agent::expand_prompt_async` to map a slash
1487 /// command's trailing free text onto this prompt's named arguments
1488 /// (single-argument prompts get the whole trailing text; multi-argument
1489 /// prompts expect `key=value` pairs — see that method's doc comment).
1490 arg_names: Vec<String>,
1491}
1492
1493impl McpPromptSource {
1494 /// Render this prompt with `args` (see [`McpClient::get_prompt`]).
1495 pub async fn render(&self, args: BTreeMap<String, String>) -> Result<String> {
1496 self.client
1497 .lock()
1498 .await
1499 .get_prompt(&self.remote_name, args)
1500 .await
1501 }
1502
1503 /// This prompt's declared argument names, in order.
1504 pub fn arg_names(&self) -> &[String] {
1505 &self.arg_names
1506 }
1507}
1508
1509#[async_trait]
1510impl crate::sdk::SdkPromptSource for McpPromptSource {
1511 async fn render(&self, args: BTreeMap<String, String>) -> Result<String> {
1512 McpPromptSource::render(self, args).await
1513 }
1514
1515 fn arg_names(&self) -> &[String] {
1516 McpPromptSource::arg_names(self)
1517 }
1518}
1519
1520// ---- resource tools ---------------------------------------------------
1521
1522struct McpResourcesListTool {
1523 name: String,
1524 client: Arc<Mutex<McpClient>>,
1525}
1526
1527#[async_trait]
1528impl Tool for McpResourcesListTool {
1529 fn name(&self) -> &str {
1530 &self.name
1531 }
1532 fn description(&self) -> &str {
1533 "List this MCP server's available resources and resource templates."
1534 }
1535 fn parameters(&self) -> Value {
1536 json!({"type": "object", "properties": {}, "additionalProperties": false})
1537 }
1538 async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
1539 let mut client = self.client.lock().await;
1540 let resources = client.list_resources().await?;
1541 let templates = client.list_resource_templates().await?;
1542 let mut out = String::new();
1543 for r in &resources {
1544 out.push_str(&format!("- {} ({})\n", r.uri, r.name));
1545 }
1546 for t in &templates {
1547 out.push_str(&format!("- template: {} ({})\n", t.uri_template, t.name));
1548 }
1549 if out.is_empty() {
1550 out.push_str("(no resources or templates)\n");
1551 }
1552 Ok(out)
1553 }
1554}
1555
1556#[derive(serde::Deserialize)]
1557struct ResourceUriArgs {
1558 uri: String,
1559}
1560
1561struct McpResourceReadTool {
1562 name: String,
1563 client: Arc<Mutex<McpClient>>,
1564}
1565
1566#[async_trait]
1567impl Tool for McpResourceReadTool {
1568 fn name(&self) -> &str {
1569 &self.name
1570 }
1571 fn description(&self) -> &str {
1572 "Read one resource from this MCP server by URI."
1573 }
1574 fn parameters(&self) -> Value {
1575 json!({
1576 "type": "object",
1577 "properties": {"uri": {"type": "string"}},
1578 "required": ["uri"],
1579 "additionalProperties": false
1580 })
1581 }
1582 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1583 let a: ResourceUriArgs =
1584 serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
1585 tool: self.name.clone(),
1586 message: e.to_string(),
1587 })?;
1588 self.client.lock().await.read_resource(&a.uri).await
1589 }
1590}
1591
1592struct McpResourceSubscribeTool {
1593 name: String,
1594 client: Arc<Mutex<McpClient>>,
1595}
1596
1597#[async_trait]
1598impl Tool for McpResourceSubscribeTool {
1599 fn name(&self) -> &str {
1600 &self.name
1601 }
1602 fn description(&self) -> &str {
1603 "Subscribe to update notifications for one resource on this MCP server by URI. \
1604 Updates surface as this server's pending-notifications log (no live push into the \
1605 conversation) — call resources_list/resources_read again to see the latest content."
1606 }
1607 fn parameters(&self) -> Value {
1608 json!({
1609 "type": "object",
1610 "properties": {"uri": {"type": "string"}},
1611 "required": ["uri"],
1612 "additionalProperties": false
1613 })
1614 }
1615 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
1616 let a: ResourceUriArgs =
1617 serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
1618 tool: self.name.clone(),
1619 message: e.to_string(),
1620 })?;
1621 self.client.lock().await.subscribe_resource(&a.uri).await?;
1622 Ok(format!("subscribed to {}", a.uri))
1623 }
1624}
1625
1626// ============================================================================
1627// ---- C2 cache-invalidation signal (§2.2 C2) ---------------------------------
1628// ============================================================================
1629
1630/// P5-2 (§2.2 C2 "connect invalidates cache prefix"; §2 module 25 `cache`
1631/// is the referee): the churn notice a caller (`crates/cli`'s `attach_mcp`)
1632/// emits when connecting a server under an active `CachePlan::ImportedPrefix`
1633/// — a pure, independently-testable function so the wording/threshold logic
1634/// isn't buried in CLI plumbing. "At minimum emit the churn signal" (P5-2
1635/// build brief) — this is that signal; it does not itself reset any cache
1636/// bookkeeping (see `Agent::register_tool`'s own C2 note for the runtime
1637/// half: any tool registered after the agent's first turn resets
1638/// `cache_established`, MCP-sourced or not).
1639pub fn cache_churn_notice(server: &str, tool_count: usize) -> String {
1640 format!(
1641 "mcp: connecting `{server}` added {tool_count} tool(s) to the prompt prefix — with \
1642 an imported-prefix cache plan active, this likely invalidates the cache hit on the \
1643 next turn (C2)"
1644 )
1645}
1646
1647// ============================================================================
1648// ---- server side ------------------------------------------------------------
1649// ============================================================================
1650
1651/// MCP tool projection of the versioned SDK facade. The MCP envelope and
1652/// tool-call id never enter the SDK request or its canonical session data.
1653#[cfg(feature = "adapter-mcp")]
1654pub struct SdkMcpTool {
1655 service: Arc<Mutex<HarnessSessionService>>,
1656 runtime: Option<Arc<dyn SdkRuntime>>,
1657 attachment: Arc<Mutex<Option<FrontendAttachment>>>,
1658}
1659
1660#[cfg(feature = "adapter-mcp")]
1661impl Default for SdkMcpTool {
1662 fn default() -> Self {
1663 Self::new()
1664 }
1665}
1666
1667#[cfg(feature = "adapter-mcp")]
1668impl SdkMcpTool {
1669 /// Create an independent stateful SDK projection for one MCP server.
1670 pub fn new() -> Self {
1671 Self {
1672 service: Arc::new(Mutex::new(HarnessSessionService::new())),
1673 runtime: None,
1674 attachment: Arc::new(Mutex::new(None)),
1675 }
1676 }
1677
1678 /// Project an already-owned SDK runtime into MCP without granting MCP
1679 /// process-launch, persistence, or shutdown authority.
1680 pub async fn attached(runtime: Arc<dyn SdkRuntime>) -> std::result::Result<Self, SdkError> {
1681 let attachment = runtime.attach(200).await?;
1682 Ok(Self {
1683 service: Arc::new(Mutex::new(HarnessSessionService::new())),
1684 runtime: Some(runtime),
1685 attachment: Arc::new(Mutex::new(Some(attachment))),
1686 })
1687 }
1688
1689 async fn execute_attached(
1690 &self,
1691 runtime: &Arc<dyn SdkRuntime>,
1692 operation: SdkOperation,
1693 params: Value,
1694 ) -> std::result::Result<Value, SdkError> {
1695 let descriptor = runtime.describe().await?;
1696 let session_id = descriptor.session_id;
1697 match operation {
1698 SdkOperation::Input => {
1699 let prompt = params
1700 .get("prompt")
1701 .or_else(|| params.get("text"))
1702 .and_then(Value::as_str)
1703 .ok_or_else(|| {
1704 SdkError::new(
1705 crate::SdkErrorCode::InvalidArgument,
1706 operation,
1707 "input requires string `prompt` or `text`",
1708 )
1709 })?;
1710 let image_urls = match params.get("image_urls") {
1711 None => Vec::new(),
1712 Some(Value::Array(values)) => values
1713 .iter()
1714 .map(|value| {
1715 value.as_str().map(str::to_owned).ok_or_else(|| {
1716 SdkError::new(
1717 crate::SdkErrorCode::InvalidArgument,
1718 operation,
1719 "input requires string entries in `image_urls`",
1720 )
1721 })
1722 })
1723 .collect::<std::result::Result<Vec<_>, _>>()?,
1724 Some(_) => {
1725 return Err(SdkError::new(
1726 crate::SdkErrorCode::InvalidArgument,
1727 operation,
1728 "input requires array `image_urls`",
1729 ))
1730 }
1731 };
1732 let reply = runtime
1733 .submit_with_images(prompt.to_string(), image_urls)
1734 .await?;
1735 Ok(json!({"session_id":session_id, "reply":reply}))
1736 }
1737 SdkOperation::Events => {
1738 let mut attachment = self.attachment.lock().await;
1739 if attachment.is_none() {
1740 *attachment = Some(runtime.attach(200).await?);
1741 }
1742 let event = attachment
1743 .as_mut()
1744 .expect("attachment initialized")
1745 .next_event()
1746 .await?;
1747 Ok(json!({"session_id":session_id, "event":event}))
1748 }
1749 SdkOperation::Interrupt => Ok(json!({
1750 "session_id":session_id,
1751 "interrupted":runtime.interrupt().await?,
1752 })),
1753 SdkOperation::Steer => {
1754 let prompt = params
1755 .get("prompt")
1756 .or_else(|| params.get("text"))
1757 .and_then(Value::as_str)
1758 .ok_or_else(|| {
1759 SdkError::new(
1760 crate::SdkErrorCode::InvalidArgument,
1761 operation,
1762 "steer requires string `prompt` or `text`",
1763 )
1764 })?;
1765 runtime.steer(prompt.to_string()).await?;
1766 Ok(json!({"session_id":session_id}))
1767 }
1768 SdkOperation::Respond => {
1769 let response = serde_json::from_value::<FrontendResponse>(
1770 params.get("response").cloned().unwrap_or(Value::Null),
1771 )
1772 .map_err(|error| {
1773 SdkError::new(
1774 crate::SdkErrorCode::InvalidArgument,
1775 operation,
1776 error.to_string(),
1777 )
1778 })?;
1779 runtime.respond(response).await?;
1780 Ok(json!({"session_id":session_id}))
1781 }
1782 _ => Err(SdkError::unsupported(operation)),
1783 }
1784 }
1785}
1786
1787#[async_trait]
1788#[cfg(feature = "adapter-mcp")]
1789impl Tool for SdkMcpTool {
1790 fn name(&self) -> &str {
1791 "supercode_sdk"
1792 }
1793
1794 fn description(&self) -> &str {
1795 "Invoke one operation on Supercode's versioned session/runtime SDK facade."
1796 }
1797
1798 fn parameters(&self) -> Value {
1799 json!({
1800 "type": "object",
1801 "properties": {
1802 "operation": {
1803 "type": "string",
1804 "enum": ["discover", "load", "start", "resume", "input", "events", "interrupt", "steer", "respond", "export", "close"]
1805 },
1806 "params": {"type": "object"}
1807 },
1808 "required": ["operation"],
1809 "additionalProperties": false
1810 })
1811 }
1812
1813 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1814 let operation = serde_json::from_value::<SdkOperation>(
1815 args.get("operation").cloned().unwrap_or(Value::Null),
1816 )
1817 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1818 if let Some(runtime) = &self.runtime {
1819 return self
1820 .execute_attached(
1821 runtime,
1822 operation,
1823 args.get("params").cloned().unwrap_or_else(|| json!({})),
1824 )
1825 .await
1826 .and_then(|value| {
1827 serde_json::to_string(&value)
1828 .map_err(|error| SdkError::Transport(error.to_string()))
1829 })
1830 .map_err(|error| sdk_mcp_error(self.name(), &error));
1831 }
1832 if !matches!(
1833 operation,
1834 SdkOperation::Discover | SdkOperation::Load | SdkOperation::Export
1835 ) {
1836 return Err(Error::tool(
1837 self.name(),
1838 format!(
1839 "SUPERCODE_SDK_ERROR:{}",
1840 json!({
1841 "name":"unsupported_action",
1842 "operation":operation,
1843 "message":"the MCP SDK adapter is read-only; runtime control requires an owner surface",
1844 })
1845 ),
1846 ));
1847 }
1848 let mut params = args.get("params").cloned().unwrap_or_else(|| json!({}));
1849 confine_sdk_mcp_params(operation, &mut params, ctx)?;
1850 let result = self
1851 .service
1852 .lock()
1853 .await
1854 .execute(SdkRequest { operation, params })
1855 .await
1856 .map_err(|error| sdk_mcp_error(self.name(), &error))?;
1857 serde_json::to_string(&result).map_err(|error| Error::tool(self.name(), error.to_string()))
1858 }
1859}
1860
1861#[cfg(feature = "adapter-mcp")]
1862fn sdk_mcp_error(tool: &str, error: &SdkError) -> Error {
1863 Error::tool(
1864 tool,
1865 format!(
1866 "SUPERCODE_SDK_ERROR:{}",
1867 json!({
1868 "name":error.code(),
1869 "operation":error.operation(),
1870 "message":error.to_string(),
1871 })
1872 ),
1873 )
1874}
1875
1876#[cfg(feature = "adapter-mcp")]
1877fn confine_sdk_mcp_params(
1878 operation: SdkOperation,
1879 params: &mut Value,
1880 ctx: &ToolContext,
1881) -> Result<()> {
1882 if operation == SdkOperation::Discover {
1883 if params.get("homes").is_some() {
1884 return Err(Error::tool(
1885 "supercode_sdk",
1886 "MCP discovery cannot override harness homes",
1887 ));
1888 }
1889 params["workspace"] = json!(ctx.cwd);
1890 return Ok(());
1891 }
1892 let path = params
1893 .pointer("/locator/storage/path")
1894 .and_then(Value::as_str)
1895 .ok_or_else(|| Error::tool("supercode_sdk", "load/export requires locator.storage.path"))?;
1896 let path = ctx.resolve(path);
1897 if !crate::safe_path::contained(&ctx.cwd, &path) {
1898 return Err(Error::tool(
1899 "supercode_sdk",
1900 "session locator escapes the MCP workspace",
1901 ));
1902 }
1903 params["locator"]["storage"]["path"] = json!(path);
1904 Ok(())
1905}
1906
1907/// Register the SDK adapter alongside ordinary MCP coding tools.
1908#[cfg(feature = "adapter-mcp")]
1909pub fn register_sdk_tool(registry: &mut ToolRegistry) {
1910 registry.register(SdkMcpTool::new());
1911}
1912
1913/// Handle one JSON-RPC request against a [`ToolRegistry`], returning the
1914/// JSON-RPC response (or `None` for notifications that need no reply).
1915pub async fn handle_request(
1916 registry: &ToolRegistry,
1917 ctx: &ToolContext,
1918 request: &Value,
1919) -> Option<Value> {
1920 let id = request.get("id").cloned();
1921 let method = request.get("method").and_then(Value::as_str).unwrap_or("");
1922 let reply = |result: Value| Some(json!({"jsonrpc": "2.0", "id": id, "result": result}));
1923
1924 match method {
1925 "initialize" => reply(json!({
1926 "protocolVersion": PROTOCOL_VERSION,
1927 "capabilities": {"tools": {}},
1928 "serverInfo": {"name": "supercode", "version": env!("CARGO_PKG_VERSION")}
1929 })),
1930 "tools/list" => {
1931 let tools: Vec<Value> = registry
1932 .iter()
1933 .map(|t| {
1934 json!({
1935 "name": t.name(),
1936 "description": t.description(),
1937 "inputSchema": t.parameters(),
1938 })
1939 })
1940 .collect();
1941 reply(json!({"tools": tools}))
1942 }
1943 "tools/call" => {
1944 let params = request.get("params").cloned().unwrap_or(Value::Null);
1945 let name = params.get("name").and_then(Value::as_str).unwrap_or("");
1946 let args = params.get("arguments").cloned().unwrap_or(json!({}));
1947 match registry.get(name) {
1948 None => Some(json!({
1949 "jsonrpc": "2.0", "id": id,
1950 "error": {"code": -32601, "message": format!("unknown tool `{name}`")}
1951 })),
1952 Some(tool) => {
1953 let (text, is_error, structured) = match tool.execute(args, ctx).await {
1954 Ok(t) => {
1955 let structured = tool
1956 .structured_output()
1957 .then(|| serde_json::from_str::<Value>(&t).ok())
1958 .flatten();
1959 let is_error = structured
1960 .as_ref()
1961 .and_then(|value| value.get("ok"))
1962 .and_then(Value::as_bool)
1963 == Some(false);
1964 (t, is_error, structured)
1965 }
1966 Err(e) => {
1967 let text = e.to_string();
1968 let structured = text
1969 .split_once("SUPERCODE_SDK_ERROR:")
1970 .and_then(|(_, value)| serde_json::from_str::<Value>(value).ok())
1971 .map(|error| json!({"error":error}));
1972 (format!("Error: {text}"), true, structured)
1973 }
1974 };
1975 reply(json!({
1976 "content": [{"type": "text", "text": text}],
1977 "isError": is_error,
1978 "structuredContent": structured,
1979 }))
1980 }
1981 }
1982 }
1983 // Notifications (no id) and unknown methods.
1984 _ if id.is_none() => None,
1985 _ => Some(json!({
1986 "jsonrpc": "2.0", "id": id,
1987 "error": {"code": -32601, "message": format!("unknown method `{method}`")}
1988 })),
1989 }
1990}
1991
1992/// Run a blocking stdio MCP server exposing `registry`, reading requests from
1993/// stdin and writing responses to stdout until EOF.
1994pub async fn serve_stdio(registry: &ToolRegistry, ctx: &ToolContext) -> Result<()> {
1995 let mut stdin = BufReader::new(tokio::io::stdin());
1996 let mut stdout = tokio::io::stdout();
1997 let mut line = String::new();
1998 loop {
1999 line.clear();
2000 if stdin.read_line(&mut line).await? == 0 {
2001 break;
2002 }
2003 let Ok(req) = serde_json::from_str::<Value>(line.trim()) else {
2004 continue;
2005 };
2006 if let Some(resp) = handle_request(registry, ctx, &req).await {
2007 stdout.write_all(format!("{resp}\n").as_bytes()).await?;
2008 stdout.flush().await?;
2009 }
2010 }
2011 Ok(())
2012}
2013
2014#[cfg(test)]
2015mod tests {
2016 use super::*;
2017
2018 #[tokio::test]
2019 async fn mcp_sdk_tool_is_a_thin_named_error_projection() {
2020 let mut registry = ToolRegistry::new();
2021 register_sdk_tool(&mut registry);
2022 let ctx = ToolContext::new(std::env::temp_dir());
2023
2024 let listed = handle_request(
2025 ®istry,
2026 &ctx,
2027 &json!({"jsonrpc":"2.0", "id":1, "method":"tools/list"}),
2028 )
2029 .await
2030 .unwrap();
2031 assert_eq!(listed["result"]["tools"][0]["name"], "supercode_sdk");
2032
2033 let response = handle_request(
2034 ®istry,
2035 &ctx,
2036 &json!({
2037 "jsonrpc":"2.0",
2038 "id":2,
2039 "method":"tools/call",
2040 "params": {
2041 "name":"supercode_sdk",
2042 "arguments":{"operation":"steer", "params":{}}
2043 }
2044 }),
2045 )
2046 .await
2047 .unwrap();
2048 assert_eq!(response["result"]["isError"], true);
2049 assert_eq!(
2050 response["result"]["structuredContent"]["error"]["name"],
2051 "unsupported_action"
2052 );
2053 assert_eq!(
2054 response["result"]["structuredContent"]["error"]["operation"],
2055 "steer"
2056 );
2057 }
2058
2059 #[test]
2060 fn cache_churn_notice_names_server_and_count() {
2061 let msg = cache_churn_notice("github", 12);
2062 assert!(msg.contains("github"));
2063 assert!(msg.contains("12"));
2064 assert!(msg.contains("C2"));
2065 }
2066
2067 #[test]
2068 fn resolve_endpoint_url_passes_through_absolute_urls() {
2069 assert_eq!(
2070 resolve_endpoint_url("http://localhost:1234/sse", "https://other/msg"),
2071 "https://other/msg"
2072 );
2073 }
2074
2075 #[test]
2076 fn resolve_endpoint_url_resolves_relative_path_against_origin() {
2077 assert_eq!(
2078 resolve_endpoint_url("http://localhost:1234/sse", "/messages?session=abc"),
2079 "http://localhost:1234/messages?session=abc"
2080 );
2081 }
2082
2083 #[test]
2084 fn parse_sse_body_extracts_multiple_events() {
2085 let body = b"event: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"a\":2}\n\n";
2086 let out = parse_sse_body(body);
2087 assert_eq!(out.len(), 2);
2088 assert_eq!(out[0]["a"], 1);
2089 assert_eq!(out[1]["a"], 2);
2090 }
2091
2092 #[test]
2093 fn sse_line_accumulator_handles_a_split_chunk() {
2094 let mut acc = SseLineAccumulator::default();
2095 let first = acc.push(b"event: message\ndata: {\"a\":").unwrap();
2096 assert!(first.is_empty(), "no complete event yet");
2097 let second = acc.push(b"1}\n\n").unwrap();
2098 assert_eq!(second.len(), 1);
2099 assert_eq!(second[0].0.as_deref(), Some("message"));
2100 assert_eq!(second[0].1, "{\"a\":1}");
2101 }
2102
2103 #[test]
2104 fn sse_line_accumulator_errors_and_resets_on_an_oversized_unterminated_frame() {
2105 // Fable-5 review hardening: an SSE frame that never sends its
2106 // terminating blank line must not grow the accumulator without
2107 // bound — it must error (fail-closed) once it exceeds
2108 // MCP_MAX_SSE_FRAME_BYTES, and the buffer must be reset (not left
2109 // holding the oversized data) rather than growing on every push.
2110 let mut acc = SseLineAccumulator::default();
2111 let chunk = vec![b'x'; MCP_MAX_SSE_FRAME_BYTES + 1];
2112 let err = acc.push(&chunk).unwrap_err();
2113 assert!(
2114 err.to_string().contains("exceeded max"),
2115 "error should name the cap: {err}"
2116 );
2117 assert_eq!(
2118 acc.buf.len(),
2119 0,
2120 "buffer must be reset on overflow, not left growing"
2121 );
2122 }
2123
2124 #[test]
2125 fn sse_line_accumulator_stays_under_cap_for_legit_small_events() {
2126 // No-over-block confirmation: an ordinary small event (well under
2127 // the cap) still parses normally.
2128 let mut acc = SseLineAccumulator::default();
2129 let events = acc
2130 .push(b"event: message\ndata: {\"ok\":true}\n\n")
2131 .unwrap();
2132 assert_eq!(events.len(), 1);
2133 assert_eq!(events[0].1, "{\"ok\":true}");
2134 }
2135
2136 #[test]
2137 fn elicitation_response_decline_serializes_without_content() {
2138 let r = ElicitationResponse::decline();
2139 assert_eq!(r.to_json_rpc_result(), json!({"action": "decline"}));
2140 }
2141
2142 #[test]
2143 fn elicitation_response_accept_carries_content() {
2144 let r = ElicitationResponse {
2145 action: ElicitationAction::Accept,
2146 content: Some(json!({"name": "value"})),
2147 };
2148 assert_eq!(
2149 r.to_json_rpc_result(),
2150 json!({"action": "accept", "content": {"name": "value"}})
2151 );
2152 }
2153
2154 /// Fable-5 review, latent-SSRF-landmine finding: `reconnect` used to
2155 /// call `connect_http`/`connect_sse` with `None` for the network
2156 /// policy regardless of what the original connect used, silently
2157 /// skipping BOTH the pre-connect host check and the per-hop redirect
2158 /// re-check on every reconnect. This is a FAIL-ON-REVERT test: it
2159 /// builds an already-"connected" `McpClient` by hand (private-field
2160 /// access — this `tests` module is a child of `mcp`, so normal Rust
2161 /// visibility rules give it that) whose remembered `network_policy`
2162 /// DENIES the very host its `params` would reconnect to. A real
2163 /// `connect_http` call under a denying policy can never produce a
2164 /// connected client in the first place (see
2165 /// `mcp_remote.rs::network_policy_denies_a_disallowed_http_host_before_connecting`),
2166 /// which is why this can't be expressed as a pure public-API
2167 /// integration test — the point under test is specifically whether
2168 /// `reconnect` reuses `self.network_policy` (this test) instead of
2169 /// `None` (what a revert would reintroduce, and what this test would
2170 /// then fail to catch as an error).
2171 #[tokio::test]
2172 async fn reconnect_denies_a_disallowed_host_before_reconnecting() {
2173 use std::sync::atomic::{AtomicBool, Ordering};
2174
2175 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2176 let addr = listener.local_addr().unwrap();
2177 let connected = Arc::new(AtomicBool::new(false));
2178 let connected2 = connected.clone();
2179 tokio::spawn(async move {
2180 if let Ok((mut sock, _)) = listener.accept().await {
2181 connected2.store(true, Ordering::SeqCst);
2182 let mut buf = [0u8; 1024];
2183 use tokio::io::AsyncReadExt;
2184 let _ = sock.read(&mut buf).await;
2185 }
2186 });
2187 let url = format!("http://127.0.0.1:{}/mcp", addr.port());
2188 let deny_policy = NetworkPolicy {
2189 enabled: true,
2190 allow_domains: vec![],
2191 deny_domains: vec!["127.0.0.1".to_string()],
2192 };
2193 let client = McpClient {
2194 conn: Conn::Http {
2195 client: reqwest::Client::new(),
2196 url: url.clone(),
2197 headers: HeaderMap::new(),
2198 session_id: None,
2199 },
2200 next_id: 0,
2201 params: McpConnectParams::Http {
2202 url: url.clone(),
2203 headers: BTreeMap::new(),
2204 },
2205 network_policy: Some(deny_policy),
2206 timeout: DEFAULT_MCP_TIMEOUT,
2207 elicitation_handler: Arc::new(HeadlessElicitationHandler),
2208 instructions: None,
2209 pending_notifications: std::sync::Mutex::new(Vec::new()),
2210 };
2211
2212 let result = client.reconnect().await;
2213 assert!(
2214 result.is_err(),
2215 "reconnect must refuse to reconnect to a host its own remembered policy denies"
2216 );
2217 tokio::time::sleep(Duration::from_millis(50)).await;
2218 assert!(
2219 !connected.load(Ordering::SeqCst),
2220 "the denied host must never even be contacted on reconnect"
2221 );
2222 }
2223}