//! The MCP protocol (spec §15.1): revision 2026-07-28, stateless, with its //! mirrored headers checked against the body; and 2025-11-25, 2025-06-18 //! and 2025-03-26, which begin with `initialize`. No sessions are minted; //! Spectra's own session (spec §8.2) rides in `_meta`. use crate::tools::{self, Ctx, Output}; use crate::{INSTRUCTIONS, PROTOCOL_VERSIONS, STATELESS}; use serde_json::{json, Value as Json}; /// JSON-RPC and MCP error codes. pub const PARSE_ERROR: i64 = -32700; pub const INVALID_REQUEST: i64 = -32600; pub const METHOD_NOT_FOUND: i64 = -32601; pub const INVALID_PARAMS: i64 = -32602; pub const HEADER_MISMATCH: i64 = -32020; pub const UNSUPPORTED_VERSION: i64 = -32022; /// Implementation-defined: the request did not authenticate (spec §8.5). pub const UNAUTHENTICATED: i64 = -32010; /// How a request is answered. pub enum Reply { /// A JSON body with this HTTP status. Json(u16, Json), /// 202 and no body: a notification. Accepted, /// A tool that takes long: run it, answering as SSE with keep-alives. Stream(Json, &'static tools::Tool, Json), /// `subscriptions/listen`: an SSE stream that stays open. Listen(Json, String), } /// The revision a request speaks, and the headers it mirrors. pub struct Headers<'a> { pub version: Option<&'a str>, pub method: Option<&'a str>, pub name: Option<&'a str>, } pub fn error(id: &Json, code: i64, message: &str) -> Json { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) } fn ok(id: &Json, version: &str, mut result: Json, meta: Json) -> Json { if let Some(o) = result.as_object_mut() { if version == STATELESS { o.insert("resultType".into(), json!("complete")); } let mut m = meta.as_object().cloned().unwrap_or_default(); if version == STATELESS { m.insert("io.modelcontextprotocol/serverInfo".into(), server_info()); } if !m.is_empty() { o.insert("_meta".into(), Json::Object(m)); } } json!({ "jsonrpc": "2.0", "id": id, "result": result }) } fn server_info() -> Json { json!({ "name": "spectra", "version": env!("CARGO_PKG_VERSION") }) } fn capabilities() -> Json { json!({ "tools": { "listChanged": false } }) } /// Check the mirrored headers of a 2026-07-28 request against its body. fn check_headers(h: &Headers, req: &Json) -> Result<(), String> { let method = req.get("method").and_then(Json::as_str).unwrap_or(""); if h.method != Some(method) { return Err(format!("Mcp-Method header {:?} does not match the body's {method:?}", h.method)); } let body_version = req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str); if body_version.is_some_and(|v| Some(v) != h.version) { return Err("MCP-Protocol-Version does not match the body's protocolVersion".into()); } if matches!(method, "tools/call" | "resources/read" | "prompts/get") { let name = req.pointer("/params/name").or_else(|| req.pointer("/params/uri")).and_then(Json::as_str); if name.is_none() || h.name != name { return Err(format!("Mcp-Name header {:?} does not match the body's {name:?}", h.name)); } } Ok(()) } /// Answer one JSON-RPC message from an authenticated caller. pub async fn handle(ctx: &Ctx, h: &Headers<'_>, req: Json, meta: Json) -> Reply { let _ = &meta; let id = req.get("id").cloned().unwrap_or(Json::Null); let Some(method) = req.get("method").and_then(Json::as_str).map(String::from) else { return Reply::Json(400, error(&id, INVALID_REQUEST, "a JSON-RPC request needs a method")); }; // The revision: the header, else the legacy default of the transport. let version = h.version.unwrap_or("2025-03-26"); if !PROTOCOL_VERSIONS.contains(&version) { let mut e = error(&id, UNSUPPORTED_VERSION, &format!("unsupported protocol version {version}")); e["error"]["data"] = json!({ "supported": PROTOCOL_VERSIONS }); return Reply::Json(400, e); } if version == STATELESS { if let Err(why) = check_headers(h, &req) { return Reply::Json(400, error(&id, HEADER_MISMATCH, &why)); } } let is_notification = req.get("id").is_none(); match method.as_str() { "initialize" => { let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or("2025-03-26"); let chosen = if PROTOCOL_VERSIONS[1..].contains(&asked) { asked } else { PROTOCOL_VERSIONS[1] }; let r = json!({ "protocolVersion": chosen, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS }); Reply::Json(200, ok(&id, chosen, r, meta)) } "server/discover" => { let r = json!({ "supportedVersions": PROTOCOL_VERSIONS, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS }); Reply::Json(200, ok(&id, version, r, meta)) } "ping" => Reply::Json(200, ok(&id, version, json!({}), meta)), _ if is_notification => Reply::Accepted, "tools/list" => { let tools: Vec = tools::all().iter().map(|t| t.describe()).collect(); let mut r = json!({ "tools": tools }); if version == STATELESS { r["ttlMs"] = json!(3_600_000); r["cacheScope"] = json!("private"); } Reply::Json(200, ok(&id, version, r, meta)) } "tools/call" => { let name = req.pointer("/params/name").and_then(Json::as_str).unwrap_or(""); let Some(tool) = tools::find(name) else { return Reply::Json(200, error(&id, INVALID_PARAMS, &format!("no tool named {name}"))); }; let args = req.pointer("/params/arguments").cloned().unwrap_or_else(|| json!({})); if tool.long { return Reply::Stream(id, tool, args); } let out = tools::invoke(ctx, tool, args).await; let meta = crate::http::meta(ctx).await; Reply::Json(200, ok(&id, version, tool_result(&out), meta)) } "subscriptions/listen" if version == STATELESS => Reply::Listen(id, spectra_core::ids::ulid()), _ => Reply::Json(if version == STATELESS { 404 } else { 200 }, error(&id, METHOD_NOT_FOUND, &format!("no method {method}"))), } } /// A tool's output as a `tools/call` result. pub fn tool_result(out: &Output) -> Json { let mut r = json!({ "content": [{ "type": "text", "text": out.text }], "isError": out.is_error }); if let Some(s) = &out.structured { r["structuredContent"] = s.clone(); } r } /// The final message of a streamed tool call. pub fn stream_result(id: &Json, version: &str, out: &Output, meta: Json) -> Json { ok(id, version, tool_result(out), meta) }