Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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 {}
|
||||
}
|
||||
@@ -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"')
|
||||
}
|
||||
Reference in new issue
Block a user