mcp-server-native

Native transports for MCP 2026-07-28 servers: stdio and streamable HTTP with SSE.

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

#marianoguerra/mcp-server-native

Native transports for MCP 2026-07-28 servers: stdio and streamable HTTP.

Define the server with marianoguerra/mcp-server, then serve it with one line from here.

moon add marianoguerra/mcp-server-native

async fn main {
@transport_stdio.serve(build_server())
}

async fn main {
@transport_http.serve(build_server(), "127.0.0.1:3200", allowed_origins=["https://app.example.com"])
}

The same server value serves either. That is not a convenience — it is what a stateless protocol buys you: there is no session to set up differently, so the transport genuinely is just framing.

Native because moonbitlang/async's stdio and its real HTTP server are native-only. That constraint lives here rather than in mcp-server, which is why a wasm or browser host can still define a server and supply its own transport through the ServerTransport trait.

#stdio

Newline-delimited JSON on stdin and stdout.

Each request runs in its own coroutine. The spec says an open stdio process "is not a conversation or session: clients may interleave unrelated requests on the same transport", so a client is entitled to send a second request while the first is still running. Serving them one at a time would let a slow tool block every other caller.

Once requests are concurrent, two of them can write at the same moment, so a mutex around each frame keeps NDJSON framing intact. It is held for the write only, never for the handler.

#Streamable HTTP

One POST endpoint, defaulting to /mcp. Everything this revision removed is absent: no session ids, no standalone GET stream, no DELETE, no Last-Event-ID resumption.

The response shape is decided during the call, not before it. A request whose handler emits nothing is answered with a single application/json object. A handler that emits a progress notification commits the response to text/event-stream at that moment, and its final frame arrives as the last event. So a handler reporting progress gets a stream without asking for one, and a handler that does not never pays for one.

Handled per the spec:

GET / DELETE405 Method Not Allowed — they were the session verbs of earlier revisions
notification POST202 Accepted, no body
unimplemented method404 with a JSON-RPC -32601 body, which is what distinguishes it from a legacy server that does not host the endpoint at all
ladder rejections400 with -32020 / -32021 / -32022 / -32602
in-band errors200 — an unknown tool is not a malformed request
Originvalidated against allowed_origins, 403 otherwise
SSEX-Accel-Buffering: no, and flushed per event, so a proxy cannot batch a progress stream into one slow response
Mcp-Session-Id, Last-Event-IDignored, as the revision requires

The status is not derived from the error code here: -32602 is a 400 when the _meta envelope is malformed and a 200 when the tool name is unknown, and only the router knows which it produced. It travels with the answer.

Origin validation is skipped when allowed_origins is empty, which is only safe behind a proxy that enforces it instead. Bind to 127.0.0.1 rather than 0.0.0.0 for a local server: the spec says so, and 0.0.0.0 exposes it to anything that can route to the machine.