1use 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#[derive(Debug, Clone)]
86pub struct ServerInfo {
87 pub name: String,
88 pub version: String,
89 pub instructions: Option<String>,
93 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
112pub 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 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 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 _ => 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 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 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 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 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 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 assert_eq!(
937 message.trim(),
938 "\"Unknown operation: command_DeleteServer\""
939 );
940 }
941}