OCaml Claude SDK using Eio and Jsont
at main 572 lines 21 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** Consolidated unit tests for the Claude OCaml SDK. 7 8 This test suite covers: 9 - Protocol message encoding/decoding 10 - Tool module for custom tool definitions 11 - Mcp_server module for in-process MCP servers 12 - Structured error handling *) 13 14module J = Jsont.Json 15 16(* ============================================ 17 Protocol Tests - Incoming message codec 18 ============================================ *) 19 20let test_decode_user_message () = 21 (* User messages from CLI come wrapped in a "message" envelope *) 22 let json_str = {|{"type":"user","message":{"content":"Hello"}}|} in 23 match Jsont_bytesrw.decode_string' Claude.Proto.Incoming.jsont json_str with 24 | Ok (Claude.Proto.Incoming.Message (Claude.Proto.Message.User _)) -> () 25 | Ok _ -> Alcotest.fail "Wrong message type decoded" 26 | Error err -> Alcotest.fail (Jsont.Error.to_string err) 27 28let test_decode_assistant_message () = 29 (* Assistant messages from CLI come wrapped in a "message" envelope *) 30 let json_str = 31 {|{"type":"assistant","message":{"model":"claude-sonnet-4","content":[{"type":"text","text":"Hi"}]}}|} 32 in 33 match Jsont_bytesrw.decode_string' Claude.Proto.Incoming.jsont json_str with 34 | Ok (Claude.Proto.Incoming.Message (Claude.Proto.Message.Assistant _)) -> () 35 | Ok _ -> Alcotest.fail "Wrong message type decoded" 36 | Error err -> Alcotest.fail (Jsont.Error.to_string err) 37 38let test_decode_system_message () = 39 let json_str = 40 {|{"type":"system","subtype":"init","data":{"session_id":"test-123"}}|} 41 in 42 match Jsont_bytesrw.decode_string' Claude.Proto.Incoming.jsont json_str with 43 | Ok (Claude.Proto.Incoming.Message (Claude.Proto.Message.System _)) -> () 44 | Ok _ -> Alcotest.fail "Wrong message type decoded" 45 | Error err -> Alcotest.fail (Jsont.Error.to_string err) 46 47let test_decode_control_response_success () = 48 let json_str = 49 {|{"type":"control_response","response":{"subtype":"success","requestId":"test-req-1"}}|} 50 in 51 match Jsont_bytesrw.decode_string' Claude.Proto.Incoming.jsont json_str with 52 | Ok (Claude.Proto.Incoming.Control_response resp) -> ( 53 match resp.response with 54 | Claude.Proto.Control.Response.Success s -> 55 Alcotest.(check string) "request_id" "test-req-1" s.request_id 56 | Claude.Proto.Control.Response.Error _ -> 57 Alcotest.fail "Got error response instead of success") 58 | Ok _ -> Alcotest.fail "Wrong message type decoded" 59 | Error err -> Alcotest.fail (Jsont.Error.to_string err) 60 61let test_decode_control_response_error () = 62 let json_str = 63 {|{"type":"control_response","response":{"subtype":"error","requestId":"test-req-2","error":{"code":-32603,"message":"Something went wrong"}}}|} 64 in 65 match Jsont_bytesrw.decode_string' Claude.Proto.Incoming.jsont json_str with 66 | Ok (Claude.Proto.Incoming.Control_response resp) -> ( 67 match resp.response with 68 | Claude.Proto.Control.Response.Error e -> 69 Alcotest.(check string) "request_id" "test-req-2" e.request_id; 70 Alcotest.(check int) "error code" (-32603) e.error.code; 71 Alcotest.(check string) 72 "error message" "Something went wrong" e.error.message 73 | Claude.Proto.Control.Response.Success _ -> 74 Alcotest.fail "Got success response instead of error") 75 | Ok _ -> Alcotest.fail "Wrong message type decoded" 76 | Error err -> Alcotest.fail (Jsont.Error.to_string err) 77 78let protocol_tests = 79 [ 80 Alcotest.test_case "decode user message" `Quick test_decode_user_message; 81 Alcotest.test_case "decode assistant message" `Quick 82 test_decode_assistant_message; 83 Alcotest.test_case "decode system message" `Quick test_decode_system_message; 84 Alcotest.test_case "decode control response success" `Quick 85 test_decode_control_response_success; 86 Alcotest.test_case "decode control response error" `Quick 87 test_decode_control_response_error; 88 ] 89 90(* ============================================ 91 Tool Module Tests 92 ============================================ *) 93 94let json_testable = 95 Alcotest.testable 96 (fun fmt json -> 97 match Jsont_bytesrw.encode_string' Jsont.json json with 98 | Ok s -> Format.pp_print_string fmt s 99 | Error e -> Format.pp_print_string fmt (Jsont.Error.to_string e)) 100 (fun a b -> 101 match 102 ( Jsont_bytesrw.encode_string' Jsont.json a, 103 Jsont_bytesrw.encode_string' Jsont.json b ) 104 with 105 | Ok sa, Ok sb -> String.equal sa sb 106 | _ -> false) 107 108let test_tool_schema_string () = 109 let schema = Claude.Tool.schema_string in 110 let expected = J.object' [ J.mem (J.name "type") (J.string "string") ] in 111 Alcotest.check json_testable "schema_string" expected schema 112 113let test_tool_schema_int () = 114 let schema = Claude.Tool.schema_int in 115 let expected = J.object' [ J.mem (J.name "type") (J.string "integer") ] in 116 Alcotest.check json_testable "schema_int" expected schema 117 118let test_tool_schema_number () = 119 let schema = Claude.Tool.schema_number in 120 let expected = J.object' [ J.mem (J.name "type") (J.string "number") ] in 121 Alcotest.check json_testable "schema_number" expected schema 122 123let test_tool_schema_bool () = 124 let schema = Claude.Tool.schema_bool in 125 let expected = J.object' [ J.mem (J.name "type") (J.string "boolean") ] in 126 Alcotest.check json_testable "schema_bool" expected schema 127 128let test_tool_schema_array () = 129 let schema = Claude.Tool.schema_array Claude.Tool.schema_string in 130 let expected = 131 J.object' 132 [ 133 J.mem (J.name "type") (J.string "array"); 134 J.mem (J.name "items") 135 (J.object' [ J.mem (J.name "type") (J.string "string") ]); 136 ] 137 in 138 Alcotest.check json_testable "schema_array" expected schema 139 140let test_tool_schema_string_enum () = 141 let schema = Claude.Tool.schema_string_enum [ "foo"; "bar"; "baz" ] in 142 let expected = 143 J.object' 144 [ 145 J.mem (J.name "type") (J.string "string"); 146 J.mem (J.name "enum") 147 (J.list [ J.string "foo"; J.string "bar"; J.string "baz" ]); 148 ] 149 in 150 Alcotest.check json_testable "schema_string_enum" expected schema 151 152let test_tool_schema_object () = 153 let schema = 154 Claude.Tool.schema_object 155 [ ("name", Claude.Tool.schema_string); ("age", Claude.Tool.schema_int) ] 156 ~required:[ "name" ] 157 in 158 let expected = 159 J.object' 160 [ 161 J.mem (J.name "type") (J.string "object"); 162 J.mem (J.name "properties") 163 (J.object' 164 [ 165 J.mem (J.name "name") 166 (J.object' [ J.mem (J.name "type") (J.string "string") ]); 167 J.mem (J.name "age") 168 (J.object' [ J.mem (J.name "type") (J.string "integer") ]); 169 ]); 170 J.mem (J.name "required") (J.list [ J.string "name" ]); 171 ] 172 in 173 Alcotest.check json_testable "schema_object" expected schema 174 175let test_tool_text_result () = 176 let result = Claude.Tool.text_result "Hello, world!" in 177 let expected = 178 J.list 179 [ 180 J.object' 181 [ 182 J.mem (J.name "type") (J.string "text"); 183 J.mem (J.name "text") (J.string "Hello, world!"); 184 ]; 185 ] 186 in 187 Alcotest.check json_testable "text_result" expected result 188 189let test_tool_error_result () = 190 let result = Claude.Tool.error_result "Something went wrong" in 191 let expected = 192 J.list 193 [ 194 J.object' 195 [ 196 J.mem (J.name "type") (J.string "text"); 197 J.mem (J.name "text") (J.string "Something went wrong"); 198 J.mem (J.name "is_error") (J.bool true); 199 ]; 200 ] 201 in 202 Alcotest.check json_testable "error_result" expected result 203 204let test_tool_create_and_call () = 205 let greet = 206 Claude.Tool.create ~name:"greet" ~description:"Greet a user" 207 ~input_schema: 208 (Claude.Tool.schema_object 209 [ ("name", Claude.Tool.schema_string) ] 210 ~required:[ "name" ]) 211 ~handler:(fun args -> 212 match Claude.Tool_input.get_string args "name" with 213 | Some name -> Ok (Claude.Tool.text_result ("Hello, " ^ name ^ "!")) 214 | None -> Error "Missing name parameter") 215 in 216 Alcotest.(check string) "tool name" "greet" (Claude.Tool.name greet); 217 Alcotest.(check string) 218 "tool description" "Greet a user" 219 (Claude.Tool.description greet); 220 221 (* Test successful call *) 222 let input_json = J.object' [ J.mem (J.name "name") (J.string "Alice") ] in 223 let input = Claude.Tool_input.of_json input_json in 224 match Claude.Tool.call greet input with 225 | Ok result -> 226 let expected = Claude.Tool.text_result "Hello, Alice!" in 227 Alcotest.check json_testable "call result" expected result 228 | Error msg -> Alcotest.fail msg 229 230let test_tool_call_error () = 231 let tool = 232 Claude.Tool.create ~name:"fail" ~description:"Always fails" 233 ~input_schema:(Claude.Tool.schema_object [] ~required:[]) 234 ~handler:(fun _ -> Error "Intentional failure") 235 in 236 let input = Claude.Tool_input.of_json (J.object' []) in 237 match Claude.Tool.call tool input with 238 | Ok _ -> Alcotest.fail "Expected error" 239 | Error msg -> 240 Alcotest.(check string) "error message" "Intentional failure" msg 241 242let tool_tests = 243 [ 244 Alcotest.test_case "schema_string" `Quick test_tool_schema_string; 245 Alcotest.test_case "schema_int" `Quick test_tool_schema_int; 246 Alcotest.test_case "schema_number" `Quick test_tool_schema_number; 247 Alcotest.test_case "schema_bool" `Quick test_tool_schema_bool; 248 Alcotest.test_case "schema_array" `Quick test_tool_schema_array; 249 Alcotest.test_case "schema_string_enum" `Quick test_tool_schema_string_enum; 250 Alcotest.test_case "schema_object" `Quick test_tool_schema_object; 251 Alcotest.test_case "text_result" `Quick test_tool_text_result; 252 Alcotest.test_case "error_result" `Quick test_tool_error_result; 253 Alcotest.test_case "create and call" `Quick test_tool_create_and_call; 254 Alcotest.test_case "call error" `Quick test_tool_call_error; 255 ] 256 257(* ============================================ 258 Mcp_server Module Tests 259 ============================================ *) 260 261let test_mcp_server_create () = 262 let tool = 263 Claude.Tool.create ~name:"echo" ~description:"Echo input" 264 ~input_schema: 265 (Claude.Tool.schema_object 266 [ ("text", Claude.Tool.schema_string) ] 267 ~required:[ "text" ]) 268 ~handler:(fun args -> 269 match Claude.Tool_input.get_string args "text" with 270 | Some text -> Ok (Claude.Tool.text_result text) 271 | None -> Error "Missing text") 272 in 273 let server = 274 Claude.Mcp_server.create ~name:"test-server" ~version:"2.0.0" 275 ~tools:[ tool ] () 276 in 277 Alcotest.(check string) 278 "server name" "test-server" 279 (Claude.Mcp_server.name server); 280 Alcotest.(check string) 281 "server version" "2.0.0" 282 (Claude.Mcp_server.version server); 283 Alcotest.(check int) 284 "tools count" 1 285 (List.length (Claude.Mcp_server.tools server)) 286 287let test_mcp_server_initialize () = 288 let server = Claude.Mcp_server.create ~name:"init-test" ~tools:[] () in 289 let request = 290 J.object' 291 [ 292 J.mem (J.name "jsonrpc") (J.string "2.0"); 293 J.mem (J.name "id") (J.number 1.0); 294 J.mem (J.name "method") (J.string "initialize"); 295 J.mem (J.name "params") (J.object' []); 296 ] 297 in 298 let response = Claude.Mcp_server.handle_json_message server request in 299 (* Check it's a success response with serverInfo *) 300 match response with 301 | Jsont.Object (mems, _) -> 302 let has_result = List.exists (fun ((k, _), _) -> k = "result") mems in 303 Alcotest.(check bool) "has result" true has_result 304 | _ -> Alcotest.fail "Expected object response" 305 306let test_mcp_server_tools_list () = 307 let tool = 308 Claude.Tool.create ~name:"my_tool" ~description:"My test tool" 309 ~input_schema:(Claude.Tool.schema_object [] ~required:[]) 310 ~handler:(fun _ -> Ok (Claude.Tool.text_result "ok")) 311 in 312 let server = Claude.Mcp_server.create ~name:"list-test" ~tools:[ tool ] () in 313 let request = 314 J.object' 315 [ 316 J.mem (J.name "jsonrpc") (J.string "2.0"); 317 J.mem (J.name "id") (J.number 2.0); 318 J.mem (J.name "method") (J.string "tools/list"); 319 J.mem (J.name "params") (J.object' []); 320 ] 321 in 322 let response = Claude.Mcp_server.handle_json_message server request in 323 match response with 324 | Jsont.Object (mems, _) -> ( 325 match List.find_opt (fun ((k, _), _) -> k = "result") mems with 326 | Some (_, Jsont.Object (result_mems, _)) -> ( 327 match List.find_opt (fun ((k, _), _) -> k = "tools") result_mems with 328 | Some (_, Jsont.Array (tools, _)) -> 329 Alcotest.(check int) "tools count" 1 (List.length tools) 330 | _ -> Alcotest.fail "Missing tools in result") 331 | _ -> Alcotest.fail "Missing result in response") 332 | _ -> Alcotest.fail "Expected object response" 333 334let test_mcp_server_tools_call () = 335 let tool = 336 Claude.Tool.create ~name:"uppercase" ~description:"Convert to uppercase" 337 ~input_schema: 338 (Claude.Tool.schema_object 339 [ ("text", Claude.Tool.schema_string) ] 340 ~required:[ "text" ]) 341 ~handler:(fun args -> 342 match Claude.Tool_input.get_string args "text" with 343 | Some text -> 344 Ok (Claude.Tool.text_result (String.uppercase_ascii text)) 345 | None -> Error "Missing text") 346 in 347 let server = Claude.Mcp_server.create ~name:"call-test" ~tools:[ tool ] () in 348 let request = 349 J.object' 350 [ 351 J.mem (J.name "jsonrpc") (J.string "2.0"); 352 J.mem (J.name "id") (J.number 3.0); 353 J.mem (J.name "method") (J.string "tools/call"); 354 J.mem (J.name "params") 355 (J.object' 356 [ 357 J.mem (J.name "name") (J.string "uppercase"); 358 J.mem (J.name "arguments") 359 (J.object' [ J.mem (J.name "text") (J.string "hello") ]); 360 ]); 361 ] 362 in 363 let response = Claude.Mcp_server.handle_json_message server request in 364 (* Verify it contains the expected uppercase result *) 365 let response_str = 366 match Jsont_bytesrw.encode_string' Jsont.json response with 367 | Ok s -> s 368 | Error _ -> "" 369 in 370 (* Simple substring check for HELLO in response *) 371 let contains_hello = 372 let rec check i = 373 if i + 5 > String.length response_str then false 374 else if String.sub response_str i 5 = "HELLO" then true 375 else check (i + 1) 376 in 377 check 0 378 in 379 Alcotest.(check bool) "contains HELLO" true contains_hello 380 381let test_mcp_server_tool_not_found () = 382 let server = Claude.Mcp_server.create ~name:"notfound-test" ~tools:[] () in 383 let request = 384 J.object' 385 [ 386 J.mem (J.name "jsonrpc") (J.string "2.0"); 387 J.mem (J.name "id") (J.number 4.0); 388 J.mem (J.name "method") (J.string "tools/call"); 389 J.mem (J.name "params") 390 (J.object' [ J.mem (J.name "name") (J.string "nonexistent") ]); 391 ] 392 in 393 let response = Claude.Mcp_server.handle_json_message server request in 394 (* Should return an error response *) 395 match response with 396 | Jsont.Object (mems, _) -> 397 let has_error = List.exists (fun ((k, _), _) -> k = "error") mems in 398 Alcotest.(check bool) "has error" true has_error 399 | _ -> Alcotest.fail "Expected object response" 400 401let test_mcp_server_method_not_found () = 402 let server = 403 Claude.Mcp_server.create ~name:"method-notfound-test" ~tools:[] () 404 in 405 let request = 406 J.object' 407 [ 408 J.mem (J.name "jsonrpc") (J.string "2.0"); 409 J.mem (J.name "id") (J.number 5.0); 410 J.mem (J.name "method") (J.string "unknown/method"); 411 J.mem (J.name "params") (J.object' []); 412 ] 413 in 414 let response = Claude.Mcp_server.handle_json_message server request in 415 match response with 416 | Jsont.Object (mems, _) -> 417 let has_error = List.exists (fun ((k, _), _) -> k = "error") mems in 418 Alcotest.(check bool) "has error" true has_error 419 | _ -> Alcotest.fail "Expected object response" 420 421let mcp_server_tests = 422 [ 423 Alcotest.test_case "create server" `Quick test_mcp_server_create; 424 Alcotest.test_case "initialize" `Quick test_mcp_server_initialize; 425 Alcotest.test_case "tools/list" `Quick test_mcp_server_tools_list; 426 Alcotest.test_case "tools/call" `Quick test_mcp_server_tools_call; 427 Alcotest.test_case "tool not found" `Quick test_mcp_server_tool_not_found; 428 Alcotest.test_case "method not found" `Quick 429 test_mcp_server_method_not_found; 430 ] 431 432(* ============================================ 433 Structured Error Tests 434 ============================================ *) 435 436let test_error_detail_creation () = 437 let error = 438 Claude.Proto.Control.Response.error_detail ~code:`Method_not_found 439 ~message:"Method not found" () 440 in 441 Alcotest.(check int) "error code" (-32601) error.code; 442 Alcotest.(check string) "error message" "Method not found" error.message 443 444let test_error_code_conventions () = 445 let codes = 446 [ 447 (`Parse_error, -32700); 448 (`Invalid_request, -32600); 449 (`Method_not_found, -32601); 450 (`Invalid_params, -32602); 451 (`Internal_error, -32603); 452 (`Custom 1, 1); 453 ] 454 in 455 List.iter 456 (fun (code, expected_int) -> 457 let err = 458 Claude.Proto.Control.Response.error_detail ~code ~message:"test" () 459 in 460 Alcotest.(check int) "error code value" expected_int err.code) 461 codes 462 463let test_error_response_encoding () = 464 let error_detail = 465 Claude.Proto.Control.Response.error_detail ~code:`Invalid_params 466 ~message:"Invalid parameters" () 467 in 468 let error_resp = 469 Claude.Proto.Control.Response.error ~request_id:"test-123" 470 ~error:error_detail () 471 in 472 match Jsont.Json.encode Claude.Proto.Control.Response.jsont error_resp with 473 | Ok json -> ( 474 match Jsont.Json.decode Claude.Proto.Control.Response.jsont json with 475 | Ok (Claude.Proto.Control.Response.Error decoded) -> 476 Alcotest.(check string) "request_id" "test-123" decoded.request_id; 477 Alcotest.(check int) "error code" (-32602) decoded.error.code; 478 Alcotest.(check string) 479 "error message" "Invalid parameters" decoded.error.message 480 | Ok _ -> Alcotest.fail "Wrong response type decoded" 481 | Error e -> Alcotest.fail e) 482 | Error e -> Alcotest.fail e 483 484let structured_error_tests = 485 [ 486 Alcotest.test_case "error detail creation" `Quick test_error_detail_creation; 487 Alcotest.test_case "error code conventions" `Quick 488 test_error_code_conventions; 489 Alcotest.test_case "error response encoding" `Quick 490 test_error_response_encoding; 491 ] 492 493(* ============================================ 494 Tool_input Tests 495 ============================================ *) 496 497let test_tool_input_get_string () = 498 let json = J.object' [ J.mem (J.name "foo") (J.string "bar") ] in 499 let input = Claude.Tool_input.of_json json in 500 Alcotest.(check (option string)) 501 "get_string foo" (Some "bar") 502 (Claude.Tool_input.get_string input "foo"); 503 Alcotest.(check (option string)) 504 "get_string missing" None 505 (Claude.Tool_input.get_string input "missing") 506 507let test_tool_input_get_int () = 508 let json = J.object' [ J.mem (J.name "count") (J.number 42.0) ] in 509 let input = Claude.Tool_input.of_json json in 510 Alcotest.(check (option int)) 511 "get_int count" (Some 42) 512 (Claude.Tool_input.get_int input "count") 513 514let test_tool_input_get_float () = 515 let json = J.object' [ J.mem (J.name "pi") (J.number 3.14159) ] in 516 let input = Claude.Tool_input.of_json json in 517 match Claude.Tool_input.get_float input "pi" with 518 | Some f -> 519 Alcotest.(check bool) 520 "get_float pi approx" true 521 (abs_float (f -. 3.14159) < 0.0001) 522 | None -> Alcotest.fail "Expected float" 523 524let test_tool_input_get_bool () = 525 let json = 526 J.object' 527 [ J.mem (J.name "yes") (J.bool true); J.mem (J.name "no") (J.bool false) ] 528 in 529 let input = Claude.Tool_input.of_json json in 530 Alcotest.(check (option bool)) 531 "get_bool yes" (Some true) 532 (Claude.Tool_input.get_bool input "yes"); 533 Alcotest.(check (option bool)) 534 "get_bool no" (Some false) 535 (Claude.Tool_input.get_bool input "no") 536 537let test_tool_input_get_string_list () = 538 let json = 539 J.object' 540 [ 541 J.mem (J.name "items") 542 (J.list [ J.string "a"; J.string "b"; J.string "c" ]); 543 ] 544 in 545 let input = Claude.Tool_input.of_json json in 546 Alcotest.(check (option (list string))) 547 "get_string_list" 548 (Some [ "a"; "b"; "c" ]) 549 (Claude.Tool_input.get_string_list input "items") 550 551let tool_input_tests = 552 [ 553 Alcotest.test_case "get_string" `Quick test_tool_input_get_string; 554 Alcotest.test_case "get_int" `Quick test_tool_input_get_int; 555 Alcotest.test_case "get_float" `Quick test_tool_input_get_float; 556 Alcotest.test_case "get_bool" `Quick test_tool_input_get_bool; 557 Alcotest.test_case "get_string_list" `Quick test_tool_input_get_string_list; 558 ] 559 560(* ============================================ 561 Main test runner 562 ============================================ *) 563 564let () = 565 Alcotest.run "Claude SDK" 566 [ 567 ("Protocol", protocol_tests); 568 ("Tool", tool_tests); 569 ("Mcp_server", mcp_server_tests); 570 ("Structured errors", structured_error_tests); 571 ("Tool_input", tool_input_tests); 572 ]