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
    Download zip
    Version
    0.8.0
    License
    MIT
    Last updated
    26 days ago
    Downloads
    79

    Dependencies

    #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.

    #No transport at all

    transport_memory also ships Caller, which speaks MCP rather than JSON-RPC. It owns the two things that are wire knowledge rather than domain knowledge — the request id and the _meta envelope — and forwards everything else:

    let caller = @transport_memory.Caller::new(build_server())

    let ex = caller.call_tool("get_weather", args={ "city": "Montevideo" })
    assert_eq(ex.text(), Some("It is sunny in Montevideo"))
    assert_false(ex.is_error())

    Every method is synchronous. Server::handle is async because handlers are, but nothing on this path suspends, so the language's own %async.run bridges it and the caller never sees a coroutine. No scheduler, no moonbitlang/async, no event loop — which is what lets this run in an ordinary test block, inside moon bench's synchronous closure, and on all four backends.

    One exchange carries all four channels the engine can answer on, because a bare Json would throw three of them away:

    pub enum Outcome {
    Ok(Json) // a result body
    Failed(@jsonrpc.RpcError) // the server refused, or a handler raised ProtocolFailure
    ToolFailed(Json) // a *successful* exchange carrying isError: the tool said no
    Silent // a notification, or a raw handler that answered otherwise
    Suspended // the handler suspended; there is no scheduler here to resume it
    }

    ex.outcome() ex.body() ex.error() ex.rpc_code() ex.is_error()
    ex.text() ex.structured() ex.notifications() ex.progress() ex.logs()
    ex.status() ex.frame()

    ToolFailed is separate from Failed for the reason handle.mbt gives at length: an isError result is a successful exchange whose content the client should hand to the model, and a JSON-RPC error is rendered opaquely and never reaches it. Conflating them is the mistake this type exists to make impossible.

    Caller::send_raw sends a frame verbatim, with no envelope and no id, so a vocabulary whose job is stamping a correct envelope never becomes the reason you cannot send a malformed one. And replay(server, frames) runs a whole list of frames through Server::serve and hands back the transport that recorded the answers.

    This is what makes an MCP server testable, benchmarkable and embeddable without choosing a transport first: just bench measures the engine at roughly 5 µs per tools/call with nothing underneath it, and mcp-slack runs all eighteen of its tools this way against an in-process mock — no Slack, no token, no network.

    #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.