Skip to main content

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}