Files
agila_open_tool/tools/v/vlib/mcp/spec_compliance_test.v
T

580 lines
21 KiB
V

// Spec-compliance tests for MCP 2025-11-25 wire shapes.
// These tests intentionally check the JSON shape of responses and notifications
// produced by the server against the published schema:
// https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json
// Add new cases here whenever a schema field is touched.
module mcp
import json
fn build_initialized_session(mut server Server) {
server.dispatch_message(Request{
id: encode_id(1)
method: 'initialize'
params: encode_initialize_params(InitializeParams{
protocol_version: protocol_version
capabilities: '{"roots":{"listChanged":true}}'
client_info: Implementation{
name: 'compliance'
version: '0'
}
})
}.encode(), stdio_session_id, .stdio) or { panic(err) }
server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
stdio_session_id, .stdio) or { panic(err) }
}
fn test_initialize_result_shape_matches_spec() {
mut server := new_server(name: 's', version: '0', enable_logging: true)
server.add_tool(Tool{ name: 't' }, fn (_ Context, _ string) !ToolResult {
return tool_text_result('ok')
})!
dispatch := server.dispatch_message(Request{
id: encode_id(1)
method: 'initialize'
params: encode_initialize_params(InitializeParams{
protocol_version: protocol_version
capabilities: '{}'
client_info: Implementation{
name: 'c'
version: '0'
}
})
}.encode(), stdio_session_id, .stdio)!
response := decode_response(dispatch.response)!
assert response.error.code == 0
result := response.decode_result[InitializeResult]()!
assert result.protocol_version == protocol_version
assert result.server_info.name == 's'
assert result.server_info.version == '0'
assert result.capabilities.contains('"tools":{"listChanged":true}')
assert result.capabilities.contains('"logging":{}')
}
fn test_jsonrpc_envelope_includes_required_fields() {
mut server := new_server(name: 's', version: '0')
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'ping', empty).encode(), stdio_session_id,
.stdio)!
envelope := json.decode(MessageEnvelope, dispatch.response)!
assert envelope.jsonrpc == '2.0'
assert envelope.id == '2'
assert envelope.result == '{}'
assert envelope.method == ''
assert envelope.error.code == 0
}
fn test_tools_list_response_uses_input_schema_field() {
mut server := new_server(name: 's', version: '0')
server.add_tool(Tool{
name: 'test'
description: 'd'
input_schema: '{"type":"object","properties":{"x":{"type":"string"}}}'
}, fn (_ Context, _ string) !ToolResult {
return tool_text_result('ok')
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"inputSchema":{"type":"object","properties":{"x":{"type":"string"}}}')
assert body.contains('"name":"test"')
}
fn test_tool_call_result_has_is_error_and_content_array() {
mut server := new_server(name: 's', version: '0')
server.add_tool(Tool{ name: 'fails' }, fn (_ Context, _ string) !ToolResult {
return error('boom')
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"fails","arguments":{}}}',
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"isError":true')
assert body.contains('"content":[')
assert body.contains('"type":"text"')
}
fn test_resources_list_uses_camel_case_mime_type() {
mut server := new_server(name: 's', version: '0')
server.add_resource(Resource{
uri: 'res://a'
name: 'a'
mime_type: 'text/plain'
}, fn (_ Context, uri string) !ReadResourceResult {
return ReadResourceResult{
contents: [
ResourceContents{
uri: uri
mime_type: 'text/plain'
text: 'hi'
},
]
}
})!
build_initialized_session(mut server)
list_dispatch := server.dispatch_message(new_request(2, 'resources/list', empty).encode(),
stdio_session_id, .stdio)!
list_body := decode_response(list_dispatch.response)!.result
assert list_body.contains('"mimeType":"text/plain"')
assert !list_body.contains('"mime_type"')
read_dispatch := server.dispatch_message(new_request(3, 'resources/read', ReadResourceParams{
uri: 'res://a'
}).encode(), stdio_session_id, .stdio)!
read_body := decode_response(read_dispatch.response)!.result
assert read_body.contains('"mimeType":"text/plain"')
}
fn test_resource_templates_list_uses_camel_case_uri_template() {
mut server := new_server(name: 's', version: '0')
server.add_resource_template(ResourceTemplate{
uri_template: 'res://a/{x}'
name: 'a'
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'resources/templates/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"resourceTemplates"')
assert body.contains('"uriTemplate":"res://a/{x}"')
}
fn test_progress_notification_uses_camel_case_progress_token() {
mut server := new_server(name: 's', version: '0')
server.add_tool(Tool{ name: 'work' }, fn (ctx Context, _ string) !ToolResult {
ctx.notify_progress(0.5, 1.0, 'half')
return tool_text_result('done')
})!
build_initialized_session(mut server)
server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"work","arguments":{},"_meta":{"progressToken":"abc"}}}',
stdio_session_id, .stdio)!
queue := server.drain_session_notifications(stdio_session_id)
assert queue.len == 1
notif := decode_notification(queue[0])!
assert notif.method == 'notifications/progress'
assert notif.params.contains('"progressToken":"abc"')
}
fn test_logging_message_uses_rfc5424_levels() {
levels := [
LogLevel.debug,
LogLevel.info,
LogLevel.notice,
LogLevel.warning,
LogLevel.error,
LogLevel.critical,
LogLevel.alert,
LogLevel.emergency,
]
expected := ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']
for i, level in levels {
assert level.str() == expected[i]
parsed := parse_log_level(expected[i]) or { panic('failed to parse ${expected[i]}') }
assert parsed == level
}
}
fn test_error_codes_match_jsonrpc_specification() {
assert parse_error.code == -32700
assert invalid_request.code == -32600
assert method_not_found.code == -32601
assert invalid_params.code == -32602
assert internal_error.code == -32603
assert server_not_initialized.code == -32002
}
fn test_pagination_emits_next_cursor_when_more_pages_exist() {
mut server := new_server(name: 's', version: '0')
for i in 0 .. default_list_page_size + 5 {
server.add_tool(Tool{ name: 'tool_${i}' }, fn (_ Context, _ string) !ToolResult {
return tool_text_result('ok')
})!
}
build_initialized_session(mut server)
first_dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
stdio_session_id, .stdio)!
first := decode_response(first_dispatch.response)!.decode_result[ListToolsResult]()!
assert first.tools.len == default_list_page_size
assert first.next_cursor == default_list_page_size.str()
second_dispatch := server.dispatch_message(Request{
id: encode_id(3)
method: 'tools/list'
params: '{"cursor":"${first.next_cursor}"}'
}.encode(), stdio_session_id, .stdio)!
second := decode_response(second_dispatch.response)!.decode_result[ListToolsResult]()!
assert second.tools.len == 5
assert second.next_cursor == ''
}
fn test_completion_result_matches_spec_shape() {
mut server := new_server(name: 's', version: '0')
server.add_completion(CompletionRef{ ref_type: 'ref/prompt', name: 'p' }, 'arg', fn (_ Context, _ string, _ string) !CompletionResult {
return CompletionResult{
values: ['a', 'b']
total: 2
has_more: false
}
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"p"},"argument":{"name":"arg","value":""}}}',
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
// The result MUST be wrapped in a `completion` object per spec.
assert body.starts_with('{"completion":{')
assert body.contains('"values":["a","b"]')
assert body.contains('"total":2')
assert body.contains('"hasMore":false')
}
fn test_initialize_blocks_other_methods_until_notifications_initialized_received() {
mut server := new_server(name: 's', version: '0')
server.dispatch_message(Request{
id: encode_id(1)
method: 'initialize'
params: encode_initialize_params(InitializeParams{
protocol_version: protocol_version
capabilities: '{}'
client_info: Implementation{
name: 'c'
version: '0'
}
})
}.encode(), stdio_session_id, .stdio)!
dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
stdio_session_id, .stdio)!
response := decode_response(dispatch.response)!
assert response.error.code == server_not_initialized.code
}
fn test_implementation_carries_2025_11_25_metadata_extensions() {
icon := Icon{
src: 'https://example.com/icon.svg'
mime_type: 'image/svg+xml'
sizes: ['48x48', '96x96']
theme: 'light'
}
mut server := new_server(
name: 's'
version: '0'
title: 'Server Title'
description: 'Showcase'
website_url: 'https://example.com'
icons: [icon]
)
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'ping', empty).encode(), stdio_session_id,
.stdio)!
// The server's Implementation is folded into the cached InitializeResult; round-trip
// it through json.encode to verify the wire shape.
encoded := json.encode(server.server_info)
assert encoded.contains('"name":"s"')
assert encoded.contains('"version":"0"')
assert encoded.contains('"title":"Server Title"')
assert encoded.contains('"description":"Showcase"')
assert encoded.contains('"websiteUrl":"https://example.com"')
assert encoded.contains('"icons":[')
assert encoded.contains('"src":"https://example.com/icon.svg"')
assert encoded.contains('"mimeType":"image/svg+xml"')
assert encoded.contains('"sizes":["48x48","96x96"]')
assert encoded.contains('"theme":"light"')
assert dispatch.response.contains('"jsonrpc":"2.0"')
}
fn test_implementation_omits_optional_metadata_when_empty() {
encoded := json.encode(Implementation{ name: 'c', version: '0' })
assert encoded == '{"name":"c","version":"0"}'
}
fn test_tool_emits_icons_and_execution_task_support() {
mut server := new_server(name: 's', version: '0')
server.add_tool(Tool{
name: 'launch'
icons: [
Icon{
src: 'https://example.com/launch.png'
mime_type: 'image/png'
},
]
execution: ToolExecution{
task_support: 'optional'
}
}, fn (_ Context, _ string) !ToolResult {
return tool_text_result('ok')
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"icons":[{"src":"https://example.com/launch.png","mimeType":"image/png"}]')
assert body.contains('"execution":{"taskSupport":"optional"}')
}
fn test_tool_omits_icons_and_execution_when_unset() {
mut server := new_server(name: 's', version: '0')
server.add_tool(Tool{ name: 'plain' }, fn (_ Context, _ string) !ToolResult {
return tool_text_result('ok')
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert !body.contains('"icons"')
assert !body.contains('"execution"')
}
fn test_resource_emits_icons_size_and_annotations() {
mut server := new_server(name: 's', version: '0')
server.add_resource(Resource{
uri: 'res://big'
name: 'big'
size: 4096
icons: [Icon{
src: 'https://example.com/file.svg'
}]
annotations: Annotations{
audience: ['user']
priority: 0.8
last_modified: '2026-04-29T00:00:00Z'
}
}, fn (_ Context, uri string) !ReadResourceResult {
return ReadResourceResult{
contents: [ResourceContents{
uri: uri
text: ''
}]
}
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'resources/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"size":4096')
assert body.contains('"icons":[{"src":"https://example.com/file.svg"}]')
assert body.contains('"audience":["user"]')
assert body.contains('"priority":0.8')
assert body.contains('"lastModified":"2026-04-29T00:00:00Z"')
}
fn test_resource_template_emits_icons_and_annotations() {
mut server := new_server(name: 's', version: '0')
server.add_resource_template(ResourceTemplate{
uri_template: 'res://greet/{name}'
name: 'greet'
icons: [Icon{
src: 'https://example.com/wave.svg'
}]
annotations: Annotations{
audience: ['assistant']
}
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'resources/templates/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"icons":[{"src":"https://example.com/wave.svg"}]')
assert body.contains('"audience":["assistant"]')
}
fn test_prompt_emits_icons() {
mut server := new_server(name: 's', version: '0')
server.add_prompt(Prompt{
name: 'review'
icons: [Icon{
src: 'https://example.com/review.svg'
}]
}, fn (_ Context, _ string) !GetPromptResult {
return GetPromptResult{
messages: [prompt_text_message('user', 'hi')]
}
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'prompts/list', empty).encode(),
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"icons":[{"src":"https://example.com/review.svg"}]')
}
fn test_content_helpers_match_spec_shapes() {
assert text_content('hi') == '{"type":"text","text":"hi"}'
assert image_content('AAA=', 'image/png') == '{"type":"image","data":"AAA=","mimeType":"image/png"}'
assert audio_content('BBB=', 'audio/wav') == '{"type":"audio","data":"BBB=","mimeType":"audio/wav"}'
assert embedded_text_resource('res://a', 'text/plain', 'hi') == '{"type":"resource","resource":{"uri":"res://a","mimeType":"text/plain","text":"hi"}}'
assert embedded_blob_resource('res://b', 'image/png', 'AAA=') == '{"type":"resource","resource":{"uri":"res://b","mimeType":"image/png","blob":"AAA="}}'
link := resource_link_content(Resource{ uri: 'res://l', name: 'l' })
assert link.starts_with('{"type":"resource_link",')
assert link.contains('"uri":"res://l"')
assert link.contains('"name":"l"')
}
fn test_content_helpers_attach_annotations() {
annot := Annotations{
audience: ['user', 'assistant']
priority: 0.7
last_modified: '2026-04-29T00:00:00Z'
}
text := text_content_with_annotations('hi', annot)
assert text.contains('"type":"text"')
assert text.contains('"text":"hi"')
assert text.contains('"annotations":{')
assert text.contains('"audience":["user","assistant"]')
assert text.contains('"priority":0.7')
assert text.contains('"lastModified":"2026-04-29T00:00:00Z"')
image := image_content_with_annotations('AAA=', 'image/png', annot)
assert image.contains('"type":"image"')
assert image.contains('"data":"AAA="')
assert image.contains('"mimeType":"image/png"')
assert image.contains('"annotations":{')
audio := audio_content_with_annotations('BBB=', 'audio/wav', annot)
assert audio.contains('"type":"audio"')
assert audio.contains('"annotations":{')
embedded := embedded_text_resource_with_annotations('res://x', 'text/plain', 'hi', annot)
assert embedded.starts_with('{"type":"resource"')
assert embedded.contains('"resource":{"uri":"res://x"')
assert embedded.contains('"annotations":{')
}
fn test_progress_notifications_must_strictly_increase() {
mut server := new_server(name: 's', version: '0')
server.add_tool(Tool{ name: 'work' }, fn (ctx Context, _ string) !ToolResult {
ctx.notify_progress(0.3, 1.0, 'a')
ctx.notify_progress(0.3, 1.0, 'b') // should be dropped (equal)
ctx.notify_progress(0.1, 1.0, 'c') // should be dropped (lower)
ctx.notify_progress(0.6, 1.0, 'd') // accepted
return tool_text_result('done')
})!
build_initialized_session(mut server)
server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"work","arguments":{},"_meta":{"progressToken":"p1"}}}',
stdio_session_id, .stdio)!
queue := server.drain_session_notifications(stdio_session_id)
progress_notifications := queue.filter(it.contains('notifications/progress'))
assert progress_notifications.len == 2
assert progress_notifications[0].contains('"progress":0.3')
assert progress_notifications[1].contains('"progress":0.6')
}
fn test_completion_clamps_values_to_one_hundred() {
mut server := new_server(name: 's', version: '0')
server.add_completion(CompletionRef{ ref_type: 'ref/prompt', name: 'p' }, 'arg', fn (_ Context, _ string, _ string) !CompletionResult {
mut values := []string{cap: 150}
for i in 0 .. 150 {
values << 'item_${i}'
}
return CompletionResult{
values: values
}
})!
build_initialized_session(mut server)
dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"p"},"argument":{"name":"arg","value":""}}}',
stdio_session_id, .stdio)!
body := decode_response(dispatch.response)!.result
assert body.contains('"hasMore":true')
// 100 commas inside the values array (one between every consecutive pair).
values_section := body.all_after('"values":[').all_before(']')
assert values_section.split(',').len == 100
assert values_section.contains('"item_99"')
assert !values_section.contains('"item_100"')
}
fn test_url_elicitation_emits_mode_url_and_elicitation_id() {
encoded := encode_elicit_params(ElicitParams{
mode: 'url'
message: 'Connect your GitHub account'
url: 'https://example.com/connect/abc'
elicitation_id: '550e8400-e29b-41d4-a716-446655440000'
})
assert encoded.contains('"mode":"url"')
assert encoded.contains('"message":"Connect your GitHub account"')
assert encoded.contains('"url":"https://example.com/connect/abc"')
assert encoded.contains('"elicitationId":"550e8400-e29b-41d4-a716-446655440000"')
assert !encoded.contains('"requestedSchema"')
}
fn test_form_elicitation_omits_url_fields() {
encoded := encode_elicit_params(ElicitParams{
message: 'Pick a color'
requested_schema: ElicitSchema{
properties: '{"color":{"type":"string"}}'
required: ['color']
}
})
assert encoded.contains('"message":"Pick a color"')
assert encoded.contains('"requestedSchema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}')
assert !encoded.contains('"mode"')
assert !encoded.contains('"url"')
assert !encoded.contains('"elicitationId"')
}
fn test_resources_read_returns_resource_not_found_with_uri() {
mut server := new_server(name: 's', version: '0')
build_initialized_session(mut server)
dispatch := server.dispatch_message(new_request(2, 'resources/read', ReadResourceParams{
uri: 'res://missing'
}).encode(), stdio_session_id, .stdio)!
assert dispatch.response.contains('"code":-32002')
assert dispatch.response.contains('"data":{"uri":"res://missing"}')
}
fn test_elicitation_completion_notification_has_id() {
mut server := new_server(name: 's', version: '0')
build_initialized_session(mut server)
server.notify_elicitation_complete(stdio_session_id, '550e8400-e29b-41d4-a716-446655440000')
queue := server.drain_session_notifications(stdio_session_id)
assert queue.len == 1
notif := decode_notification(queue[0])!
assert notif.method == 'notifications/elicitation/complete'
assert notif.params.contains('"elicitationId":"550e8400-e29b-41d4-a716-446655440000"')
}
fn test_url_elicitation_required_error_code_matches_spec() {
assert url_elicitation_required.code == -32042
}
fn test_sampling_create_message_carries_tools_and_tool_choice() {
encoded := json.encode(CreateMessageParams{
messages: [
SamplingMessage{
role: 'user'
content: text_content('hello')
},
]
max_tokens: 100
tools: [
Tool{
name: 'lookup'
input_schema: '{"type":"object","properties":{"q":{"type":"string"}}}'
},
]
tool_choice: ToolChoice{
mode: 'auto'
}
include_context: 'thisServer'
})
assert encoded.contains('"tools":[')
assert encoded.contains('"name":"lookup"')
assert encoded.contains('"toolChoice":{"mode":"auto"}')
assert encoded.contains('"includeContext":"thisServer"')
}