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