Skip to main content

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(&params);
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            &registry,
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            &registry,
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}