OXYGENHUB

Coverage / crates/spectra-mcp/src/protocol.rs 87.5%

91 of 104 lines run, 10 of 26 functions · run #8 on main · run missed

1
//! The MCP protocol (spec §15.1): revision 2026-07-28, stateless, with its
2
//! mirrored headers checked against the body; and 2025-11-25, 2025-06-18
3
//! and 2025-03-26, which begin with `initialize`. No sessions are minted;
4
//! Spectra's own session (spec §8.2) rides in `_meta`.
5
6
use crate::tools::{self, Ctx, Output};
7
use crate::{INSTRUCTIONS, PROTOCOL_VERSIONS, STATELESS};
8
use serde_json::{json, Value as Json};
9
10
/// JSON-RPC and MCP error codes.
11
pub const PARSE_ERROR: i64 = -32700;
12
pub const INVALID_REQUEST: i64 = -32600;
13
pub const METHOD_NOT_FOUND: i64 = -32601;
14
pub const INVALID_PARAMS: i64 = -32602;
15
pub const HEADER_MISMATCH: i64 = -32020;
16
pub const UNSUPPORTED_VERSION: i64 = -32022;
17
/// Implementation-defined: the request did not authenticate (spec §8.5).
18
pub const UNAUTHENTICATED: i64 = -32010;
19
20
/// How a request is answered.
21
pub enum Reply {
22
    /// A JSON body with this HTTP status.
23
    Json(u16, Json),
24
    /// 202 and no body: a notification.
25
    Accepted,
26
    /// A tool that takes long: run it, answering as SSE with keep-alives.
27
    Stream(Json, &'static tools::Tool, Json),
28
    /// `subscriptions/listen`: an SSE stream that stays open.
29
    Listen(Json, String),
30
}
31
32
/// The revision a request speaks, and the headers it mirrors.
33
pub struct Headers<'a> {
34
    pub version: Option<&'a str>,
35
    pub method: Option<&'a str>,
36
    pub name: Option<&'a str>,
37
}
38
396×
pub fn error(id: &Json, code: i64, message: &str) -> Json {
406×
    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
416×
}
42
435×
fn ok(id: &Json, version: &str, mut result: Json, meta: Json) -> Json {
445×
    if let Some(o) = result.as_object_mut() {
455×
        if version == STATELESS {
463×
            o.insert("resultType".into(), json!("complete"));
473×
        }
485×
        let mut m = meta.as_object().cloned().unwrap_or_default();
495×
        if version == STATELESS {
503×
            m.insert("io.modelcontextprotocol/serverInfo".into(), server_info());
513×
        }
525×
        if !m.is_empty() {
535×
            o.insert("_meta".into(), Json::Object(m));
545×
        }
550×
    }
565×
    json!({ "jsonrpc": "2.0", "id": id, "result": result })
575×
}
58
595×
fn server_info() -> Json {
605×
    json!({ "name": "spectra", "version": env!("CARGO_PKG_VERSION") })
615×
}
62
632×
fn capabilities() -> Json {
642×
    json!({ "tools": { "listChanged": false } })
652×
}
66
67
/// Check the mirrored headers of a 2026-07-28 request against its body.
684×
fn check_headers(h: &Headers, req: &Json) -> Result<(), String> {
694×
    let method = req.get("method").and_then(Json::as_str).unwrap_or("");
704×
    if h.method != Some(method) {
711×
        return Err(format!("Mcp-Method header {:?} does not match the body's {method:?}", h.method));
723×
    }
733×
    let body_version = req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str);
743×
    if body_version.is_some_and(|v| Some(v) != h.version) {
750×
        return Err("MCP-Protocol-Version does not match the body's protocolVersion".into());
763×
    }
773×
    if matches!(method, "tools/call" | "resources/read" | "prompts/get") {
781×
        let name = req.pointer("/params/name").or_else(|| req.pointer("/params/uri")).and_then(Json::as_str);
791×
        if name.is_none() || h.name != name {
800×
            return Err(format!("Mcp-Name header {:?} does not match the body's {name:?}", h.name));
811×
        }
822×
    }
833×
    Ok(())
844×
}
85
86
/// Answer one JSON-RPC message from an authenticated caller.
878×
pub async fn handle(ctx: &Ctx, h: &Headers<'_>, req: Json, meta: Json) -> Reply {
888×
    let id = req.get("id").cloned().unwrap_or(Json::Null);
898×
    let Some(method) = req.get("method").and_then(Json::as_str).map(String::from) else {
900×
        return Reply::Json(400, error(&id, INVALID_REQUEST, "a JSON-RPC request needs a method"));
91
    };
92
    // The revision: the header, else the legacy default of the transport.
938×
    let version = h.version.unwrap_or("2025-03-26");
948×
    if !PROTOCOL_VERSIONS.contains(&version) {
951×
        let mut e = error(&id, UNSUPPORTED_VERSION, &format!("unsupported protocol version {version}"));
961×
        e["error"]["data"] = json!({ "supported": PROTOCOL_VERSIONS });
971×
        return Reply::Json(400, e);
987×
    }
997×
    if version == STATELESS {
1004×
        if let Err(why) = check_headers(h, &req) {
1011×
            return Reply::Json(400, error(&id, HEADER_MISMATCH, &why));
1023×
        }
1033×
    }
1046×
    let is_notification = req.get("id").is_none();
1056×
    match method.as_str() {
1066×
        "initialize" => {
1071×
            let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or("2025-03-26");
1081×
            let chosen = if PROTOCOL_VERSIONS[1..].contains(&asked) { asked } else { PROTOCOL_VERSIONS[1] };
1091×
            let r = json!({ "protocolVersion": chosen, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS });
1101×
            Reply::Json(200, ok(&id, chosen, r, meta))
111
        }
1125×
        "server/discover" => {
1131×
            let r = json!({ "supportedVersions": PROTOCOL_VERSIONS, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS });
1141×
            Reply::Json(200, ok(&id, version, r, meta))
115
        }
1164×
        "ping" => Reply::Json(200, ok(&id, version, json!({}), meta)),
1171×
        _ if is_notification => Reply::Accepted,
1183×
        "tools/list" => {
1192×
            let tools: Vec<Json> = tools::all().iter().map(|t| t.describe()).collect();
1202×
            let mut r = json!({ "tools": tools });
1212×
            if version == STATELESS {
1221×
                r["ttlMs"] = json!(3_600_000);
1231×
                r["cacheScope"] = json!("private");
1241×
            }
1252×
            Reply::Json(200, ok(&id, version, r, meta))
126
        }
1271×
        "tools/call" => {
1281×
            let name = req.pointer("/params/name").and_then(Json::as_str).unwrap_or("");
1291×
            let Some(tool) = tools::find(name) else {
1300×
                return Reply::Json(200, error(&id, INVALID_PARAMS, &format!("no tool named {name}")));
131
            };
1321×
            let args = req.pointer("/params/arguments").cloned().unwrap_or_else(|| json!({}));
1331×
            if tool.long {
1340×
                return Reply::Stream(id, tool, args);
1351×
            }
1361×
            let out = (tool.run)(ctx, args).await;
1371×
            Reply::Json(200, ok(&id, version, tool_result(&out), meta))
138
        }
1390×
        "subscriptions/listen" if version == STATELESS => Reply::Listen(id, spectra_core::ids::ulid()),
1400×
        _ => Reply::Json(if version == STATELESS { 404 } else { 200 }, error(&id, METHOD_NOT_FOUND, &format!("no method {method}"))),
141
    }
1428×
}
143
144
/// A tool's output as a `tools/call` result.
1451×
pub fn tool_result(out: &Output) -> Json {
1461×
    let mut r = json!({ "content": [{ "type": "text", "text": out.text }], "isError": out.is_error });
1471×
    if let Some(s) = &out.structured {
1481×
        r["structuredContent"] = s.clone();
1491×
    }
1501×
    r
1511×
}
152
153
/// The final message of a streamed tool call.
1540×
pub fn stream_result(id: &Json, version: &str, out: &Output, meta: Json) -> Json {
1550×
    ok(id, version, tool_result(out), meta)
1560×
}