mcp-server

Define MCP 2026-07-28 servers: a side-effect-free routing core, tools/resources/prompts, and a transport trait. Depends only on marianoguerra/mcp.

mcp
model-context-protocol
server
protocol
moon add marianoguerra/mcp-server@0.7.0
Download zip
Version
0.7.0
License
MIT
Last updated
2 days ago
Downloads
26

Dependencies

README

#marianoguerra/mcp-server

Define MCP servers for the 2026-07-28 ("modern") protocol era — the stateless one, with no initialize handshake, no session ids and no server-initiated requests.

Depends only on marianoguerra/mcp, so it builds on native, js, wasm and wasm-gc. Serving it over a real transport is marianoguerra/mcp-server-native; this module is the part that has no I/O in it.

moon add marianoguerra/mcp-server # define a server moon add marianoguerra/mcp-server-native # and serve it

#Defining a server

let srv = @server.new("weather", "1.0.0")
srv
..instructions("Weather lookups. Call get_forecast before booking anything outdoors.")
..tool(
"get_forecast",
(args, _ctx) => @server.structured({
"temperature": 22.5,
"conditions": Json::string("Partly cloudy in " + args.str("city")),
}),
description="Get the forecast for a city",
input_schema={
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
},
output_schema={
"type": "object",
"properties": {
"temperature": { "type": "number" },
"conditions": { "type": "string" },
},
"required": ["temperature", "conditions"],
},
)
..resource(
"readme",
"docs://readme",
(_uri, _ctx) => @server.resource_text("Ask about the weather.", mime_type="text/plain"),
description="How to use this server",
)
.prompt(
"plan-trip",
(args, _ctx) => @server.messages([
@server.user_text("Plan a trip to " + args.str("city")),
]),
arguments=[{ name: "city", description: Some("Destination"), required: true }],
)

Registration methods return Unit and are used through MoonBit's .. cascade: .. for every call but the last, a plain . for the last one.

#What it does for you

Everything below is a MUST or SHOULD in the spec, and all of it is handled before a handler runs:

server/discoveranswered from the catalog, with supported versions, capabilities, instructions and serverInfo
capabilitiesderived from what you registered, so the declaration cannot drift from the behaviour
version negotiationan unsupported version is -32022 with data.supported, since there is no handshake to renegotiate in
the _meta envelopea request missing protocolVersion or clientCapabilities is -32602, naming the keys
removed methodsinitialize, ping, logging/setLevel, resources/subscribe are -32601 — and initialize gets a message naming the versions you do speak, because a legacy client has no other diagnostic
SEP-2243 headersMcp-Method / Mcp-Name / Mcp-Param-* are validated against the body, base64 sentinel values decoded first, integers compared numerically
result enveloperesultType, _meta.serverInfo, and cache hints on the six methods allowed to carry them
paginationcursors on every list method
tool errorsa handler that raises becomes isError: true content, so the message reaches the model
HTTP statuseseach failure carries the status the spec assigns it — 400, 404 or 200

#The split: a pure core

Server::route is a total, side-effect-free function from a message and a catalog to a decision:

pub fn Server::route(self : Self, inbound : @transport.Inbound) -> Route

pub enum Route {
Answer(Json, status~ : Int) // decided outright: discovery, a listing, or a rejection
Drop // a notification, which MUST NOT get a reply
Invoke(...) // call a handler; everything else is already decided
}

Every rule in the table above lands in route. The only impure step a server has is calling the closure you registered, and that is one line in handle.mbt. So the entire conformance surface can be tested by calling a function — no process, no socket, no async runtime, which is exactly why this module's own test suite runs on wasm.

The validation order is part of the contract, not an implementation detail, and it is checked rung by rung:

#RungFailureHTTP
1jsonrpc-shape-32600400
2era-classification-32022400
3method-registry-32601404
4envelope-32602400
5standard-header-validation-32020400
6param-header-validation-32020400
7handler-32602 unknown tool, or isError200

Rung 3 outranking rung 4 is the subtle one: an unknown method is -32601 even when its _meta is also malformed, so a client is never sent off to fix an envelope for a method that does not exist. It also means a raw handler cannot reintroduce a method the era removed.

#Handlers

pub type ToolHandler = async (Args, Ctx) -> ToolResult
pub type ResourceHandler = async (String, Ctx) -> ResourceResult
pub type PromptHandler = async (Args, Ctx) -> PromptResult

Handlers raise. A raise becomes isError: true content rather than a JSON-RPC error, because the spec puts input validation and business-logic failures under Tool Execution Errors and says clients SHOULD hand those to the model so it can self-correct — a protocol error is rendered opaquely and its message never gets there. So args.str("city") can be written as straight-line code and a missing argument still reaches the model as something it can act on.

Ctx is a value, not a session — everything on it was read off this request, because the spec forbids inferring any of it from a previous one:

ctx.client_info() // who the client says it is (unverified, for display only)
ctx.has_capability("roots")
ctx.cancelled() // on HTTP, a closed stream IS cancellation
ctx.progress(0.5, total=1.0) // no-op unless the request sent a progressToken
ctx.log("debug", data) // no-op unless the request opted in; a server MUST NOT
// emit notifications/message otherwise
ctx.params // the whole params object, for what sits beside `arguments`

#Transports

pub(open) trait ServerTransport {
async fn serve(Self, async (Inbound, &Responder) -> Unit noraise) -> Unit
fn close(Self) -> Unit
fn describe(Self) -> String
}

marianoguerra/mcp-server-native ships stdio and streamable HTTP. transport_memory ships an in-process one that works on every backend, including a Loopback implementing the client Transport trait — so a client and a server can talk in one process with no I/O at all.

The trait is pub(open): a WebSocket transport, a Cloudflare Worker on js, or a test harness is a third-party file, not a fork.

#What this version does not do

subscriptions/listen, MRTR (input_required) and completion/complete are not modelled. They are still expressible — Server::on registers a raw method handler, raw_result emits a result shape this version has no struct for, and Ctx::notify emits notifications. The repo's mcp-echo-server fixture implements both MRTR and subscriptions that way, and its end-to-end tests pass unchanged against the hand-written server it replaced.

Not modelling something must not mean standing in the way of it.