use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, Mutex};
use crate::error::{Error, Result};
use crate::tools::{NetworkPolicy, Tool, ToolContext, ToolRegistry};
#[cfg(feature = "adapter-mcp")]
use crate::{
FrontendAttachment, FrontendResponse, HarnessSessionService, SdkError, SdkOperation,
SdkRequest, SdkRuntime, SdkService,
};
const PROTOCOL_VERSION: &str = "2025-06-18";
pub const DEFAULT_MCP_TIMEOUT: Duration = Duration::from_secs(30);
pub const MCP_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
pub const MCP_MAX_SSE_FRAME_BYTES: usize = 16 * 1024 * 1024;
pub const MCP_MAX_RESOURCE_BYTES: usize = 16 * 1024 * 1024;
const MCP_SSE_CHANNEL_CAPACITY: usize = 256;
enum Conn {
Stdio {
#[allow(dead_code)] child: tokio::process::Child,
stdin: tokio::process::ChildStdin,
stdout: BufReader<tokio::process::ChildStdout>,
},
Http {
client: reqwest::Client,
url: String,
headers: HeaderMap,
session_id: Option<String>,
},
Sse {
client: reqwest::Client,
post_url: String,
headers: HeaderMap,
inbox: mpsc::Receiver<SseInboxMsg>,
#[allow(dead_code)] reader: tokio::task::JoinHandle<()>,
},
}
enum SseInboxMsg {
Frame(Value),
Error(String),
}
fn build_header_map(headers: &BTreeMap<String, String>) -> HeaderMap {
let mut map = HeaderMap::new();
for (k, v) in headers {
let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(k.as_bytes()),
HeaderValue::from_str(v),
) else {
continue;
};
map.insert(name, value);
}
map
}
#[derive(Debug, Clone)]
pub enum McpConnectParams {
Stdio {
command: String,
args: Vec<String>,
env: BTreeMap<String, String>,
},
Http {
url: String,
headers: BTreeMap<String, String>,
},
Sse {
url: String,
headers: BTreeMap<String, String>,
},
}
#[derive(Debug, Clone)]
pub struct ElicitationRequest {
pub message: String,
pub requested_schema: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ElicitationAction {
Accept,
Decline,
Cancel,
}
#[derive(Debug, Clone)]
pub struct ElicitationResponse {
pub action: ElicitationAction,
pub content: Option<Value>,
}
impl ElicitationResponse {
fn decline() -> Self {
ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
}
}
fn to_json_rpc_result(&self) -> Value {
match self.action {
ElicitationAction::Accept => json!({
"action": "accept",
"content": self.content.clone().unwrap_or(json!({})),
}),
ElicitationAction::Decline => json!({"action": "decline"}),
ElicitationAction::Cancel => json!({"action": "cancel"}),
}
}
}
#[async_trait]
pub trait McpElicitationHandler: Send + Sync {
async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse;
}
pub struct HeadlessElicitationHandler;
#[async_trait]
impl McpElicitationHandler for HeadlessElicitationHandler {
async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
ElicitationResponse::decline()
}
}
fn parse_elicitation_request(params: &Value) -> ElicitationRequest {
ElicitationRequest {
message: params
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
requested_schema: params
.get("requestedSchema")
.cloned()
.unwrap_or_else(|| json!({})),
}
}
#[derive(Debug, Clone)]
pub struct McpToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, Default)]
pub struct McpResourceDef {
pub uri: String,
pub name: String,
pub description: String,
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct McpResourceTemplateDef {
pub uri_template: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Default)]
pub struct McpPromptDef {
pub name: String,
pub description: String,
pub arguments: Vec<McpPromptArgDef>,
}
#[derive(Debug, Clone, Default)]
pub struct McpPromptArgDef {
pub name: String,
pub required: bool,
}
pub struct McpClient {
conn: Conn,
next_id: i64,
params: McpConnectParams,
network_policy: Option<NetworkPolicy>,
timeout: Duration,
elicitation_handler: Arc<dyn McpElicitationHandler>,
pub instructions: Option<String>,
pending_notifications: std::sync::Mutex<Vec<Value>>,
}
impl McpClient {
pub async fn connect(
command: &str,
args: &[&str],
env: &BTreeMap<String, String>,
) -> Result<Self> {
let mut child = tokio::process::Command::new(command)
.args(args)
.envs(env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
.map_err(|e| Error::tool("mcp", format!("spawn {command}: {e}")))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| Error::tool("mcp", "no stdin"))?;
let stdout = BufReader::new(
child
.stdout
.take()
.ok_or_else(|| Error::tool("mcp", "no stdout"))?,
);
let params = McpConnectParams::Stdio {
command: command.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
env: env.clone(),
};
let mut client = McpClient {
conn: Conn::Stdio {
child,
stdin,
stdout,
},
next_id: 0,
params,
network_policy: None,
timeout: DEFAULT_MCP_TIMEOUT,
elicitation_handler: Arc::new(HeadlessElicitationHandler),
instructions: None,
pending_notifications: std::sync::Mutex::new(Vec::new()),
};
client.initialize().await?;
Ok(client)
}
pub async fn connect_http(
url: &str,
headers: &BTreeMap<String, String>,
network_policy: Option<&NetworkPolicy>,
) -> Result<Self> {
crate::tools::check_network_policy(network_policy, url)?;
let client = reqwest::Client::builder()
.timeout(DEFAULT_MCP_TIMEOUT)
.redirect(crate::tools::network_checked_redirect_policy(
network_policy.cloned(),
))
.build()
.map_err(|e| Error::tool("mcp", format!("building http client: {e}")))?;
let params = McpConnectParams::Http {
url: url.to_string(),
headers: headers.clone(),
};
let mut mcp_client = McpClient {
conn: Conn::Http {
client,
url: url.to_string(),
headers: build_header_map(headers),
session_id: None,
},
next_id: 0,
params,
network_policy: network_policy.cloned(),
timeout: DEFAULT_MCP_TIMEOUT,
elicitation_handler: Arc::new(HeadlessElicitationHandler),
instructions: None,
pending_notifications: std::sync::Mutex::new(Vec::new()),
};
mcp_client.initialize().await?;
Ok(mcp_client)
}
pub async fn connect_sse(
url: &str,
headers: &BTreeMap<String, String>,
network_policy: Option<&NetworkPolicy>,
) -> Result<Self> {
crate::tools::check_network_policy(network_policy, url)?;
let client = reqwest::Client::builder()
.redirect(crate::tools::network_checked_redirect_policy(
network_policy.cloned(),
))
.build()
.map_err(|e| Error::tool("mcp", format!("building sse client: {e}")))?;
let header_map = build_header_map(headers);
let mut req = client.get(url);
req = req.header(reqwest::header::ACCEPT, "text/event-stream");
req = req.headers(header_map.clone());
let resp = req
.send()
.await
.map_err(|e| Error::tool("mcp", format!("sse connect failed: {e}")))?;
if !resp.status().is_success() {
return Err(Error::tool(
"mcp",
format!("sse connect: http status {}", resp.status()),
));
}
let base_url = url.to_string();
let (endpoint_tx, endpoint_rx) = tokio::sync::oneshot::channel();
let (msg_tx, msg_rx) = mpsc::channel(MCP_SSE_CHANNEL_CAPACITY);
let reader = tokio::spawn(sse_reader_task(resp, base_url, endpoint_tx, msg_tx));
let post_url = tokio::time::timeout(DEFAULT_MCP_TIMEOUT, endpoint_rx)
.await
.map_err(|_| Error::tool("mcp", "timed out waiting for sse endpoint event"))?
.map_err(|_| Error::tool("mcp", "sse stream closed before an endpoint event"))?;
let params = McpConnectParams::Sse {
url: url.to_string(),
headers: headers.clone(),
};
let mut mcp_client = McpClient {
conn: Conn::Sse {
client,
post_url,
headers: header_map,
inbox: msg_rx,
reader,
},
next_id: 0,
params,
network_policy: network_policy.cloned(),
timeout: DEFAULT_MCP_TIMEOUT,
elicitation_handler: Arc::new(HeadlessElicitationHandler),
instructions: None,
pending_notifications: std::sync::Mutex::new(Vec::new()),
};
mcp_client.initialize().await?;
Ok(mcp_client)
}
pub async fn reconnect(&self) -> Result<Self> {
match &self.params {
McpConnectParams::Stdio { command, args, env } => {
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
Self::connect(command, &args_ref, env).await
}
McpConnectParams::Http { url, headers } => {
Self::connect_http(url, headers, self.network_policy.as_ref()).await
}
McpConnectParams::Sse { url, headers } => {
Self::connect_sse(url, headers, self.network_policy.as_ref()).await
}
}
}
pub fn set_elicitation_handler(&mut self, handler: Arc<dyn McpElicitationHandler>) {
self.elicitation_handler = handler;
}
pub fn set_timeout(&mut self, timeout: Duration) {
self.timeout = timeout;
}
pub fn take_pending_notifications(&self) -> Vec<Value> {
self.pending_notifications
.lock()
.map(|mut v| std::mem::take(&mut *v))
.unwrap_or_default()
}
async fn initialize(&mut self) -> Result<()> {
let result = self
.request(
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"elicitation": {}
},
"clientInfo": {"name": "supercode", "version": env!("CARGO_PKG_VERSION")}
}),
)
.await?;
self.instructions = result
.get("instructions")
.and_then(Value::as_str)
.map(str::to_string);
let _ = self.notify("notifications/initialized", json!({})).await;
Ok(())
}
async fn notify(&mut self, method: &str, params: Value) -> Result<()> {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
self.send_raw(&msg).await
}
async fn send_raw(&mut self, msg: &Value) -> Result<()> {
match &mut self.conn {
Conn::Stdio { stdin, .. } => {
stdin
.write_all(format!("{msg}\n").as_bytes())
.await
.map_err(|e| Error::tool("mcp", format!("write: {e}")))?;
stdin
.flush()
.await
.map_err(|e| Error::tool("mcp", format!("flush: {e}")))?;
Ok(())
}
Conn::Sse {
client,
post_url,
headers,
..
} => {
let resp = client
.post(post_url.as_str())
.headers(headers.clone())
.json(msg)
.send()
.await
.map_err(|e| Error::tool("mcp", format!("sse post: {e}")))?;
if !resp.status().is_success() {
return Err(Error::tool(
"mcp",
format!("sse post: http status {}", resp.status()),
));
}
Ok(())
}
Conn::Http { .. } => Err(Error::tool(
"mcp",
"cannot send an unsolicited message over the http (non-streaming) transport",
)),
}
}
async fn handle_incoming_message(
&mut self,
waiting_id: i64,
msg: Value,
) -> Result<Option<Value>> {
let id = msg.get("id").and_then(Value::as_i64);
let has_method = msg.get("method").and_then(Value::as_str);
if id == Some(waiting_id) && has_method.is_none() {
if let Some(err) = msg.get("error") {
return Err(Error::tool("mcp", format!("rpc error: {err}")));
}
return Ok(Some(msg.get("result").cloned().unwrap_or(Value::Null)));
}
match (id, has_method) {
(Some(req_id), Some(method)) => {
let reply = if method == "elicitation/create" {
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let request = parse_elicitation_request(¶ms);
let handler = self.elicitation_handler.clone();
let response = handler.handle(&request).await;
json!({"jsonrpc": "2.0", "id": req_id, "result": response.to_json_rpc_result()})
} else {
json!({
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601, "message": format!("supercode does not handle server-initiated `{method}`")}
})
};
self.send_raw(&reply).await?;
Ok(None)
}
(None, Some(_)) => {
if let Ok(mut log) = self.pending_notifications.lock() {
log.push(msg);
}
Ok(None)
}
_ => Ok(None),
}
}
async fn request(&mut self, method: &str, params: Value) -> Result<Value> {
self.next_id += 1;
let id = self.next_id;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
match &self.conn {
Conn::Stdio { .. } => self.stdio_roundtrip(id, &msg).await,
Conn::Sse { .. } => self.sse_roundtrip(id, &msg).await,
Conn::Http { .. } => self.http_roundtrip(id, &msg).await,
}
}
async fn stdio_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
self.send_raw(msg).await?;
loop {
let Conn::Stdio { stdout, .. } = &mut self.conn else {
unreachable!("stdio_roundtrip called on a non-stdio connection")
};
let mut buf = String::new();
let n = stdout
.read_line(&mut buf)
.await
.map_err(|e| Error::tool("mcp", format!("read: {e}")))?;
if n == 0 {
return Err(Error::tool("mcp", "server closed the connection"));
}
let Ok(incoming) = serde_json::from_str::<Value>(buf.trim()) else {
continue;
};
if let Some(result) = self.handle_incoming_message(id, incoming).await? {
return Ok(result);
}
}
}
async fn sse_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
self.send_raw(msg).await?;
loop {
let inbox_msg = {
let Conn::Sse { inbox, .. } = &mut self.conn else {
unreachable!("sse_roundtrip called on a non-sse connection")
};
tokio::time::timeout(self.timeout, inbox.recv())
.await
.map_err(|_| Error::tool("mcp", "timed out waiting for an sse response"))?
.ok_or_else(|| Error::tool("mcp", "sse stream closed"))?
};
let incoming = match inbox_msg {
SseInboxMsg::Frame(v) => v,
SseInboxMsg::Error(reason) => return Err(Error::tool("mcp", reason)),
};
if let Some(result) = self.handle_incoming_message(id, incoming).await? {
return Ok(result);
}
}
}
async fn http_roundtrip(&mut self, id: i64, msg: &Value) -> Result<Value> {
let (client, url, headers, session_id) = match &self.conn {
Conn::Http {
client,
url,
headers,
session_id,
} => (
client.clone(),
url.clone(),
headers.clone(),
session_id.clone(),
),
_ => unreachable!("http_roundtrip called on a non-http connection"),
};
let mut req = client.post(&url).headers(headers).json(msg);
if let Some(sid) = &session_id {
req = req.header("Mcp-Session-Id", sid.as_str());
}
let resp = req
.send()
.await
.map_err(|e| Error::tool("mcp", format!("http request failed: {e}")))?;
if !resp.status().is_success() {
return Err(Error::tool("mcp", format!("http status {}", resp.status())));
}
if let Some(new_sid) = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string)
{
if let Conn::Http { session_id, .. } = &mut self.conn {
*session_id = Some(new_sid);
}
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = read_capped_body(resp, MCP_MAX_RESPONSE_BYTES, "http response body").await?;
let frames: Vec<Value> = if content_type.starts_with("text/event-stream") {
parse_sse_body(&body)
} else {
vec![serde_json::from_slice::<Value>(&body)
.map_err(|e| Error::tool("mcp", format!("decoding http response: {e}")))?]
};
for frame in frames {
let frame_id = frame.get("id").and_then(Value::as_i64);
let has_method = frame.get("method").is_some();
if frame_id == Some(id) && !has_method {
if let Some(err) = frame.get("error") {
return Err(Error::tool("mcp", format!("rpc error: {err}")));
}
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
}
if has_method {
return Err(Error::tool(
"mcp",
"server sent a server-initiated request/notification over the http \
(non-streaming) transport — elicitation and live notifications need \
stdio or sse",
));
}
}
Err(Error::tool(
"mcp",
"http response never contained this request's result",
))
}
pub async fn list_tools(&mut self) -> Result<Vec<McpToolDef>> {
let result = self.request("tools/list", json!({})).await?;
let tools = result
.get("tools")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Ok(tools
.into_iter()
.map(|t| McpToolDef {
name: t
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
description: t
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
input_schema: t
.get("inputSchema")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
})
.collect())
}
pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<String> {
let result = self
.request("tools/call", json!({"name": name, "arguments": arguments}))
.await?;
Ok(extract_content_text(&result))
}
pub async fn list_resources(&mut self) -> Result<Vec<McpResourceDef>> {
let result = self.request("resources/list", json!({})).await?;
Ok(result
.get("resources")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|r| McpResourceDef {
uri: str_field(&r, "uri"),
name: str_field(&r, "name"),
description: str_field(&r, "description"),
mime_type: r
.get("mimeType")
.and_then(Value::as_str)
.map(str::to_string),
})
.collect())
}
pub async fn list_resource_templates(&mut self) -> Result<Vec<McpResourceTemplateDef>> {
let result = self.request("resources/templates/list", json!({})).await?;
Ok(result
.get("resourceTemplates")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|r| McpResourceTemplateDef {
uri_template: str_field(&r, "uriTemplate"),
name: str_field(&r, "name"),
description: str_field(&r, "description"),
})
.collect())
}
pub async fn read_resource(&mut self, uri: &str) -> Result<String> {
let result = self.request("resources/read", json!({"uri": uri})).await?;
let joined = result
.get("contents")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|i| {
i.get("text")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
i.get("blob")
.and_then(Value::as_str)
.map(|b| format!("[base64 blob, {} bytes encoded]", b.len()))
})
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
if joined.len() > MCP_MAX_RESOURCE_BYTES {
return Err(Error::tool(
"mcp",
format!(
"resource {uri}: joined contents exceeded max {MCP_MAX_RESOURCE_BYTES} bytes"
),
));
}
Ok(joined)
}
pub async fn subscribe_resource(&mut self, uri: &str) -> Result<()> {
self.request("resources/subscribe", json!({"uri": uri}))
.await?;
Ok(())
}
pub async fn list_prompts(&mut self) -> Result<Vec<McpPromptDef>> {
let result = self.request("prompts/list", json!({})).await?;
Ok(result
.get("prompts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|p| McpPromptDef {
name: str_field(&p, "name"),
description: str_field(&p, "description"),
arguments: p
.get("arguments")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|a| McpPromptArgDef {
name: str_field(&a, "name"),
required: a.get("required").and_then(Value::as_bool).unwrap_or(false),
})
.collect(),
})
.collect())
}
pub async fn get_prompt(
&mut self,
name: &str,
args: BTreeMap<String, String>,
) -> Result<String> {
let result = self
.request("prompts/get", json!({"name": name, "arguments": args}))
.await?;
Ok(result
.get("messages")
.and_then(Value::as_array)
.map(|msgs| {
msgs.iter()
.filter_map(|m| {
m.get("content")
.and_then(|c| c.get("text"))
.and_then(Value::as_str)
})
.collect::<Vec<_>>()
.join("\n\n")
})
.unwrap_or_default())
}
}
fn str_field(v: &Value, key: &str) -> String {
v.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn extract_content_text(result: &Value) -> String {
result
.get("content")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|i| i.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
async fn read_capped_body(resp: reqwest::Response, cap: usize, what: &str) -> Result<Vec<u8>> {
use futures::StreamExt;
if let Some(len) = resp.content_length() {
if len as usize > cap {
return Err(Error::tool(
"mcp",
format!("{what}: declared content-length {len} bytes exceeds max {cap} bytes"),
));
}
}
let mut buf: Vec<u8> = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| Error::tool("mcp", format!("reading {what}: {e}")))?;
buf.extend_from_slice(&chunk);
if buf.len() > cap {
return Err(Error::tool(
"mcp",
format!("{what}: exceeded max {cap} bytes"),
));
}
}
Ok(buf)
}
fn parse_sse_body(body: &[u8]) -> Vec<Value> {
let text = String::from_utf8_lossy(body);
let mut out = Vec::new();
for event in text.split("\n\n") {
let mut data_lines = Vec::new();
for line in event.lines() {
if let Some(d) = line.strip_prefix("data:") {
data_lines.push(d.trim_start());
}
}
if data_lines.is_empty() {
continue;
}
if let Ok(v) = serde_json::from_str::<Value>(&data_lines.join("\n")) {
out.push(v);
}
}
out
}
#[derive(Default)]
struct SseLineAccumulator {
buf: String,
}
impl SseLineAccumulator {
fn push(&mut self, chunk: &[u8]) -> Result<Vec<(Option<String>, String)>> {
self.buf.push_str(&String::from_utf8_lossy(chunk));
let mut out = Vec::new();
while let Some(pos) = self.buf.find("\n\n") {
let event_text: String = self.buf.drain(..pos + 2).collect();
let mut event_name = None;
let mut data_lines = Vec::new();
for line in event_text.lines() {
if let Some(v) = line.strip_prefix("event:") {
event_name = Some(v.trim_start().to_string());
} else if let Some(v) = line.strip_prefix("data:") {
data_lines.push(v.trim_start().to_string());
}
}
if !data_lines.is_empty() || event_name.is_some() {
out.push((event_name, data_lines.join("\n")));
}
}
if self.buf.len() > MCP_MAX_SSE_FRAME_BYTES {
self.buf.clear();
return Err(Error::tool(
"mcp",
format!(
"sse frame exceeded max {MCP_MAX_SSE_FRAME_BYTES} bytes without a \
terminating blank line"
),
));
}
Ok(out)
}
}
async fn sse_reader_task(
resp: reqwest::Response,
base_url: String,
endpoint_tx: tokio::sync::oneshot::Sender<String>,
msg_tx: mpsc::Sender<SseInboxMsg>,
) {
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut acc = SseLineAccumulator::default();
let mut endpoint_tx = Some(endpoint_tx);
while let Some(chunk) = stream.next().await {
let Ok(bytes) = chunk else { break };
let events = match acc.push(&bytes) {
Ok(events) => events,
Err(e) => {
let _ = msg_tx.send(SseInboxMsg::Error(e.to_string())).await;
return;
}
};
for (event_name, data) in events {
match event_name.as_deref() {
Some("endpoint") => {
if let Some(tx) = endpoint_tx.take() {
let resolved = resolve_endpoint_url(&base_url, data.trim());
let _ = tx.send(resolved);
}
}
_ => {
if let Ok(v) = serde_json::from_str::<Value>(&data) {
if msg_tx.send(SseInboxMsg::Frame(v)).await.is_err() {
return; }
}
}
}
}
}
}
fn resolve_endpoint_url(base_url: &str, endpoint: &str) -> String {
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
return endpoint.to_string();
}
let Some(scheme_end) = base_url.find("://") else {
return endpoint.to_string();
};
let after_scheme = &base_url[scheme_end + 3..];
let origin_end = after_scheme.find('/').map(|i| scheme_end + 3 + i);
let origin = match origin_end {
Some(end) => &base_url[..end],
None => base_url,
};
if endpoint.starts_with('/') {
format!("{origin}{endpoint}")
} else {
format!("{origin}/{endpoint}")
}
}
#[derive(Clone)]
pub struct McpServerHandle {
pub server: String,
client: Arc<Mutex<McpClient>>,
}
impl McpServerHandle {
pub fn new(server: impl Into<String>, client: McpClient) -> Self {
McpServerHandle {
server: server.into(),
client: Arc::new(Mutex::new(client)),
}
}
pub async fn instructions(&self) -> Option<String> {
self.client.lock().await.instructions.clone()
}
pub async fn tools(&self) -> Result<Vec<McpTool>> {
let defs = self.client.lock().await.list_tools().await?;
Ok(defs
.into_iter()
.map(|d| McpTool {
name: format!("mcp__{}__{}", self.server, d.name),
description: d.description,
parameters: d.input_schema,
remote_name: d.name,
client: self.client.clone(),
})
.collect())
}
pub fn resource_tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(McpResourcesListTool {
name: format!("mcp__{}__resources_list", self.server),
client: self.client.clone(),
}),
Box::new(McpResourceReadTool {
name: format!("mcp__{}__resources_read", self.server),
client: self.client.clone(),
}),
Box::new(McpResourceSubscribeTool {
name: format!("mcp__{}__resources_subscribe", self.server),
client: self.client.clone(),
}),
]
}
pub async fn prompts(&self) -> Result<Vec<(String, McpPromptSource)>> {
let defs = self.client.lock().await.list_prompts().await?;
Ok(defs
.into_iter()
.map(|d| {
(
format!("mcp__{}__{}", self.server, d.name),
McpPromptSource {
client: self.client.clone(),
remote_name: d.name,
arg_names: d.arguments.into_iter().map(|a| a.name).collect(),
},
)
})
.collect())
}
pub fn client(&self) -> Arc<Mutex<McpClient>> {
self.client.clone()
}
}
pub struct McpTool {
name: String,
description: String,
parameters: Value,
remote_name: String,
client: Arc<Mutex<McpClient>>,
}
impl McpTool {
pub async fn from_client(server: &str, client: McpClient) -> Result<Vec<McpTool>> {
McpServerHandle::new(server, client).tools().await
}
}
#[async_trait]
impl Tool for McpTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters(&self) -> Value {
self.parameters.clone()
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
self.client
.lock()
.await
.call_tool(&self.remote_name, args)
.await
}
}
#[derive(Clone)]
pub struct McpPromptSource {
client: Arc<Mutex<McpClient>>,
remote_name: String,
arg_names: Vec<String>,
}
impl McpPromptSource {
pub async fn render(&self, args: BTreeMap<String, String>) -> Result<String> {
self.client
.lock()
.await
.get_prompt(&self.remote_name, args)
.await
}
pub fn arg_names(&self) -> &[String] {
&self.arg_names
}
}
#[async_trait]
impl crate::sdk::SdkPromptSource for McpPromptSource {
async fn render(&self, args: BTreeMap<String, String>) -> Result<String> {
McpPromptSource::render(self, args).await
}
fn arg_names(&self) -> &[String] {
McpPromptSource::arg_names(self)
}
}
struct McpResourcesListTool {
name: String,
client: Arc<Mutex<McpClient>>,
}
#[async_trait]
impl Tool for McpResourcesListTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"List this MCP server's available resources and resource templates."
}
fn parameters(&self) -> Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
let mut client = self.client.lock().await;
let resources = client.list_resources().await?;
let templates = client.list_resource_templates().await?;
let mut out = String::new();
for r in &resources {
out.push_str(&format!("- {} ({})\n", r.uri, r.name));
}
for t in &templates {
out.push_str(&format!("- template: {} ({})\n", t.uri_template, t.name));
}
if out.is_empty() {
out.push_str("(no resources or templates)\n");
}
Ok(out)
}
}
#[derive(serde::Deserialize)]
struct ResourceUriArgs {
uri: String,
}
struct McpResourceReadTool {
name: String,
client: Arc<Mutex<McpClient>>,
}
#[async_trait]
impl Tool for McpResourceReadTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Read one resource from this MCP server by URI."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {"uri": {"type": "string"}},
"required": ["uri"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
let a: ResourceUriArgs =
serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
tool: self.name.clone(),
message: e.to_string(),
})?;
self.client.lock().await.read_resource(&a.uri).await
}
}
struct McpResourceSubscribeTool {
name: String,
client: Arc<Mutex<McpClient>>,
}
#[async_trait]
impl Tool for McpResourceSubscribeTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Subscribe to update notifications for one resource on this MCP server by URI. \
Updates surface as this server's pending-notifications log (no live push into the \
conversation) — call resources_list/resources_read again to see the latest content."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {"uri": {"type": "string"}},
"required": ["uri"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
let a: ResourceUriArgs =
serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
tool: self.name.clone(),
message: e.to_string(),
})?;
self.client.lock().await.subscribe_resource(&a.uri).await?;
Ok(format!("subscribed to {}", a.uri))
}
}
pub fn cache_churn_notice(server: &str, tool_count: usize) -> String {
format!(
"mcp: connecting `{server}` added {tool_count} tool(s) to the prompt prefix — with \
an imported-prefix cache plan active, this likely invalidates the cache hit on the \
next turn (C2)"
)
}
#[cfg(feature = "adapter-mcp")]
pub struct SdkMcpTool {
service: Arc<Mutex<HarnessSessionService>>,
runtime: Option<Arc<dyn SdkRuntime>>,
attachment: Arc<Mutex<Option<FrontendAttachment>>>,
}
#[cfg(feature = "adapter-mcp")]
impl Default for SdkMcpTool {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "adapter-mcp")]
impl SdkMcpTool {
pub fn new() -> Self {
Self {
service: Arc::new(Mutex::new(HarnessSessionService::new())),
runtime: None,
attachment: Arc::new(Mutex::new(None)),
}
}
pub async fn attached(runtime: Arc<dyn SdkRuntime>) -> std::result::Result<Self, SdkError> {
let attachment = runtime.attach(200).await?;
Ok(Self {
service: Arc::new(Mutex::new(HarnessSessionService::new())),
runtime: Some(runtime),
attachment: Arc::new(Mutex::new(Some(attachment))),
})
}
async fn execute_attached(
&self,
runtime: &Arc<dyn SdkRuntime>,
operation: SdkOperation,
params: Value,
) -> std::result::Result<Value, SdkError> {
let descriptor = runtime.describe().await?;
let session_id = descriptor.session_id;
match operation {
SdkOperation::Input => {
let prompt = params
.get("prompt")
.or_else(|| params.get("text"))
.and_then(Value::as_str)
.ok_or_else(|| {
SdkError::new(
crate::SdkErrorCode::InvalidArgument,
operation,
"input requires string `prompt` or `text`",
)
})?;
let image_urls = match params.get("image_urls") {
None => Vec::new(),
Some(Value::Array(values)) => values
.iter()
.map(|value| {
value.as_str().map(str::to_owned).ok_or_else(|| {
SdkError::new(
crate::SdkErrorCode::InvalidArgument,
operation,
"input requires string entries in `image_urls`",
)
})
})
.collect::<std::result::Result<Vec<_>, _>>()?,
Some(_) => {
return Err(SdkError::new(
crate::SdkErrorCode::InvalidArgument,
operation,
"input requires array `image_urls`",
))
}
};
let reply = runtime
.submit_with_images(prompt.to_string(), image_urls)
.await?;
Ok(json!({"session_id":session_id, "reply":reply}))
}
SdkOperation::Events => {
let mut attachment = self.attachment.lock().await;
if attachment.is_none() {
*attachment = Some(runtime.attach(200).await?);
}
let event = attachment
.as_mut()
.expect("attachment initialized")
.next_event()
.await?;
Ok(json!({"session_id":session_id, "event":event}))
}
SdkOperation::Interrupt => Ok(json!({
"session_id":session_id,
"interrupted":runtime.interrupt().await?,
})),
SdkOperation::Steer => {
let prompt = params
.get("prompt")
.or_else(|| params.get("text"))
.and_then(Value::as_str)
.ok_or_else(|| {
SdkError::new(
crate::SdkErrorCode::InvalidArgument,
operation,
"steer requires string `prompt` or `text`",
)
})?;
runtime.steer(prompt.to_string()).await?;
Ok(json!({"session_id":session_id}))
}
SdkOperation::Respond => {
let response = serde_json::from_value::<FrontendResponse>(
params.get("response").cloned().unwrap_or(Value::Null),
)
.map_err(|error| {
SdkError::new(
crate::SdkErrorCode::InvalidArgument,
operation,
error.to_string(),
)
})?;
runtime.respond(response).await?;
Ok(json!({"session_id":session_id}))
}
_ => Err(SdkError::unsupported(operation)),
}
}
}
#[async_trait]
#[cfg(feature = "adapter-mcp")]
impl Tool for SdkMcpTool {
fn name(&self) -> &str {
"supercode_sdk"
}
fn description(&self) -> &str {
"Invoke one operation on Supercode's versioned session/runtime SDK facade."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["discover", "load", "start", "resume", "input", "events", "interrupt", "steer", "respond", "export", "close"]
},
"params": {"type": "object"}
},
"required": ["operation"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let operation = serde_json::from_value::<SdkOperation>(
args.get("operation").cloned().unwrap_or(Value::Null),
)
.map_err(|error| Error::tool(self.name(), error.to_string()))?;
if let Some(runtime) = &self.runtime {
return self
.execute_attached(
runtime,
operation,
args.get("params").cloned().unwrap_or_else(|| json!({})),
)
.await
.and_then(|value| {
serde_json::to_string(&value)
.map_err(|error| SdkError::Transport(error.to_string()))
})
.map_err(|error| sdk_mcp_error(self.name(), &error));
}
if !matches!(
operation,
SdkOperation::Discover | SdkOperation::Load | SdkOperation::Export
) {
return Err(Error::tool(
self.name(),
format!(
"SUPERCODE_SDK_ERROR:{}",
json!({
"name":"unsupported_action",
"operation":operation,
"message":"the MCP SDK adapter is read-only; runtime control requires an owner surface",
})
),
));
}
let mut params = args.get("params").cloned().unwrap_or_else(|| json!({}));
confine_sdk_mcp_params(operation, &mut params, ctx)?;
let result = self
.service
.lock()
.await
.execute(SdkRequest { operation, params })
.await
.map_err(|error| sdk_mcp_error(self.name(), &error))?;
serde_json::to_string(&result).map_err(|error| Error::tool(self.name(), error.to_string()))
}
}
#[cfg(feature = "adapter-mcp")]
fn sdk_mcp_error(tool: &str, error: &SdkError) -> Error {
Error::tool(
tool,
format!(
"SUPERCODE_SDK_ERROR:{}",
json!({
"name":error.code(),
"operation":error.operation(),
"message":error.to_string(),
})
),
)
}
#[cfg(feature = "adapter-mcp")]
fn confine_sdk_mcp_params(
operation: SdkOperation,
params: &mut Value,
ctx: &ToolContext,
) -> Result<()> {
if operation == SdkOperation::Discover {
if params.get("homes").is_some() {
return Err(Error::tool(
"supercode_sdk",
"MCP discovery cannot override harness homes",
));
}
params["workspace"] = json!(ctx.cwd);
return Ok(());
}
let path = params
.pointer("/locator/storage/path")
.and_then(Value::as_str)
.ok_or_else(|| Error::tool("supercode_sdk", "load/export requires locator.storage.path"))?;
let path = ctx.resolve(path);
if !crate::safe_path::contained(&ctx.cwd, &path) {
return Err(Error::tool(
"supercode_sdk",
"session locator escapes the MCP workspace",
));
}
params["locator"]["storage"]["path"] = json!(path);
Ok(())
}
#[cfg(feature = "adapter-mcp")]
pub fn register_sdk_tool(registry: &mut ToolRegistry) {
registry.register(SdkMcpTool::new());
}
pub async fn handle_request(
registry: &ToolRegistry,
ctx: &ToolContext,
request: &Value,
) -> Option<Value> {
let id = request.get("id").cloned();
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
let reply = |result: Value| Some(json!({"jsonrpc": "2.0", "id": id, "result": result}));
match method {
"initialize" => reply(json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": {"name": "supercode", "version": env!("CARGO_PKG_VERSION")}
})),
"tools/list" => {
let tools: Vec<Value> = registry
.iter()
.map(|t| {
json!({
"name": t.name(),
"description": t.description(),
"inputSchema": t.parameters(),
})
})
.collect();
reply(json!({"tools": tools}))
}
"tools/call" => {
let params = request.get("params").cloned().unwrap_or(Value::Null);
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
match registry.get(name) {
None => Some(json!({
"jsonrpc": "2.0", "id": id,
"error": {"code": -32601, "message": format!("unknown tool `{name}`")}
})),
Some(tool) => {
let (text, is_error, structured) = match tool.execute(args, ctx).await {
Ok(t) => (t, false, None),
Err(e) => {
let text = e.to_string();
let structured = text
.split_once("SUPERCODE_SDK_ERROR:")
.and_then(|(_, value)| serde_json::from_str::<Value>(value).ok())
.map(|error| json!({"error":error}));
(format!("Error: {text}"), true, structured)
}
};
reply(json!({
"content": [{"type": "text", "text": text}],
"isError": is_error,
"structuredContent": structured,
}))
}
}
}
_ if id.is_none() => None,
_ => Some(json!({
"jsonrpc": "2.0", "id": id,
"error": {"code": -32601, "message": format!("unknown method `{method}`")}
})),
}
}
pub async fn serve_stdio(registry: &ToolRegistry, ctx: &ToolContext) -> Result<()> {
let mut stdin = BufReader::new(tokio::io::stdin());
let mut stdout = tokio::io::stdout();
let mut line = String::new();
loop {
line.clear();
if stdin.read_line(&mut line).await? == 0 {
break;
}
let Ok(req) = serde_json::from_str::<Value>(line.trim()) else {
continue;
};
if let Some(resp) = handle_request(registry, ctx, &req).await {
stdout.write_all(format!("{resp}\n").as_bytes()).await?;
stdout.flush().await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn mcp_sdk_tool_is_a_thin_named_error_projection() {
let mut registry = ToolRegistry::new();
register_sdk_tool(&mut registry);
let ctx = ToolContext::new(std::env::temp_dir());
let listed = handle_request(
®istry,
&ctx,
&json!({"jsonrpc":"2.0", "id":1, "method":"tools/list"}),
)
.await
.unwrap();
assert_eq!(listed["result"]["tools"][0]["name"], "supercode_sdk");
let response = handle_request(
®istry,
&ctx,
&json!({
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params": {
"name":"supercode_sdk",
"arguments":{"operation":"steer", "params":{}}
}
}),
)
.await
.unwrap();
assert_eq!(response["result"]["isError"], true);
assert_eq!(
response["result"]["structuredContent"]["error"]["name"],
"unsupported_action"
);
assert_eq!(
response["result"]["structuredContent"]["error"]["operation"],
"steer"
);
}
#[test]
fn cache_churn_notice_names_server_and_count() {
let msg = cache_churn_notice("github", 12);
assert!(msg.contains("github"));
assert!(msg.contains("12"));
assert!(msg.contains("C2"));
}
#[test]
fn resolve_endpoint_url_passes_through_absolute_urls() {
assert_eq!(
resolve_endpoint_url("http://localhost:1234/sse", "https://other/msg"),
"https://other/msg"
);
}
#[test]
fn resolve_endpoint_url_resolves_relative_path_against_origin() {
assert_eq!(
resolve_endpoint_url("http://localhost:1234/sse", "/messages?session=abc"),
"http://localhost:1234/messages?session=abc"
);
}
#[test]
fn parse_sse_body_extracts_multiple_events() {
let body = b"event: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"a\":2}\n\n";
let out = parse_sse_body(body);
assert_eq!(out.len(), 2);
assert_eq!(out[0]["a"], 1);
assert_eq!(out[1]["a"], 2);
}
#[test]
fn sse_line_accumulator_handles_a_split_chunk() {
let mut acc = SseLineAccumulator::default();
let first = acc.push(b"event: message\ndata: {\"a\":").unwrap();
assert!(first.is_empty(), "no complete event yet");
let second = acc.push(b"1}\n\n").unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second[0].0.as_deref(), Some("message"));
assert_eq!(second[0].1, "{\"a\":1}");
}
#[test]
fn sse_line_accumulator_errors_and_resets_on_an_oversized_unterminated_frame() {
let mut acc = SseLineAccumulator::default();
let chunk = vec![b'x'; MCP_MAX_SSE_FRAME_BYTES + 1];
let err = acc.push(&chunk).unwrap_err();
assert!(
err.to_string().contains("exceeded max"),
"error should name the cap: {err}"
);
assert_eq!(
acc.buf.len(),
0,
"buffer must be reset on overflow, not left growing"
);
}
#[test]
fn sse_line_accumulator_stays_under_cap_for_legit_small_events() {
let mut acc = SseLineAccumulator::default();
let events = acc
.push(b"event: message\ndata: {\"ok\":true}\n\n")
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].1, "{\"ok\":true}");
}
#[test]
fn elicitation_response_decline_serializes_without_content() {
let r = ElicitationResponse::decline();
assert_eq!(r.to_json_rpc_result(), json!({"action": "decline"}));
}
#[test]
fn elicitation_response_accept_carries_content() {
let r = ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(json!({"name": "value"})),
};
assert_eq!(
r.to_json_rpc_result(),
json!({"action": "accept", "content": {"name": "value"}})
);
}
#[tokio::test]
async fn reconnect_denies_a_disallowed_host_before_reconnecting() {
use std::sync::atomic::{AtomicBool, Ordering};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let connected = Arc::new(AtomicBool::new(false));
let connected2 = connected.clone();
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
connected2.store(true, Ordering::SeqCst);
let mut buf = [0u8; 1024];
use tokio::io::AsyncReadExt;
let _ = sock.read(&mut buf).await;
}
});
let url = format!("http://127.0.0.1:{}/mcp", addr.port());
let deny_policy = NetworkPolicy {
enabled: true,
allow_domains: vec![],
deny_domains: vec!["127.0.0.1".to_string()],
};
let client = McpClient {
conn: Conn::Http {
client: reqwest::Client::new(),
url: url.clone(),
headers: HeaderMap::new(),
session_id: None,
},
next_id: 0,
params: McpConnectParams::Http {
url: url.clone(),
headers: BTreeMap::new(),
},
network_policy: Some(deny_policy),
timeout: DEFAULT_MCP_TIMEOUT,
elicitation_handler: Arc::new(HeadlessElicitationHandler),
instructions: None,
pending_notifications: std::sync::Mutex::new(Vec::new()),
};
let result = client.reconnect().await;
assert!(
result.is_err(),
"reconnect must refuse to reconnect to a host its own remembered policy denies"
);
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!connected.load(Ordering::SeqCst),
"the denied host must never even be contacted on reconnect"
);
}
}