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;
57use crate::mcp_progress::{ProgressReporter, ProgressToken};
58use crate::mcp_session::SessionState;
59
60/// JSON-RPC 2.0 request envelope used by the HTTP transport.
61///
62/// `id` is `Option<Value>` per JSON-RPC 2.0: a missing `id` means the
63/// message is a notification (no response expected). Requests with an
64/// explicit `id: null` deserialise the same as a missing `id` (both
65/// land as `None`) — we treat both as notifications per JSON-RPC 2.0
66/// §4.1 ("`null` should not be used for the Id member of a Request
67/// object"). Real MCP clients always send numeric or string ids.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct JsonRpcRequest {
70 pub jsonrpc: String,
71 #[serde(default)]
72 pub id: Option<serde_json::Value>,
73 pub method: String,
74 #[serde(default)]
75 pub params: Option<serde_json::Value>,
76}
77
78/// JSON-RPC 2.0 successful response envelope.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct JsonRpcSuccess {
81 pub jsonrpc: String,
82 pub id: serde_json::Value,
83 pub result: serde_json::Value,
84}
85
86/// JSON-RPC 2.0 error response envelope. `id` is `Value::Null` when
87/// the server could not read the request id (parse error / unreadable
88/// envelope); otherwise it echoes the request id back so the client
89/// can correlate.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct JsonRpcErrorResponse {
92 pub jsonrpc: String,
93 pub id: serde_json::Value,
94 pub error: JsonRpcErrorBody,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct JsonRpcErrorBody {
99 pub code: i32,
100 pub message: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub data: Option<serde_json::Value>,
103}
104
105/// Either a success or error response, serialised as a single JSON-RPC
106/// 2.0 message on the wire. `serde(untagged)` so both shapes share the
107/// `{jsonrpc, id, ...}` prefix and are distinguished by the presence of
108/// `result` vs. `error`.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(untagged)]
111pub enum JsonRpcResponse {
112 Success(JsonRpcSuccess),
113 Error(JsonRpcErrorResponse),
114}
115
116impl JsonRpcResponse {
117 /// Build a success response with an explicit id.
118 pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self {
119 Self::Success(JsonRpcSuccess {
120 jsonrpc: "2.0".to_string(),
121 id,
122 result,
123 })
124 }
125
126 /// Build an error response with an explicit id. Pass
127 /// `serde_json::Value::Null` for `id` when the server could not read
128 /// the request id (parse error / unreadable envelope).
129 pub fn error(id: serde_json::Value, code: i32, message: impl Into<String>) -> Self {
130 Self::Error(JsonRpcErrorResponse {
131 jsonrpc: "2.0".to_string(),
132 id,
133 error: JsonRpcErrorBody {
134 code,
135 message: message.into(),
136 data: None,
137 },
138 })
139 }
140
141 /// Convenience constructor: map an rmcp [`McpError`] to a JSON-RPC
142 /// error response.
143 pub fn from_mcp_error(id: serde_json::Value, err: McpError) -> Self {
144 Self::error(id, err.code.0, err.message.to_string())
145 }
146}
147
148/// Transport-agnostic MCP dispatcher used by the v0.10.2 HTTP `/mcp`
149/// route. Holds the per-tenant [`SoloMcpServer`] needed to answer
150/// `tools/list` and `tools/call`.
151///
152/// The dispatcher itself is stateless beyond the held server — callers
153/// build a fresh dispatcher per request (cheap; the server is
154/// `Arc`-cloneable) and discard it after dispatch returns.
155#[derive(Clone)]
156pub struct McpDispatcher {
157 server: SoloMcpServer,
158}
159
160impl McpDispatcher {
161 /// Build a dispatcher for one tenant. The caller is expected to
162 /// resolve the tenant (via `X-Solo-Tenant` header for HTTP, or via
163 /// `--tenant` flag for stdio) and pass an `Arc<TenantHandle>` here.
164 ///
165 /// `audit_principal` is the subject that authored every tool call
166 /// dispatched through this server — typically `"bearer"` for
167 /// bearer-authenticated HTTP requests, the `SOLO_MCP_PRINCIPAL_TOKEN`
168 /// env-var value for stdio, or `None` for unauthenticated loopback.
169 pub fn new(
170 registry: Arc<TenantRegistry>,
171 tenant: Arc<TenantHandle>,
172 user_aliases: Vec<String>,
173 audit_principal: Option<String>,
174 ) -> Self {
175 let server = SoloMcpServer::new_for_tenant_with_principal(
176 registry,
177 tenant,
178 user_aliases,
179 audit_principal,
180 );
181 Self { server }
182 }
183
184 /// Wrap an already-built [`SoloMcpServer`]. Used by tests that want
185 /// to pin the underlying server's principal exactly; production
186 /// callers should prefer [`Self::new`].
187 pub fn from_server(server: SoloMcpServer) -> Self {
188 Self { server }
189 }
190
191 /// Dispatch one JSON-RPC request and return the wire response.
192 ///
193 /// Returns `None` when the input is a notification (no `id` field) —
194 /// per JSON-RPC 2.0 the server MUST NOT respond to notifications.
195 /// The HTTP transport translates `None` into a 204 No Content or
196 /// empty 200, depending on the client; the stdio path doesn't use
197 /// this method (rmcp handles framing for stdio).
198 ///
199 /// v0.11.0 P3: `session` is the `Arc<SessionState>` planted on
200 /// `/mcp` requests by the session middleware (Some on HTTP transport,
201 /// None on stdio). When the request is a `tools/call` carrying
202 /// `_meta.progressToken` AND `session` is `Some`, the handler builds
203 /// a [`ProgressReporter`] and threads it into [`SoloMcpServer::dispatch_tool`].
204 /// Other request types ignore the session argument entirely.
205 pub async fn dispatch(
206 &self,
207 request: JsonRpcRequest,
208 session: Option<Arc<SessionState>>,
209 ) -> Option<JsonRpcResponse> {
210 // Notifications: no `id`, no reply per JSON-RPC 2.0 §4.1.
211 let Some(id) = request.id.clone() else {
212 // We still want to log unexpected notification methods so
213 // operators can diagnose silent client bugs.
214 tracing::debug!(
215 method = %request.method,
216 "mcp-http: notification received (no id; no reply)"
217 );
218 return None;
219 };
220
221 let params = request.params.unwrap_or(serde_json::Value::Null);
222
223 let response = match request.method.as_str() {
224 "initialize" => self.handle_initialize(id.clone(), params),
225 "tools/list" => self.handle_tools_list(id.clone()),
226 "tools/call" => self.handle_tools_call(id.clone(), params, session).await,
227 "ping" => JsonRpcResponse::success(id.clone(), serde_json::json!({})),
228 other => JsonRpcResponse::error(
229 id.clone(),
230 ErrorCode::METHOD_NOT_FOUND.0,
231 format!("unknown method `{other}`"),
232 ),
233 };
234 Some(response)
235 }
236
237 /// `initialize` — return a minimal `ServerInfo` matching the stdio
238 /// transport's shape. v0.10.2 returns the static info; the
239 /// sampling-capability gating that lives in the rmcp `ServerHandler`
240 /// path is intentionally not replicated here — HTTP has no `Peer`
241 /// to call back into. Tenants configured with `[llm] mode =
242 /// "mcp_sampling"` will see sampling failures at consolidate-time
243 /// instead of at `initialize` (documented in v0.10.2 dev log).
244 fn handle_initialize(
245 &self,
246 id: serde_json::Value,
247 _params: serde_json::Value,
248 ) -> JsonRpcResponse {
249 // `protocolVersion` reports `2025-03-26` — the MCP spec version
250 // that introduced the Streamable HTTP transport this handler
251 // implements (single `/mcp` endpoint, POST+GET-SSE, session-id
252 // header, last-event-id resumability — see `mcp_http_post_handler`
253 // and `mcp_http_get_handler`). The stdio loop reports `2024-11-05`
254 // because `rmcp 0.1.x` only speaks that spec — different
255 // transports, different versions, each accurate.
256 //
257 // `capabilities.tools = {}` is the bare-minimum capability set
258 // (we expose tools, nothing else). `serverInfo` is pinned to
259 // `{"name": "solo", "version": <crate version>}` per the
260 // `server_info_identity_is_solo_not_rmcp_or_solo_api` invariant.
261 let server_info =
262 Implementation::new("solo".to_string(), env!("CARGO_PKG_VERSION").to_string());
263 let result = serde_json::json!({
264 "protocolVersion": "2025-03-26",
265 "capabilities": {
266 "tools": {},
267 },
268 "serverInfo": server_info,
269 });
270 JsonRpcResponse::success(id, result)
271 }
272
273 /// `tools/list` — wraps [`SoloMcpServer::dispatch_list_tools`].
274 fn handle_tools_list(&self, id: serde_json::Value) -> JsonRpcResponse {
275 let tools = self.server.dispatch_list_tools();
276 let result = serde_json::json!({ "tools": tools });
277 JsonRpcResponse::success(id, result)
278 }
279
280 /// `tools/call` — wraps [`SoloMcpServer::dispatch_tool`]. JSON-RPC
281 /// `params` carries `{"name": "...", "arguments": {...}, "_meta":
282 /// {"progressToken": ...}?}`.
283 ///
284 /// v0.11.0 P3: if `_meta.progressToken` is present in `params` AND
285 /// the request carried an `Arc<SessionState>` (HTTP transport), the
286 /// handler builds a [`ProgressReporter`] correlated to the client's
287 /// token and threads it into [`SoloMcpServer::dispatch_tool`].
288 /// Long-running tool handlers (`memory_ingest_document`,
289 /// `memory_search_docs`, `memory_remember_batch`) call
290 /// `reporter.report(...)` at sensible checkpoints. Other handlers
291 /// ignore the reporter — backward compat preserved.
292 async fn handle_tools_call(
293 &self,
294 id: serde_json::Value,
295 params: serde_json::Value,
296 session: Option<Arc<SessionState>>,
297 ) -> JsonRpcResponse {
298 let name = match params.get("name").and_then(|v| v.as_str()) {
299 Some(n) => n.to_string(),
300 None => {
301 return JsonRpcResponse::error(
302 id,
303 ErrorCode::INVALID_PARAMS.0,
304 "tools/call: missing `name` field",
305 );
306 }
307 };
308 // `arguments` is optional; treat absent / null as empty object so
309 // tools with all-optional args (e.g. `memory_themes`) can be
310 // called with `{}` from the wire.
311 let arguments = params
312 .get("arguments")
313 .cloned()
314 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
315 // v0.11.0 P3: parse the progress token from the call params'
316 // `_meta` block. Only Some when the client opted in.
317 let reporter = match (session, ProgressToken::from_meta(¶ms)) {
318 (Some(s), Some(token)) => Some(ProgressReporter::new(s, token)),
319 // Either no session (stdio) or no progressToken (client did
320 // not opt in) → no reporter. The handlers see `None` and
321 // skip emission silently.
322 _ => None,
323 };
324 match self.server.dispatch_tool(&name, arguments, reporter).await {
325 Ok(call_result) => {
326 // Serialise the rmcp `CallToolResult` directly — it
327 // already round-trips through serde and matches the
328 // MCP wire shape the stdio transport emits.
329 let result = match serde_json::to_value(&call_result) {
330 Ok(v) => v,
331 Err(e) => {
332 return JsonRpcResponse::error(
333 id,
334 ErrorCode::INTERNAL_ERROR.0,
335 format!("serialize tool result: {e}"),
336 );
337 }
338 };
339 JsonRpcResponse::success(id, result)
340 }
341 Err(e) => JsonRpcResponse::from_mcp_error(id, e),
342 }
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn jsonrpc_success_serialises_with_jsonrpc_field() {
352 let resp = JsonRpcResponse::success(serde_json::json!(1), serde_json::json!({"ok": true}));
353 let s = serde_json::to_string(&resp).unwrap();
354 assert!(s.contains(r#""jsonrpc":"2.0""#));
355 assert!(s.contains(r#""id":1"#));
356 assert!(s.contains(r#""result":{"ok":true}"#));
357 assert!(!s.contains(r#""error":"#));
358 }
359
360 #[test]
361 fn jsonrpc_error_serialises_with_error_field() {
362 let resp = JsonRpcResponse::error(
363 serde_json::json!(7),
364 ErrorCode::METHOD_NOT_FOUND.0,
365 "unknown method `foo`",
366 );
367 let s = serde_json::to_string(&resp).unwrap();
368 assert!(s.contains(r#""jsonrpc":"2.0""#));
369 assert!(s.contains(r#""id":7"#));
370 assert!(s.contains(r#""error":{"#));
371 assert!(s.contains(r#""code":-32601"#));
372 assert!(!s.contains(r#""result":"#));
373 }
374
375 #[test]
376 fn jsonrpc_notification_has_no_id() {
377 let raw = r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#;
378 let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
379 assert_eq!(req.method, "notifications/initialized");
380 assert!(req.id.is_none());
381 }
382
383 #[test]
384 fn jsonrpc_request_with_null_id_parses_as_notification() {
385 // Per JSON-RPC 2.0 §4.1 `null` is discouraged for request ids;
386 // serde's `#[serde(default)]` deserialises an explicit null the
387 // same as a missing field, so both land as a notification
388 // (no reply). Real MCP clients always send numeric/string ids.
389 let raw = r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#;
390 let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
391 assert!(req.id.is_none());
392 }
393}