Skip to main content

myko_server/mcp/
dispatch.rs

1//! Transport-agnostic MCP JSON-RPC dispatch.
2//!
3//! Handles `initialize`, `tools/list`, `tools/call`, `resources/list`,
4//! `resources/read`, and the relevant notifications.
5//!
6//! ## Tool / resource split
7//!
8//! - **Tools** — every registered query / view / report / command is
9//!   exposed as a tool the LLM can invoke on demand. Tools take structured
10//!   `arguments` matching the registration's input shape.
11//! - **Resources** — every tool also surfaces a *schema* resource at
12//!   `myko://schema/<kind>/<id>` whose content is the JSON Schema for the
13//!   tool's input. The schema goes through the resource shape rather than
14//!   the data because:
15//!   - Resources are URI-keyed and can't carry structured arguments, but
16//!     every query / view / report registration takes args.
17//!   - Even argument-less reads are backed by reactive cells (the data is
18//!     live), so pre-loading a snapshot into context at startup would
19//!     just go stale. On-demand `tools/call` is the right shape for live
20//!     reads.
21//!
22//! Reactive query subscriptions via `resources/subscribe` are future work.
23//!
24//! Error responses follow the [MCP 2025-06-18 error-handling shape][spec]:
25//!
26//! - **Protocol Error** — JSON-RPC error response with `code: -32602` and
27//!   message `"Unknown tool: …"`. Used when a tool is hidden by visibility
28//!   filtering (indistinguishable on the wire from a tool that does not
29//!   exist) or when required `tools/call` params are missing.
30//! - **Tool Execution Error** — successful JSON-RPC response with
31//!   `isError: true` content carrying a descriptive message. Used when
32//!   `tools/call` arguments fail client-supplied argument constraints
33//!   (the spec's "Invalid input data" category) or when tool execution
34//!   raises an error downstream.
35//!
36//! [spec]: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#error-handling
37
38use myko::{
39    command::CommandRegistration, query::QueryRegistration, report::ReportRegistration,
40    view::ViewRegistration,
41};
42use serde_json::{Value, json};
43
44use super::{
45    exec::Executor,
46    filter::ClientFilters,
47    types::{McpError, McpRequest, McpResource, McpResponse, McpTool},
48};
49
50const CONNECTION_STATUS_TOOL: &str = "connection_status";
51
52/// Server identity for the `initialize` response.
53#[derive(Debug, Clone)]
54pub struct ServerInfo {
55    pub name: String,
56    pub version: String,
57    /// Optional `instructions` text returned in the `initialize` response.
58    /// MCP clients surface this to the model on connect; use it to teach
59    /// agents how to use this server.
60    pub instructions: Option<String>,
61}
62
63impl Default for ServerInfo {
64    fn default() -> Self {
65        Self {
66            name: "myko-mcp".to_string(),
67            version: env!("CARGO_PKG_VERSION").to_string(),
68            instructions: None,
69        }
70    }
71}
72
73/// Dispatch one JSON-RPC request. Returns `None` for notifications that do
74/// not produce a response.
75pub async fn handle_request(
76    request: McpRequest,
77    filter: &ClientFilters,
78    executor: &Executor,
79    info: &ServerInfo,
80) -> Option<McpResponse> {
81    match request.method.as_str() {
82        "initialize" => Some(handle_initialize(request.id, info)),
83        "notifications/initialized" | "notifications/cancelled" => None,
84        "tools/list" => Some(handle_tools_list(request.id, filter)),
85        "tools/call" => Some(handle_tools_call(request.id, request.params, filter, executor).await),
86        "resources/list" => Some(handle_resources_list(request.id, filter)),
87        "resources/read" => Some(handle_resources_read(request.id, request.params, filter)),
88        _ => Some(McpResponse::error(
89            request.id,
90            McpError::method_not_found(&request.method),
91        )),
92    }
93}
94
95fn handle_initialize(id: Value, info: &ServerInfo) -> McpResponse {
96    let mut payload = json!({
97        "protocolVersion": "2024-11-05",
98        "capabilities": {
99            "tools": {},
100            "resources": {}
101        },
102        "serverInfo": {
103            "name": info.name,
104            "version": info.version,
105        }
106    });
107    if let Some(text) = &info.instructions {
108        payload
109            .as_object_mut()
110            .expect("payload is an object literal above")
111            .insert("instructions".to_string(), Value::String(text.clone()));
112    }
113    McpResponse::success(id, payload)
114}
115
116fn handle_tools_list(id: Value, filter: &ClientFilters) -> McpResponse {
117    let mut tools: Vec<McpTool> = Vec::new();
118
119    if filter.tool_visible(CONNECTION_STATUS_TOOL) {
120        tools.push(McpTool {
121            name: CONNECTION_STATUS_TOOL.to_string(),
122            description: "Check the connection status to the Myko server".to_string(),
123            input_schema: json!({
124                "type": "object",
125                "properties": {},
126                "required": []
127            }),
128        });
129    }
130
131    // NOTE(ts): tool names use the `_` separator (e.g. `query_GetAllTargets`)
132    // rather than the older `:` form. Some LLM tool-call serializers drop the
133    // `arguments` field when names contain `:` (gpt-oss-20b confirmed on
134    // 2026-06-02); `_` matches the OpenAI tool-name regex `[a-zA-Z0-9_-]+`
135    // and round-trips cleanly. Dispatch still accepts the `:` form for
136    // backward compat — see `execute_tool` below.
137    for reg in inventory::iter::<QueryRegistration> {
138        let name = format!("query_{}", reg.query_id);
139        if !filter.tool_visible(&name) {
140            continue;
141        }
142        tools.push(McpTool {
143            name,
144            description: format!("Query returning {} entities", reg.query_item_type),
145            input_schema: open_object_schema(),
146        });
147    }
148
149    for reg in inventory::iter::<ViewRegistration> {
150        let name = format!("view_{}", reg.view_id);
151        if !filter.tool_visible(&name) {
152            continue;
153        }
154        tools.push(McpTool {
155            name,
156            description: format!("View returning a list of {}", reg.view_item_type),
157            input_schema: open_object_schema(),
158        });
159    }
160
161    for reg in inventory::iter::<ReportRegistration> {
162        let name = format!("report_{}", reg.report_id);
163        if !filter.tool_visible(&name) {
164            continue;
165        }
166        tools.push(McpTool {
167            name,
168            description: format!("Report returning {}", reg.output_type),
169            input_schema: open_object_schema(),
170        });
171    }
172
173    for reg in inventory::iter::<CommandRegistration> {
174        let name = format!("command_{}", reg.command_id);
175        if !filter.tool_visible(&name) {
176            continue;
177        }
178        tools.push(McpTool {
179            name,
180            description: format!("Command returning {}", reg.result_type),
181            input_schema: open_object_schema(),
182        });
183    }
184
185    McpResponse::success(id, json!({ "tools": tools }))
186}
187
188async fn handle_tools_call(
189    id: Value,
190    params: Option<Value>,
191    filter: &ClientFilters,
192    executor: &Executor,
193) -> McpResponse {
194    let Some(params) = params else {
195        return McpResponse::error(id, McpError::invalid_params("Missing params"));
196    };
197    let Some(tool_name) = params
198        .get("name")
199        .and_then(|v| v.as_str())
200        .map(str::to_string)
201    else {
202        return McpResponse::error(id, McpError::invalid_params("Missing tool name"));
203    };
204
205    // MCP Protocol Error: a hidden tool is indistinguishable on the wire from
206    // a tool that doesn't exist. Code -32602 + "Unknown tool: …" matches the
207    // example in the MCP 2025-06-18 spec (Tools / Error Handling).
208    if !filter.tool_visible(&tool_name) {
209        return McpResponse::error(
210            id,
211            McpError {
212                code: McpError::INVALID_PARAMS,
213                message: format!("Unknown tool: {}", tool_name),
214                data: None,
215            },
216        );
217    }
218
219    let arguments = params
220        .get("arguments")
221        .cloned()
222        .unwrap_or_else(|| json!({}));
223
224    // MCP Tool Execution Error ("Invalid input data" category): result is a
225    // successful JSON-RPC response carrying `isError: true` content with the
226    // descriptive constraint message verbatim — distinct from the protocol
227    // error path above.
228    if let Err(message) = filter.tool_callable(&tool_name, &arguments) {
229        return McpResponse::success(
230            id,
231            json!({
232                "content": [{
233                    "type": "text",
234                    "text": message,
235                }],
236                "isError": true,
237            }),
238        );
239    }
240
241    let result = execute_tool(executor, &tool_name, arguments).await;
242
243    match result {
244        Ok(data) => McpResponse::success(
245            id,
246            json!({
247                "content": [{
248                    "type": "text",
249                    "text": serde_json::to_string_pretty(&data).unwrap_or_default()
250                }]
251            }),
252        ),
253        Err(message) => McpResponse::success(
254            id,
255            json!({
256                "content": [{
257                    "type": "text",
258                    "text": format!("Error: {}", message)
259                }],
260                "isError": true,
261            }),
262        ),
263    }
264}
265
266async fn execute_tool(executor: &Executor, tool_name: &str, args: Value) -> Result<Value, String> {
267    if tool_name == CONNECTION_STATUS_TOOL {
268        return Ok(executor.connection_status());
269    }
270    // Accept both the new `kind_Id` (advertised) and legacy `kind:Id` forms.
271    // See NOTE(ts) in handle_tools_list above.
272    if let Some(id) = strip_kind_prefix(tool_name, "query") {
273        return executor.execute_query(id, args).await;
274    }
275    if let Some(id) = strip_kind_prefix(tool_name, "view") {
276        return executor.execute_view(id, args).await;
277    }
278    if let Some(id) = strip_kind_prefix(tool_name, "report") {
279        return executor.execute_report(id, args).await;
280    }
281    if let Some(id) = strip_kind_prefix(tool_name, "command") {
282        return executor.execute_command(id, args).await;
283    }
284    Err(format!("Unknown tool: {}", tool_name))
285}
286
287/// Strip a `kind` prefix followed by either `_` (new, OpenAI-tool-name-safe)
288/// or `:` (legacy) from `name`, returning the remaining id. Entity ids never
289/// contain `:` (PascalCase from `#[myko_item]`), so the first separator is
290/// unambiguous.
291fn strip_kind_prefix<'a>(name: &'a str, kind: &str) -> Option<&'a str> {
292    let rest = name.strip_prefix(kind)?;
293    let sep = rest.as_bytes().first()?;
294    if *sep == b'_' || *sep == b':' {
295        Some(&rest[1..])
296    } else {
297        None
298    }
299}
300
301fn handle_resources_list(id: Value, filter: &ClientFilters) -> McpResponse {
302    let mut resources: Vec<McpResource> = Vec::new();
303
304    for reg in inventory::iter::<QueryRegistration> {
305        let tool_name = format!("query_{}", reg.query_id);
306        if !filter.tool_visible(&tool_name) {
307            continue;
308        }
309        resources.push(McpResource {
310            uri: format!("myko://schema/query/{}", reg.query_id),
311            name: reg.query_id.to_string(),
312            description: Some(format!("Query returning {} entities", reg.query_item_type)),
313            mime_type: Some("application/json".to_string()),
314        });
315    }
316
317    for reg in inventory::iter::<ViewRegistration> {
318        let tool_name = format!("view_{}", reg.view_id);
319        if !filter.tool_visible(&tool_name) {
320            continue;
321        }
322        resources.push(McpResource {
323            uri: format!("myko://schema/view/{}", reg.view_id),
324            name: reg.view_id.to_string(),
325            description: Some(format!("View returning a list of {}", reg.view_item_type)),
326            mime_type: Some("application/json".to_string()),
327        });
328    }
329
330    for reg in inventory::iter::<ReportRegistration> {
331        let tool_name = format!("report_{}", reg.report_id);
332        if !filter.tool_visible(&tool_name) {
333            continue;
334        }
335        resources.push(McpResource {
336            uri: format!("myko://schema/report/{}", reg.report_id),
337            name: reg.report_id.to_string(),
338            description: Some(format!("Report returning {}", reg.output_type)),
339            mime_type: Some("application/json".to_string()),
340        });
341    }
342
343    for reg in inventory::iter::<CommandRegistration> {
344        let tool_name = format!("command_{}", reg.command_id);
345        if !filter.tool_visible(&tool_name) {
346            continue;
347        }
348        resources.push(McpResource {
349            uri: format!("myko://schema/command/{}", reg.command_id),
350            name: format!("{} (command)", reg.command_id),
351            description: Some(format!("Command returning {}", reg.result_type)),
352            mime_type: Some("application/json".to_string()),
353        });
354    }
355
356    McpResponse::success(id, json!({ "resources": resources }))
357}
358
359fn handle_resources_read(id: Value, params: Option<Value>, filter: &ClientFilters) -> McpResponse {
360    let Some(params) = params else {
361        return McpResponse::error(id, McpError::invalid_params("Missing params"));
362    };
363    let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else {
364        return McpResponse::error(id, McpError::invalid_params("Missing uri"));
365    };
366
367    if let Some(path) = uri.strip_prefix("myko://schema/") {
368        let parts: Vec<&str> = path.splitn(2, '/').collect();
369        if parts.len() == 2 {
370            let (schema_type, schema_id) = (parts[0], parts[1]);
371            let tool_name = format!("{}:{}", schema_type, schema_id);
372            if !filter.tool_visible(&tool_name) {
373                return McpResponse::error(
374                    id,
375                    McpError {
376                        code: McpError::INVALID_PARAMS,
377                        message: format!("Resource not accessible: {}", uri),
378                        data: None,
379                    },
380                );
381            }
382            let content = match schema_type {
383                "query" => get_query_schema(schema_id),
384                "view" => get_view_schema(schema_id),
385                "report" => get_report_schema(schema_id),
386                "command" => get_command_schema(schema_id),
387                _ => None,
388            };
389            if let Some(content) = content {
390                return McpResponse::success(
391                    id,
392                    json!({
393                        "contents": [{
394                            "uri": uri,
395                            "mimeType": "application/json",
396                            "text": content,
397                        }]
398                    }),
399                );
400            }
401        }
402    }
403
404    McpResponse::error(
405        id,
406        McpError {
407            code: McpError::INVALID_PARAMS,
408            message: format!("Resource not found: {}", uri),
409            data: None,
410        },
411    )
412}
413
414fn open_object_schema() -> Value {
415    json!({
416        "type": "object",
417        "additionalProperties": true
418    })
419}
420
421fn get_query_schema(query_id: &str) -> Option<String> {
422    for reg in inventory::iter::<QueryRegistration> {
423        if reg.query_id == query_id {
424            let schema = json!({
425                "$schema": "http://json-schema.org/draft-07/schema#",
426                "title": reg.query_id,
427                "description": format!("Query returning {} entities", reg.query_item_type),
428                "type": "object",
429                "additionalProperties": true,
430            });
431            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
432        }
433    }
434    None
435}
436
437fn get_view_schema(view_id: &str) -> Option<String> {
438    for reg in inventory::iter::<ViewRegistration> {
439        if reg.view_id == view_id {
440            let schema = json!({
441                "$schema": "http://json-schema.org/draft-07/schema#",
442                "title": reg.view_id,
443                "description": format!("View returning a list of {}", reg.view_item_type),
444                "type": "object",
445                "additionalProperties": true,
446            });
447            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
448        }
449    }
450    None
451}
452
453fn get_report_schema(report_id: &str) -> Option<String> {
454    for reg in inventory::iter::<ReportRegistration> {
455        if reg.report_id == report_id {
456            let schema = json!({
457                "$schema": "http://json-schema.org/draft-07/schema#",
458                "title": reg.report_id,
459                "description": format!("Report returning {}", reg.output_type),
460                "type": "object",
461                "additionalProperties": true,
462            });
463            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
464        }
465    }
466    None
467}
468
469fn get_command_schema(command_id: &str) -> Option<String> {
470    for reg in inventory::iter::<CommandRegistration> {
471        if reg.command_id == command_id {
472            let schema = json!({
473                "$schema": "http://json-schema.org/draft-07/schema#",
474                "title": reg.command_id,
475                "description": format!("Command returning {}", reg.result_type),
476                "type": "object",
477                "additionalProperties": true,
478            });
479            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
480        }
481    }
482    None
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use serde_json::Value;
489
490    fn make_request(method: &str) -> McpRequest {
491        McpRequest {
492            jsonrpc: "2.0".to_string(),
493            id: Value::Number(1.into()),
494            method: method.to_string(),
495            params: None,
496        }
497    }
498
499    #[test]
500    fn server_info_default_omits_instructions() {
501        let info = ServerInfo::default();
502        assert_eq!(info.instructions, None);
503    }
504
505    #[test]
506    fn server_info_can_carry_instructions() {
507        let info = ServerInfo {
508            name: "test".into(),
509            version: "0.0.0".into(),
510            instructions: Some("test instructions text".into()),
511        };
512        assert_eq!(info.instructions.as_deref(), Some("test instructions text"));
513    }
514
515    #[tokio::test]
516    async fn initialize_returns_server_info() {
517        let filter = ClientFilters::allow_all();
518        let info = ServerInfo {
519            name: "test".into(),
520            version: "0.0.0".into(),
521            instructions: None,
522        };
523        // Executor is irrelevant for initialize but we need *some* executor;
524        // use an in-process one wrapped around a minimal ctx is heavy here,
525        // so just use a dummy MykoClient.
526        let client = std::sync::Arc::new(myko::client::MykoClient::new());
527        let executor = Executor::Client(client);
528        let response = handle_request(make_request("initialize"), &filter, &executor, &info)
529            .await
530            .expect("initialize must produce a response");
531        let result = response.result.expect("initialize must have a result");
532        assert_eq!(result["serverInfo"]["name"], "test");
533        assert_eq!(result["serverInfo"]["version"], "0.0.0");
534    }
535
536    #[tokio::test]
537    async fn initialize_includes_instructions_when_set() {
538        let filter = ClientFilters::allow_all();
539        let info = ServerInfo {
540            name: "pulse-mcp".into(),
541            version: "0.2.0".into(),
542            instructions: Some("teach me".into()),
543        };
544        let client = std::sync::Arc::new(myko::client::MykoClient::new());
545        let executor = Executor::Client(client);
546
547        let resp = handle_request(make_request("initialize"), &filter, &executor, &info)
548            .await
549            .expect("initialize must return a response");
550        let result = resp.result.expect("initialize must succeed");
551
552        assert_eq!(result["serverInfo"]["name"], json!("pulse-mcp"));
553        assert_eq!(result["serverInfo"]["version"], json!("0.2.0"));
554        assert_eq!(result["instructions"], json!("teach me"));
555    }
556
557    #[tokio::test]
558    async fn initialize_omits_instructions_when_unset() {
559        let filter = ClientFilters::allow_all();
560        let info = ServerInfo::default();
561        let client = std::sync::Arc::new(myko::client::MykoClient::new());
562        let executor = Executor::Client(client);
563
564        let resp = handle_request(make_request("initialize"), &filter, &executor, &info)
565            .await
566            .expect("response");
567        let result = resp.result.expect("ok");
568        assert!(
569            result.get("instructions").is_none(),
570            "instructions must be omitted when ServerInfo.instructions is None"
571        );
572    }
573
574    #[tokio::test]
575    async fn notifications_produce_no_response() {
576        let filter = ClientFilters::allow_all();
577        let info = ServerInfo::default();
578        let client = std::sync::Arc::new(myko::client::MykoClient::new());
579        let executor = Executor::Client(client);
580        assert!(
581            handle_request(
582                make_request("notifications/initialized"),
583                &filter,
584                &executor,
585                &info,
586            )
587            .await
588            .is_none()
589        );
590    }
591
592    #[tokio::test]
593    async fn unknown_method_returns_error() {
594        let filter = ClientFilters::allow_all();
595        let info = ServerInfo::default();
596        let client = std::sync::Arc::new(myko::client::MykoClient::new());
597        let executor = Executor::Client(client);
598        let response = handle_request(make_request("unknown/method"), &filter, &executor, &info)
599            .await
600            .expect("must produce a response");
601        assert!(response.error.is_some());
602    }
603}