Model Context Protocol in OCaml
1open Mcp 2open Jsonrpc 3open Mcp_sdk 4 5(* Create a proper JSONRPC error with code and data *) 6let create_jsonrpc_error id code message ?data () = 7 let error_code = ErrorCode.to_int code in 8 let error_data = match data with Some d -> d | None -> `Null in 9 create_error ~id ~code:error_code ~message ~data:(Some error_data) () 10 11(* Process initialize request *) 12let handle_initialize server req = 13 Log.debug "Processing initialize request"; 14 let result = 15 match req.JSONRPCMessage.params with 16 | Some params -> 17 let req_data = Initialize.Request.t_of_yojson params in 18 Log.debugf "Client info: %s v%s" req_data.client_info.name 19 req_data.client_info.version; 20 Log.debugf "Client protocol version: %s" req_data.protocol_version; 21 22 (* Create initialize response *) 23 let result = 24 Initialize.Result.create ~capabilities:(capabilities server) 25 ~server_info: 26 Implementation.{ name = name server; version = version server } 27 ~protocol_version:(protocol_version server) 28 ~instructions: 29 (Printf.sprintf "This server provides tools for %s." (name server)) 30 () 31 in 32 Initialize.Result.yojson_of_t result 33 | None -> 34 Log.error "Missing params for initialize request"; 35 `Assoc [ ("error", `String "Missing params for initialize request") ] 36 in 37 Some (create_response ~id:req.id ~result) 38 39(* Process tools/list request *) 40let handle_tools_list server (req : JSONRPCMessage.request) = 41 Log.debug "Processing tools/list request"; 42 let tools_list = Tool.to_rpc_tools_list (tools server) in 43 let response = 44 Mcp_rpc.ToolsList.create_response ~id:req.id ~tools:tools_list () 45 in 46 Some response 47 48(* Process prompts/list request *) 49let handle_prompts_list server (req : JSONRPCMessage.request) = 50 Log.debug "Processing prompts/list request"; 51 let prompts_list = Prompt.to_rpc_prompts_list (prompts server) in 52 let response = 53 Mcp_rpc.PromptsList.create_response ~id:req.id ~prompts:prompts_list () 54 in 55 Some response 56 57(* Process resources/list request *) 58let handle_resources_list server (req : JSONRPCMessage.request) = 59 Log.debug "Processing resources/list request"; 60 let resources_list = Resource.to_rpc_resources_list (resources server) in 61 let response = 62 Mcp_rpc.ResourcesList.create_response ~id:req.id ~resources:resources_list 63 () 64 in 65 Some response 66 67(* Process resources/templates/list request *) 68let handle_resource_templates_list server (req : JSONRPCMessage.request) = 69 Log.debug "Processing resources/templates/list request"; 70 let templates_list = 71 ResourceTemplate.to_rpc_resource_templates_list (resource_templates server) 72 in 73 let response = 74 Mcp_rpc.ListResourceTemplatesResult.create_response ~id:req.id 75 ~resource_templates:templates_list () 76 in 77 Some response 78 79(* Utility module for resource template matching *) 80module Resource_matcher = struct 81 (* Define variants for resource handling result *) 82 type resource_match = 83 | DirectResource of Resource.t * string list 84 | TemplateResource of ResourceTemplate.t * string list 85 | NoMatch 86 87 (* Extract parameters from a template URI *) 88 let extract_template_vars template_uri uri = 89 (* Simple template variable extraction - could be enhanced with regex *) 90 let template_parts = String.split_on_char '/' template_uri in 91 let uri_parts = String.split_on_char '/' uri in 92 93 if List.length template_parts <> List.length uri_parts then None 94 else 95 (* Match parts and extract variables *) 96 let rec match_parts tparts uparts acc = 97 match (tparts, uparts) with 98 | [], [] -> Some (List.rev acc) 99 | th :: tt, uh :: ut -> 100 (* Check if this part is a template variable *) 101 if 102 String.length th > 2 103 && String.get th 0 = '{' 104 && String.get th (String.length th - 1) = '}' 105 then 106 (* Extract variable value and continue *) 107 match_parts tt ut (uh :: acc) 108 else if th = uh then 109 (* Fixed part matches, continue *) 110 match_parts tt ut acc 111 else 112 (* Fixed part doesn't match, fail *) 113 None 114 | _, _ -> None 115 in 116 match_parts template_parts uri_parts [] 117 118 (* Find a matching resource or template for a URI *) 119 let find_match server uri = 120 (* Try direct resource match first *) 121 match 122 List.find_opt 123 (fun resource -> resource.Resource.uri = uri) 124 (resources server) 125 with 126 | Some resource -> DirectResource (resource, []) 127 | None -> 128 (* Try template match next *) 129 let templates = resource_templates server in 130 131 (* Try each template to see if it matches *) 132 let rec try_templates templates = 133 match templates with 134 | [] -> NoMatch 135 | template :: rest -> ( 136 match 137 extract_template_vars template.ResourceTemplate.uri_template uri 138 with 139 | Some params -> TemplateResource (template, params) 140 | None -> try_templates rest) 141 in 142 try_templates templates 143end 144 145(* Process resources/read request *) 146let handle_resources_read server (req : JSONRPCMessage.request) = 147 Log.debug "Processing resources/read request"; 148 match req.JSONRPCMessage.params with 149 | None -> 150 Log.error "Missing params for resources/read request"; 151 Some 152 (create_jsonrpc_error req.id ErrorCode.InvalidParams 153 "Missing params for resources/read request" ()) 154 | Some params -> ( 155 let req_data = Mcp_rpc.ResourcesRead.Request.t_of_yojson params in 156 let uri = req_data.uri in 157 Log.debugf "Resource URI: %s" uri; 158 159 (* Find matching resource or template *) 160 match Resource_matcher.find_match server uri with 161 | Resource_matcher.DirectResource (resource, params) -> ( 162 (* Create context for this request *) 163 let ctx = 164 Context.create ?request_id:(Some req.id) 165 ?progress_token:req.progress_token 166 ~lifespan_context: 167 [ ("resources/read", `Assoc [ ("uri", `String uri) ]) ] 168 () 169 in 170 171 Log.debugf "Handling direct resource: %s" resource.name; 172 173 (* Call the resource handler *) 174 match resource.handler ctx params with 175 | Ok content -> 176 (* Create text resource content *) 177 let mime_type = 178 match resource.mime_type with 179 | Some mime -> mime 180 | None -> "text/plain" 181 in 182 let text_resource = 183 { 184 TextResourceContents.uri; 185 text = content; 186 mime_type = Some mime_type; 187 } 188 in 189 let resource_content = 190 Mcp_rpc.ResourcesRead.ResourceContent.TextResource text_resource 191 in 192 let response = 193 Mcp_rpc.ResourcesRead.create_response ~id:req.id 194 ~contents:[ resource_content ] () 195 in 196 Some response 197 | Error err -> 198 Log.errorf "Error reading resource: %s" err; 199 Some 200 (create_jsonrpc_error req.id ErrorCode.InternalError 201 ("Error reading resource: " ^ err) 202 ())) 203 | Resource_matcher.TemplateResource (template, params) -> ( 204 (* Create context for this request *) 205 let ctx = 206 Context.create ?request_id:(Some req.id) 207 ?progress_token:req.progress_token 208 ~lifespan_context: 209 [ ("resources/read", `Assoc [ ("uri", `String uri) ]) ] 210 () 211 in 212 213 Log.debugf "Handling resource template: %s with params: [%s]" 214 template.name 215 (String.concat ", " params); 216 217 (* Call the template handler *) 218 match template.handler ctx params with 219 | Ok content -> 220 (* Create text resource content *) 221 let mime_type = 222 match template.mime_type with 223 | Some mime -> mime 224 | None -> "text/plain" 225 in 226 let text_resource = 227 { 228 TextResourceContents.uri; 229 text = content; 230 mime_type = Some mime_type; 231 } 232 in 233 let resource_content = 234 Mcp_rpc.ResourcesRead.ResourceContent.TextResource text_resource 235 in 236 let response = 237 Mcp_rpc.ResourcesRead.create_response ~id:req.id 238 ~contents:[ resource_content ] () 239 in 240 Some response 241 | Error err -> 242 Log.errorf "Error reading resource template: %s" err; 243 Some 244 (create_jsonrpc_error req.id ErrorCode.InternalError 245 ("Error reading resource template: " ^ err) 246 ())) 247 | Resource_matcher.NoMatch -> 248 Log.errorf "Resource not found: %s" uri; 249 Some 250 (create_jsonrpc_error req.id ErrorCode.InvalidParams 251 ("Resource not found: " ^ uri) 252 ())) 253 254(* Extract the tool name from params *) 255let extract_tool_name params = 256 match List.assoc_opt "name" params with 257 | Some (`String name) -> 258 Log.debugf "Tool name: %s" name; 259 Some name 260 | _ -> 261 Log.error "Missing or invalid 'name' parameter in tool call"; 262 None 263 264(* Extract the tool arguments from params *) 265let extract_tool_arguments params = 266 match List.assoc_opt "arguments" params with 267 | Some args -> 268 Log.debugf "Tool arguments: %s" (Yojson.Safe.to_string args); 269 args 270 | _ -> 271 Log.debug "No arguments provided for tool call, using empty object"; 272 `Assoc [] (* Empty arguments is valid *) 273 274(* Execute a tool *) 275let execute_tool server ctx name args = 276 try 277 let tool = List.find (fun t -> t.Tool.name = name) (tools server) in 278 Log.debugf "Found tool: %s" name; 279 280 (* Call the tool handler *) 281 match tool.handler ctx args with 282 | Ok result -> 283 Log.debug "Tool execution succeeded"; 284 result 285 | Error err -> Tool.handle_execution_error err 286 with 287 | Not_found -> Tool.handle_unknown_tool_error name 288 | exn -> Tool.handle_execution_exception exn 289 290(* Convert JSON tool result to RPC content format *) 291let json_to_rpc_content json = 292 match json with 293 | `Assoc fields -> ( 294 match 295 (List.assoc_opt "content" fields, List.assoc_opt "isError" fields) 296 with 297 | Some (`List content_items), Some (`Bool is_error) -> 298 let mcp_content = List.map Mcp.content_of_yojson content_items in 299 let rpc_content = Tool.mcp_content_to_rpc_content mcp_content in 300 (rpc_content, is_error) 301 | _ -> 302 (* Fallback for compatibility with older formats *) 303 let text = Yojson.Safe.to_string json in 304 let text_content = { TextContent.text; annotations = None } in 305 ([ Mcp_rpc.ToolsCall.ToolContent.Text text_content ], false)) 306 | _ -> 307 (* Simple fallback for non-object results *) 308 let text = Yojson.Safe.to_string json in 309 let text_content = { TextContent.text; annotations = None } in 310 ([ Mcp_rpc.ToolsCall.ToolContent.Text text_content ], false) 311 312(* Process tools/call request *) 313let handle_tools_call server req = 314 Log.debug "Processing tools/call request"; 315 match req.JSONRPCMessage.params with 316 | Some (`Assoc params) -> ( 317 match extract_tool_name params with 318 | Some name -> 319 let args = extract_tool_arguments params in 320 321 (* Create context for this request *) 322 let ctx = 323 Context.create ?request_id:(Some req.id) 324 ?progress_token:req.progress_token 325 ~lifespan_context:[ ("tools/call", `Assoc params) ] 326 () 327 in 328 329 (* Execute the tool *) 330 let result_json = execute_tool server ctx name args in 331 332 (* Convert JSON result to RPC format *) 333 let content, is_error = json_to_rpc_content result_json in 334 335 (* Create the RPC response *) 336 let response = 337 Mcp_rpc.ToolsCall.create_response ~id:req.id ~content ~is_error () 338 in 339 340 Some response 341 | None -> 342 Some 343 (create_jsonrpc_error req.id InvalidParams 344 "Missing tool name parameter" ())) 345 | _ -> 346 Log.error "Invalid params format for tools/call"; 347 Some 348 (create_jsonrpc_error req.id InvalidParams 349 "Invalid params format for tools/call" ()) 350 351(* Process ping request *) 352let handle_ping (req : JSONRPCMessage.request) = 353 Log.debug "Processing ping request"; 354 Some (create_response ~id:req.JSONRPCMessage.id ~result:(`Assoc [])) 355 356(* Handle notifications/initialized *) 357let handle_initialized (notif : JSONRPCMessage.notification) = 358 Log.debug 359 "Client initialization complete - Server is now ready to receive requests"; 360 Log.debugf "Notification params: %s" 361 (match notif.JSONRPCMessage.params with 362 | Some p -> Yojson.Safe.to_string p 363 | None -> "null"); 364 None 365 366(* Process a single message using the MCP SDK *) 367let process_message server message = 368 try 369 Log.debugf "Processing message: %s" (Yojson.Safe.to_string message); 370 match JSONRPCMessage.t_of_yojson message with 371 | JSONRPCMessage.Request req -> ( 372 Log.debugf "Received request with method: %s" 373 (Method.to_string req.meth); 374 match req.meth with 375 | Method.Initialize -> handle_initialize server req 376 | Method.ToolsList -> handle_tools_list server req 377 | Method.ToolsCall -> handle_tools_call server req 378 | Method.PromptsList -> handle_prompts_list server req 379 | Method.ResourcesList -> handle_resources_list server req 380 | Method.ResourcesRead -> handle_resources_read server req 381 | Method.ResourceTemplatesList -> 382 handle_resource_templates_list server req 383 | _ -> 384 Log.errorf "Unknown method received: %s" (Method.to_string req.meth); 385 Some 386 (create_jsonrpc_error req.id ErrorCode.MethodNotFound 387 ("Method not found: " ^ Method.to_string req.meth) 388 ())) 389 | JSONRPCMessage.Notification notif -> ( 390 Log.debugf "Received notification with method: %s" 391 (Method.to_string notif.meth); 392 match notif.meth with 393 | Method.Initialized -> handle_initialized notif 394 | _ -> 395 Log.debugf "Ignoring notification: %s" (Method.to_string notif.meth); 396 None) 397 | JSONRPCMessage.Response _ -> 398 Log.error "Unexpected response message received"; 399 None 400 | JSONRPCMessage.Error _ -> 401 Log.error "Unexpected error message received"; 402 None 403 with 404 | Json.Of_json (msg, _) -> 405 Log.errorf "JSON error: %s" msg; 406 (* Can't respond with error because we don't have a request ID *) 407 None 408 | Yojson.Json_error msg -> 409 Log.errorf "JSON parse error: %s" msg; 410 (* Can't respond with error because we don't have a request ID *) 411 None 412 | exc -> 413 Log.errorf "Exception during message processing: %s" 414 (Printexc.to_string exc); 415 Log.errorf "Backtrace: %s" (Printexc.get_backtrace ()); 416 Log.errorf "Message was: %s" (Yojson.Safe.to_string message); 417 None 418 419(* Extract a request ID from a potentially malformed message *) 420let extract_request_id json = 421 try 422 match json with 423 | `Assoc fields -> ( 424 match List.assoc_opt "id" fields with 425 | Some (`Int id) -> Some (`Int id) 426 | Some (`String id) -> Some (`String id) 427 | _ -> None) 428 | _ -> None 429 with _ -> None 430 431(* Handle processing for an input line *) 432let process_input_line server line = 433 if line = "" then ( 434 Log.debug "Empty line received, ignoring"; 435 None) 436 else ( 437 Log.debugf "Raw input: %s" line; 438 try 439 let json = Yojson.Safe.from_string line in 440 Log.debug "Successfully parsed JSON"; 441 442 (* Process the message *) 443 process_message server json 444 with Yojson.Json_error msg -> 445 Log.errorf "Error parsing JSON: %s" msg; 446 Log.errorf "Input was: %s" line; 447 None) 448 449(* Send a response to the client *) 450let send_response stdout response = 451 let response_json = JSONRPCMessage.yojson_of_t response in 452 let response_str = Yojson.Safe.to_string response_json in 453 Log.debugf "Sending response: %s" response_str; 454 455 (* Write the response followed by a newline *) 456 Eio.Flow.copy_string response_str stdout; 457 Eio.Flow.copy_string "\n" stdout 458 459(* Run the MCP server with the given server configuration *) 460let callback mcp_server _conn (request : Http.Request.t) body = 461 match request.meth with 462 | `POST -> ( 463 Log.debug "Received POST request"; 464 let request_body_str = 465 Eio.Buf_read.(parse_exn take_all) body ~max_size:max_int 466 in 467 match process_input_line mcp_server request_body_str with 468 | Some mcp_response -> 469 let response_json = JSONRPCMessage.yojson_of_t mcp_response in 470 let response_str = Yojson.Safe.to_string response_json in 471 Log.debugf "Sending MCP response: %s" response_str; 472 let headers = 473 Http.Header.of_list [ ("Content-Type", "application/json") ] 474 in 475 Cohttp_eio.Server.respond ~status:`OK ~headers 476 ~body:(Cohttp_eio.Body.of_string response_str) 477 () 478 | None -> 479 Log.debug "No MCP response needed"; 480 Cohttp_eio.Server.respond ~status:`No_content 481 ~body:(Cohttp_eio.Body.of_string "") 482 ()) 483 | _ -> 484 Log.infof "Unsupported method: %s" (Http.Method.to_string request.meth); 485 Cohttp_eio.Server.respond ~status:`Method_not_allowed 486 ~body:(Cohttp_eio.Body.of_string "Only POST is supported") 487 () 488 489let log_warning ex = Logs.warn (fun f -> f "%a" Eio.Exn.pp ex) 490 491(** run the server using http transport *) 492let run_server ?(port = 8080) ?(on_error = log_warning) env server = 493 let net = Eio.Stdenv.net env in 494 let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in 495 496 Log.debugf "Starting MCP server: %s v%s" (name server) (version server); 497 Log.debugf "Protocol version: %s" (protocol_version server); 498 499 Eio.Switch.run @@ fun sw -> 500 let server_spec = Cohttp_eio.Server.make ~callback:(callback server) () in 501 502 let server_socket = 503 Eio.Net.listen net ~sw ~backlog:128 ~reuse_addr:true addr 504 in 505 Log.infof "MCP HTTP Server listening on http://localhost:%d" port; 506 507 Cohttp_eio.Server.run server_socket server_spec ~on_error 508 509(** run the server using the stdio transport *) 510let run_sdtio_server env server = 511 let stdin = Eio.Stdenv.stdin env in 512 let stdout = Eio.Stdenv.stdout env in 513 514 Log.debugf "Starting MCP server: %s v%s" (name server) (version server); 515 Log.debugf "Protocol version: %s" (protocol_version server); 516 517 (* Enable exception backtraces *) 518 Printexc.record_backtrace true; 519 520 let buf = Eio.Buf_read.of_flow stdin ~initial_size:100 ~max_size:1_000_000 in 521 522 (* Main processing loop *) 523 try 524 while true do 525 Log.debug "Waiting for message..."; 526 let line = Eio.Buf_read.line buf in 527 528 (* Process the input and send response if needed *) 529 match process_input_line server line with 530 | Some response -> send_response stdout response 531 | None -> Log.debug "No response needed for this message" 532 done 533 with 534 | End_of_file -> 535 Log.debug "End of file received on stdin"; 536 () 537 | Eio.Exn.Io _ as exn -> 538 Log.errorf "I/O error while reading: %s" (Printexc.to_string exn); 539 () 540 | exn -> 541 Log.errorf "Exception while reading: %s" (Printexc.to_string exn); 542 ()