v / vlib / mcp / spec_compliance_test.v
579 lines · 525 sloc · 20.57 KB · 7b55539b6e355cd5930ad6213fc49d3c884821dc
Raw
1// Spec-compliance tests for MCP 2025-11-25 wire shapes.
2// These tests intentionally check the JSON shape of responses and notifications
3// produced by the server against the published schema:
4// https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json
5// Add new cases here whenever a schema field is touched.
6module mcp
7
8import json
9
10fn build_initialized_session(mut server Server) {
11 server.dispatch_message(Request{
12 id: encode_id(1)
13 method: 'initialize'
14 params: encode_initialize_params(InitializeParams{
15 protocol_version: protocol_version
16 capabilities: '{"roots":{"listChanged":true}}'
17 client_info: Implementation{
18 name: 'compliance'
19 version: '0'
20 }
21 })
22 }.encode(), stdio_session_id, .stdio) or { panic(err) }
23 server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
24 stdio_session_id, .stdio) or { panic(err) }
25}
26
27fn test_initialize_result_shape_matches_spec() {
28 mut server := new_server(name: 's', version: '0', enable_logging: true)
29 server.add_tool(Tool{ name: 't' }, fn (_ Context, _ string) !ToolResult {
30 return tool_text_result('ok')
31 })!
32 dispatch := server.dispatch_message(Request{
33 id: encode_id(1)
34 method: 'initialize'
35 params: encode_initialize_params(InitializeParams{
36 protocol_version: protocol_version
37 capabilities: '{}'
38 client_info: Implementation{
39 name: 'c'
40 version: '0'
41 }
42 })
43 }.encode(), stdio_session_id, .stdio)!
44 response := decode_response(dispatch.response)!
45 assert response.error.code == 0
46 result := response.decode_result[InitializeResult]()!
47 assert result.protocol_version == protocol_version
48 assert result.server_info.name == 's'
49 assert result.server_info.version == '0'
50 assert result.capabilities.contains('"tools":{"listChanged":true}')
51 assert result.capabilities.contains('"logging":{}')
52}
53
54fn test_jsonrpc_envelope_includes_required_fields() {
55 mut server := new_server(name: 's', version: '0')
56 build_initialized_session(mut server)
57
58 dispatch := server.dispatch_message(new_request(2, 'ping', empty).encode(), stdio_session_id,
59 .stdio)!
60 envelope := json.decode(MessageEnvelope, dispatch.response)!
61 assert envelope.jsonrpc == '2.0'
62 assert envelope.id == '2'
63 assert envelope.result == '{}'
64 assert envelope.method == ''
65 assert envelope.error.code == 0
66}
67
68fn test_tools_list_response_uses_input_schema_field() {
69 mut server := new_server(name: 's', version: '0')
70 server.add_tool(Tool{
71 name: 'test'
72 description: 'd'
73 input_schema: '{"type":"object","properties":{"x":{"type":"string"}}}'
74 }, fn (_ Context, _ string) !ToolResult {
75 return tool_text_result('ok')
76 })!
77 build_initialized_session(mut server)
78
79 dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
80 stdio_session_id, .stdio)!
81 body := decode_response(dispatch.response)!.result
82 assert body.contains('"inputSchema":{"type":"object","properties":{"x":{"type":"string"}}}')
83 assert body.contains('"name":"test"')
84}
85
86fn test_tool_call_result_has_is_error_and_content_array() {
87 mut server := new_server(name: 's', version: '0')
88 server.add_tool(Tool{ name: 'fails' }, fn (_ Context, _ string) !ToolResult {
89 return error('boom')
90 })!
91 build_initialized_session(mut server)
92
93 dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"fails","arguments":{}}}',
94 stdio_session_id, .stdio)!
95 body := decode_response(dispatch.response)!.result
96 assert body.contains('"isError":true')
97 assert body.contains('"content":[')
98 assert body.contains('"type":"text"')
99}
100
101fn test_resources_list_uses_camel_case_mime_type() {
102 mut server := new_server(name: 's', version: '0')
103 server.add_resource(Resource{
104 uri: 'res://a'
105 name: 'a'
106 mime_type: 'text/plain'
107 }, fn (_ Context, uri string) !ReadResourceResult {
108 return ReadResourceResult{
109 contents: [
110 ResourceContents{
111 uri: uri
112 mime_type: 'text/plain'
113 text: 'hi'
114 },
115 ]
116 }
117 })!
118 build_initialized_session(mut server)
119
120 list_dispatch := server.dispatch_message(new_request(2, 'resources/list', empty).encode(),
121 stdio_session_id, .stdio)!
122 list_body := decode_response(list_dispatch.response)!.result
123 assert list_body.contains('"mimeType":"text/plain"')
124 assert !list_body.contains('"mime_type"')
125
126 read_dispatch := server.dispatch_message(new_request(3, 'resources/read', ReadResourceParams{
127 uri: 'res://a'
128 }).encode(), stdio_session_id, .stdio)!
129 read_body := decode_response(read_dispatch.response)!.result
130 assert read_body.contains('"mimeType":"text/plain"')
131}
132
133fn test_resource_templates_list_uses_camel_case_uri_template() {
134 mut server := new_server(name: 's', version: '0')
135 server.add_resource_template(ResourceTemplate{
136 uri_template: 'res://a/{x}'
137 name: 'a'
138 })!
139 build_initialized_session(mut server)
140
141 dispatch := server.dispatch_message(new_request(2, 'resources/templates/list', empty).encode(),
142 stdio_session_id, .stdio)!
143 body := decode_response(dispatch.response)!.result
144 assert body.contains('"resourceTemplates"')
145 assert body.contains('"uriTemplate":"res://a/{x}"')
146}
147
148fn test_progress_notification_uses_camel_case_progress_token() {
149 mut server := new_server(name: 's', version: '0')
150 server.add_tool(Tool{ name: 'work' }, fn (ctx Context, _ string) !ToolResult {
151 ctx.notify_progress(0.5, 1.0, 'half')
152 return tool_text_result('done')
153 })!
154 build_initialized_session(mut server)
155
156 server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"work","arguments":{},"_meta":{"progressToken":"abc"}}}',
157 stdio_session_id, .stdio)!
158 queue := server.drain_session_notifications(stdio_session_id)
159 assert queue.len == 1
160 notif := decode_notification(queue[0])!
161 assert notif.method == 'notifications/progress'
162 assert notif.params.contains('"progressToken":"abc"')
163}
164
165fn test_logging_message_uses_rfc5424_levels() {
166 levels := [
167 LogLevel.debug,
168 LogLevel.info,
169 LogLevel.notice,
170 LogLevel.warning,
171 LogLevel.error,
172 LogLevel.critical,
173 LogLevel.alert,
174 LogLevel.emergency,
175 ]
176 expected := ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']
177 for i, level in levels {
178 assert level.str() == expected[i]
179 parsed := parse_log_level(expected[i]) or { panic('failed to parse ${expected[i]}') }
180 assert parsed == level
181 }
182}
183
184fn test_error_codes_match_jsonrpc_specification() {
185 assert parse_error.code == -32700
186 assert invalid_request.code == -32600
187 assert method_not_found.code == -32601
188 assert invalid_params.code == -32602
189 assert internal_error.code == -32603
190 assert server_not_initialized.code == -32002
191}
192
193fn test_pagination_emits_next_cursor_when_more_pages_exist() {
194 mut server := new_server(name: 's', version: '0')
195 for i in 0 .. default_list_page_size + 5 {
196 server.add_tool(Tool{ name: 'tool_${i}' }, fn (_ Context, _ string) !ToolResult {
197 return tool_text_result('ok')
198 })!
199 }
200 build_initialized_session(mut server)
201
202 first_dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
203 stdio_session_id, .stdio)!
204 first := decode_response(first_dispatch.response)!.decode_result[ListToolsResult]()!
205 assert first.tools.len == default_list_page_size
206 assert first.next_cursor == default_list_page_size.str()
207
208 second_dispatch := server.dispatch_message(Request{
209 id: encode_id(3)
210 method: 'tools/list'
211 params: '{"cursor":"${first.next_cursor}"}'
212 }.encode(), stdio_session_id, .stdio)!
213 second := decode_response(second_dispatch.response)!.decode_result[ListToolsResult]()!
214 assert second.tools.len == 5
215 assert second.next_cursor == ''
216}
217
218fn test_completion_result_matches_spec_shape() {
219 mut server := new_server(name: 's', version: '0')
220 server.add_completion(CompletionRef{ ref_type: 'ref/prompt', name: 'p' }, 'arg', fn (_ Context, _ string, _ string) !CompletionResult {
221 return CompletionResult{
222 values: ['a', 'b']
223 total: 2
224 has_more: false
225 }
226 })!
227 build_initialized_session(mut server)
228
229 dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"p"},"argument":{"name":"arg","value":""}}}',
230 stdio_session_id, .stdio)!
231 body := decode_response(dispatch.response)!.result
232 // The result MUST be wrapped in a `completion` object per spec.
233 assert body.starts_with('{"completion":{')
234 assert body.contains('"values":["a","b"]')
235 assert body.contains('"total":2')
236 assert body.contains('"hasMore":false')
237}
238
239fn test_initialize_blocks_other_methods_until_notifications_initialized_received() {
240 mut server := new_server(name: 's', version: '0')
241 server.dispatch_message(Request{
242 id: encode_id(1)
243 method: 'initialize'
244 params: encode_initialize_params(InitializeParams{
245 protocol_version: protocol_version
246 capabilities: '{}'
247 client_info: Implementation{
248 name: 'c'
249 version: '0'
250 }
251 })
252 }.encode(), stdio_session_id, .stdio)!
253 dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
254 stdio_session_id, .stdio)!
255 response := decode_response(dispatch.response)!
256 assert response.error.code == server_not_initialized.code
257}
258
259fn test_implementation_carries_2025_11_25_metadata_extensions() {
260 icon := Icon{
261 src: 'https://example.com/icon.svg'
262 mime_type: 'image/svg+xml'
263 sizes: ['48x48', '96x96']
264 theme: 'light'
265 }
266 mut server := new_server(
267 name: 's'
268 version: '0'
269 title: 'Server Title'
270 description: 'Showcase'
271 website_url: 'https://example.com'
272 icons: [icon]
273 )
274 build_initialized_session(mut server)
275
276 dispatch := server.dispatch_message(new_request(2, 'ping', empty).encode(), stdio_session_id,
277 .stdio)!
278 // The server's Implementation is folded into the cached InitializeResult; round-trip
279 // it through json.encode to verify the wire shape.
280 encoded := json.encode(server.server_info)
281 assert encoded.contains('"name":"s"')
282 assert encoded.contains('"version":"0"')
283 assert encoded.contains('"title":"Server Title"')
284 assert encoded.contains('"description":"Showcase"')
285 assert encoded.contains('"websiteUrl":"https://example.com"')
286 assert encoded.contains('"icons":[')
287 assert encoded.contains('"src":"https://example.com/icon.svg"')
288 assert encoded.contains('"mimeType":"image/svg+xml"')
289 assert encoded.contains('"sizes":["48x48","96x96"]')
290 assert encoded.contains('"theme":"light"')
291 assert dispatch.response.contains('"jsonrpc":"2.0"')
292}
293
294fn test_implementation_omits_optional_metadata_when_empty() {
295 encoded := json.encode(Implementation{ name: 'c', version: '0' })
296 assert encoded == '{"name":"c","version":"0"}'
297}
298
299fn test_tool_emits_icons_and_execution_task_support() {
300 mut server := new_server(name: 's', version: '0')
301 server.add_tool(Tool{
302 name: 'launch'
303 icons: [
304 Icon{
305 src: 'https://example.com/launch.png'
306 mime_type: 'image/png'
307 },
308 ]
309 execution: ToolExecution{
310 task_support: 'optional'
311 }
312 }, fn (_ Context, _ string) !ToolResult {
313 return tool_text_result('ok')
314 })!
315 build_initialized_session(mut server)
316
317 dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
318 stdio_session_id, .stdio)!
319 body := decode_response(dispatch.response)!.result
320 assert body.contains('"icons":[{"src":"https://example.com/launch.png","mimeType":"image/png"}]')
321 assert body.contains('"execution":{"taskSupport":"optional"}')
322}
323
324fn test_tool_omits_icons_and_execution_when_unset() {
325 mut server := new_server(name: 's', version: '0')
326 server.add_tool(Tool{ name: 'plain' }, fn (_ Context, _ string) !ToolResult {
327 return tool_text_result('ok')
328 })!
329 build_initialized_session(mut server)
330
331 dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
332 stdio_session_id, .stdio)!
333 body := decode_response(dispatch.response)!.result
334 assert !body.contains('"icons"')
335 assert !body.contains('"execution"')
336}
337
338fn test_resource_emits_icons_size_and_annotations() {
339 mut server := new_server(name: 's', version: '0')
340 server.add_resource(Resource{
341 uri: 'res://big'
342 name: 'big'
343 size: 4096
344 icons: [Icon{
345 src: 'https://example.com/file.svg'
346 }]
347 annotations: Annotations{
348 audience: ['user']
349 priority: 0.8
350 last_modified: '2026-04-29T00:00:00Z'
351 }
352 }, fn (_ Context, uri string) !ReadResourceResult {
353 return ReadResourceResult{
354 contents: [ResourceContents{
355 uri: uri
356 text: ''
357 }]
358 }
359 })!
360 build_initialized_session(mut server)
361
362 dispatch := server.dispatch_message(new_request(2, 'resources/list', empty).encode(),
363 stdio_session_id, .stdio)!
364 body := decode_response(dispatch.response)!.result
365 assert body.contains('"size":4096')
366 assert body.contains('"icons":[{"src":"https://example.com/file.svg"}]')
367 assert body.contains('"audience":["user"]')
368 assert body.contains('"priority":0.8')
369 assert body.contains('"lastModified":"2026-04-29T00:00:00Z"')
370}
371
372fn test_resource_template_emits_icons_and_annotations() {
373 mut server := new_server(name: 's', version: '0')
374 server.add_resource_template(ResourceTemplate{
375 uri_template: 'res://greet/{name}'
376 name: 'greet'
377 icons: [Icon{
378 src: 'https://example.com/wave.svg'
379 }]
380 annotations: Annotations{
381 audience: ['assistant']
382 }
383 })!
384 build_initialized_session(mut server)
385
386 dispatch := server.dispatch_message(new_request(2, 'resources/templates/list', empty).encode(),
387 stdio_session_id, .stdio)!
388 body := decode_response(dispatch.response)!.result
389 assert body.contains('"icons":[{"src":"https://example.com/wave.svg"}]')
390 assert body.contains('"audience":["assistant"]')
391}
392
393fn test_prompt_emits_icons() {
394 mut server := new_server(name: 's', version: '0')
395 server.add_prompt(Prompt{
396 name: 'review'
397 icons: [Icon{
398 src: 'https://example.com/review.svg'
399 }]
400 }, fn (_ Context, _ string) !GetPromptResult {
401 return GetPromptResult{
402 messages: [prompt_text_message('user', 'hi')]
403 }
404 })!
405 build_initialized_session(mut server)
406
407 dispatch := server.dispatch_message(new_request(2, 'prompts/list', empty).encode(),
408 stdio_session_id, .stdio)!
409 body := decode_response(dispatch.response)!.result
410 assert body.contains('"icons":[{"src":"https://example.com/review.svg"}]')
411}
412
413fn test_content_helpers_match_spec_shapes() {
414 assert text_content('hi') == '{"type":"text","text":"hi"}'
415 assert image_content('AAA=', 'image/png') == '{"type":"image","data":"AAA=","mimeType":"image/png"}'
416 assert audio_content('BBB=', 'audio/wav') == '{"type":"audio","data":"BBB=","mimeType":"audio/wav"}'
417 assert embedded_text_resource('res://a', 'text/plain', 'hi') == '{"type":"resource","resource":{"uri":"res://a","mimeType":"text/plain","text":"hi"}}'
418 assert embedded_blob_resource('res://b', 'image/png', 'AAA=') == '{"type":"resource","resource":{"uri":"res://b","mimeType":"image/png","blob":"AAA="}}'
419 link := resource_link_content(Resource{ uri: 'res://l', name: 'l' })
420 assert link.starts_with('{"type":"resource_link",')
421 assert link.contains('"uri":"res://l"')
422 assert link.contains('"name":"l"')
423}
424
425fn test_content_helpers_attach_annotations() {
426 annot := Annotations{
427 audience: ['user', 'assistant']
428 priority: 0.7
429 last_modified: '2026-04-29T00:00:00Z'
430 }
431 text := text_content_with_annotations('hi', annot)
432 assert text.contains('"type":"text"')
433 assert text.contains('"text":"hi"')
434 assert text.contains('"annotations":{')
435 assert text.contains('"audience":["user","assistant"]')
436 assert text.contains('"priority":0.7')
437 assert text.contains('"lastModified":"2026-04-29T00:00:00Z"')
438
439 image := image_content_with_annotations('AAA=', 'image/png', annot)
440 assert image.contains('"type":"image"')
441 assert image.contains('"data":"AAA="')
442 assert image.contains('"mimeType":"image/png"')
443 assert image.contains('"annotations":{')
444
445 audio := audio_content_with_annotations('BBB=', 'audio/wav', annot)
446 assert audio.contains('"type":"audio"')
447 assert audio.contains('"annotations":{')
448
449 embedded := embedded_text_resource_with_annotations('res://x', 'text/plain', 'hi', annot)
450 assert embedded.starts_with('{"type":"resource"')
451 assert embedded.contains('"resource":{"uri":"res://x"')
452 assert embedded.contains('"annotations":{')
453}
454
455fn test_progress_notifications_must_strictly_increase() {
456 mut server := new_server(name: 's', version: '0')
457 server.add_tool(Tool{ name: 'work' }, fn (ctx Context, _ string) !ToolResult {
458 ctx.notify_progress(0.3, 1.0, 'a')
459 ctx.notify_progress(0.3, 1.0, 'b') // should be dropped (equal)
460 ctx.notify_progress(0.1, 1.0, 'c') // should be dropped (lower)
461 ctx.notify_progress(0.6, 1.0, 'd') // accepted
462 return tool_text_result('done')
463 })!
464 build_initialized_session(mut server)
465
466 server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"work","arguments":{},"_meta":{"progressToken":"p1"}}}',
467 stdio_session_id, .stdio)!
468 queue := server.drain_session_notifications(stdio_session_id)
469 progress_notifications := queue.filter(it.contains('notifications/progress'))
470 assert progress_notifications.len == 2
471 assert progress_notifications[0].contains('"progress":0.3')
472 assert progress_notifications[1].contains('"progress":0.6')
473}
474
475fn test_completion_clamps_values_to_one_hundred() {
476 mut server := new_server(name: 's', version: '0')
477 server.add_completion(CompletionRef{ ref_type: 'ref/prompt', name: 'p' }, 'arg', fn (_ Context, _ string, _ string) !CompletionResult {
478 mut values := []string{cap: 150}
479 for i in 0 .. 150 {
480 values << 'item_${i}'
481 }
482 return CompletionResult{
483 values: values
484 }
485 })!
486 build_initialized_session(mut server)
487
488 dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"p"},"argument":{"name":"arg","value":""}}}',
489 stdio_session_id, .stdio)!
490 body := decode_response(dispatch.response)!.result
491 assert body.contains('"hasMore":true')
492 // 100 commas inside the values array (one between every consecutive pair).
493 values_section := body.all_after('"values":[').all_before(']')
494 assert values_section.split(',').len == 100
495 assert values_section.contains('"item_99"')
496 assert !values_section.contains('"item_100"')
497}
498
499fn test_url_elicitation_emits_mode_url_and_elicitation_id() {
500 encoded := encode_elicit_params(ElicitParams{
501 mode: 'url'
502 message: 'Connect your GitHub account'
503 url: 'https://example.com/connect/abc'
504 elicitation_id: '550e8400-e29b-41d4-a716-446655440000'
505 })
506 assert encoded.contains('"mode":"url"')
507 assert encoded.contains('"message":"Connect your GitHub account"')
508 assert encoded.contains('"url":"https://example.com/connect/abc"')
509 assert encoded.contains('"elicitationId":"550e8400-e29b-41d4-a716-446655440000"')
510 assert !encoded.contains('"requestedSchema"')
511}
512
513fn test_form_elicitation_omits_url_fields() {
514 encoded := encode_elicit_params(ElicitParams{
515 message: 'Pick a color'
516 requested_schema: ElicitSchema{
517 properties: '{"color":{"type":"string"}}'
518 required: ['color']
519 }
520 })
521 assert encoded.contains('"message":"Pick a color"')
522 assert encoded.contains('"requestedSchema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}')
523 assert !encoded.contains('"mode"')
524 assert !encoded.contains('"url"')
525 assert !encoded.contains('"elicitationId"')
526}
527
528fn test_resources_read_returns_resource_not_found_with_uri() {
529 mut server := new_server(name: 's', version: '0')
530 build_initialized_session(mut server)
531
532 dispatch := server.dispatch_message(new_request(2, 'resources/read', ReadResourceParams{
533 uri: 'res://missing'
534 }).encode(), stdio_session_id, .stdio)!
535 assert dispatch.response.contains('"code":-32002')
536 assert dispatch.response.contains('"data":{"uri":"res://missing"}')
537}
538
539fn test_elicitation_completion_notification_has_id() {
540 mut server := new_server(name: 's', version: '0')
541 build_initialized_session(mut server)
542
543 server.notify_elicitation_complete(stdio_session_id, '550e8400-e29b-41d4-a716-446655440000')
544 queue := server.drain_session_notifications(stdio_session_id)
545 assert queue.len == 1
546 notif := decode_notification(queue[0])!
547 assert notif.method == 'notifications/elicitation/complete'
548 assert notif.params.contains('"elicitationId":"550e8400-e29b-41d4-a716-446655440000"')
549}
550
551fn test_url_elicitation_required_error_code_matches_spec() {
552 assert url_elicitation_required.code == -32042
553}
554
555fn test_sampling_create_message_carries_tools_and_tool_choice() {
556 encoded := json.encode(CreateMessageParams{
557 messages: [
558 SamplingMessage{
559 role: 'user'
560 content: text_content('hello')
561 },
562 ]
563 max_tokens: 100
564 tools: [
565 Tool{
566 name: 'lookup'
567 input_schema: '{"type":"object","properties":{"q":{"type":"string"}}}'
568 },
569 ]
570 tool_choice: ToolChoice{
571 mode: 'auto'
572 }
573 include_context: 'thisServer'
574 })
575 assert encoded.contains('"tools":[')
576 assert encoded.contains('"name":"lookup"')
577 assert encoded.contains('"toolChoice":{"mode":"auto"}')
578 assert encoded.contains('"includeContext":"thisServer"')
579}
580