solo_api/mcp_dispatch.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! v0.10.2 — transport-agnostic MCP JSON-RPC dispatcher.
4//!
5//! Until v0.10.1 the MCP server logic lived behind rmcp's stdio transport
6//! ([`crate::mcp::serve_stdio`]); the only way an MCP client could reach
7//! Solo's tools was by spawning `solo mcp-stdio` as a subprocess.
8//! v0.10.2 adds an HTTP transport on `/mcp` so a single `solo daemon
9//! --http-port` process can serve BOTH `/v1/graph/*` (REST, for solo-web)
10//! and `/mcp` (JSON-RPC, for solo-jarvis) without the writer-lock dance.
11//!
12//! The dispatcher is the request -> response funnel that both transports
13//! call into identically. It carries no transport-specific state — it
14//! holds an [`Arc<SoloMcpServer>`](crate::mcp::SoloMcpServer) and routes
15//! JSON-RPC method names to the existing direct-dispatch entry points
16//! ([`SoloMcpServer::dispatch_list_tools`] +
17//! [`SoloMcpServer::dispatch_tool`]). Today the stdio loop continues to
18//! use rmcp's `ServerHandler` impl (which handles MCP framing for us);
19//! the HTTP route uses this dispatcher directly to avoid hand-rolling
20//! framing for one-shot request/response.
21//!
22//! ## Supported methods
23//!
24//! * `initialize` — returns the same `ServerInfo` shape rmcp emits over
25//! stdio. v0.10.2 returns the static info; the sampling-capability
26//! gating that lives in [`crate::mcp::SoloMcpServer::initialize`] is
27//! stdio-only because there's no `Peer<RoleServer>` over HTTP. HTTP
28//! clients that try to drive `mcp_sampling`-mode tenants will see
29//! sampling errors at tool-call time instead. Documented in the dev
30//! log for v0.10.2.
31//! * `tools/list` — returns [`SoloMcpServer::dispatch_list_tools`].
32//! * `tools/call` — returns [`SoloMcpServer::dispatch_tool`].
33//! * `ping` — returns an empty object. Useful for HTTP-client liveness
34//! probes without paying the cost of `tools/list`.
35//! * Anything else returns `MethodNotFound` per JSON-RPC 2.0.
36//!
37//! ## Notifications
38//!
39//! JSON-RPC notifications carry no `id` field; per spec the server MUST
40//! NOT respond. `dispatch_notification` accepts these (e.g.
41//! `notifications/initialized`) and returns `()`.
42//!
43//! ## Out of scope (deferred to v0.10.3+)
44//!
45//! - `Mcp-Session-Id` session affinity
46//! - Resumable streams with `Last-Event-ID`
47//! - Server-initiated requests over the GET SSE stream
48//! - Per-tool streaming (progress events during long tool calls)
49
50use std::sync::Arc;
51
52use rmcp::model::{ErrorCode, ErrorData as McpError, Implementation};
53use serde::{Deserialize, Serialize};
54use solo_storage::{TenantHandle, TenantRegistry};
55
56use crate::mcp::SoloMcpServer;
57
58/// JSON-RPC 2.0 request envelope used by the HTTP transport.
59///
60/// `id` is `Option<Value>` per JSON-RPC 2.0: a missing `id` means the
61/// message is a notification (no response expected). Requests with an
62/// explicit `id: null` deserialise the same as a missing `id` (both
63/// land as `None`) — we treat both as notifications per JSON-RPC 2.0
64/// §4.1 ("`null` should not be used for the Id member of a Request
65/// object"). Real MCP clients always send numeric or string ids.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct JsonRpcRequest {
68 pub jsonrpc: String,
69 #[serde(default)]
70 pub id: Option<serde_json::Value>,
71 pub method: String,
72 #[serde(default)]
73 pub params: Option<serde_json::Value>,
74}
75
76/// JSON-RPC 2.0 successful response envelope.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct JsonRpcSuccess {
79 pub jsonrpc: String,
80 pub id: serde_json::Value,
81 pub result: serde_json::Value,
82}
83
84/// JSON-RPC 2.0 error response envelope. `id` is `Value::Null` when
85/// the server could not read the request id (parse error / unreadable
86/// envelope); otherwise it echoes the request id back so the client
87/// can correlate.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct JsonRpcErrorResponse {
90 pub jsonrpc: String,
91 pub id: serde_json::Value,
92 pub error: JsonRpcErrorBody,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct JsonRpcErrorBody {
97 pub code: i32,
98 pub message: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub data: Option<serde_json::Value>,
101}
102
103/// Either a success or error response, serialised as a single JSON-RPC
104/// 2.0 message on the wire. `serde(untagged)` so both shapes share the
105/// `{jsonrpc, id, ...}` prefix and are distinguished by the presence of
106/// `result` vs. `error`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum JsonRpcResponse {
110 Success(JsonRpcSuccess),
111 Error(JsonRpcErrorResponse),
112}
113
114impl JsonRpcResponse {
115 /// Build a success response with an explicit id.
116 pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self {
117 Self::Success(JsonRpcSuccess {
118 jsonrpc: "2.0".to_string(),
119 id,
120 result,
121 })
122 }
123
124 /// Build an error response with an explicit id. Pass
125 /// `serde_json::Value::Null` for `id` when the server could not read
126 /// the request id (parse error / unreadable envelope).
127 pub fn error(id: serde_json::Value, code: i32, message: impl Into<String>) -> Self {
128 Self::Error(JsonRpcErrorResponse {
129 jsonrpc: "2.0".to_string(),
130 id,
131 error: JsonRpcErrorBody {
132 code,
133 message: message.into(),
134 data: None,
135 },
136 })
137 }
138
139 /// Convenience constructor: map an rmcp [`McpError`] to a JSON-RPC
140 /// error response.
141 pub fn from_mcp_error(id: serde_json::Value, err: McpError) -> Self {
142 Self::error(id, err.code.0, err.message.to_string())
143 }
144}
145
146/// Transport-agnostic MCP dispatcher used by the v0.10.2 HTTP `/mcp`
147/// route. Holds the per-tenant [`SoloMcpServer`] needed to answer
148/// `tools/list` and `tools/call`.
149///
150/// The dispatcher itself is stateless beyond the held server — callers
151/// build a fresh dispatcher per request (cheap; the server is
152/// `Arc`-cloneable) and discard it after dispatch returns.
153#[derive(Clone)]
154pub struct McpDispatcher {
155 server: SoloMcpServer,
156}
157
158impl McpDispatcher {
159 /// Build a dispatcher for one tenant. The caller is expected to
160 /// resolve the tenant (via `X-Solo-Tenant` header for HTTP, or via
161 /// `--tenant` flag for stdio) and pass an `Arc<TenantHandle>` here.
162 ///
163 /// `audit_principal` is the subject that authored every tool call
164 /// dispatched through this server — typically `"bearer"` for
165 /// bearer-authenticated HTTP requests, the `SOLO_MCP_PRINCIPAL_TOKEN`
166 /// env-var value for stdio, or `None` for unauthenticated loopback.
167 pub fn new(
168 registry: Arc<TenantRegistry>,
169 tenant: Arc<TenantHandle>,
170 user_aliases: Vec<String>,
171 audit_principal: Option<String>,
172 ) -> Self {
173 let server = SoloMcpServer::new_for_tenant_with_principal(
174 registry,
175 tenant,
176 user_aliases,
177 audit_principal,
178 );
179 Self { server }
180 }
181
182 /// Wrap an already-built [`SoloMcpServer`]. Used by tests that want
183 /// to pin the underlying server's principal exactly; production
184 /// callers should prefer [`Self::new`].
185 pub fn from_server(server: SoloMcpServer) -> Self {
186 Self { server }
187 }
188
189 /// Dispatch one JSON-RPC request and return the wire response.
190 ///
191 /// Returns `None` when the input is a notification (no `id` field) —
192 /// per JSON-RPC 2.0 the server MUST NOT respond to notifications.
193 /// The HTTP transport translates `None` into a 204 No Content or
194 /// empty 200, depending on the client; the stdio path doesn't use
195 /// this method (rmcp handles framing for stdio).
196 pub async fn dispatch(&self, request: JsonRpcRequest) -> Option<JsonRpcResponse> {
197 // Notifications: no `id`, no reply per JSON-RPC 2.0 §4.1.
198 let Some(id) = request.id.clone() else {
199 // We still want to log unexpected notification methods so
200 // operators can diagnose silent client bugs.
201 tracing::debug!(
202 method = %request.method,
203 "mcp-http: notification received (no id; no reply)"
204 );
205 return None;
206 };
207
208 let params = request.params.unwrap_or(serde_json::Value::Null);
209
210 let response = match request.method.as_str() {
211 "initialize" => self.handle_initialize(id.clone(), params),
212 "tools/list" => self.handle_tools_list(id.clone()),
213 "tools/call" => self.handle_tools_call(id.clone(), params).await,
214 "ping" => JsonRpcResponse::success(id.clone(), serde_json::json!({})),
215 other => JsonRpcResponse::error(
216 id.clone(),
217 ErrorCode::METHOD_NOT_FOUND.0,
218 format!("unknown method `{other}`"),
219 ),
220 };
221 Some(response)
222 }
223
224 /// `initialize` — return a minimal `ServerInfo` matching the stdio
225 /// transport's shape. v0.10.2 returns the static info; the
226 /// sampling-capability gating that lives in the rmcp `ServerHandler`
227 /// path is intentionally not replicated here — HTTP has no `Peer`
228 /// to call back into. Tenants configured with `[llm] mode =
229 /// "mcp_sampling"` will see sampling failures at consolidate-time
230 /// instead of at `initialize` (documented in v0.10.2 dev log).
231 fn handle_initialize(
232 &self,
233 id: serde_json::Value,
234 _params: serde_json::Value,
235 ) -> JsonRpcResponse {
236 // Mirror the shape rmcp emits for stdio `initialize`. The
237 // `protocolVersion` echoes the MCP version Solo speaks today;
238 // `capabilities.tools = {}` is the bare-minimum capability set
239 // (we expose tools, nothing else); `serverInfo` is pinned to
240 // `{"name": "solo", "version": <crate version>}` per the
241 // `server_info_identity_is_solo_not_rmcp_or_solo_api` invariant.
242 let server_info = Implementation::new(
243 "solo".to_string(),
244 env!("CARGO_PKG_VERSION").to_string(),
245 );
246 let result = serde_json::json!({
247 "protocolVersion": "2024-11-05",
248 "capabilities": {
249 "tools": {},
250 },
251 "serverInfo": server_info,
252 });
253 JsonRpcResponse::success(id, result)
254 }
255
256 /// `tools/list` — wraps [`SoloMcpServer::dispatch_list_tools`].
257 fn handle_tools_list(&self, id: serde_json::Value) -> JsonRpcResponse {
258 let tools = self.server.dispatch_list_tools();
259 let result = serde_json::json!({ "tools": tools });
260 JsonRpcResponse::success(id, result)
261 }
262
263 /// `tools/call` — wraps [`SoloMcpServer::dispatch_tool`]. JSON-RPC
264 /// `params` carries `{"name": "...", "arguments": {...}}`.
265 async fn handle_tools_call(
266 &self,
267 id: serde_json::Value,
268 params: serde_json::Value,
269 ) -> JsonRpcResponse {
270 let name = match params.get("name").and_then(|v| v.as_str()) {
271 Some(n) => n.to_string(),
272 None => {
273 return JsonRpcResponse::error(
274 id,
275 ErrorCode::INVALID_PARAMS.0,
276 "tools/call: missing `name` field",
277 );
278 }
279 };
280 // `arguments` is optional; treat absent / null as empty object so
281 // tools with all-optional args (e.g. `memory_themes`) can be
282 // called with `{}` from the wire.
283 let arguments = params
284 .get("arguments")
285 .cloned()
286 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
287 match self.server.dispatch_tool(&name, arguments).await {
288 Ok(call_result) => {
289 // Serialise the rmcp `CallToolResult` directly — it
290 // already round-trips through serde and matches the
291 // MCP wire shape the stdio transport emits.
292 let result = match serde_json::to_value(&call_result) {
293 Ok(v) => v,
294 Err(e) => {
295 return JsonRpcResponse::error(
296 id,
297 ErrorCode::INTERNAL_ERROR.0,
298 format!("serialize tool result: {e}"),
299 );
300 }
301 };
302 JsonRpcResponse::success(id, result)
303 }
304 Err(e) => JsonRpcResponse::from_mcp_error(id, e),
305 }
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn jsonrpc_success_serialises_with_jsonrpc_field() {
315 let resp =
316 JsonRpcResponse::success(serde_json::json!(1), serde_json::json!({"ok": true}));
317 let s = serde_json::to_string(&resp).unwrap();
318 assert!(s.contains(r#""jsonrpc":"2.0""#));
319 assert!(s.contains(r#""id":1"#));
320 assert!(s.contains(r#""result":{"ok":true}"#));
321 assert!(!s.contains(r#""error":"#));
322 }
323
324 #[test]
325 fn jsonrpc_error_serialises_with_error_field() {
326 let resp = JsonRpcResponse::error(
327 serde_json::json!(7),
328 ErrorCode::METHOD_NOT_FOUND.0,
329 "unknown method `foo`",
330 );
331 let s = serde_json::to_string(&resp).unwrap();
332 assert!(s.contains(r#""jsonrpc":"2.0""#));
333 assert!(s.contains(r#""id":7"#));
334 assert!(s.contains(r#""error":{"#));
335 assert!(s.contains(r#""code":-32601"#));
336 assert!(!s.contains(r#""result":"#));
337 }
338
339 #[test]
340 fn jsonrpc_notification_has_no_id() {
341 let raw = r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#;
342 let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
343 assert_eq!(req.method, "notifications/initialized");
344 assert!(req.id.is_none());
345 }
346
347 #[test]
348 fn jsonrpc_request_with_null_id_parses_as_notification() {
349 // Per JSON-RPC 2.0 §4.1 `null` is discouraged for request ids;
350 // serde's `#[serde(default)]` deserialises an explicit null the
351 // same as a missing field, so both land as a notification
352 // (no reply). Real MCP clients always send numeric/string ids.
353 let raw = r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#;
354 let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
355 assert!(req.id.is_none());
356 }
357}