Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
# `mcp`
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
fn main() {
mut client := mcp.connect('http://localhost:8000/mcp')!
init := client.initialize()!
println(init.server_info.name)
client.close()
}
```
## Quick start — server
```v
import mcp
fn main() {
mut server := mcp.new_server(
name: 'my-v-mcp-server'
version: '1.0.0'
enable_logging: true
)
server.add_tool(mcp.Tool{
name: 'say_hello'
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()!
}
```
## 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.
[schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json
+911
View File
@@ -0,0 +1,911 @@
module mcp
import json
import net.http
import os
import time
pub const jsonrpc_version = '2.0'
pub const protocol_version = '2025-11-25'
pub const parse_error = ResponseError{
code: -32700
message: 'Invalid JSON.'
}
pub const invalid_request = ResponseError{
code: -32600
message: 'Invalid request.'
}
pub const method_not_found = ResponseError{
code: -32601
message: 'Method not found.'
}
pub const invalid_params = ResponseError{
code: -32602
message: 'Invalid params.'
}
pub const internal_error = ResponseError{
code: -32603
message: 'Internal error.'
}
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 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'
// ResponseError is the JSON-RPC error payload used by MCP responses.
pub struct ResponseError {
pub:
code int
message string
data string @[raw]
}
// code returns the JSON-RPC error code.
pub fn (err ResponseError) code() int {
return err.code
}
// msg returns the JSON-RPC error message.
pub fn (err ResponseError) msg() string {
return err.message
}
// err casts the response error to `IError`.
pub fn (err ResponseError) err() IError {
return IError(err)
}
// Null represents the JSON `null` literal.
pub struct Null {}
// str returns the JSON `null` literal.
pub fn (n Null) str() string {
return 'null'
}
pub const null = Null{}
// Empty omits a JSON-RPC field when used with MCP helpers.
pub struct Empty {}
// str returns the empty string.
pub fn (e Empty) str() string {
return ''
}
pub const empty = Empty{}
// EmptyObject encodes to an empty JSON object.
pub struct EmptyObject {}
// str returns the JSON empty object literal.
pub fn (e EmptyObject) str() string {
return '{}'
}
pub const empty_object = EmptyObject{}
// 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
title string @[omitempty]
description string @[omitempty]
website_url string @[json: websiteUrl; omitempty]
icons []Icon @[omitempty]
}
// InitializeParams is the typed payload for the `initialize` request.
pub struct InitializeParams {
pub:
protocol_version string @[json: protocolVersion]
capabilities string @[raw]
client_info Implementation @[json: clientInfo]
}
// InitializeResult is the typed result returned by an MCP server after initialization.
pub struct InitializeResult {
pub:
protocol_version string @[json: protocolVersion]
capabilities string @[raw]
server_info Implementation @[json: serverInfo]
instructions string
}
// Request is a JSON-RPC request message encoded for MCP.
pub struct Request {
pub:
jsonrpc string = jsonrpc_version
id string @[raw]
method string
params string @[omitempty; raw]
}
// new_request constructs an MCP request with a typed id and params payload.
pub fn new_request[I, P](id I, method string, params P) Request {
return Request{
id: encode_id(id)
method: method
params: encode_value(params)
}
}
// encode serializes the request to JSON.
pub fn (req Request) encode() string {
params_payload := if req.params.len == 0 { '' } else { ',"params":${req.params}' }
id_payload := if req.id.len == 0 { null.str() } else { req.id }
return '{"jsonrpc":"${jsonrpc_version}","id":${id_payload},"method":${json.encode(req.method)}${params_payload}}'
}
// decode_params decodes the raw request params into `T`.
pub fn (req Request) decode_params[T]() !T {
return decode_value[T](req.params)
}
// Notification is a JSON-RPC notification encoded for MCP.
pub struct Notification {
pub:
jsonrpc string = jsonrpc_version
method string
params string @[omitempty; raw]
}
// new_notification constructs an MCP notification with a typed params payload.
pub fn new_notification[P](method string, params P) Notification {
return Notification{
method: method
params: encode_value(params)
}
}
// encode serializes the notification to JSON.
pub fn (notification Notification) encode() string {
params_payload := if notification.params.len == 0 {
''
} else {
',"params":${notification.params}'
}
return '{"jsonrpc":"${jsonrpc_version}","method":${json.encode(notification.method)}${params_payload}}'
}
// decode_params decodes the raw notification params into `T`.
pub fn (notification Notification) decode_params[T]() !T {
return decode_value[T](notification.params)
}
// Response is a JSON-RPC response message encoded for MCP.
pub struct Response {
pub:
jsonrpc string = jsonrpc_version
id string @[raw]
result string @[raw]
error ResponseError
}
// new_response constructs an MCP response with a typed id and result payload.
pub fn new_response[I, R](id I, result R, err ResponseError) Response {
return Response{
id: encode_id(id)
result: if err.code != 0 { '' } else { encode_value(result) }
error: err
}
}
// encode serializes the response to JSON.
pub fn (resp Response) encode() string {
mut payload := '{"jsonrpc":"${jsonrpc_version}"'
if resp.error.code != 0 {
payload += ',"error":' + encode_response_error(resp.error)
} else {
result_payload := if resp.result.len == 0 { null.str() } else { resp.result }
payload += ',"result":' + result_payload
}
id_payload := if resp.id.len == 0 { null.str() } else { resp.id }
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 {
return resp.error.err()
}
return decode_value[T](resp.result)
}
// decode_request decodes a JSON payload into an MCP request.
pub fn decode_request(raw string) !Request {
return json.decode(Request, raw) or { return err }
}
// decode_notification decodes a JSON payload into an MCP notification.
pub fn decode_notification(raw string) !Notification {
return json.decode(Notification, raw) or { return err }
}
// decode_response decodes a JSON payload into an MCP response.
pub fn decode_response(raw string) !Response {
return json.decode(Response, raw) or { return err }
}
struct MessageEnvelope {
jsonrpc string
id string @[raw]
method string
params string @[raw]
result string @[raw]
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 is_notification_id(env.id) {
return Notification{
method: env.method
params: env.params
}.encode()
}
return Request{
id: env.id
method: env.method
params: env.params
}.encode()
}
return Response{
id: env.id
result: env.result
error: env.error
}.encode()
}
fn decode_envelope(raw string) !MessageEnvelope {
return json.decode(MessageEnvelope, raw) or { return err }
}
// Transport is the boundary between MCP messages and the wire format.
pub interface Transport {
mut:
send(message string) !
receive() !string
close()
}
@[params]
pub struct ClientConfig {
pub mut:
protocol_version string = protocol_version
client_info Implementation = Implementation{
name: default_client_name
version: default_client_version
}
capabilities string = '{}'
headers map[string]string
}
pub struct Client {
mut:
transport Transport
config ClientConfig
next_id int = 1
initialized bool
init_result InitializeResult
pending_responses map[string]Response
notifications []Notification
server_requests []Request
}
// new_client constructs an MCP client on top of a custom transport.
pub fn new_client(transport Transport, config ClientConfig) Client {
return Client{
transport: transport
config: config
pending_responses: map[string]Response{}
}
}
// connect creates an MCP client for a streamable HTTP endpoint.
pub fn connect(url string) !Client {
return connect_http(url, ClientConfig{})
}
// connect_http creates an MCP client for a streamable HTTP endpoint.
pub fn connect_http(url string, config ClientConfig) !Client {
transport := new_http_transport(url, config)!
return new_client(transport, config)
}
// connect_stdio creates an MCP client that talks to a local stdio server process.
pub fn connect_stdio(command string, args []string, config ClientConfig) !Client {
transport := new_process_transport(command, args)!
return new_client(transport, config)
}
// initialize starts the MCP initialization handshake using the client's config.
pub fn (mut c Client) initialize() !InitializeResult {
return c.initialize_with_raw(c.config.capabilities, c.config.client_info)
}
// initialize_with starts the MCP initialization handshake using typed capabilities.
pub fn (mut c Client) initialize_with[X](capabilities X, client_info Implementation) !InitializeResult {
return c.initialize_with_raw(encode_value(capabilities), client_info)
}
// send_request sends a typed request and waits for its response.
pub fn (mut c Client) send_request(request Request) !Response {
if request.method == 'initialize' {
return error('mcp.Client.initialize must be used for the MCP handshake')
}
c.ensure_initialized()!
c.transport.send(request.encode())!
return c.wait_for_response(request.id)
}
// request_message sends a method call and returns the raw MCP response.
pub fn (mut c Client) request_message[P](method string, params P) !Response {
request := new_request(c.next_request_id(), method, params)
return c.send_request(request)
}
// request sends a method call and decodes its result into `Result`.
pub fn (mut c Client) request[P, R](method string, params P) !R {
response := c.request_message(method, params)!
result := response.decode_result[R]()!
return result
}
// send_notification sends a typed notification message.
pub fn (mut c Client) send_notification(notification Notification) ! {
if notification.method == 'notifications/initialized' {
return error('notifications/initialized is sent automatically after initialization')
}
c.ensure_initialized()!
c.transport.send(notification.encode())!
}
// notify sends a method notification with a typed params payload.
pub fn (mut c Client) notify[P](method string, params P) ! {
c.send_notification(new_notification(method, params))!
}
// take_notifications drains notifications queued while waiting for responses.
pub fn (mut c Client) take_notifications() []Notification {
if c.notifications.len == 0 {
return []Notification{}
}
drained := c.notifications.clone()
c.notifications = []Notification{}
return drained
}
// take_requests drains server initiated requests queued while waiting for responses.
pub fn (mut c Client) take_requests() []Request {
if c.server_requests.len == 0 {
return []Request{}
}
drained := c.server_requests.clone()
c.server_requests = []Request{}
return drained
}
// close releases the underlying transport.
pub fn (mut c Client) close() {
c.transport.close()
}
fn (mut c Client) initialize_with_raw(capabilities string, client_info Implementation) !InitializeResult {
if c.initialized {
return c.init_result
}
c.config.capabilities = normalize_capabilities(capabilities)
c.config.client_info = normalize_client_info(client_info)
params := InitializeParams{
protocol_version: normalize_protocol_version(c.config.protocol_version)
capabilities: c.config.capabilities
client_info: c.config.client_info
}
request := Request{
id: encode_id(c.next_request_id())
method: 'initialize'
params: encode_initialize_params(params)
}
c.transport.send(request.encode())!
response := c.wait_for_response(request.id)!
result := response.decode_result[InitializeResult]()!
c.transport.send(new_notification('notifications/initialized', empty).encode())!
c.initialized = true
c.init_result = result
return result
}
fn (mut c Client) ensure_initialized() ! {
if !c.initialized {
c.initialize()!
}
}
fn (mut c Client) next_request_id() int {
request_id := c.next_id
c.next_id++
return request_id
}
fn (mut c Client) wait_for_response(expected_id string) !Response {
if expected_id in c.pending_responses {
response := c.pending_responses[expected_id]
c.pending_responses.delete(expected_id)
return response
}
for {
raw_message := c.transport.receive()!
envelope := decode_envelope(raw_message)!
if envelope.method.len != 0 {
if is_notification_id(envelope.id) {
c.notifications << Notification{
method: envelope.method
params: envelope.params
}
} else {
c.server_requests << Request{
id: envelope.id
method: envelope.method
params: envelope.params
}
}
continue
}
response := Response{
id: envelope.id
result: envelope.result
error: envelope.error
}
if response.id == expected_id {
return response
}
c.pending_responses[response.id] = response
}
return error('mcp: response loop exited unexpectedly')
}
fn encode_initialize_params(params InitializeParams) string {
return '{"protocolVersion":${json.encode(params.protocol_version)},"capabilities":${normalize_capabilities(params.capabilities)},"clientInfo":${json.encode(params.client_info)}}'
}
struct NoFrameError {}
fn (err NoFrameError) msg() string {
return 'no complete frame available'
}
fn (err NoFrameError) code() int {
return 0
}
// FrameExtraction holds a single stdio message and the unconsumed buffer remainder.
struct FrameExtraction {
message string
remaining string
}
struct HttpTransport {
mut:
url string
header http.Header
session_id string
protocol_version string
pending []string
}
fn new_http_transport(url string, config ClientConfig) !HttpTransport {
if url == '' {
return error('mcp.connect_http: empty url')
}
if !url.starts_with('http://') && !url.starts_with('https://') {
return error('mcp.connect_http: expected an http:// or https:// MCP endpoint')
}
mut header := http.new_header()
header.set(.user_agent, default_client_name)
if config.headers.len != 0 {
header.add_custom_map(config.headers)!
}
return HttpTransport{
url: url
header: header
}
}
fn (mut transport HttpTransport) send(message string) ! {
mut header := transport.header
header.set(.content_type, default_content_type)
header.set(.accept, streamable_http_accept)
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
data: message
header: header
)!
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
return
}
if response.status_code >= 400 {
return error('mcp.http: server returned HTTP ${response.status_code} without an MCP payload')
}
}
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')
}
message := transport.pending[0]
transport.pending = if transport.pending.len == 1 {
[]string{}
} else {
transport.pending[1..].clone()
}
return message
}
fn (mut transport HttpTransport) close() {
if transport.session_id == '' {
return
}
mut header := transport.header
header.set_custom(mcp_session_id_header, transport.session_id) or { return }
http.fetch(
method: .delete
url: transport.url
header: header
) or {}
transport.session_id = ''
}
struct ProcessTransport {
mut:
process &os.Process
buffer string
}
fn new_process_transport(command string, args []string) !ProcessTransport {
if command == '' {
return error('mcp.connect_stdio: empty command')
}
mut process := os.new_process(command)
process.set_args(args)
process.set_redirect_stdio()
process.run()
return ProcessTransport{
process: process
}
}
fn (mut transport ProcessTransport) send(message string) ! {
transport.process.stdin_write(encode_stdio_message(message))
}
fn (mut transport ProcessTransport) receive() !string {
for {
frame := try_extract_stdio_message(transport.buffer) or {
if err.msg() != NoFrameError{}.msg() {
return err
}
FrameExtraction{}
}
if frame.message.len != 0 {
transport.buffer = frame.remaining
return frame.message
}
if transport.process.is_pending(.stdout) {
chunk := transport.process.stdout_read()
if chunk.len != 0 {
transport.buffer += chunk
continue
}
}
if !transport.process.is_alive() {
transport.buffer += transport.process.stdout_slurp()
frame_after_exit := try_extract_stdio_message(transport.buffer) or {
if err.msg() != NoFrameError{}.msg() {
return err
}
FrameExtraction{}
}
if frame_after_exit.message.len != 0 {
transport.buffer = frame_after_exit.remaining
return frame_after_exit.message
}
stderr_output := transport.process.stderr_slurp().trim_space()
if stderr_output.len != 0 {
return error('mcp.stdio: process exited before a full MCP message was received: ${stderr_output}')
}
return error('mcp.stdio: process exited before a full MCP message was received')
}
time.sleep(process_poll_interval)
}
return error('mcp.stdio: receive loop exited unexpectedly')
}
fn (mut transport ProcessTransport) close() {
if transport.process.is_alive() {
transport.process.signal_term()
for _ in 0 .. 20 {
if !transport.process.is_alive() {
break
}
time.sleep(10 * time.millisecond)
}
if transport.process.is_alive() {
transport.process.signal_kill()
} else if transport.process.status in [.running, .stopped] {
transport.process.wait()
}
}
transport.process.close()
}
fn parse_http_response_messages(response http.Response) ![]string {
content_type := response.header.get(.content_type) or { '' }
body := response.body.trim_space()
if body.len == 0 {
return []string{}
}
content_type_lower := content_type.to_lower()
if content_type_lower.starts_with('application/json')
|| (content_type == '' && is_json_payload(body)) {
return split_json_payloads(body)
}
if content_type_lower.starts_with('text/event-stream') {
return parse_sse_messages(body)
}
return error('mcp.http: unsupported content type `${content_type}`')
}
fn split_json_payloads(body string) ![]string {
trimmed := body.trim_space()
if trimmed.len == 0 {
return []string{}
}
if trimmed[0] != `[` {
return [trimmed]
}
envelopes := json.decode([]MessageEnvelope, trimmed) or { return err }
mut messages := []string{cap: envelopes.len}
for envelope in envelopes {
messages << envelope.encode()
}
return messages
}
fn parse_sse_messages(body string) ![]string {
normalized := body.replace('\r\n', '\n').replace('\r', '\n')
mut data_lines := []string{}
mut messages := []string{}
for line in normalized.split('\n') {
if line.len == 0 {
if data_lines.len != 0 {
append_sse_payload(mut messages, data_lines.join('\n'))!
data_lines = []string{}
}
continue
}
if line.starts_with(':') {
continue
}
if line.starts_with('data:') {
mut payload := line[5..]
if payload.len != 0 && payload[0] == ` ` {
payload = payload[1..]
}
data_lines << payload
}
}
if data_lines.len != 0 {
append_sse_payload(mut messages, data_lines.join('\n'))!
}
return messages
}
fn append_sse_payload(mut messages []string, payload string) ! {
trimmed := payload.trim_space()
if !is_json_payload(trimmed) {
return
}
payloads := split_json_payloads(trimmed)!
for item in payloads {
messages << item
}
}
fn is_json_payload(payload string) bool {
trimmed := payload.trim_space()
if trimmed.len == 0 {
return false
}
return trimmed[0] == `{` || trimmed[0] == `[`
}
// 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
}
}
return FrameExtraction{
message: message
remaining: remaining
}
}
fn encode_id[I](id I) string {
return $if I is int {
id.str()
} $else {
json.encode(id)
}
}
fn encode_value[T](value T) string {
return $if T is Empty {
value.str()
} $else $if T is EmptyObject {
value.str()
} $else $if T is Null {
value.str()
} $else {
json.encode(value)
}
}
fn decode_value[T](value string) !T {
$if T is Empty {
if value == '' || value == null.str() {
return Empty{}
}
return error('mcp: expected an empty payload, got `${value}`')
} $else $if T is EmptyObject {
if value == '{}' {
return EmptyObject{}
}
return error('mcp: expected an empty object payload, got `${value}`')
} $else $if T is Null {
if value == null.str() {
return null
}
return error('mcp: expected null, got `${value}`')
} $else $if T is string {
if value.len >= 2 && value[0] == `"` && value[value.len - 1] == `"` {
return json.decode(string, value) or { return err }
}
return error('mcp: could not decode `${value}` into string')
} $else $if T is bool {
if value == 'true' {
return true
}
if value == 'false' {
return false
}
return error('mcp: could not decode `${value}` into bool')
} $else {
return json.decode(T, value) or { return err }
}
}
fn normalize_client_info(client_info Implementation) Implementation {
return if client_info.name == '' {
Implementation{
name: default_client_name
version: if client_info.version == '' {
default_client_version
} else {
client_info.version
}
}
} else if client_info.version == '' {
Implementation{
name: client_info.name
version: default_client_version
}
} else {
client_info
}
}
fn normalize_capabilities(capabilities string) string {
trimmed := capabilities.trim_space()
return if trimmed.len == 0 { '{}' } else { trimmed }
}
fn normalize_protocol_version(version string) string {
trimmed := version.trim_space()
return if trimmed.len == 0 { protocol_version } else { trimmed }
}
+157
View File
@@ -0,0 +1,157 @@
module mcp
struct MockTransport {
mut:
incoming []string
sent []string
closed bool
}
fn (mut transport MockTransport) send(message string) ! {
transport.sent << message
}
fn (mut transport MockTransport) receive() !string {
if transport.incoming.len == 0 {
return error('no messages queued in MockTransport')
}
message := transport.incoming[0]
transport.incoming = if transport.incoming.len == 1 {
[]string{}
} else {
transport.incoming[1..].clone()
}
return message
}
fn (mut transport MockTransport) close() {
transport.closed = true
}
fn test_initialize_sends_the_mcp_handshake() {
mut transport := &MockTransport{
incoming: [
new_response(1, InitializeResult{
protocol_version: protocol_version
capabilities: '{"tools":{}}'
server_info: Implementation{
name: 'mock-server'
version: '1.0.0'
}
}, ResponseError{}).encode(),
]
}
mut client := new_client(transport, ClientConfig{
client_info: Implementation{
name: 'mcp-test-client'
version: '0.1.0'
}
capabilities: '{"roots":{"listChanged":true}}'
})
result := client.initialize()!
assert result.server_info.name == 'mock-server'
assert transport.sent.len == 2
request := decode_request(transport.sent[0])!
params := request.decode_params[InitializeParams]()!
assert request.method == 'initialize'
assert params.protocol_version == protocol_version
assert params.client_info.name == 'mcp-test-client'
assert params.capabilities == '{"roots":{"listChanged":true}}'
notification := decode_notification(transport.sent[1])!
assert notification.method == 'notifications/initialized'
assert notification.params == ''
}
fn test_request_buffers_server_messages_after_initialize() {
mut transport := &MockTransport{
incoming: [
new_response(1, InitializeResult{
protocol_version: protocol_version
capabilities: '{"tools":{}}'
server_info: Implementation{
name: 'mock-server'
version: '1.0.0'
}
}, ResponseError{}).encode(),
new_notification('notifications/tools/list_changed', empty).encode(),
new_request('server-1', 'roots/list', empty).encode(),
new_response(2, true, ResponseError{}).encode(),
]
}
mut client := new_client(transport, ClientConfig{})
client.initialize()!
response := client.request_message('ping', empty)!
assert response.result == 'true'
assert transport.sent.len == 3
assert decode_request(transport.sent[2])!.method == 'ping'
notifications := client.take_notifications()
assert notifications.len == 1
assert notifications[0].method == 'notifications/tools/list_changed'
requests := client.take_requests()
assert requests.len == 1
assert requests[0].method == 'roots/list'
assert requests[0].id == '"server-1"'
}
fn test_parse_sse_messages_reads_json_rpc_events() {
body := 'event: message\r\n' +
'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":0.5}}\r\n' +
'\r\n' + 'event: message\r\n' + 'data: {"jsonrpc":"2.0","id":1,"result":true}\r\n' + '\r\n'
messages := parse_sse_messages(body)!
assert messages.len == 2
assert decode_notification(messages[0])!.method == 'notifications/progress'
assert decode_response(messages[1])!.result == 'true'
}
fn test_stdio_messages_handle_partial_reads() {
payload := new_notification('notifications/initialized', empty).encode()
frame := encode_stdio_message(payload)
assert frame.ends_with('\n')
mut buffer := frame[..frame.len - 1]
try_extract_stdio_message(buffer) or { assert err.msg() == NoFrameError{}.msg() }
buffer += frame[frame.len - 1..]
extracted := try_extract_stdio_message(buffer)!
buffer = extracted.remaining
message := extracted.message
assert buffer == ''
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{})
client.close()
assert transport.closed
}
File diff suppressed because it is too large. Load diff
+965
View File
@@ -0,0 +1,965 @@
module mcp
import net.http
import time
fn test_server_routes_initialize_and_registered_features() {
mut server := new_server(
name: 'test-server'
version: '1.2.3'
instructions: 'Be precise.'
)
server.add_tool(Tool{
name: 'say_hello'
description: 'Returns a greeting'
}, fn (ctx Context, arguments string) !ToolResult {
assert ctx.session_id == stdio_session_id
assert ctx.transport == .stdio
assert arguments == '{"name":"V"}'
return tool_text_result('Hello, V!')
})!
server.add_resource(Resource{
uri: 'resource://guide'
name: 'guide'
mime_type: 'text/plain'
}, fn (_ Context, uri string) !ReadResourceResult {
return ReadResourceResult{
contents: [
ResourceContents{
uri: uri
mime_type: 'text/plain'
text: 'guide contents'
},
]
}
})!
server.add_resource_template(ResourceTemplate{
uri_template: 'resource://docs/{slug}'
name: 'docs'
})!
server.add_prompt(Prompt{
name: 'review'
description: 'Review some code'
arguments: [
PromptArgument{
name: 'code'
required: true
},
]
}, fn (_ Context, arguments string) !GetPromptResult {
assert arguments == '{"code":"fn main() {}"}'
return GetPromptResult{
description: 'Review prompt'
messages: [
prompt_text_message('user', 'Review this code'),
]
}
})!
init_request := Request{
id: encode_id(1)
method: 'initialize'
params: encode_initialize_params(InitializeParams{
protocol_version: protocol_version
capabilities: '{"roots":{}}'
client_info: Implementation{
name: 'test-client'
version: '0.1.0'
}
})
}
init_dispatch := server.dispatch_message(init_request.encode(), stdio_session_id, .stdio)!
assert init_dispatch.has_response
init_response := decode_response(init_dispatch.response)!
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":{"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)!
blocked_response := decode_response(blocked_dispatch.response)!
assert blocked_response.error.code == server_not_initialized.code
initialized := server.dispatch_message(new_notification('notifications/initialized', empty).encode(),
stdio_session_id, .stdio)!
assert !initialized.has_response
tools_list := server.dispatch_message(new_request(3, 'tools/list', empty).encode(),
stdio_session_id, .stdio)!
tools_result := decode_response(tools_list.response)!.decode_result[ListToolsResult]()!
assert tools_result.tools.len == 1
assert tools_result.tools[0].name == 'say_hello'
tool_call := server.dispatch_message(Request{
id: encode_id(4)
method: 'tools/call'
params: '{"name":"say_hello","arguments":{"name":"V"}}'
}.encode(), stdio_session_id, .stdio)!
tool_result := decode_response(tool_call.response)!.decode_result[ToolResult]()!
assert tool_result.content.contains('Hello, V!')
assert !tool_result.is_error
resource_list := server.dispatch_message(new_request(5, 'resources/list', empty).encode(),
stdio_session_id, .stdio)!
resource_list_result :=
decode_response(resource_list.response)!.decode_result[ListResourcesResult]()!
assert resource_list_result.resources.len == 1
assert resource_list_result.resources[0].uri == 'resource://guide'
resource_templates := server.dispatch_message(new_request(6, 'resources/templates/list', empty).encode(),
stdio_session_id, .stdio)!
resource_template_result :=
decode_response(resource_templates.response)!.decode_result[ListResourceTemplatesResult]()!
assert resource_template_result.resource_templates.len == 1
assert resource_template_result.resource_templates[0].uri_template == 'resource://docs/{slug}'
resource_read := server.dispatch_message(new_request(7, 'resources/read', ReadResourceParams{
uri: 'resource://guide'
}).encode(), stdio_session_id, .stdio)!
resource_read_result :=
decode_response(resource_read.response)!.decode_result[ReadResourceResult]()!
assert resource_read_result.contents.len == 1
assert resource_read_result.contents[0].text == 'guide contents'
prompts_list := server.dispatch_message(new_request(8, 'prompts/list', empty).encode(),
stdio_session_id, .stdio)!
prompts_list_result :=
decode_response(prompts_list.response)!.decode_result[ListPromptsResult]()!
assert prompts_list_result.prompts.len == 1
assert prompts_list_result.prompts[0].name == 'review'
prompt_get := server.dispatch_message(Request{
id: encode_id(9)
method: 'prompts/get'
params: '{"name":"review","arguments":{"code":"fn main() {}"}}'
}.encode(), stdio_session_id, .stdio)!
prompt_result := decode_response(prompt_get.response)!.decode_result[GetPromptResult]()!
assert prompt_result.messages.len == 1
assert prompt_result.messages[0].role == 'user'
ping := server.dispatch_message(new_request(10, 'ping', empty).encode(), stdio_session_id,
.stdio)!
ping_result := decode_response(ping.response)!.decode_result[EmptyObject]()!
assert ping_result == empty_object
}
fn test_server_http_sessions_and_delete() {
mut server_value := new_server(
name: 'http-server'
version: '0.0.1'
)
mut server := &server_value
server.add_tool(Tool{
name: 'ping_tool'
}, fn (_ Context, _ string) !ToolResult {
return tool_text_result('pong')
})!
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)
addr := server.http_server.addr
url := 'http://${addr}/mcp'
mut init_header := http.new_header(http.HeaderConfig{
key: .content_type
value: 'application/json'
}, http.HeaderConfig{
key: .accept
value: 'application/json'
})
init_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: 'http-client'
version: '0.1.0'
}
})
}.encode()
header: init_header
)!
assert init_response.status_code == 200
session_id := init_response.header.get_custom(mcp_session_id_header) or {
assert false
return
}
assert session_id != ''
mut notification_header := init_header
notification_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: notification_header
)!
assert notification_response.status_code == 202
mut list_header := notification_header
list_header.set(.accept, 'text/event-stream')
list_response := http.fetch(
method: .post
url: url
data: new_request(2, 'tools/list', empty).encode()
header: list_header
)!
assert list_response.status_code == 200
assert list_response.header.get(.content_type)?.starts_with(event_stream_content_type)
list_messages := parse_sse_messages(list_response.body)!
assert list_messages.len == 1
list_result := decode_response(list_messages[0])!.decode_result[ListToolsResult]()!
assert list_result.tools.len == 1
assert list_result.tools[0].name == 'ping_tool'
mut delete_header := http.new_header()
delete_header.set_custom(mcp_session_id_header, session_id)!
delete_response := http.fetch(
method: .delete
url: url
header: delete_header
)!
assert delete_response.status_code == 200
mut stale_header := init_header
stale_header.set_custom(mcp_session_id_header, session_id)!
stale_response := http.fetch(
method: .post
url: url
data: new_request(3, 'tools/list', empty).encode()
header: stale_header
)!
assert stale_response.status_code == 404
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
// 10x the timeout to account for CI scheduler jitter.
elapsed := time.now() - started
assert elapsed < 500 * 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 {}
}
+579
View File
@@ -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"')
}