From 7b55539b6e355cd5930ad6213fc49d3c884821dc Mon Sep 17 00:00:00 2001 From: David Legrand Date: Sun, 24 May 2026 00:22:14 +0200 Subject: [PATCH] mcp: align with the 2025-11-25 spec (#27133) --- examples/mcp/server.v | 210 +++++ vlib/mcp/README.md | 144 +++- vlib/mcp/mcp.v | 161 ++-- vlib/mcp/mcp_test.v | 30 +- vlib/mcp/server.v | 1414 +++++++++++++++++++++++++++++-- vlib/mcp/server_test.v | 725 +++++++++++++++- vlib/mcp/spec_compliance_test.v | 579 +++++++++++++ 7 files changed, 3101 insertions(+), 162 deletions(-) create mode 100644 examples/mcp/server.v create mode 100644 vlib/mcp/spec_compliance_test.v diff --git a/examples/mcp/server.v b/examples/mcp/server.v new file mode 100644 index 000000000..e712d5970 --- /dev/null +++ b/examples/mcp/server.v @@ -0,0 +1,210 @@ +// Comprehensive MCP server example covering every feature exposed by `vlib/mcp`: +// tools (with annotations), concrete resources, resource templates, prompts, +// per-argument completions, RFC 5424 logging, cooperative cancellation, +// progress notifications, subscriptions, and server-initiated requests. +// +// Usage: +// v run examples/mcp/server.v # stdio transport (default) +// v run examples/mcp/server.v -- --http # HTTP transport on 127.0.0.1:8080 +// v run examples/mcp/server.v -- --http 127.0.0.1:9000 # HTTP transport on 127.0.0.1:9000 +// +// Connect a client (e.g. Claude Desktop / Cursor / a custom MCP client) to the +// command above for stdio, or POST to `http://127.0.0.1:8080/mcp` for HTTP. +module main + +import json +import mcp +import os +import time + +struct CountArgs { + n int +} + +const supported_languages = ['rust', 'python', 'go', 'v', 'typescript', 'zig']! +const welcome_text = 'Welcome to the V MCP showcase server.' + +fn main() { + mut server := mcp.new_server( + name: 'v.mcp.showcase' + version: '1.0.0' + title: 'V MCP Showcase' + description: 'Reference server for vlib/mcp covering every capability of the 2025-11-25 spec.' + website_url: 'https://vlang.io' + icons: [ + mcp.Icon{ + src: 'https://vlang.io/img/v-logo.png' + mime_type: 'image/png' + sizes: ['256x256'] + }, + ] + instructions: 'Demo server exercising every MCP capability shipped by vlib/mcp.' + enable_logging: true + // `*` only for the demo; tighten this for real deployments. + allowed_origins: ['*'] + ) + register_tools(mut server)! + register_resources(mut server)! + register_prompts(mut server)! + register_completions(mut server)! + + // Strip a leading `--` so the same binary works whether launched as + // `./v run server.v -- --http :8080` (V's run forwards `--`) or as the + // pre-compiled binary `./server --http :8080`. + args := os.args[1..].filter(it != '--') + if args.len > 0 && args[0] == '--http' { + addr := if args.len > 1 { args[1] } else { '127.0.0.1:8080' } + eprintln('mcp showcase listening on http://${addr}/mcp') + server.serve_http(addr)! + } else { + server.serve_stdio()! + } +} + +fn register_tools(mut server mcp.Server) ! { + // `echo` — pure, idempotent, read-only. Demonstrates annotations + icons. + server.add_tool(mcp.Tool{ + name: 'echo' + title: 'Echo' + description: 'Return the provided text unchanged.' + input_schema: '{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}' + icons: [ + mcp.Icon{ + src: 'https://vlang.io/img/echo.svg' + mime_type: 'image/svg+xml' + }, + ] + annotations: mcp.ToolAnnotations{ + title: 'Echo input' + read_only_hint: true + idempotent_hint: true + open_world_hint: false + } + }, fn (_ mcp.Context, arguments string) !mcp.ToolResult { + // `arguments` is a JSON string the caller may decode. Keep it simple here. + return mcp.tool_text_result(arguments) + })! + + // `count_to` — long-running tool that reports progress and is cancellable. + server.add_tool(mcp.Tool{ + name: 'count_to' + title: 'Counter' + description: 'Count up to N with progress notifications. Cooperatively cancellable.' + input_schema: '{"type":"object","required":["n"],"properties":{"n":{"type":"integer","minimum":1,"maximum":50}}}' + }, fn (ctx mcp.Context, arguments string) !mcp.ToolResult { + args := json.decode(CountArgs, arguments) or { + return mcp.tool_text_result('invalid arguments: ${err.msg()}') + } + if args.n < 1 || args.n > 50 { + return mcp.tool_text_result('n must be in [1, 50]') + } + for i in 1 .. args.n + 1 { + if ctx.is_cancelled() { + return mcp.tool_text_result('cancelled at ${i - 1}') + } + ctx.notify_progress(f64(i), f64(args.n), 'tick ${i}') + time.sleep(50 * time.millisecond) + } + return mcp.tool_text_result('counted to ${args.n}') + })! + + // `delete_record` — destructive; a host can warn the user before invoking. + server.add_tool(mcp.Tool{ + name: 'delete_record' + title: 'Delete record (demo)' + description: 'Demonstration only — does not delete anything.' + annotations: mcp.ToolAnnotations{ + destructive_hint: true + idempotent_hint: false + } + }, fn (_ mcp.Context, _ string) !mcp.ToolResult { + return mcp.tool_text_result('record removed (no-op)') + })! +} + +fn register_resources(mut server mcp.Server) ! { + server.add_resource(mcp.Resource{ + uri: 'demo://welcome.txt' + name: 'welcome' + title: 'Welcome message' + description: 'A static welcome string.' + mime_type: 'text/plain' + size: welcome_text.len + annotations: mcp.Annotations{ + audience: ['user'] + priority: 0.5 + } + }, fn (_ mcp.Context, uri string) !mcp.ReadResourceResult { + return mcp.ReadResourceResult{ + contents: [ + mcp.ResourceContents{ + uri: uri + mime_type: 'text/plain' + text: welcome_text + }, + ] + } + })! + + server.add_resource_template(mcp.ResourceTemplate{ + uri_template: 'demo://greet/{language}' + name: 'greet' + title: 'Localised greeting' + description: 'Returns a greeting in {language}.' + mime_type: 'text/plain' + })! +} + +fn register_prompts(mut server mcp.Server) ! { + server.add_prompt(mcp.Prompt{ + name: 'review' + title: 'Code review' + description: 'Ask the assistant to review a snippet in the requested language.' + arguments: [ + mcp.PromptArgument{ + name: 'language' + description: 'Source language of the snippet.' + required: true + }, + mcp.PromptArgument{ + name: 'snippet' + description: 'The code to review.' + required: true + }, + ] + }, fn (_ mcp.Context, arguments string) !mcp.GetPromptResult { + return mcp.GetPromptResult{ + description: 'Code review prompt' + messages: [ + mcp.prompt_text_message('user', + 'Please review the following code. Arguments: ${arguments}'), + ] + } + })! +} + +fn register_completions(mut server mcp.Server) ! { + // Auto-complete the prompt argument `language` against a known list. + server.add_completion(mcp.CompletionRef{ + ref_type: 'ref/prompt' + name: 'review' + }, 'language', fn (_ mcp.Context, current_value string, _ string) !mcp.CompletionResult { + matches := supported_languages.filter(it.starts_with(current_value.to_lower())) + return mcp.CompletionResult{ + values: matches + total: matches.len + has_more: false + } + })! + + // Auto-complete the resource template variable `language` similarly. + server.add_completion(mcp.CompletionRef{ + ref_type: 'ref/resource' + uri: 'demo://greet/{language}' + }, 'language', fn (_ mcp.Context, current_value string, _ string) !mcp.CompletionResult { + matches := supported_languages.filter(it.starts_with(current_value.to_lower())) + return mcp.CompletionResult{ + values: matches + } + })! +} diff --git a/vlib/mcp/README.md b/vlib/mcp/README.md index 135eaaf85..0dfb897c6 100644 --- a/vlib/mcp/README.md +++ b/vlib/mcp/README.md @@ -1,17 +1,38 @@ # `mcp` -`mcp` is a Model Context Protocol module for V. - -It provides: - -- typed JSON-RPC request, notification, response, and initialize payload helpers -- a synchronous `Client` that performs the MCP initialize handshake automatically -- a server-side `Server` with tool, resource, resource template, and prompt registration -- streamable HTTP and stdio transports behind a common transport interface -- queues for server notifications and server-initiated requests -- buffered while waiting for a response - -## HTTP example +Native [Model Context Protocol][spec] implementation for V — both client and +server, full coverage of the **2025-11-25** revision of the spec. + +[spec]: https://modelcontextprotocol.io/specification/2025-11-25 + +## Capabilities + +| Feature | Status | +| ------------------------------------------------------ | :----: | +| JSON-RPC 2.0 base protocol | ✅ | +| stdio transport (newline-delimited) | ✅ | +| Streamable HTTP transport (POST + GET, SSE, sessions) | ✅ | +| `Origin` header validation (DNS rebinding protection) | ✅ | +| `MCP-Session-Id` and `MCP-Protocol-Version` headers | ✅ | +| `Last-Event-ID` resumption | ✅ | +| Tools (with `annotations`) | ✅ | +| Resources, resource templates, `subscribe`/`updated` | ✅ | +| Prompts | ✅ | +| `completion/complete` | ✅ | +| `logging/setLevel` + `notifications/message` | ✅ | +| `notifications/progress` + cooperative cancellation | ✅ | +| `*/list_changed` notifications (auto on `add_*`) | ✅ | +| Server-initiated `roots/list`, `sampling/createMessage`, `elicitation/create` | ✅ | +| `Icon`, `BaseMetadata` (title), `Annotations` on tools/resources/prompts | ✅ | +| `Tool.execution.taskSupport` advertisement | ✅ | +| Content helpers (`text`, `image`, `audio`, embedded resource, resource link) | ✅ | +| Tasks utility (`tasks/*`) | ⏳ deferred (experimental) | +| OAuth Authorization | ⏳ deferred (`SHOULD`) | + +A comprehensive demo server lives at +[`examples/mcp/server.v`](../../examples/mcp/server.v). + +## Quick start — client ```v import mcp @@ -24,46 +45,93 @@ fn main() { } ``` -## Stdio example - -```v -import mcp - -fn main() { - mut client := mcp.connect_stdio('my-mcp-server', ['--stdio'], mcp.ClientConfig{})! - client.notify('notifications/cancelled', { - 'requestId': 1 - })! - client.close() -} -``` - -## Server example +## Quick start — server ```v import mcp fn main() { mut server := mcp.new_server( - name: 'my-v-mcp-server' - version: '1.0.0' + name: 'my-v-mcp-server' + version: '1.0.0' + enable_logging: true ) - server.add_tool(mcp.Tool{ name: 'say_hello' - description: 'Greets a user by name' + description: 'Greets the caller' + annotations: mcp.ToolAnnotations{ + read_only_hint: true + } }, fn (_ mcp.Context, _ string) !mcp.ToolResult { return mcp.tool_text_result('Hello, user!') })! - server.serve_stdio()! } ``` -## Notes +## Cancellation and progress + +Tool/resource/prompt handlers receive a `Context`. When the client supplies a +`_meta.progressToken`, the handler can call `ctx.notify_progress(progress, total, message)`. +For long-running work, poll `ctx.is_cancelled()` regularly — when the client +sends `notifications/cancelled`, the flag flips to `true` until the request +completes. + +## Server-initiated requests + +```v oksyntax +import mcp +import time + +mut server := mcp.new_server(name: 'demo', version: '0') +session_id := 'session' +roots := server.list_roots(session_id, 5 * time.second)! +sampled := server.sample(session_id, mcp.CreateMessageParams{}, 30 * time.second)! +elicited := server.elicit(session_id, mcp.ElicitParams{}, 60 * time.second)! +``` + +These block until the client returns the matching JSON-RPC response (or until +the timeout fires). + +## Content blocks + +Tool, prompt and resource handlers return arrays of MCP content blocks. The +module ships ready-made helpers — pass the result through `tool_text_result` +or compose them by hand: + +```v oksyntax +import mcp + +text := mcp.text_content('done') +img := mcp.image_content('AAA=', 'image/png') +audio := mcp.audio_content('BBB=', 'audio/wav') +embedded_text := mcp.embedded_text_resource('res://config', 'application/json', '{}') +embedded_blob := mcp.embedded_blob_resource('res://blob', 'image/png', 'AAA=') +resource_link := mcp.resource_link_content(mcp.Resource{ + uri: 'res://docs' + name: 'docs' +}) +``` + +Each helper returns a JSON string conforming to the spec's `ContentBlock` +union (`type: "text" | "image" | "audio" | "resource" | "resource_link"`). + +## Streamable HTTP details + +- POST: returns JSON by default. Returns SSE if the client sends + `Accept: text/event-stream` only. +- GET: opens an SSE stream of queued notifications. Resume with `Last-Event-ID`. +- DELETE: terminates the session (`MCP-Session-Id` required). +- 403 on disallowed `Origin`, 400 on unsupported `MCP-Protocol-Version`, + 406 when `Accept` lists neither `application/json` nor `text/event-stream`. + +## Tests + +``` +v test vlib/mcp +``` + +`spec_compliance_test.v` cross-checks wire shapes against the +[official schema][schema]. Add a case there whenever a payload field changes. -- `Client.request` auto-initializes when needed. -- `Client.take_notifications` and `Client.take_requests` drain queued server messages. -- The HTTP transport supports JSON responses and `text/event-stream` POST responses. -- `Server.serve_http` uses a single MCP endpoint, returns JSON by default, and will return SSE for POST - requests that accept only `text/event-stream`. +[schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json \ No newline at end of file diff --git a/vlib/mcp/mcp.v b/vlib/mcp/mcp.v index bd6f72ad0..bd493a59d 100644 --- a/vlib/mcp/mcp.v +++ b/vlib/mcp/mcp.v @@ -31,11 +31,22 @@ pub const server_not_initialized = ResponseError{ code: -32002 message: 'Server not initialized.' } +pub const resource_not_found = ResponseError{ + code: -32002 + message: 'Resource not found.' +} +pub const url_elicitation_required = ResponseError{ + code: -32042 + message: 'URL mode elicitation required.' +} const default_content_type = 'application/json' -const streamable_http_accept = 'application/json, text/event-stream' -const content_length_header = 'Content-Length' -const mcp_session_id_header = 'Mcp-Session-Id' +const event_stream_content_type = 'text/event-stream' +const streamable_http_accept = '${default_content_type}, ${event_stream_content_type}' +const mcp_session_id_header = 'MCP-Session-Id' +const mcp_protocol_version_header = 'MCP-Protocol-Version' +const last_event_id_header = 'Last-Event-ID' +const default_protocol_version = '2025-03-26' const process_poll_interval = 5 * time.millisecond const default_client_name = 'v.mcp' const default_client_version = 'dev' @@ -93,11 +104,28 @@ pub fn (e EmptyObject) str() string { pub const empty_object = EmptyObject{} -// Implementation identifies an MCP client or server implementation. +// Icon describes a UI icon advertised by an MCP implementation, tool, +// resource, resource template or prompt. `src` is required; `mime_type`, +// `sizes` and `theme` are optional metadata mirroring the spec's `Icon` shape. +pub struct Icon { +pub: + src string + mime_type string @[json: mimeType; omitempty] + sizes []string @[omitempty] + theme string @[omitempty] +} + +// Implementation identifies an MCP client or server implementation. `name` +// and `version` are required; `title`, `description`, `website_url` and +// `icons` are optional 2025-11-25 metadata extensions (BaseMetadata + Icons). pub struct Implementation { pub: - name string - version string + name string + version string + title string @[omitempty] + description string @[omitempty] + website_url string @[json: websiteUrl; omitempty] + icons []Icon @[omitempty] } // InitializeParams is the typed payload for the `initialize` request. @@ -200,7 +228,7 @@ pub fn new_response[I, R](id I, result R, err ResponseError) Response { pub fn (resp Response) encode() string { mut payload := '{"jsonrpc":"${jsonrpc_version}"' if resp.error.code != 0 { - payload += ',"error":' + json.encode(resp.error) + payload += ',"error":' + encode_response_error(resp.error) } else { result_payload := if resp.result.len == 0 { null.str() } else { resp.result } payload += ',"result":' + result_payload @@ -209,6 +237,18 @@ pub fn (resp Response) encode() string { return payload + ',"id":${id_payload}}' } +// encode_response_error renders a ResponseError as JSON, preserving the +// `data` payload as raw JSON. V's `json.encode` ignores the `@[raw]` tag on +// encode, so a hand-rolled writer is required to keep the wire shape spec +// compliant (the `data` field MAY be any JSON value per JSON-RPC 2.0). +fn encode_response_error(err ResponseError) string { + mut fields := ['"code":${err.code}', '"message":${json.encode(err.message)}'] + if err.data.trim_space() != '' { + fields << '"data":${err.data}' + } + return '{${fields.join(',')}}' +} + // decode_result decodes the response result into `T`. pub fn (resp Response) decode_result[T]() !T { if resp.error.code != 0 { @@ -241,9 +281,15 @@ struct MessageEnvelope { error ResponseError } +// is_notification_id reports whether a JSON-RPC `id` field encodes the +// "absent or null" form, which per spec marks the envelope as a notification. +fn is_notification_id(id string) bool { + return id == '' || id == null.str() +} + fn (env MessageEnvelope) encode() string { if env.method.len != 0 { - if env.id.len == 0 || env.id == null.str() { + if is_notification_id(env.id) { return Notification{ method: env.method params: env.params @@ -443,7 +489,7 @@ fn (mut c Client) wait_for_response(expected_id string) !Response { raw_message := c.transport.receive()! envelope := decode_envelope(raw_message)! if envelope.method.len != 0 { - if envelope.id.len == 0 || envelope.id == null.str() { + if is_notification_id(envelope.id) { c.notifications << Notification{ method: envelope.method params: envelope.params @@ -484,6 +530,7 @@ fn (err NoFrameError) code() int { return 0 } +// FrameExtraction holds a single stdio message and the unconsumed buffer remainder. struct FrameExtraction { message string remaining string @@ -491,10 +538,11 @@ struct FrameExtraction { struct HttpTransport { mut: - url string - header http.Header - session_id string - pending []string + url string + header http.Header + session_id string + protocol_version string + pending []string } fn new_http_transport(url string, config ClientConfig) !HttpTransport { @@ -522,6 +570,9 @@ fn (mut transport HttpTransport) send(message string) ! { if transport.session_id != '' { header.set_custom(mcp_session_id_header, transport.session_id)! } + if transport.protocol_version != '' { + header.set_custom(mcp_protocol_version_header, transport.protocol_version)! + } response := http.fetch( method: .post url: transport.url @@ -531,6 +582,11 @@ fn (mut transport HttpTransport) send(message string) ! { if session_id := response.header.get_custom(mcp_session_id_header) { transport.session_id = session_id } + if transport.protocol_version == '' && transport.session_id != '' { + // First handshake response: capture the negotiated version so all + // subsequent requests carry MCP-Protocol-Version per spec §Transports. + transport.protocol_version = read_negotiated_version(response.body) + } messages := parse_http_response_messages(response)! if messages.len != 0 { transport.pending << messages @@ -541,6 +597,12 @@ fn (mut transport HttpTransport) send(message string) ! { } } +fn read_negotiated_version(body string) string { + envelope := decode_envelope(body) or { return '' } + result := json.decode(InitializeResult, envelope.result) or { return '' } + return result.protocol_version +} + fn (mut transport HttpTransport) receive() !string { if transport.pending.len == 0 { return error('mcp.http: no pending messages are available') @@ -588,12 +650,12 @@ fn new_process_transport(command string, args []string) !ProcessTransport { } fn (mut transport ProcessTransport) send(message string) ! { - transport.process.stdin_write(encode_framed_message(message)) + transport.process.stdin_write(encode_stdio_message(message)) } fn (mut transport ProcessTransport) receive() !string { for { - frame := try_extract_framed_message(transport.buffer) or { + frame := try_extract_stdio_message(transport.buffer) or { if err.msg() != NoFrameError{}.msg() { return err } @@ -612,7 +674,7 @@ fn (mut transport ProcessTransport) receive() !string { } if !transport.process.is_alive() { transport.buffer += transport.process.stdout_slurp() - frame_after_exit := try_extract_framed_message(transport.buffer) or { + frame_after_exit := try_extract_stdio_message(transport.buffer) or { if err.msg() != NoFrameError{}.msg() { return err } @@ -732,43 +794,32 @@ fn is_json_payload(payload string) bool { return trimmed[0] == `{` || trimmed[0] == `[` } -fn encode_framed_message(message string) string { - return '${content_length_header}: ${message.len}\r\n\r\n${message}' -} - -fn try_extract_framed_message(buffer string) !FrameExtraction { - header_end := buffer.index('\r\n\r\n') or { return NoFrameError{} } - header_text := buffer[..header_end] - mut content_length := -1 - for line in header_text.split('\r\n') { - if line.len == 0 { - continue - } - parts := line.split_nth(':', 2) - if parts.len != 2 { - continue - } - if parts[0].trim_space().to_lower() != content_length_header.to_lower() { - continue - } - length_text := parts[1].trim_space() - parsed_length := length_text.int() - if parsed_length == 0 && length_text != '0' { - return error('mcp.stdio: invalid Content-Length header `${length_text}`') +// encode_stdio_message produces a newline-delimited stdio frame per MCP spec. +// The MCP spec mandates that messages are delimited by newlines and MUST NOT +// contain embedded newlines. Compact JSON encoding satisfies the latter; we +// strip stray CR/LF defensively to keep the on-wire contract. +fn encode_stdio_message(message string) string { + return message.replace('\r', '').replace('\n', '') + '\n' +} + +// try_extract_stdio_message consumes the next newline-delimited frame from +// `buffer`, returning the message body without its trailing newline. +fn try_extract_stdio_message(buffer string) !FrameExtraction { + newline := buffer.index('\n') or { return NoFrameError{} } + mut end := newline + if end > 0 && buffer[end - 1] == `\r` { + end-- + } + message := buffer[..end].trim_space() + remaining := if newline + 1 >= buffer.len { '' } else { buffer[newline + 1..] } + if message.len == 0 { + return try_extract_stdio_message(remaining) or { + if err.msg() == NoFrameError{}.msg() { + return NoFrameError{} + } + err } - content_length = parsed_length - break - } - if content_length < 0 { - return error('mcp.stdio: missing Content-Length header') - } - body_start := header_end + 4 - body_end := body_start + content_length - if buffer.len < body_end { - return NoFrameError{} } - message := buffer[body_start..body_end] - remaining := if body_end >= buffer.len { '' } else { buffer[body_end..] } return FrameExtraction{ message: message remaining: remaining @@ -776,9 +827,7 @@ fn try_extract_framed_message(buffer string) !FrameExtraction { } fn encode_id[I](id I) string { - return $if I is string { - json.encode(id) - } $else $if I is int { + return $if I is int { id.str() } $else { json.encode(id) @@ -792,8 +841,6 @@ fn encode_value[T](value T) string { value.str() } $else $if T is Null { value.str() - } $else $if T is string { - json.encode(value) } $else { json.encode(value) } @@ -801,7 +848,7 @@ fn encode_value[T](value T) string { fn decode_value[T](value string) !T { $if T is Empty { - if value.len == 0 || value == null.str() { + if value == '' || value == null.str() { return Empty{} } return error('mcp: expected an empty payload, got `${value}`') diff --git a/vlib/mcp/mcp_test.v b/vlib/mcp/mcp_test.v index 5346f40e0..4851a9e89 100644 --- a/vlib/mcp/mcp_test.v +++ b/vlib/mcp/mcp_test.v @@ -113,15 +113,16 @@ fn test_parse_sse_messages_reads_json_rpc_events() { assert decode_response(messages[1])!.result == 'true' } -fn test_framed_messages_handle_partial_reads() { +fn test_stdio_messages_handle_partial_reads() { payload := new_notification('notifications/initialized', empty).encode() - frame := encode_framed_message(payload) - mut buffer := frame[..12] + frame := encode_stdio_message(payload) + assert frame.ends_with('\n') + mut buffer := frame[..frame.len - 1] - try_extract_framed_message(buffer) or { assert err.msg() == NoFrameError{}.msg() } + try_extract_stdio_message(buffer) or { assert err.msg() == NoFrameError{}.msg() } - buffer += frame[12..] - extracted := try_extract_framed_message(buffer)! + buffer += frame[frame.len - 1..] + extracted := try_extract_stdio_message(buffer)! buffer = extracted.remaining message := extracted.message @@ -129,6 +130,23 @@ fn test_framed_messages_handle_partial_reads() { assert decode_notification(message)!.method == 'notifications/initialized' } +fn test_stdio_messages_strip_embedded_newlines() { + payload := '{"jsonrpc":"2.0",\n"method":"ping"}' + frame := encode_stdio_message(payload) + assert frame == '{"jsonrpc":"2.0","method":"ping"}\n' +} + +fn test_stdio_messages_skip_blank_lines() { + first := encode_stdio_message(new_notification('a', empty).encode()) + second := encode_stdio_message(new_notification('b', empty).encode()) + buffer := first + '\n\n' + second + + frame_a := try_extract_stdio_message(buffer)! + assert decode_notification(frame_a.message)!.method == 'a' + frame_b := try_extract_stdio_message(frame_a.remaining)! + assert decode_notification(frame_b.message)!.method == 'b' +} + fn test_close_delegates_to_the_transport() { mut transport := &MockTransport{} mut client := new_client(transport, ClientConfig{}) diff --git a/vlib/mcp/server.v b/vlib/mcp/server.v index 65ac7fffa..daf41e0fe 100644 --- a/vlib/mcp/server.v +++ b/vlib/mcp/server.v @@ -5,14 +5,16 @@ import io import net.http import os import rand +import sync import time const default_server_name = 'v.mcp.server' const default_server_version = 'dev' const default_http_path = '/mcp' const default_list_page_size = 50 +const completion_values_limit = 100 const stdio_session_id = 'stdio' -const event_stream_content_type = 'text/event-stream' +const event_log_capacity = 1024 // SessionTransport identifies how an MCP session is connected. pub enum SessionTransport { @@ -20,6 +22,47 @@ pub enum SessionTransport { http } +// LogLevel is the RFC 5424 severity used by `notifications/message`. +pub enum LogLevel { + debug + info + notice + warning + error + critical + alert + emergency +} + +// str returns the wire string for a log level (lowercase per spec). +pub fn (l LogLevel) str() string { + return match l { + .debug { 'debug' } + .info { 'info' } + .notice { 'notice' } + .warning { 'warning' } + .error { 'error' } + .critical { 'critical' } + .alert { 'alert' } + .emergency { 'emergency' } + } +} + +// parse_log_level decodes the wire string for a log level. +pub fn parse_log_level(value string) ?LogLevel { + return match value { + 'debug' { LogLevel.debug } + 'info' { LogLevel.info } + 'notice' { LogLevel.notice } + 'warning' { LogLevel.warning } + 'error' { LogLevel.error } + 'critical' { LogLevel.critical } + 'alert' { LogLevel.alert } + 'emergency' { LogLevel.emergency } + else { none } + } +} + // Context provides request-scoped server metadata to handlers. pub struct Context { pub: @@ -30,6 +73,58 @@ pub: protocol_version string @[json: protocolVersion] client_info Implementation @[json: clientInfo] client_capabilities string @[json: clientCapabilities; raw] + progress_token string @[json: progressToken; raw] +mut: + server &Server = unsafe { nil } @[skip] +} + +// is_cancelled reports whether the client has sent `notifications/cancelled` +// for this request. Handlers should poll this for cooperative cancellation. +pub fn (ctx Context) is_cancelled() bool { + if isnil(ctx.server) { + return false + } + return ctx.server.is_request_cancelled(ctx.session_id, ctx.request_id) +} + +// notify_progress sends `notifications/progress` for this request when the +// client included a `_meta.progressToken`. `total` and `message` are optional +// (pass 0 / '' to omit). +pub fn (ctx Context) notify_progress(progress f64, total f64, message string) { + if isnil(ctx.server) || ctx.progress_token.trim_space() == '' { + return + } + ctx.server.notify_progress_for(ctx.session_id, ctx.progress_token, progress, total, message) +} + +// ToolAnnotations exposes the optional behavioural hints described by the +// MCP spec (`tools/list` Annotations object). +pub struct ToolAnnotations { +pub: + title string @[omitempty] + read_only_hint ?bool @[json: readOnlyHint] + destructive_hint ?bool @[json: destructiveHint] + idempotent_hint ?bool @[json: idempotentHint] + open_world_hint ?bool @[json: openWorldHint] +} + +// ToolExecution carries optional execution metadata for a tool. Set +// `task_support` to one of `'forbidden'`, `'optional'` or `'required'` to +// advertise tasks/* support; the default (empty) omits the field on the wire. +pub struct ToolExecution { +pub: + task_support string @[json: taskSupport; omitempty] +} + +// Annotations are the optional rendering hints attached to resources, resource +// templates and content blocks. `audience` is a list of MCP `Role` values +// (e.g. `'user'`, `'assistant'`); `priority` is in the `[0.0, 1.0]` range with +// higher meaning more important; `last_modified` is an ISO 8601 timestamp. +pub struct Annotations { +pub: + audience []string @[omitempty] + priority ?f64 + last_modified string @[json: lastModified; omitempty] } // Tool describes an MCP tool exposed by the server. @@ -40,6 +135,9 @@ pub: description string @[omitempty] input_schema string = default_tool_input_schema @[json: inputSchema; raw] output_schema string @[json: outputSchema; omitempty; raw] + annotations ToolAnnotations + icons []Icon @[omitempty] + execution ToolExecution } // Resource describes a concrete MCP resource exposed by the server. @@ -50,6 +148,9 @@ pub: title string @[omitempty] description string @[omitempty] mime_type string @[json: mimeType; omitempty] + size ?int + icons []Icon @[omitempty] + annotations Annotations } // ResourceTemplate describes a parameterized MCP resource URI template. @@ -60,6 +161,8 @@ pub: title string @[omitempty] description string @[omitempty] mime_type string @[json: mimeType; omitempty] + icons []Icon @[omitempty] + annotations Annotations } // ResourceContents contains the result of `resources/read`. @@ -92,6 +195,7 @@ pub: title string @[omitempty] description string @[omitempty] arguments []PromptArgument + icons []Icon @[omitempty] } // PromptMessage is one message returned by `prompts/get`. @@ -125,16 +229,52 @@ pub type ResourceHandler = fn (ctx Context, uri string) !ReadResourceResult // PromptHandler handles `prompts/get` for a registered prompt. pub type PromptHandler = fn (ctx Context, arguments string) !GetPromptResult +// CompletionRef identifies the prompt or resource template a completion +// targets. Prompt refs set `name`; resource refs set `uri`. +pub struct CompletionRef { +pub: + ref_type string @[json: type] // 'ref/prompt' or 'ref/resource' + name string @[omitempty] + uri string @[omitempty] +} + +// CompletionResult is the payload returned by `completion/complete`. +pub struct CompletionResult { +pub: + values []string + total ?int + has_more ?bool +} + +// CompletionHandler returns candidate values for a partial argument value. +// `arguments_json` is the JSON object of already-supplied sibling arguments +// (the spec's `context.arguments`). +pub type CompletionHandler = fn (ctx Context, current_value string, arguments_json string) !CompletionResult + // ServerConfig configures an MCP server instance. @[params] pub struct ServerConfig { pub: name string version string + title string + description string + website_url string + icons []Icon protocol_version string = protocol_version capabilities string instructions string http_path string = default_http_path + // enable_logging declares the `logging` capability and lets clients call + // `logging/setLevel`. Disabled by default — turn on when the server emits + // `notifications/message` payloads. + enable_logging bool + // allowed_origins lists the Origin header values accepted on Streamable + // HTTP. Use `*` to accept any origin (NOT recommended). When empty the + // server only accepts requests without an Origin header or from the + // loopback addresses (`http://localhost[:port]`, `http://127.0.0.1[:port]`, + // `http://[::1][:port]`, or the literal string `null`). + allowed_origins []string } struct RegisteredTool { @@ -152,6 +292,113 @@ struct RegisteredPrompt { handler PromptHandler = unsafe { nil } } +struct RegisteredCompletion { + ref CompletionRef + argument string + handler CompletionHandler = unsafe { nil } +} + +// Root identifies a filesystem-or-URI boundary advertised by the client in +// response to `roots/list`. +pub struct Root { +pub: + uri string + name string @[omitempty] +} + +// ListRootsResult is the typed payload returned by the client to a server +// `roots/list` request. +pub struct ListRootsResult { +pub: + roots []Root +} + +// SamplingMessage is one message of a sampling/createMessage exchange. +pub struct SamplingMessage { +pub: + role string + content string @[raw] +} + +// ModelHint is a name hint for sampling/createMessage model selection. +pub struct ModelHint { +pub: + name string +} + +// ModelPreferences expresses sampling/createMessage routing weights. +pub struct ModelPreferences { +pub: + hints []ModelHint + cost_priority f64 @[json: costPriority; omitempty] + speed_priority f64 @[json: speedPriority; omitempty] + intelligence_priority f64 @[json: intelligencePriority; omitempty] +} + +// ToolChoice configures `sampling/createMessage` tool-use behaviour. `mode` +// is one of `'auto'` (default), `'required'`, or `'none'`. +pub struct ToolChoice { +pub: + mode string +} + +// CreateMessageParams are the typed parameters for sampling/createMessage. +pub struct CreateMessageParams { +pub: + messages []SamplingMessage + model_preferences ModelPreferences @[json: modelPreferences] + system_prompt string @[json: systemPrompt; omitempty] + max_tokens int @[json: maxTokens] + temperature f64 @[omitempty] + stop_sequences []string @[json: stopSequences; omitempty] + metadata string @[omitempty; raw] + tools []Tool @[omitempty] + tool_choice ToolChoice @[json: toolChoice; omitempty] + include_context string @[json: includeContext; omitempty] +} + +// CreateMessageResult is the typed payload returned by the client. +pub struct CreateMessageResult { +pub: + role string + content string @[raw] + model string + stop_reason string @[json: stopReason] +} + +// ElicitSchema is the requested object schema sent to the client for elicitation. +pub struct ElicitSchema { +pub: + type_ string = 'object' @[json: type] + properties string @[raw] + required []string @[omitempty] +} + +// ElicitParams are the typed parameters for elicitation/create. Set `mode` +// to `'url'` and supply `url` + `elicitation_id` to send a URL-mode request; +// otherwise the call defaults to form mode and `requested_schema` is +// expected to describe the form fields. +pub struct ElicitParams { +pub: + mode string @[omitempty] + message string + requested_schema ElicitSchema @[json: requestedSchema; omitempty] + url string @[omitempty] + elicitation_id string @[json: elicitationId; omitempty] +} + +// ElicitResult is the typed payload returned by the client. +pub struct ElicitResult { +pub: + action string + content string @[omitempty; raw] +} + +struct LoggedEvent { + id int + body string +} + struct Session { mut: id string @@ -161,11 +408,36 @@ mut: client_capabilities string initialize_complete bool initialized bool + notification_queue []string + subscribed_uris []string + log_level LogLevel = .debug + log_level_set bool + cancelled_requests []string + event_log []LoggedEvent + next_event_id int = 1 + next_request_seq int = 1 + pending_responses map[string]Response + // progress_seen tracks the last progress value emitted for a given + // `progressToken`. The MCP spec requires `progress` values to be strictly + // monotonically increasing, so we silently drop any non-increasing call + // rather than send an out-of-order notification on the wire. + progress_seen map[string]f64 } struct ServerState { mut: sessions map[string]Session + // response_signals carries one semaphore per in-flight server-initiated + // request, keyed by `pending_signal_key(session_id, request_id)`. + // `wait_for_response` blocks on it (with a deadline) and + // `deliver_response` / `delete_session` post it so we never burn CPU + // polling for a response that may take seconds to arrive. Kept outside + // `Session` to avoid V's pointer-in-shared-map access warnings. + response_signals map[string]&sync.Semaphore +} + +fn pending_signal_key(session_id string, request_id string) string { + return '${session_id}\x00${request_id}' } struct DispatchResult { @@ -205,6 +477,10 @@ struct ReadResourceParams { uri string } +struct SubscribeParams { + uri string +} + struct GetPromptParams { name string arguments string @[raw] @@ -241,6 +517,8 @@ mut: capabilities_override string instructions string http_path string + allowed_origins []string + enable_logging bool http_server &http.Server = unsafe { nil } tools map[string]RegisteredTool tool_names []string @@ -250,21 +528,25 @@ mut: resource_template_ids []string prompts map[string]RegisteredPrompt prompt_names []string + completions map[string]RegisteredCompletion state shared ServerState } // new_server constructs a new MCP server. pub fn new_server(config ServerConfig) Server { return Server{ - server_info: normalize_server_info(config.name, config.version) + server_info: normalize_server_info(config) protocol_version: normalize_protocol_version(config.protocol_version) capabilities_override: config.capabilities.trim_space() instructions: config.instructions http_path: normalize_http_path(config.http_path) + allowed_origins: config.allowed_origins.clone() + enable_logging: config.enable_logging tools: map[string]RegisteredTool{} resources: map[string]RegisteredResource{} resource_templates: map[string]ResourceTemplate{} prompts: map[string]RegisteredPrompt{} + completions: map[string]RegisteredCompletion{} state: ServerState{} } } @@ -281,12 +563,16 @@ pub fn (mut s Server) add_tool(tool Tool, handler ToolHandler) ! { description: tool.description input_schema: normalize_tool_input_schema(tool.input_schema) output_schema: tool.output_schema.trim_space() + annotations: tool.annotations + icons: tool.icons + execution: tool.execution } s.tools[tool.name] = RegisteredTool{ tool: normalized handler: handler } s.tool_names << tool.name + s.broadcast_notification('notifications/tools/list_changed', empty_object.str()) } // add_resource registers a concrete resource and its read handler. @@ -302,6 +588,7 @@ pub fn (mut s Server) add_resource(resource Resource, handler ResourceHandler) ! handler: handler } s.resource_uris << resource.uri + s.broadcast_notification('notifications/resources/list_changed', empty_object.str()) } // add_resource_template registers a resource template exposed by `resources/templates/list`. @@ -314,6 +601,7 @@ pub fn (mut s Server) add_resource_template(template ResourceTemplate) ! { } s.resource_templates[template.uri_template] = template s.resource_template_ids << template.uri_template + s.broadcast_notification('notifications/resources/list_changed', empty_object.str()) } // add_prompt registers a prompt and its handler. @@ -329,13 +617,408 @@ pub fn (mut s Server) add_prompt(prompt Prompt, handler PromptHandler) ! { handler: handler } s.prompt_names << prompt.name + s.broadcast_notification('notifications/prompts/list_changed', empty_object.str()) +} + +// add_completion registers a completion handler for one argument of a prompt +// or resource template. `argument` is the argument name being completed. +pub fn (mut s Server) add_completion(ref CompletionRef, argument string, handler CompletionHandler) ! { + if argument.trim_space() == '' { + return error('mcp.Server.add_completion: empty argument name') + } + key := completion_key(ref, argument) or { + return error('mcp.Server.add_completion: ${err.msg()}') + } + if key in s.completions { + return error('mcp.Server.add_completion: duplicate completion `${key}`') + } + s.completions[key] = RegisteredCompletion{ + ref: ref + argument: argument + handler: handler + } +} + +fn completion_key(ref CompletionRef, argument string) !string { + match ref.ref_type { + 'ref/prompt' { + if ref.name.trim_space() == '' { + return error('ref/prompt requires a `name`') + } + return 'prompt|${ref.name}|${argument}' + } + 'ref/resource' { + if ref.uri.trim_space() == '' { + return error('ref/resource requires a `uri`') + } + return 'resource|${ref.uri}|${argument}' + } + else { + return error('unsupported ref type `${ref.ref_type}`') + } + } +} + +// notify_tools_list_changed broadcasts the tool catalog change notification. +pub fn (mut s Server) notify_tools_list_changed() { + s.broadcast_notification('notifications/tools/list_changed', empty_object.str()) +} + +// notify_resources_list_changed broadcasts the resource catalog change notification. +pub fn (mut s Server) notify_resources_list_changed() { + s.broadcast_notification('notifications/resources/list_changed', empty_object.str()) } -// serve_stdio starts serving MCP messages over stdio using Content-Length framing. +// notify_prompts_list_changed broadcasts the prompt catalog change notification. +pub fn (mut s Server) notify_prompts_list_changed() { + s.broadcast_notification('notifications/prompts/list_changed', empty_object.str()) +} + +// notify_log emits a `notifications/message` payload at `level` filtered per +// session by the most recent `logging/setLevel` call. `data_json` MUST be a +// valid JSON value (object preferred per spec). `logger` is optional. +pub fn (mut s Server) notify_log(level LogLevel, logger string, data_json string) { + if !s.enable_logging { + return + } + mut fields := ['"level":${json.encode(level.str())}'] + if logger != '' { + fields << '"logger":${json.encode(logger)}' + } + payload := if data_json.trim_space() == '' { 'null' } else { data_json.trim_space() } + fields << '"data":${payload}' + params := '{${fields.join(',')}}' + message := build_notification_message('notifications/message', params) + lock s.state { + for id in s.state.sessions.keys() { + mut session := s.state.sessions[id] + if !session.initialized { + continue + } + if session.log_level_set && int(level) < int(session.log_level) { + continue + } + session.notification_queue << message + s.state.sessions[id] = session + } + } +} + +// list_roots issues `roots/list` to the session and waits for the response. +pub fn (mut s Server) list_roots(session_id string, timeout time.Duration) !ListRootsResult { + response := s.send_server_request(session_id, 'roots/list', empty_object.str(), timeout)! + return response.decode_result[ListRootsResult]() +} + +// sample issues `sampling/createMessage` and waits for the LLM result. +pub fn (mut s Server) sample(session_id string, params CreateMessageParams, timeout time.Duration) !CreateMessageResult { + encoded := json.encode(params) + response := s.send_server_request(session_id, 'sampling/createMessage', encoded, timeout)! + return response.decode_result[CreateMessageResult]() +} + +// elicit issues `elicitation/create` and waits for the user-supplied content. +pub fn (mut s Server) elicit(session_id string, params ElicitParams, timeout time.Duration) !ElicitResult { + encoded := encode_elicit_params(params) + response := s.send_server_request(session_id, 'elicitation/create', encoded, timeout)! + return response.decode_result[ElicitResult]() +} + +fn encode_elicit_params(params ElicitParams) string { + mut fields := []string{} + if params.mode != '' { + fields << '"mode":${json.encode(params.mode)}' + } + fields << '"message":${json.encode(params.message)}' + if params.url != '' { + fields << '"url":${json.encode(params.url)}' + } + if params.elicitation_id != '' { + fields << '"elicitationId":${json.encode(params.elicitation_id)}' + } + if params.requested_schema.properties.trim_space() != '' + || params.requested_schema.required.len > 0 { + fields << '"requestedSchema":${encode_elicit_schema(params.requested_schema)}' + } + return '{${fields.join(',')}}' +} + +fn encode_elicit_schema(schema ElicitSchema) string { + type_value := if schema.type_.trim_space() == '' { 'object' } else { schema.type_ } + mut fields := ['"type":${json.encode(type_value)}'] + if schema.properties.trim_space() != '' { + fields << '"properties":${schema.properties.trim_space()}' + } + if schema.required.len > 0 { + fields << '"required":[${schema.required.map(json.encode(it)).join(',')}]' + } + return '{${fields.join(',')}}' +} + +fn (mut s Server) send_server_request(session_id string, method string, params_json string, timeout time.Duration) !Response { + request_id_quoted := s.allocate_server_request_id(session_id) or { + return error('mcp.Server.send_server_request: unknown session `${session_id}`') + } + encoded := Request{ + id: request_id_quoted + method: method + params: params_json + }.encode() + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + session.notification_queue << encoded + s.state.sessions[session_id] = session + } + } + return s.wait_for_response(session_id, request_id_quoted, timeout) +} + +fn (mut s Server) allocate_server_request_id(session_id string) ?string { + mut request_id := '' + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + request_id = '"server-${session.next_request_seq}"' + session.next_request_seq++ + s.state.sessions[session_id] = session + s.state.response_signals[pending_signal_key(session_id, request_id)] = + sync.new_semaphore() + } + } + if request_id == '' { + return none + } + return request_id +} + +fn (mut s Server) deliver_response(session_id string, response Response) { + key := pending_signal_key(session_id, response.id) + mut signal := unsafe { &sync.Semaphore(nil) } + lock s.state { + // Only stash the reply if a waiter is still listening on this id. + // `wait_for_response` removes its semaphore entry on timeout, so a + // missing signal means the request has been abandoned and any late + // reply must be dropped instead of accumulating in `pending_responses`. + signal = s.state.response_signals[key] or { unsafe { nil } } + if !isnil(signal) && session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + session.pending_responses[response.id] = response + s.state.sessions[session_id] = session + } + } + if !isnil(signal) { + signal.post() + } +} + +fn (mut s Server) wait_for_response(session_id string, request_id string, timeout time.Duration) !Response { + key := pending_signal_key(session_id, request_id) + mut signal := unsafe { &sync.Semaphore(nil) } + rlock s.state { + signal = s.state.response_signals[key] or { unsafe { nil } } + } + if isnil(signal) { + return error('mcp.Server.wait_for_response: no pending request `${request_id}`') + } + signaled := signal.timed_wait(timeout) + mut response := Response{} + mut found := false + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + if request_id in session.pending_responses { + response = session.pending_responses[request_id] + session.pending_responses.delete(request_id) + found = true + } + s.state.sessions[session_id] = session + } + s.state.response_signals.delete(key) + } + if found { + return response + } + if !signaled { + return error('mcp.Server.wait_for_response: timeout waiting for ${request_id}') + } + return error('mcp.Server.wait_for_response: session `${session_id}` closed while waiting for ${request_id}') +} + +// notify_elicitation_complete emits a `notifications/elicitation/complete` +// notification for the session that initiated a URL-mode elicitation. Pass +// the same `elicitation_id` that was supplied to `elicit()` so the client +// can correlate the out-of-band interaction with its pending request. +pub fn (mut s Server) notify_elicitation_complete(session_id string, elicitation_id string) { + params := '{"elicitationId":${json.encode(elicitation_id)}}' + message := build_notification_message('notifications/elicitation/complete', params) + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + session.notification_queue << message + s.state.sessions[session_id] = session + } + } +} + +// notify_resource_updated emits notifications/resources/updated to every +// session that has subscribed to `uri`. +pub fn (mut s Server) notify_resource_updated(uri string) { + params := '{"uri":${json.encode(uri)}}' + message := build_notification_message('notifications/resources/updated', params) + lock s.state { + for id in s.state.sessions.keys() { + mut session := s.state.sessions[id] + if !session.initialized { + continue + } + if uri !in session.subscribed_uris { + continue + } + session.notification_queue << message + s.state.sessions[id] = session + } + } +} + +fn (mut s Server) subscribe(session_id string, uri string) { + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + if uri !in session.subscribed_uris { + session.subscribed_uris << uri + s.state.sessions[session_id] = session + } + } + } +} + +fn (mut s Server) unsubscribe(session_id string, uri string) { + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + session.subscribed_uris = session.subscribed_uris.filter(it != uri) + s.state.sessions[session_id] = session + } + } +} + +fn build_notification_message(method string, params_json string) string { + return Notification{ + method: method + params: params_json + }.encode() +} + +fn (mut s Server) broadcast_notification(method string, params_json string) { + message := build_notification_message(method, params_json) + lock s.state { + for id in s.state.sessions.keys() { + mut session := s.state.sessions[id] + if !session.initialized { + continue + } + session.notification_queue << message + s.state.sessions[id] = session + } + } +} + +fn (mut s Server) drain_session_notifications(session_id string) []string { + mut messages := []string{} + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + messages = session.notification_queue.clone() + session.notification_queue = []string{} + s.state.sessions[session_id] = session + } + } + return messages +} + +// drain_to_event_log moves queued notifications into the session's bounded +// event log and returns the assigned (id, body) pairs ready for SSE framing. +fn (mut s Server) drain_to_event_log(session_id string) []LoggedEvent { + mut events := []LoggedEvent{} + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + for body in session.notification_queue { + event := LoggedEvent{ + id: session.next_event_id + body: body + } + session.next_event_id++ + session.event_log << event + events << event + } + session.notification_queue = []string{} + if session.event_log.len > event_log_capacity { + session.event_log = + session.event_log[session.event_log.len - event_log_capacity..].clone() + } + s.state.sessions[session_id] = session + } + } + return events +} + +fn (s &Server) replay_events_after(session_id string, last_event_id int) []LoggedEvent { + mut events := []LoggedEvent{} + rlock s.state { + if session_id in s.state.sessions { + session := s.state.sessions[session_id] + for event in session.event_log { + if event.id > last_event_id { + events << event + } + } + } + } + return events +} + +// serve_stdio starts serving MCP messages over stdio using newline framing. +// MCP 2025-11-25 mandates that stdio messages are delimited by newlines and +// MUST NOT contain embedded newlines. We bypass libc stdio buffering on the +// way in (raw `read()` on fd 0) and flush stdout after every frame on the way +// out, so peers behind a pipe see responses immediately and the server +// reacts to each line as soon as it arrives. pub fn (mut s Server) serve_stdio() ! { - mut stdin := os.stdin() mut stdout := os.stdout() - s.serve_framed_transport(mut stdin, mut stdout, stdio_session_id, .stdio)! + mut source := StdinReader{} + mut sink := StdioWriter{ + file: &stdout + } + s.serve_stdio_transport(mut source, mut sink, stdio_session_id, .stdio)! +} + +struct StdinReader {} + +fn (mut r StdinReader) read(mut buf []u8) !int { + if buf.len == 0 { + return io.Eof{} + } + n := unsafe { C.read(0, buf.data, buf.len) } + if n == 0 { + return io.Eof{} + } + if n < 0 { + return error('mcp.stdio: read(0) failed') + } + return int(n) +} + +struct StdioWriter { +mut: + file &os.File = unsafe { nil } +} + +fn (mut w StdioWriter) write(buf []u8) !int { + n := w.file.write(buf)! + w.file.flush() + return n } // serve_http starts serving MCP over a single HTTP endpoint. @@ -386,7 +1069,135 @@ pub fn (mut s Server) wait_till_running(params http.WaitTillRunningParams) !int // text_content creates a raw MCP text content item. pub fn text_content(text string) string { - return '{"type":"text","text":${json.encode(text)}}' + return text_content_with_annotations(text, Annotations{}) +} + +// text_content_with_annotations behaves like `text_content` but attaches the +// provided `annotations` (audience, priority, lastModified) to the block. +pub fn text_content_with_annotations(text string, annotations Annotations) string { + mut fields := ['"type":"text"', '"text":${json.encode(text)}'] + if encoded := encode_annotations(annotations) { + fields << '"annotations":${encoded}' + } + return '{${fields.join(',')}}' +} + +// image_content creates a raw MCP image content block. `data` must be the +// base64-encoded payload and `mime_type` the IANA media type (e.g. `image/png`). +pub fn image_content(data string, mime_type string) string { + return image_content_with_annotations(data, mime_type, Annotations{}) +} + +// image_content_with_annotations behaves like `image_content` but attaches +// the provided `annotations` to the block. +pub fn image_content_with_annotations(data string, mime_type string, annotations Annotations) string { + mut fields := ['"type":"image"', '"data":${json.encode(data)}', + '"mimeType":${json.encode(mime_type)}'] + if encoded := encode_annotations(annotations) { + fields << '"annotations":${encoded}' + } + return '{${fields.join(',')}}' +} + +// audio_content creates a raw MCP audio content block. `data` must be the +// base64-encoded payload and `mime_type` the IANA media type (e.g. `audio/wav`). +pub fn audio_content(data string, mime_type string) string { + return audio_content_with_annotations(data, mime_type, Annotations{}) +} + +// audio_content_with_annotations behaves like `audio_content` but attaches +// the provided `annotations` to the block. +pub fn audio_content_with_annotations(data string, mime_type string, annotations Annotations) string { + mut fields := ['"type":"audio"', '"data":${json.encode(data)}', + '"mimeType":${json.encode(mime_type)}'] + if encoded := encode_annotations(annotations) { + fields << '"annotations":${encoded}' + } + return '{${fields.join(',')}}' +} + +// resource_link_content creates a raw MCP `resource_link` content block from +// a `Resource` description. The block carries the resource's metadata — +// including any `annotations` set on the resource — so the host can render +// or read it later. +pub fn resource_link_content(resource Resource) string { + encoded := json.encode(resource) + if encoded.len <= 2 { + return '{"type":"resource_link"}' + } + return '{"type":"resource_link",${encoded[1..]}' +} + +// embedded_text_resource creates an embedded MCP text resource content block. +pub fn embedded_text_resource(uri string, mime_type string, text string) string { + return embedded_text_resource_with_annotations(uri, mime_type, text, Annotations{}) +} + +// embedded_text_resource_with_annotations behaves like `embedded_text_resource` +// but attaches the provided `annotations` to the outer `EmbeddedResource`. +pub fn embedded_text_resource_with_annotations(uri string, mime_type string, text string, annotations Annotations) string { + inner := encode_resource_text_payload(uri, mime_type, text) + return wrap_embedded_resource(inner, annotations) +} + +// embedded_blob_resource creates an embedded MCP binary resource content +// block. `blob` must be the base64-encoded payload. +pub fn embedded_blob_resource(uri string, mime_type string, blob string) string { + return embedded_blob_resource_with_annotations(uri, mime_type, blob, Annotations{}) +} + +// embedded_blob_resource_with_annotations behaves like `embedded_blob_resource` +// but attaches the provided `annotations` to the outer `EmbeddedResource`. +pub fn embedded_blob_resource_with_annotations(uri string, mime_type string, blob string, annotations Annotations) string { + inner := encode_resource_blob_payload(uri, mime_type, blob) + return wrap_embedded_resource(inner, annotations) +} + +fn encode_resource_text_payload(uri string, mime_type string, text string) string { + encoded_uri := json.encode(uri) + encoded_text := json.encode(text) + if mime_type == '' { + return '{"uri":${encoded_uri},"text":${encoded_text}}' + } + encoded_mime := json.encode(mime_type) + return '{"uri":${encoded_uri},"mimeType":${encoded_mime},"text":${encoded_text}}' +} + +fn encode_resource_blob_payload(uri string, mime_type string, blob string) string { + encoded_uri := json.encode(uri) + encoded_blob := json.encode(blob) + if mime_type == '' { + return '{"uri":${encoded_uri},"blob":${encoded_blob}}' + } + encoded_mime := json.encode(mime_type) + return '{"uri":${encoded_uri},"mimeType":${encoded_mime},"blob":${encoded_blob}}' +} + +fn wrap_embedded_resource(inner string, annotations Annotations) string { + mut fields := ['"type":"resource"', '"resource":${inner}'] + if encoded := encode_annotations(annotations) { + fields << '"annotations":${encoded}' + } + return '{${fields.join(',')}}' +} + +// encode_annotations renders an `Annotations` value as JSON, omitting the +// whole object when no field is populated (returns `none`). +fn encode_annotations(annotations Annotations) ?string { + mut fields := []string{} + if annotations.audience.len > 0 { + fields << '"audience":[${annotations.audience.map(json.encode(it)).join(',')}]' + } + if priority := annotations.priority { + fields << '"priority":${format_number(priority)}' + } + if annotations.last_modified != '' { + fields << '"lastModified":${json.encode(annotations.last_modified)}' + } + if fields.len == 0 { + return none + } + return '{${fields.join(',')}}' } // prompt_text_message creates a prompt message with text content. @@ -450,14 +1261,51 @@ fn encode_tool(tool Tool) string { if tool.output_schema.trim_space() != '' { fields << '"outputSchema":${tool.output_schema.trim_space()}' } + if encoded_annotations := encode_tool_annotations(tool.annotations) { + fields << '"annotations":${encoded_annotations}' + } + if tool.icons.len > 0 { + fields << '"icons":${json.encode(tool.icons)}' + } + if tool.execution.task_support != '' { + fields << '"execution":{"taskSupport":${json.encode(tool.execution.task_support)}}' + } + return '{${fields.join(',')}}' +} + +fn encode_tool_annotations(annotations ToolAnnotations) ?string { + mut fields := []string{} + if annotations.title != '' { + fields << '"title":${json.encode(annotations.title)}' + } + if hint := annotations.read_only_hint { + fields << '"readOnlyHint":${hint.str()}' + } + if hint := annotations.destructive_hint { + fields << '"destructiveHint":${hint.str()}' + } + if hint := annotations.idempotent_hint { + fields << '"idempotentHint":${hint.str()}' + } + if hint := annotations.open_world_hint { + fields << '"openWorldHint":${hint.str()}' + } + if fields.len == 0 { + return none + } return '{${fields.join(',')}}' } fn encode_tool_result(result ToolResult) string { - mut fields := ['"isError":${result.is_error.str()}'] - if result.content.trim_space() != '' { - fields << '"content":${result.content.trim_space()}' + // `content` is REQUIRED by the 2025-11-25 schema (CallToolResult.content), + // so always emit at least an empty array; clients reading the result MUST + // be able to find `content` whether or not the tool produced output. + content_payload := if result.content.trim_space() == '' { + '[]' + } else { + result.content.trim_space() } + mut fields := ['"content":${content_payload}', '"isError":${result.is_error.str()}'] if result.structured_content.trim_space() != '' { fields << '"structuredContent":${result.structured_content.trim_space()}' } @@ -478,15 +1326,15 @@ fn encode_prompt_message(message PromptMessage) string { return '{"role":${json.encode(message.role)},"content":${message.content}}' } -fn (mut s Server) serve_framed_transport(mut reader io.Reader, mut writer io.Writer, session_id string, transport SessionTransport) ! { +fn (mut s Server) serve_stdio_transport(mut reader io.Reader, mut writer io.Writer, session_id string, transport SessionTransport) ! { mut buffer := '' for { - frame := try_extract_framed_message(buffer) or { + frame := try_extract_stdio_message(buffer) or { if err.msg() != NoFrameError{}.msg() { error_response := Response{ error: normalize_response_error(err) }.encode() - writer.write(encode_framed_message(error_response).bytes())! + writer.write(encode_stdio_message(error_response).bytes())! return err } FrameExtraction{} @@ -497,11 +1345,14 @@ fn (mut s Server) serve_framed_transport(mut reader io.Reader, mut writer io.Wri error_response := Response{ error: normalize_response_error(err) }.encode() - writer.write(encode_framed_message(error_response).bytes())! + writer.write(encode_stdio_message(error_response).bytes())! continue } if dispatch_result.has_response { - writer.write(encode_framed_message(dispatch_result.response).bytes())! + writer.write(encode_stdio_message(dispatch_result.response).bytes())! + } + for notification in s.drain_session_notifications(session_id) { + writer.write(encode_stdio_message(notification).bytes())! } continue } @@ -539,9 +1390,16 @@ fn (mut s Server) dispatch_message(raw string, session_id string, transport Sess fn (mut s Server) dispatch_envelope(envelope MessageEnvelope, session_id string, transport SessionTransport) !DispatchResult { if envelope.method.len == 0 { + if !is_notification_id(envelope.id) { + s.deliver_response(session_id, Response{ + id: envelope.id + result: envelope.result + error: envelope.error + }) + } return DispatchResult{} } - if envelope.id.len == 0 || envelope.id == null.str() { + if is_notification_id(envelope.id) { s.handle_notification(Notification{ method: envelope.method params: envelope.params @@ -562,6 +1420,9 @@ fn (mut s Server) dispatch_envelope(envelope MessageEnvelope, session_id string, } fn (mut s Server) handle_request(req Request, session_id string, transport SessionTransport) HandledRequest { + defer { + s.clear_cancelled(session_id, req.id) + } return s.handle_request_impl(req, session_id, transport) or { return HandledRequest{ response: error_response_for(req.id, err) @@ -607,6 +1468,18 @@ fn (mut s Server) handle_request_impl(req Request, session_id string, transport 'resources/templates/list' { return s.handle_resource_templates_list(req) } + 'resources/subscribe' { + return s.handle_resources_subscribe(req, session_id) + } + 'resources/unsubscribe' { + return s.handle_resources_unsubscribe(req, session_id) + } + 'logging/setLevel' { + return s.handle_logging_set_level(req, session_id) + } + 'completion/complete' { + return s.handle_completion_complete(req, ctx) + } 'prompts/list' { return s.handle_prompts_list(req) } @@ -620,11 +1493,6 @@ fn (mut s Server) handle_request_impl(req Request, session_id string, transport } } } - - return ProtocolError{ - response_error: method_not_found - request_id: req.id - } } fn (mut s Server) handle_initialize(req Request, session_id string, transport SessionTransport) !HandledRequest { @@ -641,11 +1509,9 @@ fn (mut s Server) handle_initialize(req Request, session_id string, transport Se request_id: req.id } } - session.protocol_version = if params.protocol_version == s.protocol_version { - s.protocol_version - } else { - s.protocol_version - } + // Per MCP lifecycle: server replies with the protocol version it supports; + // the client decides whether to proceed or disconnect on a mismatch. + session.protocol_version = s.protocol_version session.client_info = normalize_client_info(params.client_info) session.client_capabilities = normalize_capabilities(params.capabilities) session.initialize_complete = true @@ -742,7 +1608,11 @@ fn (mut s Server) handle_resources_read(req Request, ctx Context) !HandledReques } if params.uri !in s.resources { return ProtocolError{ - response_error: invalid_params + response_error: ResponseError{ + code: resource_not_found.code + message: 'Resource not found.' + data: '{"uri":${json.encode(params.uri)}}' + } request_id: req.id } } @@ -753,6 +1623,163 @@ fn (mut s Server) handle_resources_read(req Request, ctx Context) !HandledReques } } +struct SetLevelParams { + level string +} + +struct CompletionRequestArgument { + name string + value string +} + +struct CompletionRequestContext { + arguments string @[raw] +} + +struct CompletionRequestParams { + ref CompletionRef + argument CompletionRequestArgument + context CompletionRequestContext +} + +fn (mut s Server) handle_completion_complete(req Request, ctx Context) !HandledRequest { + if s.completions.len == 0 { + return ProtocolError{ + response_error: method_not_found + request_id: req.id + } + } + params := req.decode_params[CompletionRequestParams]() or { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + key := completion_key(params.ref, params.argument.name) or { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + entry := s.completions[key] or { + return HandledRequest{ + response: response_with_json(req.id, encode_completion_result(CompletionResult{})) + } + } + arguments_json := json_object_or_empty(params.context.arguments) + result := entry.handler(ctx, params.argument.value, arguments_json) or { + if err is ProtocolError { + return err + } + if err is ResponseError { + return err + } + return ProtocolError{ + response_error: ResponseError{ + code: internal_error.code + message: err.msg() + } + request_id: req.id + } + } + return HandledRequest{ + response: response_with_json(req.id, encode_completion_result(result)) + } +} + +fn encode_completion_result(result CompletionResult) string { + // MCP 2025-11-25 caps completion responses at 100 values. We clamp here + // and surface the truncation through `hasMore: true` so clients keep + // requesting refinements instead of silently losing options. + clamped := if result.values.len > completion_values_limit { + result.values[..completion_values_limit] + } else { + result.values + } + mut completion_fields := [ + '"values":[${clamped.map(json.encode(it)).join(',')}]', + ] + if total := result.total { + completion_fields << '"total":${total}' + } + mut has_more_value := result.values.len > completion_values_limit + if explicit := result.has_more { + has_more_value = explicit || has_more_value + } + if result.has_more != none || has_more_value { + completion_fields << '"hasMore":${has_more_value.str()}' + } + return '{"completion":{${completion_fields.join(',')}}}' +} + +fn (mut s Server) handle_logging_set_level(req Request, session_id string) !HandledRequest { + if !s.enable_logging { + return ProtocolError{ + response_error: method_not_found + request_id: req.id + } + } + params := req.decode_params[SetLevelParams]() or { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + level := parse_log_level(params.level) or { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + s.set_session_log_level(session_id, level) + return HandledRequest{ + response: response_with_json(req.id, empty_object.str()) + } +} + +fn (mut s Server) set_session_log_level(session_id string, level LogLevel) { + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + session.log_level = level + session.log_level_set = true + s.state.sessions[session_id] = session + } + } +} + +fn (mut s Server) handle_resources_subscribe(req Request, session_id string) !HandledRequest { + params := req.decode_params[SubscribeParams]() or { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + if params.uri.trim_space() == '' { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + s.subscribe(session_id, params.uri) + return HandledRequest{ + response: response_with_json(req.id, empty_object.str()) + } +} + +fn (mut s Server) handle_resources_unsubscribe(req Request, session_id string) !HandledRequest { + params := req.decode_params[SubscribeParams]() or { + return ProtocolError{ + response_error: invalid_params + request_id: req.id + } + } + s.unsubscribe(session_id, params.uri) + return HandledRequest{ + response: response_with_json(req.id, empty_object.str()) + } +} + fn (mut s Server) handle_resource_templates_list(req Request) !HandledRequest { params := decode_optional_params[CursorParams](req.params) or { return ProtocolError{ @@ -829,24 +1856,111 @@ fn (mut s Server) handle_notification(notification Notification, session_id stri session.initialized = true s.store_session(session) } - 'notifications/cancelled' {} + 'notifications/cancelled' { + params := notification.decode_params[CancelledParams]() or { return } + s.mark_cancelled(session_id, params.request_id) + } else {} } } +struct CancelledParams { + request_id string @[json: requestId; raw] + reason string @[omitempty] +} + +fn (mut s Server) mark_cancelled(session_id string, request_id string) { + id := request_id.trim_space() + if id.len == 0 { + return + } + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + if id !in session.cancelled_requests { + session.cancelled_requests << id + s.state.sessions[session_id] = session + } + } + } +} + +fn (s &Server) is_request_cancelled(session_id string, request_id string) bool { + mut cancelled := false + rlock s.state { + if session_id in s.state.sessions { + cancelled = request_id in s.state.sessions[session_id].cancelled_requests + } + } + return cancelled +} + +fn (mut s Server) clear_cancelled(session_id string, request_id string) { + lock s.state { + if session_id in s.state.sessions { + mut session := s.state.sessions[session_id] + session.cancelled_requests = session.cancelled_requests.filter(it != request_id) + s.state.sessions[session_id] = session + } + } +} + +fn (s &Server) notify_progress_for(session_id string, progress_token string, progress f64, total f64, message string) { + mut fields := [ + '"progressToken":${progress_token}', + '"progress":${format_number(progress)}', + ] + if total != 0 { + fields << '"total":${format_number(total)}' + } + if message != '' { + fields << '"message":${json.encode(message)}' + } + params := '{${fields.join(',')}}' + notification := build_notification_message('notifications/progress', params) + lock s.state { + if session_id !in s.state.sessions { + return + } + mut session := s.state.sessions[session_id] + // MCP 2025-11-25 mandates `progress` strictly increases per token. + if last := session.progress_seen[progress_token] { + if progress <= last { + return + } + } + session.progress_seen[progress_token] = progress + session.notification_queue << notification + s.state.sessions[session_id] = session + } +} + +fn format_number(value f64) string { + if value == f64(i64(value)) { + return i64(value).str() + } + return value.str() +} + fn (s &Server) capabilities_json() string { if s.capabilities_override != '' { return normalize_capabilities(s.capabilities_override) } mut parts := []string{} if s.tool_names.len != 0 { - parts << '"tools":{}' + parts << '"tools":{"listChanged":true}' } if s.resource_uris.len != 0 || s.resource_template_ids.len != 0 { - parts << '"resources":{}' + parts << '"resources":{"listChanged":true,"subscribe":true}' } if s.prompt_names.len != 0 { - parts << '"prompts":{}' + parts << '"prompts":{"listChanged":true}' + } + if s.enable_logging { + parts << '"logging":{}' + } + if s.completions.len != 0 { + parts << '"completions":{}' } return '{${parts.join(',')}}' } @@ -931,11 +2045,21 @@ fn (s &Server) session_exists(session_id string) bool { fn (mut s Server) delete_session(session_id string) bool { mut deleted := false + mut orphaned_signals := []&sync.Semaphore{} + prefix := pending_signal_key(session_id, '') lock s.state { if session_id in s.state.sessions { s.state.sessions.delete(session_id) deleted = true } + for key, signal in s.state.response_signals { + if key.starts_with(prefix) { + orphaned_signals << signal + } + } + } + for mut signal in orphaned_signals { + signal.post() } return deleted } @@ -949,7 +2073,26 @@ fn (s &Server) context_from_session(req Request, session Session) Context { protocol_version: session.protocol_version client_info: session.client_info client_capabilities: session.client_capabilities + progress_token: extract_progress_token(req.params) + server: unsafe { s } + } +} + +struct MetaParams { + meta MetaPayload @[json: '_meta'] +} + +struct MetaPayload { + progress_token string @[json: progressToken; raw] +} + +fn extract_progress_token(params string) string { + trimmed := params.trim_space() + if trimmed.len == 0 || trimmed == null.str() { + return '' } + wrapper := json.decode(MetaParams, trimmed) or { return '' } + return wrapper.meta.progress_token.trim_space() } fn decode_optional_params[T](raw string) !T { @@ -1004,14 +2147,22 @@ fn normalize_tool_input_schema(input_schema string) string { return if trimmed.len == 0 { default_tool_input_schema } else { trimmed } } -fn normalize_server_info(name string, version string) Implementation { +fn normalize_server_info(config ServerConfig) Implementation { return Implementation{ - name: if name.trim_space() == '' { default_server_name } else { name.trim_space() } - version: if version.trim_space() == '' { + name: if config.name.trim_space() == '' { + default_server_name + } else { + config.name.trim_space() + } + version: if config.version.trim_space() == '' { default_server_version } else { - version.trim_space() + config.version.trim_space() } + title: config.title.trim_space() + description: config.description.trim_space() + website_url: config.website_url.trim_space() + icons: config.icons.clone() } } @@ -1056,14 +2207,91 @@ fn build_sse_response(message string) string { return lines.join('\n') + '\n\n' } -fn accepts_event_stream_only(header http.Header) bool { +fn build_sse_event(event LoggedEvent) string { + mut lines := ['id: ${event.id}'] + for line in event.body.split('\n') { + lines << 'data: ${line}' + } + return lines.join('\n') + '\n\n' +} + +fn build_sse_stream(events []LoggedEvent) string { + mut chunks := []string{cap: events.len} + for event in events { + chunks << build_sse_event(event) + } + return chunks.join('') +} + +// accept_modes describes which response Content-Types the client tolerates. +struct AcceptModes { + json bool + sse bool +} + +// parse_accept extracts JSON/SSE acceptance from a comma-separated Accept header. +// `*/*` and `application/*` / `text/*` wildcards count as accepting the matching type. +fn parse_accept(header http.Header) AcceptModes { accept := header.get(.accept) or { '' } - if accept == '' { + if accept.trim_space() == '' { + return AcceptModes{ + json: true + sse: true + } + } + mut modes := AcceptModes{} + for raw in accept.split(',') { + entry := raw.split(';')[0].trim_space().to_lower() + match entry { + '*/*', '' { modes = AcceptModes{true, true} } + 'application/json', 'application/*' { modes = AcceptModes{true, modes.sse} } + 'text/event-stream', 'text/*' { modes = AcceptModes{modes.json, true} } + else {} + } + } + return modes +} + +// origin_is_allowed enforces the spec MUST: validate Origin to prevent DNS rebinding. +fn (s &Server) origin_is_allowed(origin string) bool { + if origin == '' { + return true + } + for allowed in s.allowed_origins { + if allowed == '*' || allowed == origin { + return true + } + } + if s.allowed_origins.len != 0 { return false } - accept_lower := accept.to_lower() - return accept_lower.contains(event_stream_content_type) - && !accept_lower.contains(default_content_type) + return is_loopback_origin(origin) +} + +fn is_loopback_origin(origin string) bool { + if origin == 'null' { + return true + } + mut without_scheme := origin + for prefix in ['http://', 'https://'] { + if origin.starts_with(prefix) { + without_scheme = origin[prefix.len..] + break + } + } + host := without_scheme.all_before('/').all_before(':').to_lower() + bracketed := without_scheme.all_before('/') + return host in ['localhost', '127.0.0.1', '::1'] || bracketed.starts_with('[::1]') +} + +// supports_protocol_version applies spec rules: missing header defaults to +// 2025-03-26 (back-compat), otherwise the value MUST match the negotiated one. +fn (s &Server) supports_protocol_version(value string, has_session bool) bool { + if value == '' { + return !has_session || s.protocol_version == default_protocol_version + || s.protocol_version == protocol_version + } + return value == s.protocol_version } fn json_http_response(status http.Status, body string, content_type string) http.Response { @@ -1079,6 +2307,13 @@ fn json_http_response(status http.Status, body string, content_type string) http return response } +fn error_http_response(status http.Status, err ResponseError) http.Response { + body := Response{ + error: err + }.encode() + return json_http_response(status, body, default_content_type) +} + struct HttpHandler { mut: server &Server = unsafe { nil } @@ -1088,10 +2323,53 @@ fn (mut h HttpHandler) handle(req http.Request) http.Response { return h.server.handle_http_request(req) } +fn (mut s Server) handle_http_get(req http.Request, session_id string) http.Response { + if session_id == '' { + return error_http_response(.bad_request, ResponseError{ + code: invalid_request.code + message: 'GET requires an active MCP-Session-Id.' + }) + } + if !s.session_exists(session_id) { + return json_http_response(.not_found, '', '') + } + accept := parse_accept(req.header) + if !accept.sse { + return error_http_response(.not_acceptable, ResponseError{ + code: invalid_request.code + message: 'GET requires Accept: text/event-stream.' + }) + } + last_event_id_text := req.header.get_custom(last_event_id_header) or { '' } + last_event_id := last_event_id_text.int() + mut events := []LoggedEvent{} + if last_event_id_text != '' { + // Flush anything generated while the client was disconnected into the + // event log first, so the replay covers the whole gap. Without this + // step the resume only sees what was already drained before the drop, + // which defeats the point of `Last-Event-ID`. + s.drain_to_event_log(session_id) + events = s.replay_events_after(session_id, last_event_id) + } else { + events = s.drain_to_event_log(session_id) + } + body := build_sse_stream(events) + mut response := json_http_response(.ok, body, event_stream_content_type) + response.header.set_custom(mcp_session_id_header, session_id) or {} + return response +} + fn (mut s Server) handle_http_request(req http.Request) http.Response { if req.url.all_before('?') != s.http_path { return json_http_response(.not_found, '', '') } + origin := req.header.get_custom('Origin') or { '' } + if !s.origin_is_allowed(origin) { + return error_http_response(.forbidden, ResponseError{ + code: invalid_request.code + message: 'Origin not allowed.' + }) + } session_id := req.header.get_custom(mcp_session_id_header) or { '' } match req.method { .delete { @@ -1104,7 +2382,7 @@ fn (mut s Server) handle_http_request(req http.Request) http.Response { return json_http_response(.ok, '', '') } .get { - return json_http_response(.method_not_allowed, '', '') + return s.handle_http_get(req, session_id) } .post {} else { @@ -1112,42 +2390,51 @@ fn (mut s Server) handle_http_request(req http.Request) http.Response { } } + protocol_value := req.header.get_custom(mcp_protocol_version_header) or { '' } + if !s.supports_protocol_version(protocol_value, session_id != '') { + return error_http_response(.bad_request, ResponseError{ + code: invalid_request.code + message: 'Unsupported MCP-Protocol-Version `${protocol_value}`.' + }) + } + + accept := parse_accept(req.header) + if !accept.json && !accept.sse { + return error_http_response(.not_acceptable, ResponseError{ + code: invalid_request.code + message: 'Accept must include application/json or text/event-stream.' + }) + } + trimmed := req.data.trim_space() if trimmed.len == 0 || trimmed[0] == `[` { - return json_http_response(.bad_request, Response{ - error: invalid_request - }.encode(), default_content_type) + return error_http_response(.bad_request, invalid_request) } envelope := decode_envelope(trimmed) or { - return json_http_response(.bad_request, Response{ - error: parse_error - }.encode(), default_content_type) + return error_http_response(.bad_request, parse_error) } if session_id != '' && !s.session_exists(session_id) { return json_http_response(.not_found, '', '') } if envelope.method != 'initialize' && envelope.method.len != 0 && session_id == '' { - return json_http_response(.bad_request, Response{ - error: server_not_initialized - }.encode(), default_content_type) + return error_http_response(.bad_request, server_not_initialized) } dispatch_result := s.dispatch_envelope(envelope, session_id, .http) or { - return json_http_response(.bad_request, Response{ - error: normalize_response_error(err) - }.encode(), default_content_type) + return error_http_response(.bad_request, normalize_response_error(err)) } if !dispatch_result.has_response { return json_http_response(.accepted, '', '') } - body := if accepts_event_stream_only(req.header) { - build_sse_response(dispatch_result.response) - } else { - dispatch_result.response - } - content_type := if accepts_event_stream_only(req.header) { - event_stream_content_type + use_sse := accept.sse && !accept.json + mut body := '' + mut content_type := default_content_type + if use_sse { + notifications := s.drain_to_event_log(session_for_drain(session_id, + dispatch_result.session_id)) + body = build_sse_stream(notifications) + build_sse_response(dispatch_result.response) + content_type = event_stream_content_type } else { - default_content_type + body = dispatch_result.response } mut response := json_http_response(.ok, body, content_type) if dispatch_result.session_id != '' { @@ -1155,3 +2442,10 @@ fn (mut s Server) handle_http_request(req http.Request) http.Response { } return response } + +fn session_for_drain(request_session string, dispatch_session string) string { + if request_session != '' { + return request_session + } + return dispatch_session +} diff --git a/vlib/mcp/server_test.v b/vlib/mcp/server_test.v index ade3dd556..aae3ffc44 100644 --- a/vlib/mcp/server_test.v +++ b/vlib/mcp/server_test.v @@ -74,7 +74,7 @@ fn test_server_routes_initialize_and_registered_features() { init_result := init_response.decode_result[InitializeResult]()! assert init_result.server_info.name == 'test-server' assert init_result.instructions == 'Be precise.' - assert init_result.capabilities == '{"tools":{},"resources":{},"prompts":{}}' + assert init_result.capabilities == '{"tools":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"prompts":{"listChanged":true}}' blocked_dispatch := server.dispatch_message(new_request(2, 'tools/list', empty).encode(), stdio_session_id, .stdio)! @@ -240,3 +240,726 @@ fn test_server_http_sessions_and_delete() { server.close() server_thread.wait() or {} } + +fn http_initialize(url string) !(string, http.Header) { + mut header := http.new_header(http.HeaderConfig{ + key: .content_type + value: 'application/json' + }, http.HeaderConfig{ + key: .accept + value: 'application/json, text/event-stream' + }) + response := http.fetch( + method: .post + url: url + data: Request{ + id: encode_id(1) + method: 'initialize' + params: encode_initialize_params(InitializeParams{ + protocol_version: protocol_version + capabilities: '{}' + client_info: Implementation{ + name: 'spec-test' + version: '0.0.1' + } + }) + }.encode() + header: header + )! + if response.status_code != 200 { + return error('initialize failed: ${response.status_code}') + } + session_id := response.header.get_custom(mcp_session_id_header) or { + return error('missing session id') + } + return session_id, header +} + +fn test_http_rejects_disallowed_origin() { + mut server_value := new_server( + name: 'origin-server' + version: '0.0.1' + allowed_origins: ['https://allowed.example'] + ) + mut server := &server_value + server_thread := spawn server.serve_http('127.0.0.1:0') + server.wait_till_running(max_retries: 200, retry_period_ms: 10)! + time.sleep(20 * time.millisecond) + url := 'http://${server.http_server.addr}/mcp' + + mut header := http.new_header(http.HeaderConfig{ + key: .content_type + value: 'application/json' + }, http.HeaderConfig{ + key: .accept + value: 'application/json, text/event-stream' + }) + header.set_custom('Origin', 'https://attacker.example')! + response := http.fetch( + method: .post + url: url + data: Request{ + id: encode_id(1) + method: 'initialize' + params: encode_initialize_params(InitializeParams{ + protocol_version: protocol_version + capabilities: '{}' + client_info: Implementation{ + name: 'attacker' + version: '0.0.1' + } + }) + }.encode() + header: header + )! + assert response.status_code == 403 + + server.close() + server_thread.wait() or {} +} + +fn test_http_rejects_unsupported_protocol_version_header() { + mut server_value := new_server( + name: 'version-server' + version: '0.0.1' + ) + mut server := &server_value + server.add_tool(Tool{ name: 'noop' }, fn (_ Context, _ string) !ToolResult { + return tool_text_result('ok') + })! + + server_thread := spawn server.serve_http('127.0.0.1:0') + server.wait_till_running(max_retries: 200, retry_period_ms: 10)! + time.sleep(20 * time.millisecond) + url := 'http://${server.http_server.addr}/mcp' + + session_id, mut header := http_initialize(url)! + header.set_custom(mcp_session_id_header, session_id)! + header.set_custom(mcp_protocol_version_header, '1999-01-01')! + response := http.fetch( + method: .post + url: url + data: new_notification('notifications/initialized', empty).encode() + header: header + )! + assert response.status_code == 400 + + server.close() + server_thread.wait() or {} +} + +fn test_http_rejects_unacceptable_accept_header() { + mut server_value := new_server( + name: 'accept-server' + version: '0.0.1' + ) + mut server := &server_value + server_thread := spawn server.serve_http('127.0.0.1:0') + server.wait_till_running(max_retries: 200, retry_period_ms: 10)! + time.sleep(20 * time.millisecond) + url := 'http://${server.http_server.addr}/mcp' + + mut header := http.new_header(http.HeaderConfig{ + key: .content_type + value: 'application/json' + }, http.HeaderConfig{ + key: .accept + value: 'image/png' + }) + response := http.fetch( + method: .post + url: url + data: new_request(1, 'ping', empty).encode() + header: header + )! + assert response.status_code == 406 + + server.close() + server_thread.wait() or {} +} + +fn test_tool_annotations_are_serialized() { + mut server := new_server(name: 'annot', version: '0') + server.add_tool(Tool{ + name: 'annotated' + description: 'demo' + annotations: ToolAnnotations{ + title: 'Read-only fetcher' + read_only_hint: true + open_world_hint: false + } + }, fn (_ Context, _ string) !ToolResult { + return tool_text_result('ok') + })! + + encoded := encode_tool(server.tools['annotated'].tool) + assert encoded.contains('"annotations":{"title":"Read-only fetcher","readOnlyHint":true,"openWorldHint":false}') +} + +fn test_list_changed_notifications_are_queued_after_initialize() { + mut server := new_server(name: 'listchanged', version: '0') + init_request := Request{ + id: encode_id(1) + method: 'initialize' + params: encode_initialize_params(InitializeParams{ + protocol_version: protocol_version + capabilities: '{}' + client_info: Implementation{ + name: 'c' + version: '0' + } + }) + } + server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + server.add_tool(Tool{ name: 'late' }, fn (_ Context, _ string) !ToolResult { + return tool_text_result('ok') + })! + server.add_resource(Resource{ uri: 'res://x', name: 'x' }, fn (_ Context, uri string) !ReadResourceResult { + return ReadResourceResult{ + contents: [ResourceContents{ + uri: uri + text: 'x' + }] + } + })! + server.add_prompt(Prompt{ name: 'late_prompt' }, fn (_ Context, _ string) !GetPromptResult { + return GetPromptResult{} + })! + + queue := server.drain_session_notifications(stdio_session_id) + assert queue.len == 3 + assert decode_notification(queue[0])!.method == 'notifications/tools/list_changed' + assert decode_notification(queue[1])!.method == 'notifications/resources/list_changed' + assert decode_notification(queue[2])!.method == 'notifications/prompts/list_changed' +} + +fn test_resources_subscribe_then_updated_notifies_only_subscribers() { + mut server := new_server(name: 'sub', version: '0') + server.add_resource(Resource{ uri: 'res://x', name: 'x' }, fn (_ Context, uri string) !ReadResourceResult { + return ReadResourceResult{ + contents: [ResourceContents{ + uri: uri + text: 'x' + }] + } + })! + init_request := Request{ + id: encode_id(1) + method: 'initialize' + params: encode_initialize_params(InitializeParams{ + protocol_version: protocol_version + capabilities: '{}' + client_info: Implementation{ + name: 'c' + version: '0' + } + }) + } + server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + subscribe := server.dispatch_message(new_request(2, 'resources/subscribe', SubscribeParams{ + uri: 'res://x' + }).encode(), stdio_session_id, .stdio)! + assert decode_response(subscribe.response)!.error.code == 0 + + server.notify_resource_updated('res://x') + server.notify_resource_updated('res://other') + + queue := server.drain_session_notifications(stdio_session_id) + assert queue.len == 1 + notif := decode_notification(queue[0])! + assert notif.method == 'notifications/resources/updated' + assert notif.params.contains('"uri":"res://x"') + + unsubscribe := server.dispatch_message(new_request(3, 'resources/unsubscribe', SubscribeParams{ + uri: 'res://x' + }).encode(), stdio_session_id, .stdio)! + assert decode_response(unsubscribe.response)!.error.code == 0 + server.notify_resource_updated('res://x') + assert server.drain_session_notifications(stdio_session_id).len == 0 +} + +fn test_logging_set_level_filters_messages_below_threshold() { + mut server := new_server(name: 'log', version: '0', enable_logging: true) + init_request := Request{ + id: encode_id(1) + method: 'initialize' + params: encode_initialize_params(InitializeParams{ + protocol_version: protocol_version + capabilities: '{}' + client_info: Implementation{ + name: 'c' + version: '0' + } + }) + } + init_dispatch := server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)! + init_response := decode_response(init_dispatch.response)! + init_result := init_response.decode_result[InitializeResult]()! + assert init_result.capabilities.contains('"logging":{}') + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + set_level := server.dispatch_message(new_request(2, 'logging/setLevel', SetLevelParams{ + level: 'warning' + }).encode(), stdio_session_id, .stdio)! + assert decode_response(set_level.response)!.error.code == 0 + + server.notify_log(.debug, 'svc', '"low"') + server.notify_log(.warning, 'svc', '"hi"') + server.notify_log(.error, 'svc', '{"k":1}') + + queue := server.drain_session_notifications(stdio_session_id) + assert queue.len == 2 + first := decode_notification(queue[0])! + assert first.method == 'notifications/message' + assert first.params.contains('"level":"warning"') + assert first.params.contains('"logger":"svc"') + second := decode_notification(queue[1])! + assert second.params.contains('"level":"error"') + assert second.params.contains('"data":{"k":1}') +} + +fn test_logging_set_level_unknown_returns_invalid_params() { + mut server := new_server(name: 'log', version: '0', enable_logging: true) + 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + dispatch := server.dispatch_message(new_request(2, 'logging/setLevel', SetLevelParams{ + level: 'fatal' + }).encode(), stdio_session_id, .stdio)! + assert decode_response(dispatch.response)!.error.code == invalid_params.code +} + +fn test_logging_disabled_rejects_set_level() { + mut server := new_server(name: 'log', 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + dispatch := server.dispatch_message(new_request(2, 'logging/setLevel', SetLevelParams{ + level: 'info' + }).encode(), stdio_session_id, .stdio)! + assert decode_response(dispatch.response)!.error.code == method_not_found.code +} + +fn test_progress_token_is_extracted_and_notification_is_sent() { + mut server := new_server(name: 'p', version: '0') + server.add_tool(Tool{ name: 'work' }, fn (ctx Context, _ string) !ToolResult { + ctx.notify_progress(0.25, 1.0, 'starting') + ctx.notify_progress(1.0, 1.0, 'done') + return tool_text_result('done') + })! + + 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + 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 == 2 + first := decode_notification(queue[0])! + assert first.method == 'notifications/progress' + assert first.params.contains('"progressToken":"abc"') + assert first.params.contains('"progress":0.25') + assert first.params.contains('"total":1') + assert first.params.contains('"message":"starting"') +} + +fn test_cancellation_marks_request_until_cleared() { + mut server := new_server(name: 'c', version: '0') + server.add_tool(Tool{ name: 'check' }, fn (ctx Context, _ string) !ToolResult { + assert ctx.is_cancelled() + return tool_text_result('seen') + })! + + 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + // Request ids carry their JSON form unchanged (string vs number) so the + // cancellation must use the exact same form per spec. + server.dispatch_message('{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7,"reason":"user"}}', + stdio_session_id, .stdio)! + assert server.is_request_cancelled(stdio_session_id, '7') + + server.dispatch_message('{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"check","arguments":{}}}', + stdio_session_id, .stdio)! + assert !server.is_request_cancelled(stdio_session_id, '7') +} + +fn test_completion_complete_routes_to_registered_handler() { + mut server := new_server(name: 'cplt', version: '0') + server.add_prompt(Prompt{ + name: 'review' + arguments: [PromptArgument{ + name: 'lang' + }] + }, fn (_ Context, _ string) !GetPromptResult { + return GetPromptResult{} + })! + server.add_completion(CompletionRef{ ref_type: 'ref/prompt', name: 'review' }, 'lang', fn (_ Context, current_value string, _ string) !CompletionResult { + candidates := ['rust', 'python', 'go', 'v'] + matches := candidates.filter(it.starts_with(current_value)) + return CompletionResult{ + values: matches + total: matches.len + has_more: false + } + })! + + 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"review"},"argument":{"name":"lang","value":"r"}}}', + stdio_session_id, .stdio)! + body := decode_response(dispatch.response)!.result + assert body.contains('"values":["rust"]') + assert body.contains('"total":1') + assert body.contains('"hasMore":false') +} + +fn test_completion_unknown_handler_returns_empty_values() { + mut server := new_server(name: 'cplt', version: '0') + server.add_completion(CompletionRef{ ref_type: 'ref/resource', uri: 'res://a' }, 'k', fn (_ Context, _ string, _ string) !CompletionResult { + return CompletionResult{ + values: ['x'] + } + })! + + 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + dispatch := server.dispatch_message('{"jsonrpc":"2.0","id":2,"method":"completion/complete","params":{"ref":{"type":"ref/resource","uri":"res://other"},"argument":{"name":"k","value":""}}}', + stdio_session_id, .stdio)! + body := decode_response(dispatch.response)!.result + assert body == '{"completion":{"values":[]}}' +} + +fn test_http_get_streams_queued_notifications_with_event_ids() { + mut server_value := new_server(name: 'sse', version: '0', enable_logging: true) + mut server := &server_value + server.add_resource(Resource{ uri: 'res://x', name: 'x' }, fn (_ Context, uri string) !ReadResourceResult { + return ReadResourceResult{ + contents: [ResourceContents{ + uri: uri + text: 'x' + }] + } + })! + + server_thread := spawn server.serve_http('127.0.0.1:0') + server.wait_till_running(max_retries: 200, retry_period_ms: 10)! + time.sleep(20 * time.millisecond) + url := 'http://${server.http_server.addr}/mcp' + + session_id, mut header := http_initialize(url)! + header.set_custom(mcp_session_id_header, session_id)! + notification_response := http.fetch( + method: .post + url: url + data: new_notification('notifications/initialized', empty).encode() + header: header + )! + assert notification_response.status_code == 202 + + server.notify_log(.info, 'svc', '"hello"') + server.notify_log(.warning, 'svc', '"world"') + + mut get_header := http.new_header() + get_header.set(.accept, 'text/event-stream') + get_header.set_custom(mcp_session_id_header, session_id)! + stream := http.fetch( + method: .get + url: url + header: get_header + )! + assert stream.status_code == 200 + assert stream.header.get(.content_type)!.starts_with(event_stream_content_type) + assert stream.body.contains('id: 1') + assert stream.body.contains('id: 2') + first_messages := parse_sse_messages(stream.body)! + assert first_messages.len == 2 + assert decode_notification(first_messages[0])!.method == 'notifications/message' + + mut resume_header := get_header + resume_header.set_custom(last_event_id_header, '1')! + resume := http.fetch( + method: .get + url: url + header: resume_header + )! + assert resume.body.contains('id: 2') + assert !resume.body.contains('id: 1\n') + + server.close() + server_thread.wait() or {} +} + +fn test_server_initiated_list_roots_round_trip() { + mut server := new_server(name: 'roots', 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + mut request_thread := spawn fn [mut server] () !ListRootsResult { + return server.list_roots(stdio_session_id, 1 * time.second) + }() + + // Drain the queued request and respond as a client would. + mut server_request_id := '' + deadline := time.now().add(1 * time.second) + for time.now() < deadline { + queued := server.drain_session_notifications(stdio_session_id) + if queued.len != 0 { + req := decode_request(queued[0])! + assert req.method == 'roots/list' + server_request_id = req.id + break + } + time.sleep(2 * time.millisecond) + } + assert server_request_id != '' + + server.dispatch_message('{"jsonrpc":"2.0","id":${server_request_id},"result":{"roots":[{"uri":"file:///tmp","name":"tmp"}]}}', + stdio_session_id, .stdio)! + roots := request_thread.wait()! + assert roots.roots.len == 1 + assert roots.roots[0].uri == 'file:///tmp' +} + +fn test_server_initiated_request_returns_on_timeout() { + mut server := new_server(name: 'roots', 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + started := time.now() + server.list_roots(stdio_session_id, 50 * time.millisecond) or { + // `timed_wait` should return promptly after the deadline; allow up to + // 5x the timeout to account for CI scheduler jitter. + elapsed := time.now() - started + assert elapsed < 250 * time.millisecond + assert err.msg().contains('timeout') + return + } + assert false, 'list_roots should have timed out' +} + +fn test_late_response_after_timeout_does_not_leak_pending() { + mut server := new_server(name: 'late', 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)! + server.dispatch_message(new_notification('notifications/initialized', empty).encode(), + stdio_session_id, .stdio)! + + server.list_roots(stdio_session_id, 30 * time.millisecond) or { + assert err.msg().contains('timeout') + } + + // The waiter has cleaned up its semaphore; a late reply must be dropped + // rather than parked in `pending_responses` forever. + mut server_request_id := '' + rlock server.state { + session := server.state.sessions[stdio_session_id] + server_request_id = '"server-${session.next_request_seq - 1}"' + } + server.dispatch_message('{"jsonrpc":"2.0","id":${server_request_id},"result":{"roots":[]}}', + stdio_session_id, .stdio)! + + rlock server.state { + session := server.state.sessions[stdio_session_id] + assert session.pending_responses.len == 0 + } +} + +fn test_http_get_resume_drains_queue_before_replay() { + mut server_value := new_server(name: 'resume', version: '0', enable_logging: true) + mut server := &server_value + server_thread := spawn server.serve_http('127.0.0.1:0') + server.wait_till_running(max_retries: 200, retry_period_ms: 10)! + time.sleep(20 * time.millisecond) + url := 'http://${server.http_server.addr}/mcp' + + session_id, mut header := http_initialize(url)! + header.set_custom(mcp_session_id_header, session_id)! + notification_response := http.fetch( + method: .post + url: url + data: new_notification('notifications/initialized', empty).encode() + header: header + )! + assert notification_response.status_code == 202 + + // First batch: drained on the initial GET, assigns ids 1..2. + server.notify_log(.info, 'svc', '"first"') + server.notify_log(.info, 'svc', '"second"') + + mut get_header := http.new_header() + get_header.set(.accept, 'text/event-stream') + get_header.set_custom(mcp_session_id_header, session_id)! + first := http.fetch(method: .get, url: url, header: get_header)! + assert first.status_code == 200 + assert first.body.contains('id: 1') + assert first.body.contains('id: 2') + + // Second batch arrives while the client is between GETs — it stays in the + // notification queue and would be missed if the resume only replayed the + // event log without draining first. + server.notify_log(.info, 'svc', '"third"') + server.notify_log(.info, 'svc', '"fourth"') + + mut resume_header := get_header + resume_header.set_custom(last_event_id_header, '2')! + resume := http.fetch(method: .get, url: url, header: resume_header)! + assert resume.status_code == 200 + assert resume.body.contains('id: 3') + assert resume.body.contains('id: 4') + assert resume.body.contains('"third"') + assert resume.body.contains('"fourth"') + + server.close() + server_thread.wait() or {} +} + +fn test_http_returns_json_when_accept_lists_both() { + mut server_value := new_server( + name: 'json-default-server' + version: '0.0.1' + ) + mut server := &server_value + server_thread := spawn server.serve_http('127.0.0.1:0') + server.wait_till_running(max_retries: 200, retry_period_ms: 10)! + time.sleep(20 * time.millisecond) + url := 'http://${server.http_server.addr}/mcp' + + session_id, mut header := http_initialize(url)! + header.set_custom(mcp_session_id_header, session_id)! + notification_response := http.fetch( + method: .post + url: url + data: new_notification('notifications/initialized', empty).encode() + header: header + )! + assert notification_response.status_code == 202 + + ping_response := http.fetch( + method: .post + url: url + data: new_request(2, 'ping', empty).encode() + header: header + )! + assert ping_response.status_code == 200 + assert ping_response.header.get(.content_type)!.starts_with(default_content_type) + + server.close() + server_thread.wait() or {} +} diff --git a/vlib/mcp/spec_compliance_test.v b/vlib/mcp/spec_compliance_test.v new file mode 100644 index 000000000..44619ec12 --- /dev/null +++ b/vlib/mcp/spec_compliance_test.v @@ -0,0 +1,579 @@ +// 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"') +} -- 2.39.5