| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 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 | |
|
| 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 | |
|
| 18 | | pub const UNAUTHENTICATED: i64 = -32010; |
| 19 | |
|
| 20 | |
|
| 21 | | pub enum Reply { |
| 22 | | |
| 23 | | Json(u16, Json), |
| 24 | | |
| 25 | | Accepted, |
| 26 | | |
| 27 | | Stream(Json, &'static tools::Tool, Json), |
| 28 | | |
| 29 | | Listen(Json, String), |
| 30 | | } |
| 31 | |
|
| 32 | |
|
| 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 | |
|
| 39 | 7× | pub fn error(id: &Json, code: i64, message: &str) -> Json { |
| 40 | 7× | json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) |
| 41 | 7× | } |
| 42 | |
|
| 43 | 36× | fn ok(id: &Json, version: &str, mut result: Json, meta: Json) -> Json { |
| 44 | 36× | if let Some(o) = result.as_object_mut() { |
| 45 | 36× | if version == STATELESS { |
| 46 | 26× | o.insert("resultType".into(), json!("complete")); |
| 47 | 26× | } |
| 48 | 36× | let mut m = meta.as_object().cloned().unwrap_or_default(); |
| 49 | 36× | if version == STATELESS { |
| 50 | 26× | m.insert("io.modelcontextprotocol/serverInfo".into(), server_info()); |
| 51 | 26× | } |
| 52 | 36× | if !m.is_empty() { |
| 53 | 36× | o.insert("_meta".into(), Json::Object(m)); |
| 54 | 36× | } |
| 55 | 0× | } |
| 56 | 36× | json!({ "jsonrpc": "2.0", "id": id, "result": result }) |
| 57 | 36× | } |
| 58 | |
|
| 59 | 29× | fn server_info() -> Json { |
| 60 | 29× | json!({ "name": "spectra", "version": env!("CARGO_PKG_VERSION") }) |
| 61 | 29× | } |
| 62 | |
|
| 63 | 3× | fn capabilities() -> Json { |
| 64 | 3× | json!({ "tools": { "listChanged": false } }) |
| 65 | 3× | } |
| 66 | |
|
| 67 | |
|
| 68 | 27× | fn check_headers(h: &Headers, req: &Json) -> Result<(), String> { |
| 69 | 27× | let method = req.get("method").and_then(Json::as_str).unwrap_or(""); |
| 70 | 27× | if h.method != Some(method) { |
| 71 | 1× | return Err(format!("Mcp-Method header {:?} does not match the body's {method:?}", h.method)); |
| 72 | 26× | } |
| 73 | 26× | let body_version = req.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Json::as_str); |
| 74 | 26× | if body_version.is_some_and(|v| Some(v) != h.version) { |
| 75 | 0× | return Err("MCP-Protocol-Version does not match the body's protocolVersion".into()); |
| 76 | 26× | } |
| 77 | 26× | if matches!(method, "tools/call" | "resources/read" | "prompts/get") { |
| 78 | 23× | let name = req.pointer("/params/name").or_else(|| req.pointer("/params/uri")).and_then(Json::as_str); |
| 79 | 23× | if name.is_none() || h.name != name { |
| 80 | 0× | return Err(format!("Mcp-Name header {:?} does not match the body's {name:?}", h.name)); |
| 81 | 23× | } |
| 82 | 3× | } |
| 83 | 26× | Ok(()) |
| 84 | 27× | } |
| 85 | |
|
| 86 | |
|
| 87 | 40× | pub async fn handle(ctx: &Ctx, h: &Headers<'_>, req: Json, meta: Json) -> Reply { |
| 88 | 40× | let _ = &meta; |
| 89 | 40× | let id = req.get("id").cloned().unwrap_or(Json::Null); |
| 90 | 40× | let Some(method) = req.get("method").and_then(Json::as_str).map(String::from) else { |
| 91 | 0× | return Reply::Json(400, error(&id, INVALID_REQUEST, "a JSON-RPC request needs a method")); |
| 92 | | }; |
| 93 | | |
| 94 | 40× | let version = h.version.unwrap_or("2025-03-26"); |
| 95 | 40× | if !PROTOCOL_VERSIONS.contains(&version) { |
| 96 | 1× | let mut e = error(&id, UNSUPPORTED_VERSION, &format!("unsupported protocol version {version}")); |
| 97 | 1× | e["error"]["data"] = json!({ "supported": PROTOCOL_VERSIONS }); |
| 98 | 1× | return Reply::Json(400, e); |
| 99 | 39× | } |
| 100 | 39× | if version == STATELESS { |
| 101 | 27× | if let Err(why) = check_headers(h, &req) { |
| 102 | 1× | return Reply::Json(400, error(&id, HEADER_MISMATCH, &why)); |
| 103 | 26× | } |
| 104 | 12× | } |
| 105 | 38× | let is_notification = req.get("id").is_none(); |
| 106 | 38× | match method.as_str() { |
| 107 | 38× | "initialize" => { |
| 108 | 2× | let asked = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or("2025-03-26"); |
| 109 | 2× | let chosen = if PROTOCOL_VERSIONS[1..].contains(&asked) { asked } else { PROTOCOL_VERSIONS[1] }; |
| 110 | 2× | let r = json!({ "protocolVersion": chosen, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS }); |
| 111 | 2× | Reply::Json(200, ok(&id, chosen, r, meta)) |
| 112 | | } |
| 113 | 36× | "server/discover" => { |
| 114 | 1× | let r = json!({ "supportedVersions": PROTOCOL_VERSIONS, "capabilities": capabilities(), "serverInfo": server_info(), "instructions": INSTRUCTIONS }); |
| 115 | 1× | Reply::Json(200, ok(&id, version, r, meta)) |
| 116 | | } |
| 117 | 35× | "ping" => Reply::Json(200, ok(&id, version, json!({}), meta)), |
| 118 | 2× | _ if is_notification => Reply::Accepted, |
| 119 | 32× | "tools/list" => { |
| 120 | 44× | let tools: Vec<Json> = tools::all().iter().map(|t| t.describe()).collect(); |
| 121 | 4× | let mut r = json!({ "tools": tools }); |
| 122 | 4× | if version == STATELESS { |
| 123 | 2× | r["ttlMs"] = json!(3_600_000); |
| 124 | 2× | r["cacheScope"] = json!("private"); |
| 125 | 2× | } |
| 126 | 4× | Reply::Json(200, ok(&id, version, r, meta)) |
| 127 | | } |
| 128 | 28× | "tools/call" => { |
| 129 | 28× | let name = req.pointer("/params/name").and_then(Json::as_str).unwrap_or(""); |
| 130 | 28× | let Some(tool) = tools::find(name) else { |
| 131 | 0× | return Reply::Json(200, error(&id, INVALID_PARAMS, &format!("no tool named {name}"))); |
| 132 | | }; |
| 133 | 28× | let args = req.pointer("/params/arguments").cloned().unwrap_or_else(|| json!({})); |
| 134 | 28× | if tool.long { |
| 135 | 6× | return Reply::Stream(id, tool, args); |
| 136 | 22× | } |
| 137 | 22× | let out = tools::invoke(ctx, tool, args).await; |
| 138 | 22× | let meta = crate::http::meta(ctx).await; |
| 139 | 22× | Reply::Json(200, ok(&id, version, tool_result(&out), meta)) |
| 140 | | } |
| 141 | 0× | "subscriptions/listen" if version == STATELESS => Reply::Listen(id, spectra_core::ids::ulid()), |
| 142 | 0× | _ => Reply::Json(if version == STATELESS { 404 } else { 200 }, error(&id, METHOD_NOT_FOUND, &format!("no method {method}"))), |
| 143 | | } |
| 144 | 40× | } |
| 145 | |
|
| 146 | |
|
| 147 | 28× | pub fn tool_result(out: &Output) -> Json { |
| 148 | 28× | let mut r = json!({ "content": [{ "type": "text", "text": out.text }], "isError": out.is_error }); |
| 149 | 28× | if let Some(s) = &out.structured { |
| 150 | 25× | r["structuredContent"] = s.clone(); |
| 151 | 25× | } |
| 152 | 28× | r |
| 153 | 28× | } |
| 154 | |
|
| 155 | |
|
| 156 | 6× | pub fn stream_result(id: &Json, version: &str, out: &Output, meta: Json) -> Json { |
| 157 | 6× | ok(id, version, tool_result(out), meta) |
| 158 | 6× | } |