2452 lines
71 KiB
V
2452 lines
71 KiB
V
module mcp
|
|
|
|
import json
|
|
import io
|
|
import net.http
|
|
import os
|
|
import rand
|
|
import sync
|
|
import time
|
|
|
|
const default_server_name = 'v.mcp.server'
|
|
const default_server_version = 'dev'
|
|
const default_http_path = '/mcp'
|
|
const default_list_page_size = 50
|
|
const completion_values_limit = 100
|
|
const stdio_session_id = 'stdio'
|
|
const event_log_capacity = 1024
|
|
|
|
// SessionTransport identifies how an MCP session is connected.
|
|
pub enum SessionTransport {
|
|
stdio
|
|
http
|
|
}
|
|
|
|
// LogLevel is the RFC 5424 severity used by `notifications/message`.
|
|
pub enum LogLevel {
|
|
debug
|
|
info
|
|
notice
|
|
warning
|
|
error
|
|
critical
|
|
alert
|
|
emergency
|
|
}
|
|
|
|
// str returns the wire string for a log level (lowercase per spec).
|
|
pub fn (l LogLevel) str() string {
|
|
return match l {
|
|
.debug { 'debug' }
|
|
.info { 'info' }
|
|
.notice { 'notice' }
|
|
.warning { 'warning' }
|
|
.error { 'error' }
|
|
.critical { 'critical' }
|
|
.alert { 'alert' }
|
|
.emergency { 'emergency' }
|
|
}
|
|
}
|
|
|
|
// parse_log_level decodes the wire string for a log level.
|
|
pub fn parse_log_level(value string) ?LogLevel {
|
|
return match value {
|
|
'debug' { LogLevel.debug }
|
|
'info' { LogLevel.info }
|
|
'notice' { LogLevel.notice }
|
|
'warning' { LogLevel.warning }
|
|
'error' { LogLevel.error }
|
|
'critical' { LogLevel.critical }
|
|
'alert' { LogLevel.alert }
|
|
'emergency' { LogLevel.emergency }
|
|
else { none }
|
|
}
|
|
}
|
|
|
|
// Context provides request-scoped server metadata to handlers.
|
|
pub struct Context {
|
|
pub:
|
|
session_id string @[json: sessionId]
|
|
request_id string @[json: requestId]
|
|
method string
|
|
transport SessionTransport
|
|
protocol_version string @[json: protocolVersion]
|
|
client_info Implementation @[json: clientInfo]
|
|
client_capabilities string @[json: clientCapabilities; raw]
|
|
progress_token string @[json: progressToken; raw]
|
|
mut:
|
|
server &Server = unsafe { nil } @[skip]
|
|
}
|
|
|
|
// is_cancelled reports whether the client has sent `notifications/cancelled`
|
|
// for this request. Handlers should poll this for cooperative cancellation.
|
|
pub fn (ctx Context) is_cancelled() bool {
|
|
if isnil(ctx.server) {
|
|
return false
|
|
}
|
|
return ctx.server.is_request_cancelled(ctx.session_id, ctx.request_id)
|
|
}
|
|
|
|
// notify_progress sends `notifications/progress` for this request when the
|
|
// client included a `_meta.progressToken`. `total` and `message` are optional
|
|
// (pass 0 / '' to omit).
|
|
pub fn (ctx Context) notify_progress(progress f64, total f64, message string) {
|
|
if isnil(ctx.server) || ctx.progress_token.trim_space() == '' {
|
|
return
|
|
}
|
|
ctx.server.notify_progress_for(ctx.session_id, ctx.progress_token, progress, total, message)
|
|
}
|
|
|
|
// ToolAnnotations exposes the optional behavioural hints described by the
|
|
// MCP spec (`tools/list` Annotations object).
|
|
pub struct ToolAnnotations {
|
|
pub:
|
|
title string @[omitempty]
|
|
read_only_hint ?bool @[json: readOnlyHint]
|
|
destructive_hint ?bool @[json: destructiveHint]
|
|
idempotent_hint ?bool @[json: idempotentHint]
|
|
open_world_hint ?bool @[json: openWorldHint]
|
|
}
|
|
|
|
// ToolExecution carries optional execution metadata for a tool. Set
|
|
// `task_support` to one of `'forbidden'`, `'optional'` or `'required'` to
|
|
// advertise tasks/* support; the default (empty) omits the field on the wire.
|
|
pub struct ToolExecution {
|
|
pub:
|
|
task_support string @[json: taskSupport; omitempty]
|
|
}
|
|
|
|
// Annotations are the optional rendering hints attached to resources, resource
|
|
// templates and content blocks. `audience` is a list of MCP `Role` values
|
|
// (e.g. `'user'`, `'assistant'`); `priority` is in the `[0.0, 1.0]` range with
|
|
// higher meaning more important; `last_modified` is an ISO 8601 timestamp.
|
|
pub struct Annotations {
|
|
pub:
|
|
audience []string @[omitempty]
|
|
priority ?f64
|
|
last_modified string @[json: lastModified; omitempty]
|
|
}
|
|
|
|
// Tool describes an MCP tool exposed by the server.
|
|
pub struct Tool {
|
|
pub:
|
|
name string
|
|
title string @[omitempty]
|
|
description string @[omitempty]
|
|
input_schema string = default_tool_input_schema @[json: inputSchema; raw]
|
|
output_schema string @[json: outputSchema; omitempty; raw]
|
|
annotations ToolAnnotations
|
|
icons []Icon @[omitempty]
|
|
execution ToolExecution
|
|
}
|
|
|
|
// Resource describes a concrete MCP resource exposed by the server.
|
|
pub struct Resource {
|
|
pub:
|
|
uri string
|
|
name string
|
|
title string @[omitempty]
|
|
description string @[omitempty]
|
|
mime_type string @[json: mimeType; omitempty]
|
|
size ?int
|
|
icons []Icon @[omitempty]
|
|
annotations Annotations
|
|
}
|
|
|
|
// ResourceTemplate describes a parameterized MCP resource URI template.
|
|
pub struct ResourceTemplate {
|
|
pub:
|
|
uri_template string @[json: uriTemplate]
|
|
name string
|
|
title string @[omitempty]
|
|
description string @[omitempty]
|
|
mime_type string @[json: mimeType; omitempty]
|
|
icons []Icon @[omitempty]
|
|
annotations Annotations
|
|
}
|
|
|
|
// ResourceContents contains the result of `resources/read`.
|
|
pub struct ResourceContents {
|
|
pub:
|
|
uri string
|
|
mime_type string @[json: mimeType; omitempty]
|
|
text string @[omitempty]
|
|
blob string @[omitempty]
|
|
}
|
|
|
|
// ReadResourceResult is returned by `resources/read`.
|
|
pub struct ReadResourceResult {
|
|
pub:
|
|
contents []ResourceContents
|
|
}
|
|
|
|
// PromptArgument describes one prompt argument.
|
|
pub struct PromptArgument {
|
|
pub:
|
|
name string
|
|
description string @[omitempty]
|
|
required bool
|
|
}
|
|
|
|
// Prompt describes an MCP prompt exposed by the server.
|
|
pub struct Prompt {
|
|
pub:
|
|
name string
|
|
title string @[omitempty]
|
|
description string @[omitempty]
|
|
arguments []PromptArgument
|
|
icons []Icon @[omitempty]
|
|
}
|
|
|
|
// PromptMessage is one message returned by `prompts/get`.
|
|
pub struct PromptMessage {
|
|
pub:
|
|
role string
|
|
content string @[raw]
|
|
}
|
|
|
|
// GetPromptResult is returned by `prompts/get`.
|
|
pub struct GetPromptResult {
|
|
pub:
|
|
description string @[omitempty]
|
|
messages []PromptMessage
|
|
}
|
|
|
|
// ToolResult is returned by `tools/call`.
|
|
pub struct ToolResult {
|
|
pub:
|
|
content string @[omitempty; raw]
|
|
structured_content string @[json: structuredContent; omitempty; raw]
|
|
is_error bool @[json: isError]
|
|
}
|
|
|
|
// ToolHandler handles `tools/call` for a registered tool.
|
|
pub type ToolHandler = fn (ctx Context, arguments string) !ToolResult
|
|
|
|
// ResourceHandler handles `resources/read` for a registered resource URI.
|
|
pub type ResourceHandler = fn (ctx Context, uri string) !ReadResourceResult
|
|
|
|
// PromptHandler handles `prompts/get` for a registered prompt.
|
|
pub type PromptHandler = fn (ctx Context, arguments string) !GetPromptResult
|
|
|
|
// CompletionRef identifies the prompt or resource template a completion
|
|
// targets. Prompt refs set `name`; resource refs set `uri`.
|
|
pub struct CompletionRef {
|
|
pub:
|
|
ref_type string @[json: type] // 'ref/prompt' or 'ref/resource'
|
|
name string @[omitempty]
|
|
uri string @[omitempty]
|
|
}
|
|
|
|
// CompletionResult is the payload returned by `completion/complete`.
|
|
pub struct CompletionResult {
|
|
pub:
|
|
values []string
|
|
total ?int
|
|
has_more ?bool
|
|
}
|
|
|
|
// CompletionHandler returns candidate values for a partial argument value.
|
|
// `arguments_json` is the JSON object of already-supplied sibling arguments
|
|
// (the spec's `context.arguments`).
|
|
pub type CompletionHandler = fn (ctx Context, current_value string, arguments_json string) !CompletionResult
|
|
|
|
// ServerConfig configures an MCP server instance.
|
|
@[params]
|
|
pub struct ServerConfig {
|
|
pub:
|
|
name string
|
|
version string
|
|
title string
|
|
description string
|
|
website_url string
|
|
icons []Icon
|
|
protocol_version string = protocol_version
|
|
capabilities string
|
|
instructions string
|
|
http_path string = default_http_path
|
|
// enable_logging declares the `logging` capability and lets clients call
|
|
// `logging/setLevel`. Disabled by default — turn on when the server emits
|
|
// `notifications/message` payloads.
|
|
enable_logging bool
|
|
// allowed_origins lists the Origin header values accepted on Streamable
|
|
// HTTP. Use `*` to accept any origin (NOT recommended). When empty the
|
|
// server only accepts requests without an Origin header or from the
|
|
// loopback addresses (`http://localhost[:port]`, `http://127.0.0.1[:port]`,
|
|
// `http://[::1][:port]`, or the literal string `null`).
|
|
allowed_origins []string
|
|
}
|
|
|
|
struct RegisteredTool {
|
|
tool Tool
|
|
handler ToolHandler = unsafe { nil }
|
|
}
|
|
|
|
struct RegisteredResource {
|
|
resource Resource
|
|
handler ResourceHandler = unsafe { nil }
|
|
}
|
|
|
|
struct RegisteredPrompt {
|
|
prompt Prompt
|
|
handler PromptHandler = unsafe { nil }
|
|
}
|
|
|
|
struct RegisteredCompletion {
|
|
ref CompletionRef
|
|
argument string
|
|
handler CompletionHandler = unsafe { nil }
|
|
}
|
|
|
|
// Root identifies a filesystem-or-URI boundary advertised by the client in
|
|
// response to `roots/list`.
|
|
pub struct Root {
|
|
pub:
|
|
uri string
|
|
name string @[omitempty]
|
|
}
|
|
|
|
// ListRootsResult is the typed payload returned by the client to a server
|
|
// `roots/list` request.
|
|
pub struct ListRootsResult {
|
|
pub:
|
|
roots []Root
|
|
}
|
|
|
|
// SamplingMessage is one message of a sampling/createMessage exchange.
|
|
pub struct SamplingMessage {
|
|
pub:
|
|
role string
|
|
content string @[raw]
|
|
}
|
|
|
|
// ModelHint is a name hint for sampling/createMessage model selection.
|
|
pub struct ModelHint {
|
|
pub:
|
|
name string
|
|
}
|
|
|
|
// ModelPreferences expresses sampling/createMessage routing weights.
|
|
pub struct ModelPreferences {
|
|
pub:
|
|
hints []ModelHint
|
|
cost_priority f64 @[json: costPriority; omitempty]
|
|
speed_priority f64 @[json: speedPriority; omitempty]
|
|
intelligence_priority f64 @[json: intelligencePriority; omitempty]
|
|
}
|
|
|
|
// ToolChoice configures `sampling/createMessage` tool-use behaviour. `mode`
|
|
// is one of `'auto'` (default), `'required'`, or `'none'`.
|
|
pub struct ToolChoice {
|
|
pub:
|
|
mode string
|
|
}
|
|
|
|
// CreateMessageParams are the typed parameters for sampling/createMessage.
|
|
pub struct CreateMessageParams {
|
|
pub:
|
|
messages []SamplingMessage
|
|
model_preferences ModelPreferences @[json: modelPreferences]
|
|
system_prompt string @[json: systemPrompt; omitempty]
|
|
max_tokens int @[json: maxTokens]
|
|
temperature f64 @[omitempty]
|
|
stop_sequences []string @[json: stopSequences; omitempty]
|
|
metadata string @[omitempty; raw]
|
|
tools []Tool @[omitempty]
|
|
tool_choice ToolChoice @[json: toolChoice; omitempty]
|
|
include_context string @[json: includeContext; omitempty]
|
|
}
|
|
|
|
// CreateMessageResult is the typed payload returned by the client.
|
|
pub struct CreateMessageResult {
|
|
pub:
|
|
role string
|
|
content string @[raw]
|
|
model string
|
|
stop_reason string @[json: stopReason]
|
|
}
|
|
|
|
// ElicitSchema is the requested object schema sent to the client for elicitation.
|
|
pub struct ElicitSchema {
|
|
pub:
|
|
type_ string = 'object' @[json: type]
|
|
properties string @[raw]
|
|
required []string @[omitempty]
|
|
}
|
|
|
|
// ElicitParams are the typed parameters for elicitation/create. Set `mode`
|
|
// to `'url'` and supply `url` + `elicitation_id` to send a URL-mode request;
|
|
// otherwise the call defaults to form mode and `requested_schema` is
|
|
// expected to describe the form fields.
|
|
pub struct ElicitParams {
|
|
pub:
|
|
mode string @[omitempty]
|
|
message string
|
|
requested_schema ElicitSchema @[json: requestedSchema; omitempty]
|
|
url string @[omitempty]
|
|
elicitation_id string @[json: elicitationId; omitempty]
|
|
}
|
|
|
|
// ElicitResult is the typed payload returned by the client.
|
|
pub struct ElicitResult {
|
|
pub:
|
|
action string
|
|
content string @[omitempty; raw]
|
|
}
|
|
|
|
struct LoggedEvent {
|
|
id int
|
|
body string
|
|
}
|
|
|
|
struct Session {
|
|
mut:
|
|
id string
|
|
transport SessionTransport
|
|
protocol_version string
|
|
client_info Implementation
|
|
client_capabilities string
|
|
initialize_complete bool
|
|
initialized bool
|
|
notification_queue []string
|
|
subscribed_uris []string
|
|
log_level LogLevel = .debug
|
|
log_level_set bool
|
|
cancelled_requests []string
|
|
event_log []LoggedEvent
|
|
next_event_id int = 1
|
|
next_request_seq int = 1
|
|
pending_responses map[string]Response
|
|
// progress_seen tracks the last progress value emitted for a given
|
|
// `progressToken`. The MCP spec requires `progress` values to be strictly
|
|
// monotonically increasing, so we silently drop any non-increasing call
|
|
// rather than send an out-of-order notification on the wire.
|
|
progress_seen map[string]f64
|
|
}
|
|
|
|
struct ServerState {
|
|
mut:
|
|
sessions map[string]Session
|
|
// response_signals carries one semaphore per in-flight server-initiated
|
|
// request, keyed by `pending_signal_key(session_id, request_id)`.
|
|
// `wait_for_response` blocks on it (with a deadline) and
|
|
// `deliver_response` / `delete_session` post it so we never burn CPU
|
|
// polling for a response that may take seconds to arrive. Kept outside
|
|
// `Session` to avoid V's pointer-in-shared-map access warnings.
|
|
response_signals map[string]&sync.Semaphore
|
|
}
|
|
|
|
fn pending_signal_key(session_id string, request_id string) string {
|
|
return '${session_id}\x00${request_id}'
|
|
}
|
|
|
|
struct DispatchResult {
|
|
has_response bool
|
|
response string
|
|
session_id string
|
|
}
|
|
|
|
struct HandledRequest {
|
|
response Response
|
|
session_id string
|
|
}
|
|
|
|
struct ProtocolError {
|
|
response_error ResponseError
|
|
request_id string
|
|
}
|
|
|
|
fn (err ProtocolError) msg() string {
|
|
return err.response_error.message
|
|
}
|
|
|
|
fn (err ProtocolError) code() int {
|
|
return err.response_error.code
|
|
}
|
|
|
|
struct CursorParams {
|
|
cursor string
|
|
}
|
|
|
|
struct ToolCallParams {
|
|
name string
|
|
arguments string @[raw]
|
|
}
|
|
|
|
struct ReadResourceParams {
|
|
uri string
|
|
}
|
|
|
|
struct SubscribeParams {
|
|
uri string
|
|
}
|
|
|
|
struct GetPromptParams {
|
|
name string
|
|
arguments string @[raw]
|
|
}
|
|
|
|
struct ListToolsResult {
|
|
tools []Tool
|
|
next_cursor string @[json: nextCursor; omitempty]
|
|
}
|
|
|
|
struct ListResourcesResult {
|
|
resources []Resource
|
|
next_cursor string @[json: nextCursor; omitempty]
|
|
}
|
|
|
|
struct ListResourceTemplatesResult {
|
|
resource_templates []ResourceTemplate @[json: resourceTemplates]
|
|
next_cursor string @[json: nextCursor; omitempty]
|
|
}
|
|
|
|
struct ListPromptsResult {
|
|
prompts []Prompt
|
|
next_cursor string @[json: nextCursor; omitempty]
|
|
}
|
|
|
|
const default_tool_input_schema = '{"type":"object","additionalProperties":false}'
|
|
|
|
// Server handles MCP protocol requests for stdio and HTTP transports.
|
|
@[heap]
|
|
pub struct Server {
|
|
mut:
|
|
server_info Implementation
|
|
protocol_version string
|
|
capabilities_override string
|
|
instructions string
|
|
http_path string
|
|
allowed_origins []string
|
|
enable_logging bool
|
|
http_server &http.Server = unsafe { nil }
|
|
tools map[string]RegisteredTool
|
|
tool_names []string
|
|
resources map[string]RegisteredResource
|
|
resource_uris []string
|
|
resource_templates map[string]ResourceTemplate
|
|
resource_template_ids []string
|
|
prompts map[string]RegisteredPrompt
|
|
prompt_names []string
|
|
completions map[string]RegisteredCompletion
|
|
state shared ServerState
|
|
}
|
|
|
|
// new_server constructs a new MCP server.
|
|
pub fn new_server(config ServerConfig) Server {
|
|
return Server{
|
|
server_info: normalize_server_info(config)
|
|
protocol_version: normalize_protocol_version(config.protocol_version)
|
|
capabilities_override: config.capabilities.trim_space()
|
|
instructions: config.instructions
|
|
http_path: normalize_http_path(config.http_path)
|
|
allowed_origins: config.allowed_origins.clone()
|
|
enable_logging: config.enable_logging
|
|
tools: map[string]RegisteredTool{}
|
|
resources: map[string]RegisteredResource{}
|
|
resource_templates: map[string]ResourceTemplate{}
|
|
prompts: map[string]RegisteredPrompt{}
|
|
completions: map[string]RegisteredCompletion{}
|
|
state: ServerState{}
|
|
}
|
|
}
|
|
|
|
// add_tool registers a tool and its handler.
|
|
pub fn (mut s Server) add_tool(tool Tool, handler ToolHandler) ! {
|
|
validate_tool_name(tool.name)!
|
|
if tool.name in s.tools {
|
|
return error('mcp.Server.add_tool: duplicate tool `${tool.name}`')
|
|
}
|
|
normalized := Tool{
|
|
name: tool.name
|
|
title: tool.title
|
|
description: tool.description
|
|
input_schema: normalize_tool_input_schema(tool.input_schema)
|
|
output_schema: tool.output_schema.trim_space()
|
|
annotations: tool.annotations
|
|
icons: tool.icons
|
|
execution: tool.execution
|
|
}
|
|
s.tools[tool.name] = RegisteredTool{
|
|
tool: normalized
|
|
handler: handler
|
|
}
|
|
s.tool_names << tool.name
|
|
s.broadcast_notification('notifications/tools/list_changed', empty_object.str())
|
|
}
|
|
|
|
// add_resource registers a concrete resource and its read handler.
|
|
pub fn (mut s Server) add_resource(resource Resource, handler ResourceHandler) ! {
|
|
if resource.uri.trim_space() == '' {
|
|
return error('mcp.Server.add_resource: empty uri')
|
|
}
|
|
if resource.uri in s.resources {
|
|
return error('mcp.Server.add_resource: duplicate resource `${resource.uri}`')
|
|
}
|
|
s.resources[resource.uri] = RegisteredResource{
|
|
resource: resource
|
|
handler: handler
|
|
}
|
|
s.resource_uris << resource.uri
|
|
s.broadcast_notification('notifications/resources/list_changed', empty_object.str())
|
|
}
|
|
|
|
// add_resource_template registers a resource template exposed by `resources/templates/list`.
|
|
pub fn (mut s Server) add_resource_template(template ResourceTemplate) ! {
|
|
if template.uri_template.trim_space() == '' {
|
|
return error('mcp.Server.add_resource_template: empty uri template')
|
|
}
|
|
if template.uri_template in s.resource_templates {
|
|
return error('mcp.Server.add_resource_template: duplicate resource template `${template.uri_template}`')
|
|
}
|
|
s.resource_templates[template.uri_template] = template
|
|
s.resource_template_ids << template.uri_template
|
|
s.broadcast_notification('notifications/resources/list_changed', empty_object.str())
|
|
}
|
|
|
|
// add_prompt registers a prompt and its handler.
|
|
pub fn (mut s Server) add_prompt(prompt Prompt, handler PromptHandler) ! {
|
|
if prompt.name.trim_space() == '' {
|
|
return error('mcp.Server.add_prompt: empty prompt name')
|
|
}
|
|
if prompt.name in s.prompts {
|
|
return error('mcp.Server.add_prompt: duplicate prompt `${prompt.name}`')
|
|
}
|
|
s.prompts[prompt.name] = RegisteredPrompt{
|
|
prompt: prompt
|
|
handler: handler
|
|
}
|
|
s.prompt_names << prompt.name
|
|
s.broadcast_notification('notifications/prompts/list_changed', empty_object.str())
|
|
}
|
|
|
|
// add_completion registers a completion handler for one argument of a prompt
|
|
// or resource template. `argument` is the argument name being completed.
|
|
pub fn (mut s Server) add_completion(ref CompletionRef, argument string, handler CompletionHandler) ! {
|
|
if argument.trim_space() == '' {
|
|
return error('mcp.Server.add_completion: empty argument name')
|
|
}
|
|
key := completion_key(ref, argument) or {
|
|
return error('mcp.Server.add_completion: ${err.msg()}')
|
|
}
|
|
if key in s.completions {
|
|
return error('mcp.Server.add_completion: duplicate completion `${key}`')
|
|
}
|
|
s.completions[key] = RegisteredCompletion{
|
|
ref: ref
|
|
argument: argument
|
|
handler: handler
|
|
}
|
|
}
|
|
|
|
fn completion_key(ref CompletionRef, argument string) !string {
|
|
match ref.ref_type {
|
|
'ref/prompt' {
|
|
if ref.name.trim_space() == '' {
|
|
return error('ref/prompt requires a `name`')
|
|
}
|
|
return 'prompt|${ref.name}|${argument}'
|
|
}
|
|
'ref/resource' {
|
|
if ref.uri.trim_space() == '' {
|
|
return error('ref/resource requires a `uri`')
|
|
}
|
|
return 'resource|${ref.uri}|${argument}'
|
|
}
|
|
else {
|
|
return error('unsupported ref type `${ref.ref_type}`')
|
|
}
|
|
}
|
|
}
|
|
|
|
// notify_tools_list_changed broadcasts the tool catalog change notification.
|
|
pub fn (mut s Server) notify_tools_list_changed() {
|
|
s.broadcast_notification('notifications/tools/list_changed', empty_object.str())
|
|
}
|
|
|
|
// notify_resources_list_changed broadcasts the resource catalog change notification.
|
|
pub fn (mut s Server) notify_resources_list_changed() {
|
|
s.broadcast_notification('notifications/resources/list_changed', empty_object.str())
|
|
}
|
|
|
|
// notify_prompts_list_changed broadcasts the prompt catalog change notification.
|
|
pub fn (mut s Server) notify_prompts_list_changed() {
|
|
s.broadcast_notification('notifications/prompts/list_changed', empty_object.str())
|
|
}
|
|
|
|
// notify_log emits a `notifications/message` payload at `level` filtered per
|
|
// session by the most recent `logging/setLevel` call. `data_json` MUST be a
|
|
// valid JSON value (object preferred per spec). `logger` is optional.
|
|
pub fn (mut s Server) notify_log(level LogLevel, logger string, data_json string) {
|
|
if !s.enable_logging {
|
|
return
|
|
}
|
|
mut fields := ['"level":${json.encode(level.str())}']
|
|
if logger != '' {
|
|
fields << '"logger":${json.encode(logger)}'
|
|
}
|
|
payload := if data_json.trim_space() == '' { 'null' } else { data_json.trim_space() }
|
|
fields << '"data":${payload}'
|
|
params := '{${fields.join(',')}}'
|
|
message := build_notification_message('notifications/message', params)
|
|
lock s.state {
|
|
for id in s.state.sessions.keys() {
|
|
mut session := s.state.sessions[id]
|
|
if !session.initialized {
|
|
continue
|
|
}
|
|
if session.log_level_set && int(level) < int(session.log_level) {
|
|
continue
|
|
}
|
|
session.notification_queue << message
|
|
s.state.sessions[id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
// list_roots issues `roots/list` to the session and waits for the response.
|
|
pub fn (mut s Server) list_roots(session_id string, timeout time.Duration) !ListRootsResult {
|
|
response := s.send_server_request(session_id, 'roots/list', empty_object.str(), timeout)!
|
|
return response.decode_result[ListRootsResult]()
|
|
}
|
|
|
|
// sample issues `sampling/createMessage` and waits for the LLM result.
|
|
pub fn (mut s Server) sample(session_id string, params CreateMessageParams, timeout time.Duration) !CreateMessageResult {
|
|
encoded := json.encode(params)
|
|
response := s.send_server_request(session_id, 'sampling/createMessage', encoded, timeout)!
|
|
return response.decode_result[CreateMessageResult]()
|
|
}
|
|
|
|
// elicit issues `elicitation/create` and waits for the user-supplied content.
|
|
pub fn (mut s Server) elicit(session_id string, params ElicitParams, timeout time.Duration) !ElicitResult {
|
|
encoded := encode_elicit_params(params)
|
|
response := s.send_server_request(session_id, 'elicitation/create', encoded, timeout)!
|
|
return response.decode_result[ElicitResult]()
|
|
}
|
|
|
|
fn encode_elicit_params(params ElicitParams) string {
|
|
mut fields := []string{}
|
|
if params.mode != '' {
|
|
fields << '"mode":${json.encode(params.mode)}'
|
|
}
|
|
fields << '"message":${json.encode(params.message)}'
|
|
if params.url != '' {
|
|
fields << '"url":${json.encode(params.url)}'
|
|
}
|
|
if params.elicitation_id != '' {
|
|
fields << '"elicitationId":${json.encode(params.elicitation_id)}'
|
|
}
|
|
if params.requested_schema.properties.trim_space() != ''
|
|
|| params.requested_schema.required.len > 0 {
|
|
fields << '"requestedSchema":${encode_elicit_schema(params.requested_schema)}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_elicit_schema(schema ElicitSchema) string {
|
|
type_value := if schema.type_.trim_space() == '' { 'object' } else { schema.type_ }
|
|
mut fields := ['"type":${json.encode(type_value)}']
|
|
if schema.properties.trim_space() != '' {
|
|
fields << '"properties":${schema.properties.trim_space()}'
|
|
}
|
|
if schema.required.len > 0 {
|
|
fields << '"required":[${schema.required.map(json.encode(it)).join(',')}]'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn (mut s Server) send_server_request(session_id string, method string, params_json string, timeout time.Duration) !Response {
|
|
request_id_quoted := s.allocate_server_request_id(session_id) or {
|
|
return error('mcp.Server.send_server_request: unknown session `${session_id}`')
|
|
}
|
|
encoded := Request{
|
|
id: request_id_quoted
|
|
method: method
|
|
params: params_json
|
|
}.encode()
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
session.notification_queue << encoded
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
return s.wait_for_response(session_id, request_id_quoted, timeout)
|
|
}
|
|
|
|
fn (mut s Server) allocate_server_request_id(session_id string) ?string {
|
|
mut request_id := ''
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
request_id = '"server-${session.next_request_seq}"'
|
|
session.next_request_seq++
|
|
s.state.sessions[session_id] = session
|
|
s.state.response_signals[pending_signal_key(session_id, request_id)] =
|
|
sync.new_semaphore()
|
|
}
|
|
}
|
|
if request_id == '' {
|
|
return none
|
|
}
|
|
return request_id
|
|
}
|
|
|
|
fn (mut s Server) deliver_response(session_id string, response Response) {
|
|
key := pending_signal_key(session_id, response.id)
|
|
mut signal := unsafe { &sync.Semaphore(nil) }
|
|
lock s.state {
|
|
// Only stash the reply if a waiter is still listening on this id.
|
|
// `wait_for_response` removes its semaphore entry on timeout, so a
|
|
// missing signal means the request has been abandoned and any late
|
|
// reply must be dropped instead of accumulating in `pending_responses`.
|
|
signal = s.state.response_signals[key] or { unsafe { nil } }
|
|
if !isnil(signal) && session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
session.pending_responses[response.id] = response
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
if !isnil(signal) {
|
|
signal.post()
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) wait_for_response(session_id string, request_id string, timeout time.Duration) !Response {
|
|
key := pending_signal_key(session_id, request_id)
|
|
mut signal := unsafe { &sync.Semaphore(nil) }
|
|
rlock s.state {
|
|
signal = s.state.response_signals[key] or { unsafe { nil } }
|
|
}
|
|
if isnil(signal) {
|
|
return error('mcp.Server.wait_for_response: no pending request `${request_id}`')
|
|
}
|
|
signaled := signal.timed_wait(timeout)
|
|
mut response := Response{}
|
|
mut found := false
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
if request_id in session.pending_responses {
|
|
response = session.pending_responses[request_id]
|
|
session.pending_responses.delete(request_id)
|
|
found = true
|
|
}
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
s.state.response_signals.delete(key)
|
|
}
|
|
if found {
|
|
return response
|
|
}
|
|
if !signaled {
|
|
return error('mcp.Server.wait_for_response: timeout waiting for ${request_id}')
|
|
}
|
|
return error('mcp.Server.wait_for_response: session `${session_id}` closed while waiting for ${request_id}')
|
|
}
|
|
|
|
// notify_elicitation_complete emits a `notifications/elicitation/complete`
|
|
// notification for the session that initiated a URL-mode elicitation. Pass
|
|
// the same `elicitation_id` that was supplied to `elicit()` so the client
|
|
// can correlate the out-of-band interaction with its pending request.
|
|
pub fn (mut s Server) notify_elicitation_complete(session_id string, elicitation_id string) {
|
|
params := '{"elicitationId":${json.encode(elicitation_id)}}'
|
|
message := build_notification_message('notifications/elicitation/complete', params)
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
session.notification_queue << message
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
// notify_resource_updated emits notifications/resources/updated to every
|
|
// session that has subscribed to `uri`.
|
|
pub fn (mut s Server) notify_resource_updated(uri string) {
|
|
params := '{"uri":${json.encode(uri)}}'
|
|
message := build_notification_message('notifications/resources/updated', params)
|
|
lock s.state {
|
|
for id in s.state.sessions.keys() {
|
|
mut session := s.state.sessions[id]
|
|
if !session.initialized {
|
|
continue
|
|
}
|
|
if uri !in session.subscribed_uris {
|
|
continue
|
|
}
|
|
session.notification_queue << message
|
|
s.state.sessions[id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) subscribe(session_id string, uri string) {
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
if uri !in session.subscribed_uris {
|
|
session.subscribed_uris << uri
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) unsubscribe(session_id string, uri string) {
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
session.subscribed_uris = session.subscribed_uris.filter(it != uri)
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_notification_message(method string, params_json string) string {
|
|
return Notification{
|
|
method: method
|
|
params: params_json
|
|
}.encode()
|
|
}
|
|
|
|
fn (mut s Server) broadcast_notification(method string, params_json string) {
|
|
message := build_notification_message(method, params_json)
|
|
lock s.state {
|
|
for id in s.state.sessions.keys() {
|
|
mut session := s.state.sessions[id]
|
|
if !session.initialized {
|
|
continue
|
|
}
|
|
session.notification_queue << message
|
|
s.state.sessions[id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) drain_session_notifications(session_id string) []string {
|
|
mut messages := []string{}
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
messages = session.notification_queue.clone()
|
|
session.notification_queue = []string{}
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
return messages
|
|
}
|
|
|
|
// drain_to_event_log moves queued notifications into the session's bounded
|
|
// event log and returns the assigned (id, body) pairs ready for SSE framing.
|
|
fn (mut s Server) drain_to_event_log(session_id string) []LoggedEvent {
|
|
mut events := []LoggedEvent{}
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
for body in session.notification_queue {
|
|
event := LoggedEvent{
|
|
id: session.next_event_id
|
|
body: body
|
|
}
|
|
session.next_event_id++
|
|
session.event_log << event
|
|
events << event
|
|
}
|
|
session.notification_queue = []string{}
|
|
if session.event_log.len > event_log_capacity {
|
|
session.event_log =
|
|
session.event_log[session.event_log.len - event_log_capacity..].clone()
|
|
}
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
fn (s &Server) replay_events_after(session_id string, last_event_id int) []LoggedEvent {
|
|
mut events := []LoggedEvent{}
|
|
rlock s.state {
|
|
if session_id in s.state.sessions {
|
|
session := s.state.sessions[session_id]
|
|
for event in session.event_log {
|
|
if event.id > last_event_id {
|
|
events << event
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
// serve_stdio starts serving MCP messages over stdio using newline framing.
|
|
// MCP 2025-11-25 mandates that stdio messages are delimited by newlines and
|
|
// MUST NOT contain embedded newlines. We bypass libc stdio buffering on the
|
|
// way in (raw `read()` on fd 0) and flush stdout after every frame on the way
|
|
// out, so peers behind a pipe see responses immediately and the server
|
|
// reacts to each line as soon as it arrives.
|
|
pub fn (mut s Server) serve_stdio() ! {
|
|
mut stdout := os.stdout()
|
|
mut source := StdinReader{}
|
|
mut sink := StdioWriter{
|
|
file: &stdout
|
|
}
|
|
s.serve_stdio_transport(mut source, mut sink, stdio_session_id, .stdio)!
|
|
}
|
|
|
|
struct StdinReader {}
|
|
|
|
fn (mut r StdinReader) read(mut buf []u8) !int {
|
|
if buf.len == 0 {
|
|
return io.Eof{}
|
|
}
|
|
n := unsafe { C.read(0, buf.data, buf.len) }
|
|
if n == 0 {
|
|
return io.Eof{}
|
|
}
|
|
if n < 0 {
|
|
return error('mcp.stdio: read(0) failed')
|
|
}
|
|
return int(n)
|
|
}
|
|
|
|
struct StdioWriter {
|
|
mut:
|
|
file &os.File = unsafe { nil }
|
|
}
|
|
|
|
fn (mut w StdioWriter) write(buf []u8) !int {
|
|
n := w.file.write(buf)!
|
|
w.file.flush()
|
|
return n
|
|
}
|
|
|
|
// serve_http starts serving MCP over a single HTTP endpoint.
|
|
pub fn (mut s Server) serve_http(addr string) ! {
|
|
if addr.trim_space() == '' {
|
|
return error('mcp.Server.serve_http: empty address')
|
|
}
|
|
mut handler := HttpHandler{
|
|
server: s
|
|
}
|
|
mut http_server := &http.Server{
|
|
addr: addr
|
|
handler: handler
|
|
accept_timeout: 100 * time.millisecond
|
|
show_startup_message: false
|
|
}
|
|
s.http_server = http_server
|
|
http_server.listen_and_serve()
|
|
}
|
|
|
|
// close stops the HTTP server if it is running.
|
|
pub fn (mut s Server) close() {
|
|
if !isnil(s.http_server) {
|
|
s.http_server.close()
|
|
}
|
|
}
|
|
|
|
// wait_till_running waits until the HTTP server transitions to the running state.
|
|
pub fn (mut s Server) wait_till_running(params http.WaitTillRunningParams) !int {
|
|
mut retries := 0
|
|
for isnil(s.http_server) && retries < params.max_retries {
|
|
time.sleep(params.retry_period_ms * time.millisecond)
|
|
retries++
|
|
}
|
|
if isnil(s.http_server) {
|
|
return error('mcp.Server.wait_till_running: HTTP server is not running')
|
|
}
|
|
remaining_retries := if params.max_retries > retries { params.max_retries - retries } else { 0 }
|
|
if remaining_retries == 0 {
|
|
return error('mcp.Server.wait_till_running: HTTP server is not running')
|
|
}
|
|
retry_count := s.http_server.wait_till_running(
|
|
max_retries: remaining_retries
|
|
retry_period_ms: params.retry_period_ms
|
|
)!
|
|
return retries + retry_count
|
|
}
|
|
|
|
// text_content creates a raw MCP text content item.
|
|
pub fn text_content(text string) string {
|
|
return text_content_with_annotations(text, Annotations{})
|
|
}
|
|
|
|
// text_content_with_annotations behaves like `text_content` but attaches the
|
|
// provided `annotations` (audience, priority, lastModified) to the block.
|
|
pub fn text_content_with_annotations(text string, annotations Annotations) string {
|
|
mut fields := ['"type":"text"', '"text":${json.encode(text)}']
|
|
if encoded := encode_annotations(annotations) {
|
|
fields << '"annotations":${encoded}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
// image_content creates a raw MCP image content block. `data` must be the
|
|
// base64-encoded payload and `mime_type` the IANA media type (e.g. `image/png`).
|
|
pub fn image_content(data string, mime_type string) string {
|
|
return image_content_with_annotations(data, mime_type, Annotations{})
|
|
}
|
|
|
|
// image_content_with_annotations behaves like `image_content` but attaches
|
|
// the provided `annotations` to the block.
|
|
pub fn image_content_with_annotations(data string, mime_type string, annotations Annotations) string {
|
|
mut fields := ['"type":"image"', '"data":${json.encode(data)}',
|
|
'"mimeType":${json.encode(mime_type)}']
|
|
if encoded := encode_annotations(annotations) {
|
|
fields << '"annotations":${encoded}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
// audio_content creates a raw MCP audio content block. `data` must be the
|
|
// base64-encoded payload and `mime_type` the IANA media type (e.g. `audio/wav`).
|
|
pub fn audio_content(data string, mime_type string) string {
|
|
return audio_content_with_annotations(data, mime_type, Annotations{})
|
|
}
|
|
|
|
// audio_content_with_annotations behaves like `audio_content` but attaches
|
|
// the provided `annotations` to the block.
|
|
pub fn audio_content_with_annotations(data string, mime_type string, annotations Annotations) string {
|
|
mut fields := ['"type":"audio"', '"data":${json.encode(data)}',
|
|
'"mimeType":${json.encode(mime_type)}']
|
|
if encoded := encode_annotations(annotations) {
|
|
fields << '"annotations":${encoded}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
// resource_link_content creates a raw MCP `resource_link` content block from
|
|
// a `Resource` description. The block carries the resource's metadata —
|
|
// including any `annotations` set on the resource — so the host can render
|
|
// or read it later.
|
|
pub fn resource_link_content(resource Resource) string {
|
|
encoded := json.encode(resource)
|
|
if encoded.len <= 2 {
|
|
return '{"type":"resource_link"}'
|
|
}
|
|
return '{"type":"resource_link",${encoded[1..]}'
|
|
}
|
|
|
|
// embedded_text_resource creates an embedded MCP text resource content block.
|
|
pub fn embedded_text_resource(uri string, mime_type string, text string) string {
|
|
return embedded_text_resource_with_annotations(uri, mime_type, text, Annotations{})
|
|
}
|
|
|
|
// embedded_text_resource_with_annotations behaves like `embedded_text_resource`
|
|
// but attaches the provided `annotations` to the outer `EmbeddedResource`.
|
|
pub fn embedded_text_resource_with_annotations(uri string, mime_type string, text string, annotations Annotations) string {
|
|
inner := encode_resource_text_payload(uri, mime_type, text)
|
|
return wrap_embedded_resource(inner, annotations)
|
|
}
|
|
|
|
// embedded_blob_resource creates an embedded MCP binary resource content
|
|
// block. `blob` must be the base64-encoded payload.
|
|
pub fn embedded_blob_resource(uri string, mime_type string, blob string) string {
|
|
return embedded_blob_resource_with_annotations(uri, mime_type, blob, Annotations{})
|
|
}
|
|
|
|
// embedded_blob_resource_with_annotations behaves like `embedded_blob_resource`
|
|
// but attaches the provided `annotations` to the outer `EmbeddedResource`.
|
|
pub fn embedded_blob_resource_with_annotations(uri string, mime_type string, blob string, annotations Annotations) string {
|
|
inner := encode_resource_blob_payload(uri, mime_type, blob)
|
|
return wrap_embedded_resource(inner, annotations)
|
|
}
|
|
|
|
fn encode_resource_text_payload(uri string, mime_type string, text string) string {
|
|
encoded_uri := json.encode(uri)
|
|
encoded_text := json.encode(text)
|
|
if mime_type == '' {
|
|
return '{"uri":${encoded_uri},"text":${encoded_text}}'
|
|
}
|
|
encoded_mime := json.encode(mime_type)
|
|
return '{"uri":${encoded_uri},"mimeType":${encoded_mime},"text":${encoded_text}}'
|
|
}
|
|
|
|
fn encode_resource_blob_payload(uri string, mime_type string, blob string) string {
|
|
encoded_uri := json.encode(uri)
|
|
encoded_blob := json.encode(blob)
|
|
if mime_type == '' {
|
|
return '{"uri":${encoded_uri},"blob":${encoded_blob}}'
|
|
}
|
|
encoded_mime := json.encode(mime_type)
|
|
return '{"uri":${encoded_uri},"mimeType":${encoded_mime},"blob":${encoded_blob}}'
|
|
}
|
|
|
|
fn wrap_embedded_resource(inner string, annotations Annotations) string {
|
|
mut fields := ['"type":"resource"', '"resource":${inner}']
|
|
if encoded := encode_annotations(annotations) {
|
|
fields << '"annotations":${encoded}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
// encode_annotations renders an `Annotations` value as JSON, omitting the
|
|
// whole object when no field is populated (returns `none`).
|
|
fn encode_annotations(annotations Annotations) ?string {
|
|
mut fields := []string{}
|
|
if annotations.audience.len > 0 {
|
|
fields << '"audience":[${annotations.audience.map(json.encode(it)).join(',')}]'
|
|
}
|
|
if priority := annotations.priority {
|
|
fields << '"priority":${format_number(priority)}'
|
|
}
|
|
if annotations.last_modified != '' {
|
|
fields << '"lastModified":${json.encode(annotations.last_modified)}'
|
|
}
|
|
if fields.len == 0 {
|
|
return none
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
// prompt_text_message creates a prompt message with text content.
|
|
pub fn prompt_text_message(role string, text string) PromptMessage {
|
|
return PromptMessage{
|
|
role: role
|
|
content: text_content(text)
|
|
}
|
|
}
|
|
|
|
// tool_text_result wraps plain text in an MCP tool result.
|
|
pub fn tool_text_result(text string) ToolResult {
|
|
return ToolResult{
|
|
content: '[${text_content(text)}]'
|
|
}
|
|
}
|
|
|
|
fn tool_error_result(text string) ToolResult {
|
|
return ToolResult{
|
|
content: '[${text_content(text)}]'
|
|
is_error: true
|
|
}
|
|
}
|
|
|
|
fn response_with_json(request_id string, result_json string) Response {
|
|
return Response{
|
|
id: request_id
|
|
result: result_json
|
|
}
|
|
}
|
|
|
|
fn encode_initialize_result(result InitializeResult) string {
|
|
mut fields := [
|
|
'"protocolVersion":${json.encode(result.protocol_version)}',
|
|
'"capabilities":${normalize_capabilities(result.capabilities)}',
|
|
'"serverInfo":${json.encode(result.server_info)}',
|
|
]
|
|
if result.instructions != '' {
|
|
fields << '"instructions":${json.encode(result.instructions)}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_tools_list_result(result ListToolsResult) string {
|
|
mut fields := ['"tools":[${result.tools.map(encode_tool).join(',')}]']
|
|
if result.next_cursor != '' {
|
|
fields << '"nextCursor":${json.encode(result.next_cursor)}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_tool(tool Tool) string {
|
|
mut fields := ['"name":${json.encode(tool.name)}',
|
|
'"inputSchema":${normalize_tool_input_schema(tool.input_schema)}']
|
|
if tool.title != '' {
|
|
fields << '"title":${json.encode(tool.title)}'
|
|
}
|
|
if tool.description != '' {
|
|
fields << '"description":${json.encode(tool.description)}'
|
|
}
|
|
if tool.output_schema.trim_space() != '' {
|
|
fields << '"outputSchema":${tool.output_schema.trim_space()}'
|
|
}
|
|
if encoded_annotations := encode_tool_annotations(tool.annotations) {
|
|
fields << '"annotations":${encoded_annotations}'
|
|
}
|
|
if tool.icons.len > 0 {
|
|
fields << '"icons":${json.encode(tool.icons)}'
|
|
}
|
|
if tool.execution.task_support != '' {
|
|
fields << '"execution":{"taskSupport":${json.encode(tool.execution.task_support)}}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_tool_annotations(annotations ToolAnnotations) ?string {
|
|
mut fields := []string{}
|
|
if annotations.title != '' {
|
|
fields << '"title":${json.encode(annotations.title)}'
|
|
}
|
|
if hint := annotations.read_only_hint {
|
|
fields << '"readOnlyHint":${hint.str()}'
|
|
}
|
|
if hint := annotations.destructive_hint {
|
|
fields << '"destructiveHint":${hint.str()}'
|
|
}
|
|
if hint := annotations.idempotent_hint {
|
|
fields << '"idempotentHint":${hint.str()}'
|
|
}
|
|
if hint := annotations.open_world_hint {
|
|
fields << '"openWorldHint":${hint.str()}'
|
|
}
|
|
if fields.len == 0 {
|
|
return none
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_tool_result(result ToolResult) string {
|
|
// `content` is REQUIRED by the 2025-11-25 schema (CallToolResult.content),
|
|
// so always emit at least an empty array; clients reading the result MUST
|
|
// be able to find `content` whether or not the tool produced output.
|
|
content_payload := if result.content.trim_space() == '' {
|
|
'[]'
|
|
} else {
|
|
result.content.trim_space()
|
|
}
|
|
mut fields := ['"content":${content_payload}', '"isError":${result.is_error.str()}']
|
|
if result.structured_content.trim_space() != '' {
|
|
fields << '"structuredContent":${result.structured_content.trim_space()}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_prompt_result(result GetPromptResult) string {
|
|
mut fields := [
|
|
'"messages":[${result.messages.map(encode_prompt_message).join(',')}]',
|
|
]
|
|
if result.description != '' {
|
|
fields << '"description":${json.encode(result.description)}'
|
|
}
|
|
return '{${fields.join(',')}}'
|
|
}
|
|
|
|
fn encode_prompt_message(message PromptMessage) string {
|
|
return '{"role":${json.encode(message.role)},"content":${message.content}}'
|
|
}
|
|
|
|
fn (mut s Server) serve_stdio_transport(mut reader io.Reader, mut writer io.Writer, session_id string, transport SessionTransport) ! {
|
|
mut buffer := ''
|
|
for {
|
|
frame := try_extract_stdio_message(buffer) or {
|
|
if err.msg() != NoFrameError{}.msg() {
|
|
error_response := Response{
|
|
error: normalize_response_error(err)
|
|
}.encode()
|
|
writer.write(encode_stdio_message(error_response).bytes())!
|
|
return err
|
|
}
|
|
FrameExtraction{}
|
|
}
|
|
if frame.message.len != 0 {
|
|
buffer = frame.remaining
|
|
dispatch_result := s.dispatch_message(frame.message, session_id, transport) or {
|
|
error_response := Response{
|
|
error: normalize_response_error(err)
|
|
}.encode()
|
|
writer.write(encode_stdio_message(error_response).bytes())!
|
|
continue
|
|
}
|
|
if dispatch_result.has_response {
|
|
writer.write(encode_stdio_message(dispatch_result.response).bytes())!
|
|
}
|
|
for notification in s.drain_session_notifications(session_id) {
|
|
writer.write(encode_stdio_message(notification).bytes())!
|
|
}
|
|
continue
|
|
}
|
|
mut chunk := []u8{len: 4096}
|
|
bytes_read := reader.read(mut chunk) or {
|
|
if err is os.Eof {
|
|
return
|
|
}
|
|
if err is io.Eof {
|
|
return
|
|
}
|
|
return err
|
|
}
|
|
if bytes_read == 0 {
|
|
return
|
|
}
|
|
buffer += chunk[..bytes_read].bytestr()
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) dispatch_message(raw string, session_id string, transport SessionTransport) !DispatchResult {
|
|
trimmed := raw.trim_space()
|
|
if trimmed.len == 0 || trimmed[0] == `[` {
|
|
return ProtocolError{
|
|
response_error: invalid_request
|
|
}
|
|
}
|
|
envelope := decode_envelope(trimmed) or {
|
|
return ProtocolError{
|
|
response_error: parse_error
|
|
}
|
|
}
|
|
return s.dispatch_envelope(envelope, session_id, transport)
|
|
}
|
|
|
|
fn (mut s Server) dispatch_envelope(envelope MessageEnvelope, session_id string, transport SessionTransport) !DispatchResult {
|
|
if envelope.method.len == 0 {
|
|
if !is_notification_id(envelope.id) {
|
|
s.deliver_response(session_id, Response{
|
|
id: envelope.id
|
|
result: envelope.result
|
|
error: envelope.error
|
|
})
|
|
}
|
|
return DispatchResult{}
|
|
}
|
|
if is_notification_id(envelope.id) {
|
|
s.handle_notification(Notification{
|
|
method: envelope.method
|
|
params: envelope.params
|
|
}, session_id, transport)!
|
|
return DispatchResult{}
|
|
}
|
|
req := Request{
|
|
id: envelope.id
|
|
method: envelope.method
|
|
params: envelope.params
|
|
}
|
|
handled := s.handle_request(req, session_id, transport)
|
|
return DispatchResult{
|
|
has_response: true
|
|
response: handled.response.encode()
|
|
session_id: handled.session_id
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_request(req Request, session_id string, transport SessionTransport) HandledRequest {
|
|
defer {
|
|
s.clear_cancelled(session_id, req.id)
|
|
}
|
|
return s.handle_request_impl(req, session_id, transport) or {
|
|
return HandledRequest{
|
|
response: error_response_for(req.id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_request_impl(req Request, session_id string, transport SessionTransport) !HandledRequest {
|
|
if req.method == 'ping' {
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, empty_object.str())
|
|
}
|
|
}
|
|
if req.method == 'initialize' {
|
|
return s.handle_initialize(req, session_id, transport)
|
|
}
|
|
session := s.session_for_request(session_id, transport) or {
|
|
return ProtocolError{
|
|
response_error: server_not_initialized
|
|
request_id: req.id
|
|
}
|
|
}
|
|
if !session.initialize_complete || !session.initialized {
|
|
return ProtocolError{
|
|
response_error: server_not_initialized
|
|
request_id: req.id
|
|
}
|
|
}
|
|
ctx := s.context_from_session(req, session)
|
|
match req.method {
|
|
'tools/list' {
|
|
return s.handle_tools_list(req)
|
|
}
|
|
'tools/call' {
|
|
return s.handle_tools_call(req, ctx)
|
|
}
|
|
'resources/list' {
|
|
return s.handle_resources_list(req)
|
|
}
|
|
'resources/read' {
|
|
return s.handle_resources_read(req, ctx)
|
|
}
|
|
'resources/templates/list' {
|
|
return s.handle_resource_templates_list(req)
|
|
}
|
|
'resources/subscribe' {
|
|
return s.handle_resources_subscribe(req, session_id)
|
|
}
|
|
'resources/unsubscribe' {
|
|
return s.handle_resources_unsubscribe(req, session_id)
|
|
}
|
|
'logging/setLevel' {
|
|
return s.handle_logging_set_level(req, session_id)
|
|
}
|
|
'completion/complete' {
|
|
return s.handle_completion_complete(req, ctx)
|
|
}
|
|
'prompts/list' {
|
|
return s.handle_prompts_list(req)
|
|
}
|
|
'prompts/get' {
|
|
return s.handle_prompts_get(req, ctx)
|
|
}
|
|
else {
|
|
return ProtocolError{
|
|
response_error: method_not_found
|
|
request_id: req.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_initialize(req Request, session_id string, transport SessionTransport) !HandledRequest {
|
|
params := req.decode_params[InitializeParams]() or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
mut session := s.ensure_session_for_initialize(session_id, transport)
|
|
if session.initialize_complete {
|
|
return ProtocolError{
|
|
response_error: invalid_request
|
|
request_id: req.id
|
|
}
|
|
}
|
|
// Per MCP lifecycle: server replies with the protocol version it supports;
|
|
// the client decides whether to proceed or disconnect on a mismatch.
|
|
session.protocol_version = s.protocol_version
|
|
session.client_info = normalize_client_info(params.client_info)
|
|
session.client_capabilities = normalize_capabilities(params.capabilities)
|
|
session.initialize_complete = true
|
|
session.initialized = false
|
|
s.store_session(session)
|
|
result := InitializeResult{
|
|
protocol_version: session.protocol_version
|
|
capabilities: s.capabilities_json()
|
|
server_info: s.server_info
|
|
instructions: s.instructions
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_initialize_result(result))
|
|
session_id: if transport == .http { session.id } else { '' }
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_tools_list(req Request) !HandledRequest {
|
|
params := decode_optional_params[CursorParams](req.params) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
start, end, next_cursor := paginate_bounds(s.tool_names.len, params.cursor)!
|
|
mut tools := []Tool{cap: end - start}
|
|
for name in s.tool_names[start..end] {
|
|
tools << s.tools[name].tool
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_tools_list_result(ListToolsResult{
|
|
tools: tools
|
|
next_cursor: next_cursor
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_tools_call(req Request, ctx Context) !HandledRequest {
|
|
params := decode_optional_params[ToolCallParams](req.params) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
if params.name !in s.tools {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
entry := s.tools[params.name]
|
|
result := entry.handler(ctx, json_object_or_empty(params.arguments)) or {
|
|
if err is ProtocolError {
|
|
return err
|
|
}
|
|
if err is ResponseError {
|
|
return err
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_tool_result(tool_error_result(err.msg())))
|
|
}
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_tool_result(result))
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_resources_list(req Request) !HandledRequest {
|
|
params := decode_optional_params[CursorParams](req.params) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
start, end, next_cursor := paginate_bounds(s.resource_uris.len, params.cursor)!
|
|
mut resources := []Resource{cap: end - start}
|
|
for uri in s.resource_uris[start..end] {
|
|
resources << s.resources[uri].resource
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_value(ListResourcesResult{
|
|
resources: resources
|
|
next_cursor: next_cursor
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_resources_read(req Request, ctx Context) !HandledRequest {
|
|
params := req.decode_params[ReadResourceParams]() or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
if params.uri !in s.resources {
|
|
return ProtocolError{
|
|
response_error: ResponseError{
|
|
code: resource_not_found.code
|
|
message: 'Resource not found.'
|
|
data: '{"uri":${json.encode(params.uri)}}'
|
|
}
|
|
request_id: req.id
|
|
}
|
|
}
|
|
entry := s.resources[params.uri]
|
|
result := entry.handler(ctx, params.uri) or { return err }
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_value(result))
|
|
}
|
|
}
|
|
|
|
struct SetLevelParams {
|
|
level string
|
|
}
|
|
|
|
struct CompletionRequestArgument {
|
|
name string
|
|
value string
|
|
}
|
|
|
|
struct CompletionRequestContext {
|
|
arguments string @[raw]
|
|
}
|
|
|
|
struct CompletionRequestParams {
|
|
ref CompletionRef
|
|
argument CompletionRequestArgument
|
|
context CompletionRequestContext
|
|
}
|
|
|
|
fn (mut s Server) handle_completion_complete(req Request, ctx Context) !HandledRequest {
|
|
if s.completions.len == 0 {
|
|
return ProtocolError{
|
|
response_error: method_not_found
|
|
request_id: req.id
|
|
}
|
|
}
|
|
params := req.decode_params[CompletionRequestParams]() or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
key := completion_key(params.ref, params.argument.name) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
entry := s.completions[key] or {
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_completion_result(CompletionResult{}))
|
|
}
|
|
}
|
|
arguments_json := json_object_or_empty(params.context.arguments)
|
|
result := entry.handler(ctx, params.argument.value, arguments_json) or {
|
|
if err is ProtocolError {
|
|
return err
|
|
}
|
|
if err is ResponseError {
|
|
return err
|
|
}
|
|
return ProtocolError{
|
|
response_error: ResponseError{
|
|
code: internal_error.code
|
|
message: err.msg()
|
|
}
|
|
request_id: req.id
|
|
}
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_completion_result(result))
|
|
}
|
|
}
|
|
|
|
fn encode_completion_result(result CompletionResult) string {
|
|
// MCP 2025-11-25 caps completion responses at 100 values. We clamp here
|
|
// and surface the truncation through `hasMore: true` so clients keep
|
|
// requesting refinements instead of silently losing options.
|
|
clamped := if result.values.len > completion_values_limit {
|
|
result.values[..completion_values_limit]
|
|
} else {
|
|
result.values
|
|
}
|
|
mut completion_fields := [
|
|
'"values":[${clamped.map(json.encode(it)).join(',')}]',
|
|
]
|
|
if total := result.total {
|
|
completion_fields << '"total":${total}'
|
|
}
|
|
mut has_more_value := result.values.len > completion_values_limit
|
|
if explicit := result.has_more {
|
|
has_more_value = explicit || has_more_value
|
|
}
|
|
if result.has_more != none || has_more_value {
|
|
completion_fields << '"hasMore":${has_more_value.str()}'
|
|
}
|
|
return '{"completion":{${completion_fields.join(',')}}}'
|
|
}
|
|
|
|
fn (mut s Server) handle_logging_set_level(req Request, session_id string) !HandledRequest {
|
|
if !s.enable_logging {
|
|
return ProtocolError{
|
|
response_error: method_not_found
|
|
request_id: req.id
|
|
}
|
|
}
|
|
params := req.decode_params[SetLevelParams]() or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
level := parse_log_level(params.level) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
s.set_session_log_level(session_id, level)
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, empty_object.str())
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) set_session_log_level(session_id string, level LogLevel) {
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
session.log_level = level
|
|
session.log_level_set = true
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_resources_subscribe(req Request, session_id string) !HandledRequest {
|
|
params := req.decode_params[SubscribeParams]() or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
if params.uri.trim_space() == '' {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
s.subscribe(session_id, params.uri)
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, empty_object.str())
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_resources_unsubscribe(req Request, session_id string) !HandledRequest {
|
|
params := req.decode_params[SubscribeParams]() or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
s.unsubscribe(session_id, params.uri)
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, empty_object.str())
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_resource_templates_list(req Request) !HandledRequest {
|
|
params := decode_optional_params[CursorParams](req.params) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
start, end, next_cursor := paginate_bounds(s.resource_template_ids.len, params.cursor)!
|
|
mut templates := []ResourceTemplate{cap: end - start}
|
|
for uri_template in s.resource_template_ids[start..end] {
|
|
templates << s.resource_templates[uri_template]
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_value(ListResourceTemplatesResult{
|
|
resource_templates: templates
|
|
next_cursor: next_cursor
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_prompts_list(req Request) !HandledRequest {
|
|
params := decode_optional_params[CursorParams](req.params) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
start, end, next_cursor := paginate_bounds(s.prompt_names.len, params.cursor)!
|
|
mut prompts := []Prompt{cap: end - start}
|
|
for name in s.prompt_names[start..end] {
|
|
prompts << s.prompts[name].prompt
|
|
}
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_value(ListPromptsResult{
|
|
prompts: prompts
|
|
next_cursor: next_cursor
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_prompts_get(req Request, ctx Context) !HandledRequest {
|
|
params := decode_optional_params[GetPromptParams](req.params) or {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
if params.name !in s.prompts {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
request_id: req.id
|
|
}
|
|
}
|
|
entry := s.prompts[params.name]
|
|
result := entry.handler(ctx, json_object_or_empty(params.arguments)) or { return err }
|
|
return HandledRequest{
|
|
response: response_with_json(req.id, encode_prompt_result(result))
|
|
}
|
|
}
|
|
|
|
fn (mut s Server) handle_notification(notification Notification, session_id string, transport SessionTransport) ! {
|
|
match notification.method {
|
|
'notifications/initialized' {
|
|
mut session := s.session_for_request(session_id, transport) or {
|
|
return ProtocolError{
|
|
response_error: server_not_initialized
|
|
}
|
|
}
|
|
if !session.initialize_complete {
|
|
return ProtocolError{
|
|
response_error: server_not_initialized
|
|
}
|
|
}
|
|
session.initialized = true
|
|
s.store_session(session)
|
|
}
|
|
'notifications/cancelled' {
|
|
params := notification.decode_params[CancelledParams]() or { return }
|
|
s.mark_cancelled(session_id, params.request_id)
|
|
}
|
|
else {}
|
|
}
|
|
}
|
|
|
|
struct CancelledParams {
|
|
request_id string @[json: requestId; raw]
|
|
reason string @[omitempty]
|
|
}
|
|
|
|
fn (mut s Server) mark_cancelled(session_id string, request_id string) {
|
|
id := request_id.trim_space()
|
|
if id.len == 0 {
|
|
return
|
|
}
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
if id !in session.cancelled_requests {
|
|
session.cancelled_requests << id
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (s &Server) is_request_cancelled(session_id string, request_id string) bool {
|
|
mut cancelled := false
|
|
rlock s.state {
|
|
if session_id in s.state.sessions {
|
|
cancelled = request_id in s.state.sessions[session_id].cancelled_requests
|
|
}
|
|
}
|
|
return cancelled
|
|
}
|
|
|
|
fn (mut s Server) clear_cancelled(session_id string, request_id string) {
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
mut session := s.state.sessions[session_id]
|
|
session.cancelled_requests = session.cancelled_requests.filter(it != request_id)
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
}
|
|
|
|
fn (s &Server) notify_progress_for(session_id string, progress_token string, progress f64, total f64, message string) {
|
|
mut fields := [
|
|
'"progressToken":${progress_token}',
|
|
'"progress":${format_number(progress)}',
|
|
]
|
|
if total != 0 {
|
|
fields << '"total":${format_number(total)}'
|
|
}
|
|
if message != '' {
|
|
fields << '"message":${json.encode(message)}'
|
|
}
|
|
params := '{${fields.join(',')}}'
|
|
notification := build_notification_message('notifications/progress', params)
|
|
lock s.state {
|
|
if session_id !in s.state.sessions {
|
|
return
|
|
}
|
|
mut session := s.state.sessions[session_id]
|
|
// MCP 2025-11-25 mandates `progress` strictly increases per token.
|
|
if last := session.progress_seen[progress_token] {
|
|
if progress <= last {
|
|
return
|
|
}
|
|
}
|
|
session.progress_seen[progress_token] = progress
|
|
session.notification_queue << notification
|
|
s.state.sessions[session_id] = session
|
|
}
|
|
}
|
|
|
|
fn format_number(value f64) string {
|
|
if value == f64(i64(value)) {
|
|
return i64(value).str()
|
|
}
|
|
return value.str()
|
|
}
|
|
|
|
fn (s &Server) capabilities_json() string {
|
|
if s.capabilities_override != '' {
|
|
return normalize_capabilities(s.capabilities_override)
|
|
}
|
|
mut parts := []string{}
|
|
if s.tool_names.len != 0 {
|
|
parts << '"tools":{"listChanged":true}'
|
|
}
|
|
if s.resource_uris.len != 0 || s.resource_template_ids.len != 0 {
|
|
parts << '"resources":{"listChanged":true,"subscribe":true}'
|
|
}
|
|
if s.prompt_names.len != 0 {
|
|
parts << '"prompts":{"listChanged":true}'
|
|
}
|
|
if s.enable_logging {
|
|
parts << '"logging":{}'
|
|
}
|
|
if s.completions.len != 0 {
|
|
parts << '"completions":{}'
|
|
}
|
|
return '{${parts.join(',')}}'
|
|
}
|
|
|
|
fn (mut s Server) ensure_session_for_initialize(session_id string, transport SessionTransport) Session {
|
|
if transport == .stdio {
|
|
return s.ensure_session(stdio_session_id, .stdio)
|
|
}
|
|
if session_id != '' {
|
|
return s.ensure_session(session_id, .http)
|
|
}
|
|
return s.create_http_session()
|
|
}
|
|
|
|
fn (mut s Server) ensure_session(session_id string, transport SessionTransport) Session {
|
|
if session := s.get_session(session_id) {
|
|
return session
|
|
}
|
|
session := Session{
|
|
id: session_id
|
|
transport: transport
|
|
protocol_version: s.protocol_version
|
|
}
|
|
s.store_session(session)
|
|
return session
|
|
}
|
|
|
|
fn (mut s Server) create_http_session() Session {
|
|
for {
|
|
session_id := rand.uuid_v7()
|
|
if !s.session_exists(session_id) {
|
|
session := Session{
|
|
id: session_id
|
|
transport: .http
|
|
protocol_version: s.protocol_version
|
|
}
|
|
s.store_session(session)
|
|
return session
|
|
}
|
|
}
|
|
return Session{}
|
|
}
|
|
|
|
fn (s &Server) session_for_request(session_id string, transport SessionTransport) ?Session {
|
|
if transport == .stdio {
|
|
return s.get_session(stdio_session_id)
|
|
}
|
|
if session_id == '' {
|
|
return none
|
|
}
|
|
return s.get_session(session_id)
|
|
}
|
|
|
|
fn (mut s Server) store_session(session Session) {
|
|
lock s.state {
|
|
s.state.sessions[session.id] = session
|
|
}
|
|
}
|
|
|
|
fn (s &Server) get_session(session_id string) ?Session {
|
|
mut session := Session{}
|
|
mut found := false
|
|
rlock s.state {
|
|
if session_id in s.state.sessions {
|
|
session = s.state.sessions[session_id]
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return none
|
|
}
|
|
return session
|
|
}
|
|
|
|
fn (s &Server) session_exists(session_id string) bool {
|
|
mut found := false
|
|
rlock s.state {
|
|
found = session_id in s.state.sessions
|
|
}
|
|
return found
|
|
}
|
|
|
|
fn (mut s Server) delete_session(session_id string) bool {
|
|
mut deleted := false
|
|
mut orphaned_signals := []&sync.Semaphore{}
|
|
prefix := pending_signal_key(session_id, '')
|
|
lock s.state {
|
|
if session_id in s.state.sessions {
|
|
s.state.sessions.delete(session_id)
|
|
deleted = true
|
|
}
|
|
for key, signal in s.state.response_signals {
|
|
if key.starts_with(prefix) {
|
|
orphaned_signals << signal
|
|
}
|
|
}
|
|
}
|
|
for mut signal in orphaned_signals {
|
|
signal.post()
|
|
}
|
|
return deleted
|
|
}
|
|
|
|
fn (s &Server) context_from_session(req Request, session Session) Context {
|
|
return Context{
|
|
session_id: session.id
|
|
request_id: req.id
|
|
method: req.method
|
|
transport: session.transport
|
|
protocol_version: session.protocol_version
|
|
client_info: session.client_info
|
|
client_capabilities: session.client_capabilities
|
|
progress_token: extract_progress_token(req.params)
|
|
server: unsafe { s }
|
|
}
|
|
}
|
|
|
|
struct MetaParams {
|
|
meta MetaPayload @[json: '_meta']
|
|
}
|
|
|
|
struct MetaPayload {
|
|
progress_token string @[json: progressToken; raw]
|
|
}
|
|
|
|
fn extract_progress_token(params string) string {
|
|
trimmed := params.trim_space()
|
|
if trimmed.len == 0 || trimmed == null.str() {
|
|
return ''
|
|
}
|
|
wrapper := json.decode(MetaParams, trimmed) or { return '' }
|
|
return wrapper.meta.progress_token.trim_space()
|
|
}
|
|
|
|
fn decode_optional_params[T](raw string) !T {
|
|
trimmed := raw.trim_space()
|
|
if trimmed.len == 0 || trimmed == null.str() {
|
|
return T{}
|
|
}
|
|
return decode_value[T](trimmed)
|
|
}
|
|
|
|
fn json_object_or_empty(raw string) string {
|
|
trimmed := raw.trim_space()
|
|
if trimmed.len == 0 || trimmed == null.str() {
|
|
return '{}'
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
fn paginate_bounds(total int, cursor string) !(int, int, string) {
|
|
mut start := 0
|
|
if cursor.trim_space() != '' {
|
|
start = cursor.int()
|
|
if start < 0 || start > total || (start == 0 && cursor != '0') {
|
|
return ProtocolError{
|
|
response_error: invalid_params
|
|
}
|
|
}
|
|
}
|
|
mut end := start + default_list_page_size
|
|
if end > total {
|
|
end = total
|
|
}
|
|
next_cursor := if end < total { end.str() } else { '' }
|
|
return start, end, next_cursor
|
|
}
|
|
|
|
fn validate_tool_name(name string) ! {
|
|
trimmed := name.trim_space()
|
|
if trimmed.len == 0 || trimmed.len > 128 {
|
|
return error('mcp.Server.add_tool: invalid tool name `${name}`')
|
|
}
|
|
for ch in trimmed {
|
|
if ch.is_letter() || ch.is_digit() || ch in [`_`, `-`, `.`] {
|
|
continue
|
|
}
|
|
return error('mcp.Server.add_tool: invalid tool name `${name}`')
|
|
}
|
|
}
|
|
|
|
fn normalize_tool_input_schema(input_schema string) string {
|
|
trimmed := input_schema.trim_space()
|
|
return if trimmed.len == 0 { default_tool_input_schema } else { trimmed }
|
|
}
|
|
|
|
fn normalize_server_info(config ServerConfig) Implementation {
|
|
return Implementation{
|
|
name: if config.name.trim_space() == '' {
|
|
default_server_name
|
|
} else {
|
|
config.name.trim_space()
|
|
}
|
|
version: if config.version.trim_space() == '' {
|
|
default_server_version
|
|
} else {
|
|
config.version.trim_space()
|
|
}
|
|
title: config.title.trim_space()
|
|
description: config.description.trim_space()
|
|
website_url: config.website_url.trim_space()
|
|
icons: config.icons.clone()
|
|
}
|
|
}
|
|
|
|
fn normalize_http_path(path string) string {
|
|
trimmed := path.trim_space()
|
|
if trimmed == '' {
|
|
return default_http_path
|
|
}
|
|
return if trimmed.starts_with('/') { trimmed } else { '/' + trimmed }
|
|
}
|
|
|
|
fn error_response_for(request_id string, err IError) Response {
|
|
mut response_id := request_id
|
|
response_error := normalize_response_error(err)
|
|
if err is ProtocolError && err.request_id != '' {
|
|
response_id = err.request_id
|
|
}
|
|
return Response{
|
|
id: response_id
|
|
error: response_error
|
|
}
|
|
}
|
|
|
|
fn normalize_response_error(err IError) ResponseError {
|
|
if err is ProtocolError {
|
|
return err.response_error
|
|
}
|
|
if err is ResponseError {
|
|
return err
|
|
}
|
|
return ResponseError{
|
|
code: internal_error.code
|
|
message: err.msg()
|
|
}
|
|
}
|
|
|
|
fn build_sse_response(message string) string {
|
|
mut lines := []string{}
|
|
for line in message.split('\n') {
|
|
lines << 'data: ${line}'
|
|
}
|
|
return lines.join('\n') + '\n\n'
|
|
}
|
|
|
|
fn build_sse_event(event LoggedEvent) string {
|
|
mut lines := ['id: ${event.id}']
|
|
for line in event.body.split('\n') {
|
|
lines << 'data: ${line}'
|
|
}
|
|
return lines.join('\n') + '\n\n'
|
|
}
|
|
|
|
fn build_sse_stream(events []LoggedEvent) string {
|
|
mut chunks := []string{cap: events.len}
|
|
for event in events {
|
|
chunks << build_sse_event(event)
|
|
}
|
|
return chunks.join('')
|
|
}
|
|
|
|
// accept_modes describes which response Content-Types the client tolerates.
|
|
struct AcceptModes {
|
|
json bool
|
|
sse bool
|
|
}
|
|
|
|
// parse_accept extracts JSON/SSE acceptance from a comma-separated Accept header.
|
|
// `*/*` and `application/*` / `text/*` wildcards count as accepting the matching type.
|
|
fn parse_accept(header http.Header) AcceptModes {
|
|
accept := header.get(.accept) or { '' }
|
|
if accept.trim_space() == '' {
|
|
return AcceptModes{
|
|
json: true
|
|
sse: true
|
|
}
|
|
}
|
|
mut modes := AcceptModes{}
|
|
for raw in accept.split(',') {
|
|
entry := raw.split(';')[0].trim_space().to_lower()
|
|
match entry {
|
|
'*/*', '' { modes = AcceptModes{true, true} }
|
|
'application/json', 'application/*' { modes = AcceptModes{true, modes.sse} }
|
|
'text/event-stream', 'text/*' { modes = AcceptModes{modes.json, true} }
|
|
else {}
|
|
}
|
|
}
|
|
return modes
|
|
}
|
|
|
|
// origin_is_allowed enforces the spec MUST: validate Origin to prevent DNS rebinding.
|
|
fn (s &Server) origin_is_allowed(origin string) bool {
|
|
if origin == '' {
|
|
return true
|
|
}
|
|
for allowed in s.allowed_origins {
|
|
if allowed == '*' || allowed == origin {
|
|
return true
|
|
}
|
|
}
|
|
if s.allowed_origins.len != 0 {
|
|
return false
|
|
}
|
|
return is_loopback_origin(origin)
|
|
}
|
|
|
|
fn is_loopback_origin(origin string) bool {
|
|
if origin == 'null' {
|
|
return true
|
|
}
|
|
mut without_scheme := origin
|
|
for prefix in ['http://', 'https://'] {
|
|
if origin.starts_with(prefix) {
|
|
without_scheme = origin[prefix.len..]
|
|
break
|
|
}
|
|
}
|
|
host := without_scheme.all_before('/').all_before(':').to_lower()
|
|
bracketed := without_scheme.all_before('/')
|
|
return host in ['localhost', '127.0.0.1', '::1'] || bracketed.starts_with('[::1]')
|
|
}
|
|
|
|
// supports_protocol_version applies spec rules: missing header defaults to
|
|
// 2025-03-26 (back-compat), otherwise the value MUST match the negotiated one.
|
|
fn (s &Server) supports_protocol_version(value string, has_session bool) bool {
|
|
if value == '' {
|
|
return !has_session || s.protocol_version == default_protocol_version
|
|
|| s.protocol_version == protocol_version
|
|
}
|
|
return value == s.protocol_version
|
|
}
|
|
|
|
fn json_http_response(status http.Status, body string, content_type string) http.Response {
|
|
mut header := http.new_header()
|
|
if content_type != '' {
|
|
header.set(.content_type, content_type)
|
|
}
|
|
mut response := http.Response{
|
|
body: body
|
|
header: header
|
|
}
|
|
response.set_status(status)
|
|
return response
|
|
}
|
|
|
|
fn error_http_response(status http.Status, err ResponseError) http.Response {
|
|
body := Response{
|
|
error: err
|
|
}.encode()
|
|
return json_http_response(status, body, default_content_type)
|
|
}
|
|
|
|
struct HttpHandler {
|
|
mut:
|
|
server &Server = unsafe { nil }
|
|
}
|
|
|
|
fn (mut h HttpHandler) handle(req http.Request) http.Response {
|
|
return h.server.handle_http_request(req)
|
|
}
|
|
|
|
fn (mut s Server) handle_http_get(req http.Request, session_id string) http.Response {
|
|
if session_id == '' {
|
|
return error_http_response(.bad_request, ResponseError{
|
|
code: invalid_request.code
|
|
message: 'GET requires an active MCP-Session-Id.'
|
|
})
|
|
}
|
|
if !s.session_exists(session_id) {
|
|
return json_http_response(.not_found, '', '')
|
|
}
|
|
accept := parse_accept(req.header)
|
|
if !accept.sse {
|
|
return error_http_response(.not_acceptable, ResponseError{
|
|
code: invalid_request.code
|
|
message: 'GET requires Accept: text/event-stream.'
|
|
})
|
|
}
|
|
last_event_id_text := req.header.get_custom(last_event_id_header) or { '' }
|
|
last_event_id := last_event_id_text.int()
|
|
mut events := []LoggedEvent{}
|
|
if last_event_id_text != '' {
|
|
// Flush anything generated while the client was disconnected into the
|
|
// event log first, so the replay covers the whole gap. Without this
|
|
// step the resume only sees what was already drained before the drop,
|
|
// which defeats the point of `Last-Event-ID`.
|
|
s.drain_to_event_log(session_id)
|
|
events = s.replay_events_after(session_id, last_event_id)
|
|
} else {
|
|
events = s.drain_to_event_log(session_id)
|
|
}
|
|
body := build_sse_stream(events)
|
|
mut response := json_http_response(.ok, body, event_stream_content_type)
|
|
response.header.set_custom(mcp_session_id_header, session_id) or {}
|
|
return response
|
|
}
|
|
|
|
fn (mut s Server) handle_http_request(req http.Request) http.Response {
|
|
if req.url.all_before('?') != s.http_path {
|
|
return json_http_response(.not_found, '', '')
|
|
}
|
|
origin := req.header.get_custom('Origin') or { '' }
|
|
if !s.origin_is_allowed(origin) {
|
|
return error_http_response(.forbidden, ResponseError{
|
|
code: invalid_request.code
|
|
message: 'Origin not allowed.'
|
|
})
|
|
}
|
|
session_id := req.header.get_custom(mcp_session_id_header) or { '' }
|
|
match req.method {
|
|
.delete {
|
|
if session_id == '' {
|
|
return json_http_response(.bad_request, '', '')
|
|
}
|
|
if !s.delete_session(session_id) {
|
|
return json_http_response(.not_found, '', '')
|
|
}
|
|
return json_http_response(.ok, '', '')
|
|
}
|
|
.get {
|
|
return s.handle_http_get(req, session_id)
|
|
}
|
|
.post {}
|
|
else {
|
|
return json_http_response(.method_not_allowed, '', '')
|
|
}
|
|
}
|
|
|
|
protocol_value := req.header.get_custom(mcp_protocol_version_header) or { '' }
|
|
if !s.supports_protocol_version(protocol_value, session_id != '') {
|
|
return error_http_response(.bad_request, ResponseError{
|
|
code: invalid_request.code
|
|
message: 'Unsupported MCP-Protocol-Version `${protocol_value}`.'
|
|
})
|
|
}
|
|
|
|
accept := parse_accept(req.header)
|
|
if !accept.json && !accept.sse {
|
|
return error_http_response(.not_acceptable, ResponseError{
|
|
code: invalid_request.code
|
|
message: 'Accept must include application/json or text/event-stream.'
|
|
})
|
|
}
|
|
|
|
trimmed := req.data.trim_space()
|
|
if trimmed.len == 0 || trimmed[0] == `[` {
|
|
return error_http_response(.bad_request, invalid_request)
|
|
}
|
|
envelope := decode_envelope(trimmed) or {
|
|
return error_http_response(.bad_request, parse_error)
|
|
}
|
|
if session_id != '' && !s.session_exists(session_id) {
|
|
return json_http_response(.not_found, '', '')
|
|
}
|
|
if envelope.method != 'initialize' && envelope.method.len != 0 && session_id == '' {
|
|
return error_http_response(.bad_request, server_not_initialized)
|
|
}
|
|
dispatch_result := s.dispatch_envelope(envelope, session_id, .http) or {
|
|
return error_http_response(.bad_request, normalize_response_error(err))
|
|
}
|
|
if !dispatch_result.has_response {
|
|
return json_http_response(.accepted, '', '')
|
|
}
|
|
use_sse := accept.sse && !accept.json
|
|
mut body := ''
|
|
mut content_type := default_content_type
|
|
if use_sse {
|
|
notifications := s.drain_to_event_log(session_for_drain(session_id,
|
|
dispatch_result.session_id))
|
|
body = build_sse_stream(notifications) + build_sse_response(dispatch_result.response)
|
|
content_type = event_stream_content_type
|
|
} else {
|
|
body = dispatch_result.response
|
|
}
|
|
mut response := json_http_response(.ok, body, content_type)
|
|
if dispatch_result.session_id != '' {
|
|
response.header.set_custom(mcp_session_id_header, dispatch_result.session_id) or {}
|
|
}
|
|
return response
|
|
}
|
|
|
|
fn session_for_drain(request_session string, dispatch_session string) string {
|
|
if request_session != '' {
|
|
return request_session
|
|
}
|
|
return dispatch_session
|
|
}
|