v / vlib / mcp / server_test.v
965 lines · 874 sloc · 31.05 KB · d1b46e92e1faa3a6eb943070f508920306158ac6
Raw
1module mcp
2
3import net.http
4import time
5
6fn test_server_routes_initialize_and_registered_features() {
7 mut server := new_server(
8 name: 'test-server'
9 version: '1.2.3'
10 instructions: 'Be precise.'
11 )
12 server.add_tool(Tool{
13 name: 'say_hello'
14 description: 'Returns a greeting'
15 }, fn (ctx Context, arguments string) !ToolResult {
16 assert ctx.session_id == stdio_session_id
17 assert ctx.transport == .stdio
18 assert arguments == '{"name":"V"}'
19 return tool_text_result('Hello, V!')
20 })!
21 server.add_resource(Resource{
22 uri: 'resource://guide'
23 name: 'guide'
24 mime_type: 'text/plain'
25 }, fn (_ Context, uri string) !ReadResourceResult {
26 return ReadResourceResult{
27 contents: [
28 ResourceContents{
29 uri: uri
30 mime_type: 'text/plain'
31 text: 'guide contents'
32 },
33 ]
34 }
35 })!
36 server.add_resource_template(ResourceTemplate{
37 uri_template: 'resource://docs/{slug}'
38 name: 'docs'
39 })!
40 server.add_prompt(Prompt{
41 name: 'review'
42 description: 'Review some code'
43 arguments: [
44 PromptArgument{
45 name: 'code'
46 required: true
47 },
48 ]
49 }, fn (_ Context, arguments string) !GetPromptResult {
50 assert arguments == '{"code":"fn main() {}"}'
51 return GetPromptResult{
52 description: 'Review prompt'
53 messages: [
54 prompt_text_message('user', 'Review this code'),
55 ]
56 }
57 })!
58
59 init_request := Request{
60 id: encode_id(1)
61 method: 'initialize'
62 params: encode_initialize_params(InitializeParams{
63 protocol_version: protocol_version
64 capabilities: '{"roots":{}}'
65 client_info: Implementation{
66 name: 'test-client'
67 version: '0.1.0'
68 }
69 })
70 }
71 init_dispatch := server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)!
72 assert init_dispatch.has_response
73 init_response := decode_response(init_dispatch.response)!
74 init_result := init_response.decode_result[InitializeResult]()!
75 assert init_result.server_info.name == 'test-server'
76 assert init_result.instructions == 'Be precise.'
77 assert init_result.capabilities == '{"tools":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"prompts":{"listChanged":true}}'
78
79 blocked_dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
80 stdio_session_id, .stdio)!
81 blocked_response := decode_response(blocked_dispatch.response)!
82 assert blocked_response.error.code == server_not_initialized.code
83
84 initialized := server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
85 stdio_session_id, .stdio)!
86 assert !initialized.has_response
87
88 tools_list := server.dispatch_message(new_request(3, 'tools/list', empty).encode(),
89 stdio_session_id, .stdio)!
90 tools_result := decode_response(tools_list.response)!.decode_result[ListToolsResult]()!
91 assert tools_result.tools.len == 1
92 assert tools_result.tools[0].name == 'say_hello'
93
94 tool_call := server.dispatch_message(Request{
95 id: encode_id(4)
96 method: 'tools/call'
97 params: '{"name":"say_hello","arguments":{"name":"V"}}'
98 }.encode(), stdio_session_id, .stdio)!
99 tool_result := decode_response(tool_call.response)!.decode_result[ToolResult]()!
100 assert tool_result.content.contains('Hello, V!')
101 assert !tool_result.is_error
102
103 resource_list := server.dispatch_message(new_request(5, 'resources/list', empty).encode(),
104 stdio_session_id, .stdio)!
105 resource_list_result :=
106 decode_response(resource_list.response)!.decode_result[ListResourcesResult]()!
107 assert resource_list_result.resources.len == 1
108 assert resource_list_result.resources[0].uri == 'resource://guide'
109
110 resource_templates := server.dispatch_message(new_request(6, 'resources/templates/list', empty).encode(),
111 stdio_session_id, .stdio)!
112 resource_template_result :=
113 decode_response(resource_templates.response)!.decode_result[ListResourceTemplatesResult]()!
114 assert resource_template_result.resource_templates.len == 1
115 assert resource_template_result.resource_templates[0].uri_template == 'resource://docs/{slug}'
116
117 resource_read := server.dispatch_message(new_request(7, 'resources/read', ReadResourceParams{
118 uri: 'resource://guide'
119 }).encode(), stdio_session_id, .stdio)!
120 resource_read_result :=
121 decode_response(resource_read.response)!.decode_result[ReadResourceResult]()!
122 assert resource_read_result.contents.len == 1
123 assert resource_read_result.contents[0].text == 'guide contents'
124
125 prompts_list := server.dispatch_message(new_request(8, 'prompts/list', empty).encode(),
126 stdio_session_id, .stdio)!
127 prompts_list_result :=
128 decode_response(prompts_list.response)!.decode_result[ListPromptsResult]()!
129 assert prompts_list_result.prompts.len == 1
130 assert prompts_list_result.prompts[0].name == 'review'
131
132 prompt_get := server.dispatch_message(Request{
133 id: encode_id(9)
134 method: 'prompts/get'
135 params: '{"name":"review","arguments":{"code":"fn main() {}"}}'
136 }.encode(), stdio_session_id, .stdio)!
137 prompt_result := decode_response(prompt_get.response)!.decode_result[GetPromptResult]()!
138 assert prompt_result.messages.len == 1
139 assert prompt_result.messages[0].role == 'user'
140
141 ping := server.dispatch_message(new_request(10, 'ping', empty).encode(), stdio_session_id,
142 .stdio)!
143 ping_result := decode_response(ping.response)!.decode_result[EmptyObject]()!
144 assert ping_result == empty_object
145}
146
147fn test_server_http_sessions_and_delete() {
148 mut server_value := new_server(
149 name: 'http-server'
150 version: '0.0.1'
151 )
152 mut server := &server_value
153 server.add_tool(Tool{
154 name: 'ping_tool'
155 }, fn (_ Context, _ string) !ToolResult {
156 return tool_text_result('pong')
157 })!
158
159 server_thread := spawn server.serve_http('127.0.0.1:0')
160 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
161 time.sleep(20 * time.millisecond)
162 addr := server.http_server.addr
163 url := 'http://${addr}/mcp'
164
165 mut init_header := http.new_header(http.HeaderConfig{
166 key: .content_type
167 value: 'application/json'
168 }, http.HeaderConfig{
169 key: .accept
170 value: 'application/json'
171 })
172 init_response := http.fetch(
173 method: .post
174 url: url
175 data: Request{
176 id: encode_id(1)
177 method: 'initialize'
178 params: encode_initialize_params(InitializeParams{
179 protocol_version: protocol_version
180 capabilities: '{}'
181 client_info: Implementation{
182 name: 'http-client'
183 version: '0.1.0'
184 }
185 })
186 }.encode()
187 header: init_header
188 )!
189 assert init_response.status_code == 200
190 session_id := init_response.header.get_custom(mcp_session_id_header) or {
191 assert false
192 return
193 }
194 assert session_id != ''
195
196 mut notification_header := init_header
197 notification_header.set_custom(mcp_session_id_header, session_id)!
198 notification_response := http.fetch(
199 method: .post
200 url: url
201 data: new_notification('notifications/initialized', empty).encode()
202 header: notification_header
203 )!
204 assert notification_response.status_code == 202
205
206 mut list_header := notification_header
207 list_header.set(.accept, 'text/event-stream')
208 list_response := http.fetch(
209 method: .post
210 url: url
211 data: new_request(2, 'tools/list', empty).encode()
212 header: list_header
213 )!
214 assert list_response.status_code == 200
215 assert list_response.header.get(.content_type)?.starts_with(event_stream_content_type)
216 list_messages := parse_sse_messages(list_response.body)!
217 assert list_messages.len == 1
218 list_result := decode_response(list_messages[0])!.decode_result[ListToolsResult]()!
219 assert list_result.tools.len == 1
220 assert list_result.tools[0].name == 'ping_tool'
221
222 mut delete_header := http.new_header()
223 delete_header.set_custom(mcp_session_id_header, session_id)!
224 delete_response := http.fetch(
225 method: .delete
226 url: url
227 header: delete_header
228 )!
229 assert delete_response.status_code == 200
230
231 mut stale_header := init_header
232 stale_header.set_custom(mcp_session_id_header, session_id)!
233 stale_response := http.fetch(
234 method: .post
235 url: url
236 data: new_request(3, 'tools/list', empty).encode()
237 header: stale_header
238 )!
239 assert stale_response.status_code == 404
240 server.close()
241 server_thread.wait() or {}
242}
243
244fn http_initialize(url string) !(string, http.Header) {
245 mut header := http.new_header(http.HeaderConfig{
246 key: .content_type
247 value: 'application/json'
248 }, http.HeaderConfig{
249 key: .accept
250 value: 'application/json, text/event-stream'
251 })
252 response := http.fetch(
253 method: .post
254 url: url
255 data: Request{
256 id: encode_id(1)
257 method: 'initialize'
258 params: encode_initialize_params(InitializeParams{
259 protocol_version: protocol_version
260 capabilities: '{}'
261 client_info: Implementation{
262 name: 'spec-test'
263 version: '0.0.1'
264 }
265 })
266 }.encode()
267 header: header
268 )!
269 if response.status_code != 200 {
270 return error('initialize failed: ${response.status_code}')
271 }
272 session_id := response.header.get_custom(mcp_session_id_header) or {
273 return error('missing session id')
274 }
275 return session_id, header
276}
277
278fn test_http_rejects_disallowed_origin() {
279 mut server_value := new_server(
280 name: 'origin-server'
281 version: '0.0.1'
282 allowed_origins: ['https://allowed.example']
283 )
284 mut server := &server_value
285 server_thread := spawn server.serve_http('127.0.0.1:0')
286 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
287 time.sleep(20 * time.millisecond)
288 url := 'http://${server.http_server.addr}/mcp'
289
290 mut header := http.new_header(http.HeaderConfig{
291 key: .content_type
292 value: 'application/json'
293 }, http.HeaderConfig{
294 key: .accept
295 value: 'application/json, text/event-stream'
296 })
297 header.set_custom('Origin', 'https://attacker.example')!
298 response := http.fetch(
299 method: .post
300 url: url
301 data: Request{
302 id: encode_id(1)
303 method: 'initialize'
304 params: encode_initialize_params(InitializeParams{
305 protocol_version: protocol_version
306 capabilities: '{}'
307 client_info: Implementation{
308 name: 'attacker'
309 version: '0.0.1'
310 }
311 })
312 }.encode()
313 header: header
314 )!
315 assert response.status_code == 403
316
317 server.close()
318 server_thread.wait() or {}
319}
320
321fn test_http_rejects_unsupported_protocol_version_header() {
322 mut server_value := new_server(
323 name: 'version-server'
324 version: '0.0.1'
325 )
326 mut server := &server_value
327 server.add_tool(Tool{ name: 'noop' }, fn (_ Context, _ string) !ToolResult {
328 return tool_text_result('ok')
329 })!
330
331 server_thread := spawn server.serve_http('127.0.0.1:0')
332 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
333 time.sleep(20 * time.millisecond)
334 url := 'http://${server.http_server.addr}/mcp'
335
336 session_id, mut header := http_initialize(url)!
337 header.set_custom(mcp_session_id_header, session_id)!
338 header.set_custom(mcp_protocol_version_header, '1999-01-01')!
339 response := http.fetch(
340 method: .post
341 url: url
342 data: new_notification('notifications/initialized', empty).encode()
343 header: header
344 )!
345 assert response.status_code == 400
346
347 server.close()
348 server_thread.wait() or {}
349}
350
351fn test_http_rejects_unacceptable_accept_header() {
352 mut server_value := new_server(
353 name: 'accept-server'
354 version: '0.0.1'
355 )
356 mut server := &server_value
357 server_thread := spawn server.serve_http('127.0.0.1:0')
358 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
359 time.sleep(20 * time.millisecond)
360 url := 'http://${server.http_server.addr}/mcp'
361
362 mut header := http.new_header(http.HeaderConfig{
363 key: .content_type
364 value: 'application/json'
365 }, http.HeaderConfig{
366 key: .accept
367 value: 'image/png'
368 })
369 response := http.fetch(
370 method: .post
371 url: url
372 data: new_request(1, 'ping', empty).encode()
373 header: header
374 )!
375 assert response.status_code == 406
376
377 server.close()
378 server_thread.wait() or {}
379}
380
381fn test_tool_annotations_are_serialized() {
382 mut server := new_server(name: 'annot', version: '0')
383 server.add_tool(Tool{
384 name: 'annotated'
385 description: 'demo'
386 annotations: ToolAnnotations{
387 title: 'Read-only fetcher'
388 read_only_hint: true
389 open_world_hint: false
390 }
391 }, fn (_ Context, _ string) !ToolResult {
392 return tool_text_result('ok')
393 })!
394
395 encoded := encode_tool(server.tools['annotated'].tool)
396 assert encoded.contains('"annotations":{"title":"Read-only fetcher","readOnlyHint":true,"openWorldHint":false}')
397}
398
399fn test_list_changed_notifications_are_queued_after_initialize() {
400 mut server := new_server(name: 'listchanged', version: '0')
401 init_request := Request{
402 id: encode_id(1)
403 method: 'initialize'
404 params: encode_initialize_params(InitializeParams{
405 protocol_version: protocol_version
406 capabilities: '{}'
407 client_info: Implementation{
408 name: 'c'
409 version: '0'
410 }
411 })
412 }
413 server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)!
414 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
415 stdio_session_id, .stdio)!
416
417 server.add_tool(Tool{ name: 'late' }, fn (_ Context, _ string) !ToolResult {
418 return tool_text_result('ok')
419 })!
420 server.add_resource(Resource{ uri: 'res://x', name: 'x' }, fn (_ Context, uri string) !ReadResourceResult {
421 return ReadResourceResult{
422 contents: [ResourceContents{
423 uri: uri
424 text: 'x'
425 }]
426 }
427 })!
428 server.add_prompt(Prompt{ name: 'late_prompt' }, fn (_ Context, _ string) !GetPromptResult {
429 return GetPromptResult{}
430 })!
431
432 queue := server.drain_session_notifications(stdio_session_id)
433 assert queue.len == 3
434 assert decode_notification(queue[0])!.method == 'notifications/tools/list_changed'
435 assert decode_notification(queue[1])!.method == 'notifications/resources/list_changed'
436 assert decode_notification(queue[2])!.method == 'notifications/prompts/list_changed'
437}
438
439fn test_resources_subscribe_then_updated_notifies_only_subscribers() {
440 mut server := new_server(name: 'sub', version: '0')
441 server.add_resource(Resource{ uri: 'res://x', name: 'x' }, fn (_ Context, uri string) !ReadResourceResult {
442 return ReadResourceResult{
443 contents: [ResourceContents{
444 uri: uri
445 text: 'x'
446 }]
447 }
448 })!
449 init_request := Request{
450 id: encode_id(1)
451 method: 'initialize'
452 params: encode_initialize_params(InitializeParams{
453 protocol_version: protocol_version
454 capabilities: '{}'
455 client_info: Implementation{
456 name: 'c'
457 version: '0'
458 }
459 })
460 }
461 server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)!
462 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
463 stdio_session_id, .stdio)!
464
465 subscribe := server.dispatch_message(new_request(2, 'resources/subscribe', SubscribeParams{
466 uri: 'res://x'
467 }).encode(), stdio_session_id, .stdio)!
468 assert decode_response(subscribe.response)!.error.code == 0
469
470 server.notify_resource_updated('res://x')
471 server.notify_resource_updated('res://other')
472
473 queue := server.drain_session_notifications(stdio_session_id)
474 assert queue.len == 1
475 notif := decode_notification(queue[0])!
476 assert notif.method == 'notifications/resources/updated'
477 assert notif.params.contains('"uri":"res://x"')
478
479 unsubscribe := server.dispatch_message(new_request(3, 'resources/unsubscribe', SubscribeParams{
480 uri: 'res://x'
481 }).encode(), stdio_session_id, .stdio)!
482 assert decode_response(unsubscribe.response)!.error.code == 0
483 server.notify_resource_updated('res://x')
484 assert server.drain_session_notifications(stdio_session_id).len == 0
485}
486
487fn test_logging_set_level_filters_messages_below_threshold() {
488 mut server := new_server(name: 'log', version: '0', enable_logging: true)
489 init_request := Request{
490 id: encode_id(1)
491 method: 'initialize'
492 params: encode_initialize_params(InitializeParams{
493 protocol_version: protocol_version
494 capabilities: '{}'
495 client_info: Implementation{
496 name: 'c'
497 version: '0'
498 }
499 })
500 }
501 init_dispatch := server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)!
502 init_response := decode_response(init_dispatch.response)!
503 init_result := init_response.decode_result[InitializeResult]()!
504 assert init_result.capabilities.contains('"logging":{}')
505 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
506 stdio_session_id, .stdio)!
507
508 set_level := server.dispatch_message(new_request(2, 'logging/setLevel', SetLevelParams{
509 level: 'warning'
510 }).encode(), stdio_session_id, .stdio)!
511 assert decode_response(set_level.response)!.error.code == 0
512
513 server.notify_log(.debug, 'svc', '"low"')
514 server.notify_log(.warning, 'svc', '"hi"')
515 server.notify_log(.error, 'svc', '{"k":1}')
516
517 queue := server.drain_session_notifications(stdio_session_id)
518 assert queue.len == 2
519 first := decode_notification(queue[0])!
520 assert first.method == 'notifications/message'
521 assert first.params.contains('"level":"warning"')
522 assert first.params.contains('"logger":"svc"')
523 second := decode_notification(queue[1])!
524 assert second.params.contains('"level":"error"')
525 assert second.params.contains('"data":{"k":1}')
526}
527
528fn test_logging_set_level_unknown_returns_invalid_params() {
529 mut server := new_server(name: 'log', version: '0', enable_logging: true)
530 server.dispatch_message(Request{
531 id: encode_id(1)
532 method: 'initialize'
533 params: encode_initialize_params(InitializeParams{
534 protocol_version: protocol_version
535 capabilities: '{}'
536 client_info: Implementation{
537 name: 'c'
538 version: '0'
539 }
540 })
541 }.encode(), stdio_session_id, .stdio)!
542 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
543 stdio_session_id, .stdio)!
544
545 dispatch := server.dispatch_message(new_request(2, 'logging/setLevel', SetLevelParams{
546 level: 'fatal'
547 }).encode(), stdio_session_id, .stdio)!
548 assert decode_response(dispatch.response)!.error.code == invalid_params.code
549}
550
551fn test_logging_disabled_rejects_set_level() {
552 mut server := new_server(name: 'log', version: '0')
553 server.dispatch_message(Request{
554 id: encode_id(1)
555 method: 'initialize'
556 params: encode_initialize_params(InitializeParams{
557 protocol_version: protocol_version
558 capabilities: '{}'
559 client_info: Implementation{
560 name: 'c'
561 version: '0'
562 }
563 })
564 }.encode(), stdio_session_id, .stdio)!
565 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
566 stdio_session_id, .stdio)!
567
568 dispatch := server.dispatch_message(new_request(2, 'logging/setLevel', SetLevelParams{
569 level: 'info'
570 }).encode(), stdio_session_id, .stdio)!
571 assert decode_response(dispatch.response)!.error.code == method_not_found.code
572}
573
574fn test_progress_token_is_extracted_and_notification_is_sent() {
575 mut server := new_server(name: 'p', version: '0')
576 server.add_tool(Tool{ name: 'work' }, fn (ctx Context, _ string) !ToolResult {
577 ctx.notify_progress(0.25, 1.0, 'starting')
578 ctx.notify_progress(1.0, 1.0, 'done')
579 return tool_text_result('done')
580 })!
581
582 server.dispatch_message(Request{
583 id: encode_id(1)
584 method: 'initialize'
585 params: encode_initialize_params(InitializeParams{
586 protocol_version: protocol_version
587 capabilities: '{}'
588 client_info: Implementation{
589 name: 'c'
590 version: '0'
591 }
592 })
593 }.encode(), stdio_session_id, .stdio)!
594 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
595 stdio_session_id, .stdio)!
596
597 server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"work","arguments":{},"_meta":{"progressToken":"abc"}}}',
598 stdio_session_id, .stdio)!
599
600 queue := server.drain_session_notifications(stdio_session_id)
601 assert queue.len == 2
602 first := decode_notification(queue[0])!
603 assert first.method == 'notifications/progress'
604 assert first.params.contains('"progressToken":"abc"')
605 assert first.params.contains('"progress":0.25')
606 assert first.params.contains('"total":1')
607 assert first.params.contains('"message":"starting"')
608}
609
610fn test_cancellation_marks_request_until_cleared() {
611 mut server := new_server(name: 'c', version: '0')
612 server.add_tool(Tool{ name: 'check' }, fn (ctx Context, _ string) !ToolResult {
613 assert ctx.is_cancelled()
614 return tool_text_result('seen')
615 })!
616
617 server.dispatch_message(Request{
618 id: encode_id(1)
619 method: 'initialize'
620 params: encode_initialize_params(InitializeParams{
621 protocol_version: protocol_version
622 capabilities: '{}'
623 client_info: Implementation{
624 name: 'c'
625 version: '0'
626 }
627 })
628 }.encode(), stdio_session_id, .stdio)!
629 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
630 stdio_session_id, .stdio)!
631
632 // Request ids carry their JSON form unchanged (string vs number) so the
633 // cancellation must use the exact same form per spec.
634 server.dispatch_message('{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7,"reason":"user"}}',
635 stdio_session_id, .stdio)!
636 assert server.is_request_cancelled(stdio_session_id, '7')
637
638 server.dispatch_message('{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"check","arguments":{}}}',
639 stdio_session_id, .stdio)!
640 assert !server.is_request_cancelled(stdio_session_id, '7')
641}
642
643fn test_completion_complete_routes_to_registered_handler() {
644 mut server := new_server(name: 'cplt', version: '0')
645 server.add_prompt(Prompt{
646 name: 'review'
647 arguments: [PromptArgument{
648 name: 'lang'
649 }]
650 }, fn (_ Context, _ string) !GetPromptResult {
651 return GetPromptResult{}
652 })!
653 server.add_completion(CompletionRef{ ref_type: 'ref/prompt', name: 'review' }, 'lang', fn (_ Context, current_value string, _ string) !CompletionResult {
654 candidates := ['rust', 'python', 'go', 'v']
655 matches := candidates.filter(it.starts_with(current_value))
656 return CompletionResult{
657 values: matches
658 total: matches.len
659 has_more: false
660 }
661 })!
662
663 server.dispatch_message(Request{
664 id: encode_id(1)
665 method: 'initialize'
666 params: encode_initialize_params(InitializeParams{
667 protocol_version: protocol_version
668 capabilities: '{}'
669 client_info: Implementation{
670 name: 'c'
671 version: '0'
672 }
673 })
674 }.encode(), stdio_session_id, .stdio)!
675 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
676 stdio_session_id, .stdio)!
677
678 dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"review"},"argument":{"name":"lang","value":"r"}}}',
679 stdio_session_id, .stdio)!
680 body := decode_response(dispatch.response)!.result
681 assert body.contains('"values":["rust"]')
682 assert body.contains('"total":1')
683 assert body.contains('"hasMore":false')
684}
685
686fn test_completion_unknown_handler_returns_empty_values() {
687 mut server := new_server(name: 'cplt', version: '0')
688 server.add_completion(CompletionRef{ ref_type: 'ref/resource', uri: 'res://a' }, 'k', fn (_ Context, _ string, _ string) !CompletionResult {
689 return CompletionResult{
690 values: ['x']
691 }
692 })!
693
694 server.dispatch_message(Request{
695 id: encode_id(1)
696 method: 'initialize'
697 params: encode_initialize_params(InitializeParams{
698 protocol_version: protocol_version
699 capabilities: '{}'
700 client_info: Implementation{
701 name: 'c'
702 version: '0'
703 }
704 })
705 }.encode(), stdio_session_id, .stdio)!
706 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
707 stdio_session_id, .stdio)!
708
709 dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/resource","uri":"res://other"},"argument":{"name":"k","value":""}}}',
710 stdio_session_id, .stdio)!
711 body := decode_response(dispatch.response)!.result
712 assert body == '{"completion":{"values":[]}}'
713}
714
715fn test_http_get_streams_queued_notifications_with_event_ids() {
716 mut server_value := new_server(name: 'sse', version: '0', enable_logging: true)
717 mut server := &server_value
718 server.add_resource(Resource{ uri: 'res://x', name: 'x' }, fn (_ Context, uri string) !ReadResourceResult {
719 return ReadResourceResult{
720 contents: [ResourceContents{
721 uri: uri
722 text: 'x'
723 }]
724 }
725 })!
726
727 server_thread := spawn server.serve_http('127.0.0.1:0')
728 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
729 time.sleep(20 * time.millisecond)
730 url := 'http://${server.http_server.addr}/mcp'
731
732 session_id, mut header := http_initialize(url)!
733 header.set_custom(mcp_session_id_header, session_id)!
734 notification_response := http.fetch(
735 method: .post
736 url: url
737 data: new_notification('notifications/initialized', empty).encode()
738 header: header
739 )!
740 assert notification_response.status_code == 202
741
742 server.notify_log(.info, 'svc', '"hello"')
743 server.notify_log(.warning, 'svc', '"world"')
744
745 mut get_header := http.new_header()
746 get_header.set(.accept, 'text/event-stream')
747 get_header.set_custom(mcp_session_id_header, session_id)!
748 stream := http.fetch(
749 method: .get
750 url: url
751 header: get_header
752 )!
753 assert stream.status_code == 200
754 assert stream.header.get(.content_type)?.starts_with(event_stream_content_type)
755 assert stream.body.contains('id: 1')
756 assert stream.body.contains('id: 2')
757 first_messages := parse_sse_messages(stream.body)!
758 assert first_messages.len == 2
759 assert decode_notification(first_messages[0])!.method == 'notifications/message'
760
761 mut resume_header := get_header
762 resume_header.set_custom(last_event_id_header, '1')!
763 resume := http.fetch(
764 method: .get
765 url: url
766 header: resume_header
767 )!
768 assert resume.body.contains('id: 2')
769 assert !resume.body.contains('id: 1\n')
770
771 server.close()
772 server_thread.wait() or {}
773}
774
775fn test_server_initiated_list_roots_round_trip() {
776 mut server := new_server(name: 'roots', version: '0')
777 server.dispatch_message(Request{
778 id: encode_id(1)
779 method: 'initialize'
780 params: encode_initialize_params(InitializeParams{
781 protocol_version: protocol_version
782 capabilities: '{}'
783 client_info: Implementation{
784 name: 'c'
785 version: '0'
786 }
787 })
788 }.encode(), stdio_session_id, .stdio)!
789 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
790 stdio_session_id, .stdio)!
791
792 mut request_thread := spawn fn [mut server] () !ListRootsResult {
793 return server.list_roots(stdio_session_id, 1 * time.second)
794 }()
795
796 // Drain the queued request and respond as a client would.
797 mut server_request_id := ''
798 deadline := time.now().add(1 * time.second)
799 for time.now() < deadline {
800 queued := server.drain_session_notifications(stdio_session_id)
801 if queued.len != 0 {
802 req := decode_request(queued[0])!
803 assert req.method == 'roots/list'
804 server_request_id = req.id
805 break
806 }
807 time.sleep(2 * time.millisecond)
808 }
809 assert server_request_id != ''
810
811 server.dispatch_message('{"jsonrpc":"2.0","id":${server_request_id},"result":{"roots":[{"uri":"file:///tmp","name":"tmp"}]}}',
812 stdio_session_id, .stdio)!
813 roots := request_thread.wait()!
814 assert roots.roots.len == 1
815 assert roots.roots[0].uri == 'file:///tmp'
816}
817
818fn test_server_initiated_request_returns_on_timeout() {
819 mut server := new_server(name: 'roots', version: '0')
820 server.dispatch_message(Request{
821 id: encode_id(1)
822 method: 'initialize'
823 params: encode_initialize_params(InitializeParams{
824 protocol_version: protocol_version
825 capabilities: '{}'
826 client_info: Implementation{
827 name: 'c'
828 version: '0'
829 }
830 })
831 }.encode(), stdio_session_id, .stdio)!
832 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
833 stdio_session_id, .stdio)!
834
835 started := time.now()
836 server.list_roots(stdio_session_id, 50 * time.millisecond) or {
837 // `timed_wait` should return promptly after the deadline; allow up to
838 // 10x the timeout to account for CI scheduler jitter.
839 elapsed := time.now() - started
840 assert elapsed < 500 * time.millisecond
841 assert err.msg().contains('timeout')
842 return
843 }
844 assert false, 'list_roots should have timed out'
845}
846
847fn test_late_response_after_timeout_does_not_leak_pending() {
848 mut server := new_server(name: 'late', version: '0')
849 server.dispatch_message(Request{
850 id: encode_id(1)
851 method: 'initialize'
852 params: encode_initialize_params(InitializeParams{
853 protocol_version: protocol_version
854 capabilities: '{}'
855 client_info: Implementation{
856 name: 'c'
857 version: '0'
858 }
859 })
860 }.encode(), stdio_session_id, .stdio)!
861 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
862 stdio_session_id, .stdio)!
863
864 server.list_roots(stdio_session_id, 30 * time.millisecond) or {
865 assert err.msg().contains('timeout')
866 }
867
868 // The waiter has cleaned up its semaphore; a late reply must be dropped
869 // rather than parked in `pending_responses` forever.
870 mut server_request_id := ''
871 rlock server.state {
872 session := server.state.sessions[stdio_session_id]
873 server_request_id = '"server-${session.next_request_seq - 1}"'
874 }
875 server.dispatch_message('{"jsonrpc":"2.0","id":${server_request_id},"result":{"roots":[]}}',
876 stdio_session_id, .stdio)!
877
878 rlock server.state {
879 session := server.state.sessions[stdio_session_id]
880 assert session.pending_responses.len == 0
881 }
882}
883
884fn test_http_get_resume_drains_queue_before_replay() {
885 mut server_value := new_server(name: 'resume', version: '0', enable_logging: true)
886 mut server := &server_value
887 server_thread := spawn server.serve_http('127.0.0.1:0')
888 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
889 time.sleep(20 * time.millisecond)
890 url := 'http://${server.http_server.addr}/mcp'
891
892 session_id, mut header := http_initialize(url)!
893 header.set_custom(mcp_session_id_header, session_id)!
894 notification_response := http.fetch(
895 method: .post
896 url: url
897 data: new_notification('notifications/initialized', empty).encode()
898 header: header
899 )!
900 assert notification_response.status_code == 202
901
902 // First batch: drained on the initial GET, assigns ids 1..2.
903 server.notify_log(.info, 'svc', '"first"')
904 server.notify_log(.info, 'svc', '"second"')
905
906 mut get_header := http.new_header()
907 get_header.set(.accept, 'text/event-stream')
908 get_header.set_custom(mcp_session_id_header, session_id)!
909 first := http.fetch(method: .get, url: url, header: get_header)!
910 assert first.status_code == 200
911 assert first.body.contains('id: 1')
912 assert first.body.contains('id: 2')
913
914 // Second batch arrives while the client is between GETs — it stays in the
915 // notification queue and would be missed if the resume only replayed the
916 // event log without draining first.
917 server.notify_log(.info, 'svc', '"third"')
918 server.notify_log(.info, 'svc', '"fourth"')
919
920 mut resume_header := get_header
921 resume_header.set_custom(last_event_id_header, '2')!
922 resume := http.fetch(method: .get, url: url, header: resume_header)!
923 assert resume.status_code == 200
924 assert resume.body.contains('id: 3')
925 assert resume.body.contains('id: 4')
926 assert resume.body.contains('"third"')
927 assert resume.body.contains('"fourth"')
928
929 server.close()
930 server_thread.wait() or {}
931}
932
933fn test_http_returns_json_when_accept_lists_both() {
934 mut server_value := new_server(
935 name: 'json-default-server'
936 version: '0.0.1'
937 )
938 mut server := &server_value
939 server_thread := spawn server.serve_http('127.0.0.1:0')
940 server.wait_till_running(max_retries: 200, retry_period_ms: 10)!
941 time.sleep(20 * time.millisecond)
942 url := 'http://${server.http_server.addr}/mcp'
943
944 session_id, mut header := http_initialize(url)!
945 header.set_custom(mcp_session_id_header, session_id)!
946 notification_response := http.fetch(
947 method: .post
948 url: url
949 data: new_notification('notifications/initialized', empty).encode()
950 header: header
951 )!
952 assert notification_response.status_code == 202
953
954 ping_response := http.fetch(
955 method: .post
956 url: url
957 data: new_request(2, 'ping', empty).encode()
958 header: header
959 )!
960 assert ping_response.status_code == 200
961 assert ping_response.header.get(.content_type)?.starts_with(default_content_type)
962
963 server.close()
964 server_thread.wait() or {}
965}
966