1use 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#[derive(Debug, Clone)]
54pub struct ServerInfo {
55 pub name: String,
56 pub version: String,
57 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
73pub 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 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 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 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 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
287fn 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 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}