OXYGENHUB

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

97 of 106 lines run, 11 of 26 functions · run #18 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
398×
pub fn error(id: &Json, code: i64, message: &str) -> Json {
408×
    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
418×
}
42
4338×
fn ok(id: &Json, version: &str, mut result: Json, meta: Json) -> Json {
4438×
    if let Some(o) = result.as_object_mut() {
4538×
        if version == STATELESS {
4628×
            o.insert("resultType".into(), json!("complete"));
4728×
        }
4838×
        let mut m = meta.as_object().cloned().unwrap_or_default();
4938×
        if version == STATELESS {
5028×
            m.insert("io.modelcontextprotocol/serverInfo".into(), server_info());
5128×
        }
5238×
        if !m.is_empty() {
5338×
            o.insert("_meta".into(), Json::Object(m));
5438×
        }
550×
    }
5638×
    json!({ "jsonrpc": "2.0", "id": id, "result": result })
5738×
}
58
5931×
fn server_info() -> Json {
6031×
    json!({ "name": "spectra", "version": env!("CARGO_PKG_VERSION") })
6131×
}
62
633×
fn capabilities() -> Json {
643×
    json!({ "tools": { "listChanged": false } })
653×
}
66
67
/// Check the mirrored headers of a 2026-07-28 request against its body.
6829×
fn check_headers(h: &Headers, req: &Json) -> Result<(), String> {
6929×
    let method = req.get("method").and_then(Json::as_str).unwrap_or("");
7029×
    if h.method != Some(method) {
711×
        return Err(format!("Mcp-Method header {:?} does not match the body's {method:?}", h.method));
7228×
    }
7328×
    let body_version = req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str);
7428×
    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());
7628×
    }
7728×
    if matches!(method, "tools/call" | "resources/read" | "prompts/get") {
7824×
        let name = req.pointer("/params/name").or_else(|| req.pointer("/params/uri")).and_then(Json::as_str);
7924×
        if name.is_none() || h.name != name {
800×
            return Err(format!("Mcp-Name header {:?} does not match the body's {name:?}", h.name));
8124×
        }
824×
    }
8328×
    Ok(())
8429×
}
85
86
/// Answer one JSON-RPC message from an authenticated caller.
8742×
pub async fn handle(ctx: &Ctx, h: &Headers<'_>, req: Json, meta: Json) -> Reply {
8842×
    let _ = &meta;
8942×
    let id = req.get("id").cloned().unwrap_or(Json::Null);
9042×
    let Some(method) = req.get("method").and_then(Json::as_str).map(String::from) else {
910×
        return Reply::Json(400, error(&id, INVALID_REQUEST, "a JSON-RPC request needs a method"));
92
    };
93
    // The revision: the header, else the legacy default of the transport.
9442×
    let version = h.version.unwrap_or("2025-03-26");
9542×
    if !PROTOCOL_VERSIONS.contains(&version) {
961×
        let mut e = error(&id, UNSUPPORTED_VERSION, &format!("unsupported protocol version {version}"));
971×
        e["error"]["data"] = json!({ "supported": PROTOCOL_VERSIONS });
981×
        return Reply::Json(400, e);
9941×
    }
10041×
    if version == STATELESS {
10129×
        if let Err(why) = check_headers(h, &req) {
1021×
            return Reply::Json(400, error(&id, HEADER_MISMATCH, &why));
10328×
        }
10412×
    }
10540×
    let is_notification = req.get("id").is_none();
10640×
    match method.as_str() {
10740×
        "initialize" => {
1082×
            let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or("2025-03-26");
1092×
            let chosen = if PROTOCOL_VERSIONS[1..].contains(&asked) { asked } else { PROTOCOL_VERSIONS[1] };
1102×
            let r = json!({ "protocolVersion": chosen, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS });
1112×
            Reply::Json(200, ok(&id, chosen, r, meta))
112
        }
11338×
        "server/discover" => {
1141×
            let r = json!({ "supportedVersions": PROTOCOL_VERSIONS, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS });
1151×
            Reply::Json(200, ok(&id, version, r, meta))
116
        }
11737×
        "ping" => Reply::Json(200, ok(&id, version, json!({}), meta)),
1182×
        _ if is_notification => Reply::Accepted,
11934×
        "tools/list" => {
12055×
            let tools: Vec<Json> = tools::all().iter().map(|t| t.describe()).collect();
1215×
            let mut r = json!({ "tools": tools });
1225×
            if version == STATELESS {
1233×
                r["ttlMs"] = json!(3_600_000);
1243×
                r["cacheScope"] = json!("private");
1253×
            }
1265×
            Reply::Json(200, ok(&id, version, r, meta))
127
        }
12829×
        "tools/call" => {
12929×
            let name = req.pointer("/params/name").and_then(Json::as_str).unwrap_or("");
13029×
            let Some(tool) = tools::find(name) else {
1310×
                return Reply::Json(200, error(&id, INVALID_PARAMS, &format!("no tool named {name}")));
132
            };
13329×
            let args = req.pointer("/params/arguments").cloned().unwrap_or_else(|| json!({}));
13429×
            if tool.long {
1356×
                return Reply::Stream(id, tool, args);
13623×
            }
13723×
            let out = tools::invoke(ctx, tool, args).await;
13823×
            let meta = crate::http::meta(ctx).await;
13923×
            Reply::Json(200, ok(&id, version, tool_result(&out), meta))
140
        }
1410×
        "subscriptions/listen" if version == STATELESS => Reply::Listen(id, spectra_core::ids::ulid()),
1420×
        _ => Reply::Json(if version == STATELESS { 404 } else { 200 }, error(&id, METHOD_NOT_FOUND, &format!("no method {method}"))),
143
    }
14442×
}
145
146
/// A tool's output as a `tools/call` result.
14729×
pub fn tool_result(out: &Output) -> Json {
14829×
    let mut r = json!({ "content": [{ "type": "text", "text": out.text }], "isError": out.is_error });
14929×
    if let Some(s) = &out.structured {
15026×
        r["structuredContent"] = s.clone();
15126×
    }
15229×
    r
15329×
}
154
155
/// The final message of a streamed tool call.
1566×
pub fn stream_result(id: &Json, version: &str, out: &Output, meta: Json) -> Json {
1576×
    ok(id, version, tool_result(out), meta)
1586×
}