OCaml Claude SDK using Eio and Jsont
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 ]