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//! ## Code Mode: `search` + `execute`
7//!
8//! Rather than one MCP tool per registered query/view/report/command (which
9//! scales as `N_entities × ~8 auto-ops`, blowing up the tools/list token
10//! footprint the same way a large hand-rolled REST-per-endpoint MCP server
11//! does — see Cloudflare's ["Code Mode"][code-mode] writeup), `tools/list`
12//! advertises exactly two operational tools:
13//!
14//! - **`search`** — looks up operations in [`ServerInfo::operation_index`]
15//!   by substring/kind, returning compact `{id, kind, args, outputType}`
16//!   entries instead of a full per-operation tool + JSON Schema.
17//! - **`execute`** — runs a JS function body (see [`sandbox`]) against a
18//!   generated `myko.*` API bound to the *same* [`Executor`] methods the
19//!   old per-operation tools called, so a script can chain several
20//!   query/command calls in one round trip instead of one MCP call each.
21//!
22//! [`ClientFilters`] visibility/callability checks move accordingly: they
23//! used to gate `tools/list`/`tools/call` per operation name; now `search`
24//! filters its index by the same names, and `execute`'s sandbox re-checks
25//! them per `myko.*` call the script makes (see [`sandbox::call_operation`]
26//! — not public, but that's where the check lives).
27//!
28//! This is a breaking change from the prior one-tool-per-operation wire
29//! shape — existing `ClientFilters` glob configs (`query_*`, etc.) still
30//! work exactly as before since they match against the same `{kind}_{id}`
31//! strings, just from a different call site.
32//!
33//! [code-mode]: https://blog.cloudflare.com/code-mode-mcp/
34//!
35//! ## Resources
36//!
37//! Every tool also surfaces a *schema* resource at `myko://schema/<kind>/<id>`
38//! whose content is the JSON Schema for the tool's input. This predates (and
39//! is orthogonal to) `search`/`execute` — it's not part of the tool-count
40//! problem `search`/`execute` fixes, since resources aren't tool
41//! definitions loaded into the model's context by default. Left unchanged:
42//! - Resources are URI-keyed and can't carry structured arguments, but
43//!   every query / view / report registration takes args.
44//! - Even argument-less reads are backed by reactive cells (the data is
45//!   live), so pre-loading a snapshot into context at startup would
46//!   just go stale. On-demand `tools/call` is the right shape for live
47//!   reads.
48//!
49//! Reactive query subscriptions via `resources/subscribe` are future work.
50//!
51//! Error responses follow the [MCP 2025-06-18 error-handling shape][spec]:
52//!
53//! - **Protocol Error** — JSON-RPC error response with `code: -32602` and
54//!   message `"Unknown tool: …"`. Used when a tool is hidden by visibility
55//!   filtering (indistinguishable on the wire from a tool that does not
56//!   exist) or when required `tools/call` params are missing.
57//! - **Tool Execution Error** — successful JSON-RPC response with
58//!   `isError: true` content carrying a descriptive message. Used when
59//!   `tools/call` arguments fail client-supplied argument constraints
60//!   (the spec's "Invalid input data" category) or when tool execution
61//!   raises an error downstream.
62//!
63//! [spec]: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#error-handling
64
65use std::sync::Arc;
66
67use myko::{
68    command::CommandRegistration, operation_index::OperationSchema, query::QueryRegistration,
69    report::ReportRegistration, view::ViewRegistration,
70};
71use serde_json::{Value, json};
72
73use super::{
74    exec::Executor,
75    filter::ClientFilters,
76    sandbox,
77    types::{McpError, McpRequest, McpResource, McpResponse, McpTool},
78};
79
80const CONNECTION_STATUS_TOOL: &str = "connection_status";
81const SEARCH_TOOL: &str = "search";
82const EXECUTE_TOOL: &str = "execute";
83
84/// Server identity for the `initialize` response.
85#[derive(Debug, Clone)]
86pub struct ServerInfo {
87    pub name: String,
88    pub version: String,
89    /// Optional `instructions` text returned in the `initialize` response.
90    /// MCP clients surface this to the model on connect; use it to teach
91    /// agents how to use this server.
92    pub instructions: Option<String>,
93    /// Backs the `search` tool and the `execute` sandbox's `myko.*` API
94    /// surface. Built automatically from `inventory`-registered operations
95    /// (see [`myko::operation_index::build_operation_index`]) — no I/O, no
96    /// configuration, works for every crate's operations regardless of
97    /// which crate hosts the MCP server.
98    pub operation_index: Arc<Vec<OperationSchema>>,
99}
100
101impl Default for ServerInfo {
102    fn default() -> Self {
103        Self {
104            name: "myko-mcp".to_string(),
105            version: env!("CARGO_PKG_VERSION").to_string(),
106            instructions: None,
107            operation_index: Arc::new(myko::operation_index::build_operation_index()),
108        }
109    }
110}
111
112/// Dispatch one JSON-RPC request. Returns `None` for notifications that do
113/// not produce a response.
114pub async fn handle_request(
115    request: McpRequest,
116    filter: &ClientFilters,
117    executor: &Executor,
118    info: &ServerInfo,
119) -> Option<McpResponse> {
120    match request.method.as_str() {
121        "initialize" => Some(handle_initialize(request.id, info)),
122        "notifications/initialized" | "notifications/cancelled" => None,
123        "tools/list" => Some(handle_tools_list(request.id, filter)),
124        "tools/call" => {
125            Some(handle_tools_call(request.id, request.params, filter, executor, info).await)
126        }
127        "resources/list" => Some(handle_resources_list(request.id, filter)),
128        "resources/read" => Some(handle_resources_read(request.id, request.params, filter)),
129        _ => Some(McpResponse::error(
130            request.id,
131            McpError::method_not_found(&request.method),
132        )),
133    }
134}
135
136fn handle_initialize(id: Value, info: &ServerInfo) -> McpResponse {
137    let mut payload = json!({
138        "protocolVersion": "2024-11-05",
139        "capabilities": {
140            "tools": {},
141            "resources": {}
142        },
143        "serverInfo": {
144            "name": info.name,
145            "version": info.version,
146        }
147    });
148    if let Some(text) = &info.instructions {
149        payload
150            .as_object_mut()
151            .expect("payload is an object literal above")
152            .insert("instructions".to_string(), Value::String(text.clone()));
153    }
154    McpResponse::success(id, payload)
155}
156
157fn handle_tools_list(id: Value, filter: &ClientFilters) -> McpResponse {
158    let mut tools: Vec<McpTool> = Vec::new();
159
160    if filter.meta_tool_visible(CONNECTION_STATUS_TOOL) {
161        tools.push(McpTool {
162            name: CONNECTION_STATUS_TOOL.to_string(),
163            description: "Check the connection status to the Myko server".to_string(),
164            input_schema: json!({
165                "type": "object",
166                "properties": {},
167                "required": []
168            }),
169        });
170    }
171
172    if filter.meta_tool_visible(SEARCH_TOOL) {
173        tools.push(McpTool {
174            name: SEARCH_TOOL.to_string(),
175            description: "Search the index of available Myko queries/views/reports/commands. \
176                Returns compact {id, kind, args, outputType} entries — call this before \
177                `execute` to find operation ids and argument shapes."
178                .to_string(),
179            input_schema: json!({
180                "type": "object",
181                "properties": {
182                    "query": {
183                        "type": "string",
184                        "description": "Case-insensitive substring match against operation id/description."
185                    },
186                    "kind": {
187                        "type": "string",
188                        "enum": ["query", "view", "report", "command"],
189                        "description": "Restrict results to one operation kind."
190                    }
191                },
192                "required": []
193            }),
194        });
195    }
196
197    if filter.meta_tool_visible(EXECUTE_TOOL) {
198        tools.push(McpTool {
199            name: EXECUTE_TOOL.to_string(),
200            description: "Run JavaScript against the Myko API. The code runs as an async \
201                function body — use `await myko.query(id, args)`, `myko.view(id, args)`, \
202                `myko.report(id, args)`, or `myko.command(id, args)` (ids/args from `search`), \
203                and optionally `return` a JSON-serializable value. Chain multiple calls in one \
204                script instead of one `execute` call per operation. Each call resolves to a \
205                wrapper object, not the raw payload directly: query/view resolve to \
206                {query_id|view_id, item_type, count, items} (items is the payload); \
207                report resolves to {report_id, output_type, result} (result is the payload); \
208                command resolves to {command_id, success, result} (result is the payload). \
209                E.g. `(await myko.report('ServerStats', {})).result`, not `.serverStats`."
210                .to_string(),
211            input_schema: json!({
212                "type": "object",
213                "properties": {
214                    "code": {
215                        "type": "string",
216                        "description": "JavaScript function body to run."
217                    }
218                },
219                "required": ["code"]
220            }),
221        });
222    }
223
224    McpResponse::success(id, json!({ "tools": tools }))
225}
226
227async fn handle_tools_call(
228    id: Value,
229    params: Option<Value>,
230    filter: &ClientFilters,
231    executor: &Executor,
232    info: &ServerInfo,
233) -> McpResponse {
234    let Some(params) = params else {
235        return McpResponse::error(id, McpError::invalid_params("Missing params"));
236    };
237    let Some(tool_name) = params
238        .get("name")
239        .and_then(|v| v.as_str())
240        .map(str::to_string)
241    else {
242        return McpResponse::error(id, McpError::invalid_params("Missing tool name"));
243    };
244
245    // MCP Protocol Error: a hidden tool is indistinguishable on the wire from
246    // a tool that doesn't exist. Code -32602 + "Unknown tool: …" matches the
247    // example in the MCP 2025-06-18 spec (Tools / Error Handling). Uses
248    // `meta_tool_visible` (deny-only) since "search"/"execute"/
249    // "connection_status" are the only top-level tools now — see its doc
250    // comment for why a positive allow list shouldn't gate them.
251    if !filter.meta_tool_visible(&tool_name) {
252        return McpResponse::error(
253            id,
254            McpError {
255                code: McpError::INVALID_PARAMS,
256                message: format!("Unknown tool: {}", tool_name),
257                data: None,
258            },
259        );
260    }
261
262    let arguments = params
263        .get("arguments")
264        .cloned()
265        .unwrap_or_else(|| json!({}));
266
267    // MCP Tool Execution Error ("Invalid input data" category): result is a
268    // successful JSON-RPC response carrying `isError: true` content with the
269    // descriptive constraint message verbatim — distinct from the protocol
270    // error path above.
271    if let Err(message) = filter.tool_callable(&tool_name, &arguments) {
272        return McpResponse::success(
273            id,
274            json!({
275                "content": [{
276                    "type": "text",
277                    "text": message,
278                }],
279                "isError": true,
280            }),
281        );
282    }
283
284    let result = execute_tool(executor, info, filter, &tool_name, arguments).await;
285
286    match result {
287        Ok(data) => McpResponse::success(
288            id,
289            json!({
290                "content": [{
291                    "type": "text",
292                    "text": serde_json::to_string_pretty(&data).unwrap_or_default()
293                }]
294            }),
295        ),
296        Err(message) => McpResponse::success(
297            id,
298            json!({
299                "content": [{
300                    "type": "text",
301                    "text": format!("Error: {}", message)
302                }],
303                "isError": true,
304            }),
305        ),
306    }
307}
308
309async fn execute_tool(
310    executor: &Executor,
311    info: &ServerInfo,
312    filter: &ClientFilters,
313    tool_name: &str,
314    args: Value,
315) -> Result<Value, String> {
316    match tool_name {
317        CONNECTION_STATUS_TOOL => Ok(executor.connection_status(info)),
318        SEARCH_TOOL => Ok(handle_search(&args, filter, &info.operation_index)),
319        EXECUTE_TOOL => handle_execute(&args, executor, filter).await,
320        // Most commonly hit by a client with a cached pre-Code-Mode tool
321        // name (e.g. `report_ServerStats`) from before this server
322        // collapsed to search/execute — point it at the fix directly
323        // rather than leaving it to guess from a bare "unknown tool".
324        _ => Err(format!(
325            "Unknown tool: {tool_name}. This server uses search + execute — \
326             call `search` to discover operations, then `execute` to run them."
327        )),
328    }
329}
330
331fn handle_search(args: &Value, filter: &ClientFilters, index: &[OperationSchema]) -> Value {
332    let query = args
333        .get("query")
334        .and_then(|v| v.as_str())
335        .map(str::to_lowercase);
336    let kind = args.get("kind").and_then(|v| v.as_str());
337
338    let operations: Vec<&OperationSchema> = index
339        .iter()
340        .filter(|op| filter.tool_visible(&format!("{}_{}", op.kind, op.id)))
341        .filter(|op| kind.is_none_or(|k| op.kind == k))
342        .filter(|op| {
343            query.as_deref().is_none_or(|q| {
344                op.id.to_lowercase().contains(q)
345                    || op
346                        .description
347                        .as_deref()
348                        .is_some_and(|d| d.to_lowercase().contains(q))
349            })
350        })
351        .collect();
352
353    json!({ "operations": operations })
354}
355
356async fn handle_execute(
357    args: &Value,
358    executor: &Executor,
359    filter: &ClientFilters,
360) -> Result<Value, String> {
361    let Some(code) = args.get("code").and_then(|v| v.as_str()) else {
362        return Err("Missing required `code` argument".to_string());
363    };
364    sandbox::execute(code, Arc::new(executor.clone()), filter.clone()).await
365}
366
367fn handle_resources_list(id: Value, filter: &ClientFilters) -> McpResponse {
368    let mut resources: Vec<McpResource> = Vec::new();
369
370    for reg in inventory::iter::<QueryRegistration> {
371        let tool_name = format!("query_{}", reg.query_id);
372        if !filter.tool_visible(&tool_name) {
373            continue;
374        }
375        resources.push(McpResource {
376            uri: format!("myko://schema/query/{}", reg.query_id),
377            name: reg.query_id.to_string(),
378            description: Some(format!("Query returning {} entities", reg.query_item_type)),
379            mime_type: Some("application/json".to_string()),
380        });
381    }
382
383    for reg in inventory::iter::<ViewRegistration> {
384        let tool_name = format!("view_{}", reg.view_id);
385        if !filter.tool_visible(&tool_name) {
386            continue;
387        }
388        resources.push(McpResource {
389            uri: format!("myko://schema/view/{}", reg.view_id),
390            name: reg.view_id.to_string(),
391            description: Some(format!("View returning a list of {}", reg.view_item_type)),
392            mime_type: Some("application/json".to_string()),
393        });
394    }
395
396    for reg in inventory::iter::<ReportRegistration> {
397        let tool_name = format!("report_{}", reg.report_id);
398        if !filter.tool_visible(&tool_name) {
399            continue;
400        }
401        resources.push(McpResource {
402            uri: format!("myko://schema/report/{}", reg.report_id),
403            name: reg.report_id.to_string(),
404            description: Some(format!("Report returning {}", reg.output_type)),
405            mime_type: Some("application/json".to_string()),
406        });
407    }
408
409    for reg in inventory::iter::<CommandRegistration> {
410        let tool_name = format!("command_{}", reg.command_id);
411        if !filter.tool_visible(&tool_name) {
412            continue;
413        }
414        resources.push(McpResource {
415            uri: format!("myko://schema/command/{}", reg.command_id),
416            name: format!("{} (command)", reg.command_id),
417            description: Some(format!("Command returning {}", reg.result_type)),
418            mime_type: Some("application/json".to_string()),
419        });
420    }
421
422    McpResponse::success(id, json!({ "resources": resources }))
423}
424
425fn handle_resources_read(id: Value, params: Option<Value>, filter: &ClientFilters) -> McpResponse {
426    let Some(params) = params else {
427        return McpResponse::error(id, McpError::invalid_params("Missing params"));
428    };
429    let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else {
430        return McpResponse::error(id, McpError::invalid_params("Missing uri"));
431    };
432
433    if let Some(path) = uri.strip_prefix("myko://schema/") {
434        let parts: Vec<&str> = path.splitn(2, '/').collect();
435        if parts.len() == 2 {
436            let (schema_type, schema_id) = (parts[0], parts[1]);
437            let tool_name = format!("{}:{}", schema_type, schema_id);
438            if !filter.tool_visible(&tool_name) {
439                return McpResponse::error(
440                    id,
441                    McpError {
442                        code: McpError::INVALID_PARAMS,
443                        message: format!("Resource not accessible: {}", uri),
444                        data: None,
445                    },
446                );
447            }
448            let content = match schema_type {
449                "query" => get_query_schema(schema_id),
450                "view" => get_view_schema(schema_id),
451                "report" => get_report_schema(schema_id),
452                "command" => get_command_schema(schema_id),
453                _ => None,
454            };
455            if let Some(content) = content {
456                return McpResponse::success(
457                    id,
458                    json!({
459                        "contents": [{
460                            "uri": uri,
461                            "mimeType": "application/json",
462                            "text": content,
463                        }]
464                    }),
465                );
466            }
467        }
468    }
469
470    McpResponse::error(
471        id,
472        McpError {
473            code: McpError::INVALID_PARAMS,
474            message: format!("Resource not found: {}", uri),
475            data: None,
476        },
477    )
478}
479
480fn get_query_schema(query_id: &str) -> Option<String> {
481    for reg in inventory::iter::<QueryRegistration> {
482        if reg.query_id == query_id {
483            let schema = json!({
484                "$schema": "http://json-schema.org/draft-07/schema#",
485                "title": reg.query_id,
486                "description": format!("Query returning {} entities", reg.query_item_type),
487                "type": "object",
488                "additionalProperties": true,
489            });
490            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
491        }
492    }
493    None
494}
495
496fn get_view_schema(view_id: &str) -> Option<String> {
497    for reg in inventory::iter::<ViewRegistration> {
498        if reg.view_id == view_id {
499            let schema = json!({
500                "$schema": "http://json-schema.org/draft-07/schema#",
501                "title": reg.view_id,
502                "description": format!("View returning a list of {}", reg.view_item_type),
503                "type": "object",
504                "additionalProperties": true,
505            });
506            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
507        }
508    }
509    None
510}
511
512fn get_report_schema(report_id: &str) -> Option<String> {
513    for reg in inventory::iter::<ReportRegistration> {
514        if reg.report_id == report_id {
515            let schema = json!({
516                "$schema": "http://json-schema.org/draft-07/schema#",
517                "title": reg.report_id,
518                "description": format!("Report returning {}", reg.output_type),
519                "type": "object",
520                "additionalProperties": true,
521            });
522            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
523        }
524    }
525    None
526}
527
528fn get_command_schema(command_id: &str) -> Option<String> {
529    for reg in inventory::iter::<CommandRegistration> {
530        if reg.command_id == command_id {
531            let schema = json!({
532                "$schema": "http://json-schema.org/draft-07/schema#",
533                "title": reg.command_id,
534                "description": format!("Command returning {}", reg.result_type),
535                "type": "object",
536                "additionalProperties": true,
537            });
538            return Some(serde_json::to_string_pretty(&schema).unwrap_or_default());
539        }
540    }
541    None
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use serde_json::Value;
548
549    fn make_request(method: &str) -> McpRequest {
550        McpRequest {
551            jsonrpc: "2.0".to_string(),
552            id: Value::Number(1.into()),
553            method: method.to_string(),
554            params: None,
555        }
556    }
557
558    #[test]
559    fn server_info_default_omits_instructions() {
560        let info = ServerInfo::default();
561        assert_eq!(info.instructions, None);
562    }
563
564    #[test]
565    fn server_info_can_carry_instructions() {
566        let info = ServerInfo {
567            name: "test".into(),
568            version: "0.0.0".into(),
569            instructions: Some("test instructions text".into()),
570            ..Default::default()
571        };
572        assert_eq!(info.instructions.as_deref(), Some("test instructions text"));
573    }
574
575    #[tokio::test]
576    async fn initialize_returns_server_info() {
577        let filter = ClientFilters::allow_all();
578        let info = ServerInfo {
579            name: "test".into(),
580            version: "0.0.0".into(),
581            instructions: None,
582            ..Default::default()
583        };
584        // Executor is irrelevant for initialize but we need *some* executor;
585        // use an in-process one wrapped around a minimal ctx is heavy here,
586        // so just use a dummy MykoClient.
587        let client = std::sync::Arc::new(myko::client::MykoClient::new());
588        let executor = Executor::Client(client);
589        let response = handle_request(make_request("initialize"), &filter, &executor, &info)
590            .await
591            .expect("initialize must produce a response");
592        let result = response.result.expect("initialize must have a result");
593        assert_eq!(result["serverInfo"]["name"], "test");
594        assert_eq!(result["serverInfo"]["version"], "0.0.0");
595    }
596
597    #[tokio::test]
598    async fn initialize_includes_instructions_when_set() {
599        let filter = ClientFilters::allow_all();
600        let info = ServerInfo {
601            name: "pulse-mcp".into(),
602            version: "0.2.0".into(),
603            instructions: Some("teach me".into()),
604            ..Default::default()
605        };
606        let client = std::sync::Arc::new(myko::client::MykoClient::new());
607        let executor = Executor::Client(client);
608
609        let resp = handle_request(make_request("initialize"), &filter, &executor, &info)
610            .await
611            .expect("initialize must return a response");
612        let result = resp.result.expect("initialize must succeed");
613
614        assert_eq!(result["serverInfo"]["name"], json!("pulse-mcp"));
615        assert_eq!(result["serverInfo"]["version"], json!("0.2.0"));
616        assert_eq!(result["instructions"], json!("teach me"));
617    }
618
619    #[tokio::test]
620    async fn initialize_omits_instructions_when_unset() {
621        let filter = ClientFilters::allow_all();
622        let info = ServerInfo::default();
623        let client = std::sync::Arc::new(myko::client::MykoClient::new());
624        let executor = Executor::Client(client);
625
626        let resp = handle_request(make_request("initialize"), &filter, &executor, &info)
627            .await
628            .expect("response");
629        let result = resp.result.expect("ok");
630        assert!(
631            result.get("instructions").is_none(),
632            "instructions must be omitted when ServerInfo.instructions is None"
633        );
634    }
635
636    #[tokio::test]
637    async fn notifications_produce_no_response() {
638        let filter = ClientFilters::allow_all();
639        let info = ServerInfo::default();
640        let client = std::sync::Arc::new(myko::client::MykoClient::new());
641        let executor = Executor::Client(client);
642        assert!(
643            handle_request(
644                make_request("notifications/initialized"),
645                &filter,
646                &executor,
647                &info,
648            )
649            .await
650            .is_none()
651        );
652    }
653
654    #[tokio::test]
655    async fn unknown_method_returns_error() {
656        let filter = ClientFilters::allow_all();
657        let info = ServerInfo::default();
658        let client = std::sync::Arc::new(myko::client::MykoClient::new());
659        let executor = Executor::Client(client);
660        let response = handle_request(make_request("unknown/method"), &filter, &executor, &info)
661            .await
662            .expect("must produce a response");
663        assert!(response.error.is_some());
664    }
665
666    // ─── Code Mode: search / execute ──────────────────────────────────────
667
668    fn make_tool_call(name: &str, arguments: Value) -> McpRequest {
669        McpRequest {
670            jsonrpc: "2.0".to_string(),
671            id: Value::Number(1.into()),
672            method: "tools/call".to_string(),
673            params: Some(json!({ "name": name, "arguments": arguments })),
674        }
675    }
676
677    fn dummy_executor() -> Executor {
678        Executor::Client(std::sync::Arc::new(myko::client::MykoClient::new()))
679    }
680
681    fn info_with_index() -> ServerInfo {
682        ServerInfo {
683            operation_index: Arc::new(vec![
684                OperationSchema {
685                    id: "GetAllServers".to_string(),
686                    kind: "query".to_string(),
687                    description: Some("All servers".to_string()),
688                    args: vec![],
689                    output_type: "Server[]".to_string(),
690                },
691                OperationSchema {
692                    id: "DeleteServer".to_string(),
693                    kind: "command".to_string(),
694                    description: Some("Delete a server".to_string()),
695                    args: vec![],
696                    output_type: "DeleteServerResult".to_string(),
697                },
698            ]),
699            ..Default::default()
700        }
701    }
702
703    #[tokio::test]
704    async fn tools_list_only_exposes_search_execute_and_connection_status() {
705        let filter = ClientFilters::allow_all();
706        let info = ServerInfo::default();
707        let executor = dummy_executor();
708        let resp = handle_request(make_request("tools/list"), &filter, &executor, &info)
709            .await
710            .expect("response");
711        let tools = resp.result.expect("ok")["tools"]
712            .as_array()
713            .expect("array")
714            .iter()
715            .map(|t| t["name"].as_str().unwrap().to_string())
716            .collect::<std::collections::HashSet<_>>();
717        assert_eq!(
718            tools,
719            std::collections::HashSet::from([
720                CONNECTION_STATUS_TOOL.to_string(),
721                SEARCH_TOOL.to_string(),
722                EXECUTE_TOOL.to_string(),
723            ])
724        );
725    }
726
727    #[tokio::test]
728    async fn search_filters_by_kind_and_query_text() {
729        let filter = ClientFilters::allow_all();
730        let info = info_with_index();
731        let executor = dummy_executor();
732
733        let resp = handle_request(
734            make_tool_call("search", json!({ "kind": "command" })),
735            &filter,
736            &executor,
737            &info,
738        )
739        .await
740        .expect("response");
741        let text = resp.result.expect("ok")["content"][0]["text"]
742            .as_str()
743            .unwrap()
744            .to_string();
745        let parsed: Value = serde_json::from_str(&text).expect("valid JSON content");
746        let ops = parsed["operations"].as_array().expect("array");
747        assert_eq!(ops.len(), 1);
748        assert_eq!(ops[0]["id"], "DeleteServer");
749    }
750
751    #[tokio::test]
752    async fn search_respects_visibility_filter() {
753        // Same glob patterns used to hide per-operation tools before Code
754        // Mode still apply — just checked inside `search` now.
755        let filter = ClientFilters::from_strings(None, Some("command_*"), None, None);
756        let info = info_with_index();
757        let executor = dummy_executor();
758
759        let resp = handle_request(
760            make_tool_call("search", json!({})),
761            &filter,
762            &executor,
763            &info,
764        )
765        .await
766        .expect("response");
767        let text = resp.result.expect("ok")["content"][0]["text"]
768            .as_str()
769            .unwrap()
770            .to_string();
771        let parsed: Value = serde_json::from_str(&text).unwrap();
772        let ids: Vec<&str> = parsed["operations"]
773            .as_array()
774            .unwrap()
775            .iter()
776            .map(|o| o["id"].as_str().unwrap())
777            .collect();
778        assert_eq!(ids, vec!["GetAllServers"]);
779    }
780
781    #[tokio::test]
782    async fn execute_runs_a_script_and_returns_its_value() {
783        let filter = ClientFilters::allow_all();
784        let info = ServerInfo::default();
785        let executor = dummy_executor();
786
787        let resp = handle_request(
788            make_tool_call("execute", json!({ "code": "return 21 * 2;" })),
789            &filter,
790            &executor,
791            &info,
792        )
793        .await
794        .expect("response");
795        let result = resp.result.expect("ok");
796        assert_ne!(result["isError"], json!(true));
797        let text = result["content"][0]["text"].as_str().unwrap();
798        assert_eq!(text.trim(), "42");
799    }
800
801    #[tokio::test]
802    async fn connection_status_identifies_the_server_instance() {
803        let filter = ClientFilters::allow_all();
804        let info = ServerInfo {
805            name: "pulse-ctx".into(),
806            version: "1.2.3".into(),
807            ..Default::default()
808        };
809        let executor = dummy_executor();
810
811        let resp = handle_request(
812            make_tool_call("connection_status", json!({})),
813            &filter,
814            &executor,
815            &info,
816        )
817        .await
818        .expect("response");
819        let text = resp.result.expect("ok")["content"][0]["text"]
820            .as_str()
821            .unwrap()
822            .to_string();
823        let parsed: Value = serde_json::from_str(&text).unwrap();
824        assert_eq!(parsed["name"], "pulse-ctx");
825        assert_eq!(parsed["version"], "1.2.3");
826    }
827
828    #[tokio::test]
829    async fn execute_without_code_argument_is_a_tool_execution_error() {
830        let filter = ClientFilters::allow_all();
831        let info = ServerInfo::default();
832        let executor = dummy_executor();
833
834        let resp = handle_request(
835            make_tool_call("execute", json!({})),
836            &filter,
837            &executor,
838            &info,
839        )
840        .await
841        .expect("response");
842        let result = resp.result.expect("ok");
843        assert_eq!(result["isError"], json!(true));
844    }
845
846    #[tokio::test]
847    async fn hidden_execute_tool_is_a_protocol_error() {
848        let filter = ClientFilters::from_strings(None, Some("execute"), None, None);
849        let info = ServerInfo::default();
850        let executor = dummy_executor();
851
852        let resp = handle_request(
853            make_tool_call("execute", json!({ "code": "return 1;" })),
854            &filter,
855            &executor,
856            &info,
857        )
858        .await
859        .expect("response");
860        assert!(
861            resp.error.is_some(),
862            "denied tool must be a protocol error, not a tool result"
863        );
864    }
865
866    #[tokio::test]
867    async fn op_level_allow_list_scopes_operations_without_hiding_search_and_execute() {
868        // The exact pulse-ctx upgrade scenario: an allow list written for
869        // the old one-tool-per-operation wire shape (op-level patterns,
870        // no explicit "search"/"execute" entry) must still expose
871        // search/execute — and search/execute must still only surface and
872        // allow the operations the list actually names.
873        // Allows only the query, not the command — so the assertion below
874        // actually exercises scoping (not just "search still works").
875        let filter = ClientFilters::from_strings(Some("query_GetAllServers"), None, None, None);
876        let info = info_with_index();
877        let executor = dummy_executor();
878
879        let list_resp = handle_request(make_request("tools/list"), &filter, &executor, &info)
880            .await
881            .expect("response");
882        let tools: Vec<String> = list_resp.result.expect("ok")["tools"]
883            .as_array()
884            .expect("array")
885            .iter()
886            .map(|t| t["name"].as_str().unwrap().to_string())
887            .collect();
888        assert!(
889            tools.contains(&SEARCH_TOOL.to_string()) && tools.contains(&EXECUTE_TOOL.to_string()),
890            "op-level allow list must not hide search/execute, got {tools:?}"
891        );
892
893        let search_resp = handle_request(
894            make_tool_call("search", json!({})),
895            &filter,
896            &executor,
897            &info,
898        )
899        .await
900        .expect("response");
901        let text = search_resp.result.expect("ok")["content"][0]["text"]
902            .as_str()
903            .unwrap()
904            .to_string();
905        let parsed: Value = serde_json::from_str(&text).unwrap();
906        let ids: Vec<&str> = parsed["operations"]
907            .as_array()
908            .unwrap()
909            .iter()
910            .map(|o| o["id"].as_str().unwrap())
911            .collect();
912        assert_eq!(
913            ids,
914            vec!["GetAllServers"],
915            "search must only surface the allow-listed query, not the un-listed DeleteServer command"
916        );
917
918        // execute itself is reachable (op-level allow list doesn't hide
919        // it)...
920        let execute_resp = handle_request(
921            make_tool_call(
922                "execute",
923                json!({ "code": "try { await myko.command('DeleteServer', {id: 'x'}); return 'no-throw'; } catch (e) { return e.message; }" }),
924            ),
925            &filter,
926            &executor,
927            &info,
928        )
929        .await
930        .expect("response");
931        let execute_result = execute_resp.result.expect("ok");
932        assert_ne!(execute_result["isError"], json!(true));
933        let message = execute_result["content"][0]["text"].as_str().unwrap();
934        // ...but the un-listed command it tries to call is still rejected
935        // per-call inside the sandbox.
936        assert_eq!(
937            message.trim(),
938            "\"Unknown operation: command_DeleteServer\""
939        );
940    }
941}