Build a server, step by step
A short walk through moonmcp. The same server, written three ways: annotated functions, one registration per capability, and a type that holds each of them. Add resources, prompts and layers, pick a transport, and point a client at it.
MCP 2026-07-28 · 2025-11-25 · 2024-11-05 · Streamable HTTP · stdio
Install, and one tool
moonmcp is a MoonBit package. A server is a value: the capabilities it offers, and who it says it is. A capability can be written three ways, and the switch above decides which one every example below shows.
moon add moonbitstack/moonmcpAll three build the same server, and answer the same requests in the same way. The code on this page comes from the three packages of src/examples/guide/; a program there sends each request below to all three and fails if one of them differs.
///|
/// Add two numbers.
#mcp.tool(description="Add two numbers")
fn add(a : Double, b : Double) -> Double {
a + b
}
///|
/// Greet someone, in Chinese when asked. An optional parameter is left out of
/// the schema's `required`, and a missing one arrives as `None`.
#mcp.tool(description="Greet someone, in Chinese when asked")
fn greet(name : String, language? : String) -> String {
match language {
Some("zh") => "你好," + name
_ => "hello, " + name
}
}The generator reads these annotations at build time and writes the register call that makes each of them a tool. It is wired in once: a rule in moon.mod and a dev_build line in the package's moon.pkg.
///|
/// Two tools, each one call on the server.
pub fn add_tools(mcp : @moonmcp.Server) -> Unit {
mcp.tool(
"add",
args => {
guard args.get("a") is Some(Number(a, ..)) &&
args.get("b") is Some(Number(b, ..)) else {
raise @exceptions.Tool("a and b must be numbers")
}
Json(a + b)
},
description="Add two numbers",
input_schema={
"type": "object",
"properties": { "a": { "type": "number" }, "b": { "type": "number" } },
"required": ["a", "b"],
},
)
mcp.tool(
"greet",
args => {
let name = match args.get("name") {
Some(String(name)) => name
_ => "stranger"
}
let language = match args.get("language") {
Some(String(language)) => language
_ => ""
}
if language == "zh" {
Json("你好," + name)
} else {
Json("hello, " + name)
}
},
description="Greet someone, in Chinese when asked",
input_schema={
"type": "object",
"properties": {
"name": { "type": "string" },
"language": { "type": "string" },
},
"required": ["name"],
},
)
}Each capability is one call on the server. This is what the annotations generate, and the form to reach for when a capability is decided at run time.
///|
/// The arithmetic tools, and how many times they were used.
pub struct Arithmetic {
priv mut calls : Int32
}
///|
/// A set of arithmetic tools that has not been used yet.
pub fn Arithmetic::new() -> Arithmetic {
{ calls: 0, }
}
///|
/// How many calls the tools have answered.
pub fn Arithmetic::calls(self : Arithmetic) -> Int32 {
self.calls
}
///|
fn Arithmetic::add(
self : Arithmetic,
a : Double,
b : Double,
) -> @tools.ToolResult raise @exceptions.FastMCPError {
self.calls = self.calls + 1
@tools.ToolResult::new(content=[@types.text_block("\{a + b}")])
}
///|
fn Arithmetic::greet(
self : Arithmetic,
name : String,
language : String,
) -> @tools.ToolResult raise @exceptions.FastMCPError {
self.calls = self.calls + 1
let greeting = if language == "zh" {
"你好," + name
} else {
"hello, " + name
}
@tools.ToolResult::new(content=[@types.text_block(greeting)])
}
///|
impl @providers.Provider for Arithmetic with fn tools(self) {
[
@tools.Tool::new(
"add",
parameters={
"type": "object",
"properties": { "a": { "type": "number" }, "b": { "type": "number" } },
"required": ["a", "b"],
},
run_fn=args => {
let a = match args.get("a") {
Some(Number(value, ..)) => value
_ => 0.0
}
let b = match args.get("b") {
Some(Number(value, ..)) => value
_ => 0.0
}
self.add(a, b)
},
) catch {
e => abort("add: \{e}")
},
@tools.Tool::new(
"greet",
parameters={
"type": "object",
"properties": {
"name": { "type": "string" },
"language": { "type": "string" },
},
"required": ["name"],
},
run_fn=args => {
let name = match args.get("name") {
Some(String(name)) => name
_ => "stranger"
}
let language = match args.get("language") {
Some(String(language)) => language
_ => ""
}
self.greet(name, language)
},
) catch {
e => abort("greet: \{e}")
},
]
}The state lives in the struct and each method is the work of one tool; impl Provider says which of them the server offers. The server is handed the object, and asks it again whenever the tools are listed.
A tool has a name, a handler, and an input schema a client can show. A parameter left out of required may be omitted; a name the protocol does not allow is refused when the server is built, not when it is called. Here is what these requests come back with:
An unknown tool is a protocol error, -32602: the client asked for something the server never offered. A tool that raises is answered differently — its result carries isError, so the model reads the message and can correct itself.
Resources and templates
A resource has a URI and a reader. A template puts {id} in the URI, and every matching read runs the reader with the variable already read as its MoonBit type.
///|
/// The application's settings. A resource that answers something other than
/// text is sent as JSON.
#mcp.resource("config://app", description="The application settings")
fn config() -> Json {
{ "theme": "dark", "language": "en" }
}
///|
/// A profile per user: `{id}` makes a template, and the value it matched is
/// read as an `Int`.
#mcp.resource("users://{id}/profile")
fn profile(id : Int32) -> String {
"profile of user \{id}"
}The URI is the annotation's argument. A URI with {id} in it makes a template, and the value it matched is read as the parameter's type.
///|
/// A resource at a fixed URI, and a template for the ones with an id in them.
pub fn add_resources(mcp : @moonmcp.Server) -> Unit {
mcp.resource(
"config://app",
() => "{\"theme\":\"dark\",\"language\":\"en\"}",
description="The application settings",
mime_type="application/json",
)
mcp.template("users://{id}/profile", params => {
let id = match params.get("id") {
Some(String(id)) => id
_ => "?"
}
"profile of user " + id
})
}resource reads a fixed URI; template takes the parameters the URI matched.
///|
/// The application's settings, and one resource per user.
pub struct Catalog {}
///|
fn Catalog::settings(self : Catalog) -> String {
let _ = self
"{\"theme\":\"dark\",\"language\":\"en\"}"
}
///|
fn Catalog::profile(self : Catalog, id : String) -> String {
let _ = self
"profile of user " + id
}
///|
impl @providers.Provider for Catalog with fn resources(self) {
[
@resources.Resource::new(
"config://app",
read_fn=() => Text(self.settings()),
description="The application settings",
mime_type="application/json",
),
]
}
///|
impl @providers.Provider for Catalog with fn templates(self) {
[
@resources.ResourceTemplate::new(
"users://{id}/profile",
parameters={
"type": "object",
"properties": { "id": { "type": "string" } },
},
read_fn=params => {
let id = match params.get("id") {
Some(String(id)) => id
_ => "?"
}
Text(self.profile(id))
},
) catch {
e => abort("users://{id}/profile: \{e}")
},
]
}A resource and a template are two more methods of the object, offered through the same impl.
A read that finds nothing is -32602 under the 2026-07-28 revision; the revisions before it use -32002 for the same case. The server knows which revision a request is in, so a client of either era gets the code it expects.
Prompts
A prompt turns arguments into the messages a client hands to its model. Its parameters become the prompt's arguments, which a client shows before it fills the form in.
///|
/// Ask for a code review. The parameters become the prompt's arguments.
#mcp.prompt(description="Ask for a code review")
fn review(code : String, focus? : String) -> String {
let what = match focus {
Some(focus) => " Focus on \{focus}."
None => ""
}
"Review this code.\{what}\n\n\{code}"
}The parameters of the annotated function become the prompt's arguments; an optional one may be left out of the call.
///|
/// A prompt whose arguments a client fills in.
pub fn add_review(mcp : @moonmcp.Server) -> Unit {
mcp.prompt(
"review",
args => {
let code = match args.get("code") {
Some(String(code)) => code
_ => ""
}
let what = match args.get("focus") {
Some(String(focus)) => " Focus on " + focus + "."
_ => ""
}
"Review this code." + what + "\n\n" + code
},
description="Ask for a code review",
arguments=[
{ name: "code", title: None, description: None, required: Some(true), },
{ name: "focus", title: None, description: None, required: Some(false), },
],
)
}prompt takes the renderer and the argument list a client shows before it fills the form in.
///|
/// The reviewer: one method, one prompt.
pub struct Reviewer {}
///|
fn Reviewer::review(self : Reviewer, code : String, focus : String) -> String {
let _ = self
let what = if focus.is_empty() { "" } else { " Focus on " + focus + "." }
"Review this code." + what + "\n\n" + code
}
///|
impl @providers.Provider for Reviewer with fn prompts(self) {
[
@prompts.Prompt::new(
"review",
render_fn=args => {
let code = match args.get("code") {
Some(String(code)) => code
_ => ""
}
let focus = match args.get("focus") {
Some(String(focus)) => focus
_ => ""
}
Text(self.review(code, focus))
},
description="Ask for a code review",
arguments=[
{ name: "code", title: None, description: None, required: Some(true), },
{
name: "focus",
title: None,
description: None,
required: Some(false),
},
],
),
]
}The reviewer is a type with a method, offered through impl Provider.
A handler that sees its request
A handler that needs to know who is calling, or to report while it works, is handed the request: its headers, the client's identity, the progress token it offered, and the answers it sent on a retry.
There is no annotation for a handler that wants the request: the generator registers the function it is given. Write these two forms instead — the annotation is the shorter way to say what they say.
///|
/// A tool that sees the request it serves, and reports on it while it works.
pub fn add_counter(mcp : @moonmcp.Server) -> Unit {
mcp.tool_context(
"count",
async fn(args : Map[String, Json], context : @context.Context) {
let up_to = match args.get("up_to") {
Some(Number(value, ..)) => value.to_int()
_ => 3
}
if up_to < 0 {
raise @exceptions.Tool("up_to must not be negative")
}
for step in 0..<up_to {
context.report_progress(
progress=(step + 1).to_double(),
total=up_to.to_double(),
message="step \{step + 1}",
) catch {
_ => ()
}
}
@tools.ToolResult::new(content=[@types.text_block("counted to \{up_to}")])
},
description="Count to a number, reporting progress as it goes",
input_schema={
"type": "object",
"properties": { "up_to": { "type": "number" } },
},
)
}tool_context hands the handler the request it serves: its headers, the client's identity, its progress token, and the answers it sent back.
///|
/// A counter that reports how far it has got, through the request it serves.
pub struct Counter {}
///|
impl @providers.Provider for Counter with fn tools(self) {
[
@tools.Tool::new(
"count",
parameters={
"type": "object",
"properties": { "up_to": { "type": "number" } },
},
run_fn=_ => {
let _ = self
abort("count is served with its request, through context_run_fn")
},
description="Count to a number, reporting progress as it goes",
context_run_fn=async fn(
args,
context,
) -> @tools.ToolResult raise @exceptions.FastMCPError {
let up_to = match args.get("up_to") {
Some(Number(value, ..)) => value.to_int()
_ => 3
}
if up_to < 0 {
raise @exceptions.Tool("up_to must not be negative")
}
for step in 0..<up_to {
context.report_progress(
progress=(step + 1).to_double(),
total=up_to.to_double(),
message="step \{step + 1}",
) catch {
_ => ()
}
}
@tools.ToolResult::new(content=[
@types.text_block("counted to \{up_to}"),
])
},
) catch {
e => abort("count: \{e}")
},
]
}The tool is built with context_run_fn, so the object's tools see the request too.
The reports travel to the client while the answer is still being worked out, so a client with a progress token sees notifications/progress as the counting happens, not after it:
The same shape reaches resources and prompts through resource_context, template_context and prompt_context. A handler registered without them ignores the request, so the shorter calls keep working unchanged.
Extending the server
Three things can sit between the program and the client: providers, which supply components from somewhere other than the registry; transforms, which rewrite what the client is shown; and layers, which see every request before it is served.
Nothing here is a declaration, so no annotation covers it: a transform and a layer are values handed to the server, or types the server is given. The two forms beside this one differ only in which.
///|
/// Hold a capability back from the listing, and put a cache in front of every
/// request.
pub fn with_extensions(mcp : @moonmcp.Server) -> @moonmcp.Server {
mcp.add_transform(@transforms.Hidden::new(names=["greet"]))
mcp.use_layer(@layers.Caching::new(ttl_ms=1_000).layer())
mcp
}A transform and a layer are values: add_transform rewrites what the client is shown, use_layer puts code in front of every request.
///|
/// A filter is a contract, so holding the greeting back is a type of its own.
pub struct Quiet {}
///|
impl @transforms.Transform for Quiet with fn tools(self, tools) {
let _ = self
tools.filter(tool => tool.component.name != "greet")
}
///|
/// Hold a capability back from the listing, and put a cache in front of every
/// request.
pub fn with_extensions(mcp : @moonmcp.Server) -> @moonmcp.Server {
mcp.add_transform(Quiet::{ })
mcp.use_layer(@layers.Caching::new(ttl_ms=1_000).layer())
mcp
}A transform is a contract, so the filter is a type of its own. A layer is a value either way, since it wraps a call rather than describing a component.
Namespace(prefix) · Hidden(names) · Tagged(any_of) · or a type implementing TransformCaching · RateLimiting · Telemetry · or Layer::new(run=)Transforms run in the order they were added, each on the last one's output, so [Namespace, Hidden] hides what is left of a name after the prefix and [Hidden, Namespace] hides it before. Layers nest the same way: the first one added is outermost, and a layer that answers without calling on to the next one answers for the server.
A cache keys on the operation, the name, the caller and the arguments, with object keys sorted, so the same call in another order hits the same entry. A rate limiter counts a token bucket per caller and refuses with -32000, saying when to try again. Telemetry records a span per request, answered or refused, and hands each one to whatever sink the program gave it.
Choose a transport
One server, two ways to speak. to_asgi gives an ASGI application to serve over Streamable HTTP; run_stdio speaks line-delimited JSON on standard input and output, and is built on serve_lines, which takes any pair of streams.
async fn main {
let mcp = @server.Server::new("demo", version="1.0.0")
@mooncat.serve(mcp.to_asgi(path="/mcp"), port=12000) catch {
e => println("stopped: \{e}")
}
}One POST per message, origin-checked against DNS rebinding. A foreign Origin is refused with 403.
async fn main {
let mcp = @server.Server::new("demo", version="1.0.0")
mcp.run_stdio()
}Standard input and output, one JSON-RPC message per line — the shape every MCP client launches.
Progress & subscriptions
A 2026‑07‑28 request that offers a progress token is answered with text/event-stream: what the handler reports arrives on that stream, and the reply ends it. The same stream carries a subscription, which stays open for the changes the client opted in to.
let mcp = @server.Server::new("demo", version="1.0.0")
mcp.tool_context("count", async fn(_args : Map[String, Json], context : @context.Context) {
context.report_progress(progress=1.0, total=2.0, message="half way") catch {
_ => ()
}
@tools.ToolResult::new(content=[@types.text_block("done")])
})
// a change, announced to whoever asked for it
mcp.notify_tools_changed()
mcp.notify_resource_updated("config://app")Registering a tool, a resource or a prompt sends the matching list-changed notification by itself. A subscription names what it wants a notification for, and hears nothing else:
subscriptions/listen · { "notifications": { "toolsListChanged": true } }notifications/subscriptions/acknowledged · notifications/tools/list_changedClosing the stream on the HTTP side is the cancellation signal: a handler that is still working sees is_cancelled() answer the next time it reports. Over stdio the client says so with notifications/cancelled, naming the request.
Ask the client
A handler that cannot finish without something from the client says so instead of answering. The client answers, then sends the same call again with inputResponses and the requestState it was handed.
let mcp = @server.Server::new("demo", version="1.0.0")
mcp.tool_context("greet", async fn(_args : Map[String, Json], context : @context.Context) {
match context.input_responses().get("name") {
Some(String(name)) =>
@tools.ToolResult::new(content=[@types.text_block("hello " + name)])
_ =>
@tools.ToolResult::input_required({
input_requests: [
{
id: "name",
request: {
"method": Json::string("elicitation/create"),
"params": {
"message": Json::string("Your name?"),
"requestedSchema": { "type": "object" },
},
},
},
],
request_state: Some("greeting"),
meta: None,
})
}
}){ "resultType": "input_required", "inputRequests": [ … ], "requestState": "greeting" }{ "name": "greet", "inputResponses": { "name": "Ada" }, "requestState": "greeting" }Authorization
An endpoint that should not be open takes a bearer token. The header is checked before the body is read; a request without a good one is answered 401 with the WWW-Authenticate challenge the specification asks for.
let mcp = @server.Server::new("demo", version="1.0.0")
let app = mcp.to_asgi(auth=@auth.Bearer::hmac(secret="s3cret"))
// anywhere else: a check of your own
let own = @auth.Bearer::own(check=fn(token) {
if token == "let-me-in" {
Ok({ subject: Some("alice"), claims: Map([]) })
} else {
Err("no such token")
}
})Bearer::hmac checks a token signed with a shared secret — its signature, its expiry, and the issuer and audience when they were named. For anything else — an RSA or ECDSA key, a key set fetched from the issuer, an opaque token in a table — Bearer::own takes the whole decision, so the framework never has an opinion about how a token is judged.
A handler reads who called from its context, and the claims as the issuer wrote them:
subject() · claims()401 · WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="the token has expired"Logging
The server keeps no log of its own unless it is handed one. Given a structured logger it writes every request it served, every one it refused, and the lines a handler asks for.
let sink = @moonlog.Lines::new(fn(line) { println(line) })
let hub = @moonlog.Hub::new()
let slot = @moonlog.Slot::new(sink, name="console").layout(
@moonlog.Pattern::parse("{level} {target}: {msg}"),
)
ignore(hub.add(slot) catch {
e => abort("the log hub refused the slot: " + e.to_text())
})
let mcp = @server.Server::new("demo", version="1.0.0")
mcp.log_to(logger=hub.logger("moonmcp"))Context::log writes to that logger and sends notifications/message, so a client that went away does not take the record with it; Context::logger hands a handler the same logger scoped to its request, for lines of its own.
The documentation page
Beside the endpoint a browser page lists every tool, resource, template and prompt, fills a form from each input schema, and shows the raw JSON-RPC request next to the reply — in either protocol era.
let mcp = @server.Server::new("demo", version="1.0.0")
let app = mcp.to_asgi(path="/mcp") // the page at /docs
let moved = mcp.to_asgi(path="/mcp", docs=Some("/tools")) // somewhere else
let bare = mcp.to_asgi(path="/mcp", docs=None) // no page
let plain = mcp.to_asgi(path="/mcp", theme=Plain) // a neutral lookOn at /docs by default, in this website's look. A path equal to the endpoint's is ignored, so GET /mcp stays a 405.
Connect a client
Build the server for native stdio and point any MCP client at the binary. Claude Desktop, and most others, take a config like this:
{
"mcpServers": {
"moonmcp-demo": {
"command": "./demo",
"args": []
}
}
}Point command at the binary produced by moon build --target native for an example that calls run_stdio(). The server speaks the 2025‑11‑25 handshake on stdio; over HTTP it also answers the 2026‑07‑28 stateless era when the client names it in _meta.
Reach a server from MoonBit
The client speaks the same protocol from the other end, over an in-memory server, a pair of line streams, or HTTP — and returns the same types the server takes.
let mcp = @server.Server::new("demo", version="1.0.0")
mcp.tool("add", args => {
guard args.get("a") is Some(Number(a, ..)) else { raise @exceptions.Tool("a?") }
{ "sum": Json(a) }
})
let client = @client.Client::connect(
@client.Memory::new(mcp),
protocol_version="2026-07-28",
)
let tools = client.list_tools()
let reply = client.call_tool("add", arguments={ "a": 2 })
let sum = reply.structured_content.unwrap().get("sum").unwrap()
println("\{tools.length()} tools, sum \{sum.stringify()}")A transport is five methods — ask, tell, listen, close and answer_with — so a connection of your own plugs in without touching the client. The client fills in _meta, runs the handshake when the revision it was given needs one, and answers the requests the server makes of it through whichever transport it is on.
The server may ask for a completion, a form, or the roots this client may read. Say what to do about each when connecting; anything left out is refused, not ignored:
let handlers = @client.Handlers::new(
elicitation=request => {
println(request.message)
@types.ElicitResult::accept({ "answer": Json::string("yes") })
},
)
// Client::connect(transport, handlers~) makes them this client's answers.An accepted elicitation leaves out every field the person did not touch; the default the requested schema names is filled in before the answer goes back, so the server sees a complete form.
A server that asks for a token
A server behind OAuth answers 401 with a challenge that names its metadata. Oauth reads the metadata, registers, sends the user to authorize, and exchanges the code — and the HTTP transport calls it whenever the server asks for a token the client does not have.
async fn main {
let oauth = @oauth.Oauth::new(
resource_url="https://example.com/mcp",
client_name="my-agent",
redirect_uri="http://127.0.0.1:53682/callback",
present=url => {
// Open a browser on the URL and listen on the redirect port; give back
// where the browser was sent, or "" when the person gave up.
println("authorize: \{url}")
""
},
)
let transport = @http.Http::new(
"https://example.com/mcp",
token=fn() { oauth.bearer() },
renew=async fn(_status, challenge) {
oauth.renew(challenge?=@auth.Challenge::parse(challenge))
},
)
let client = @client.Client::connect(transport)
println("\{client.list_tools().length()} tools")
client.close()
}The flow is the protocol's, in its order: protected resource metadata at the location the challenge named or under the endpoint's own path, authorization server metadata by RFC 8414 then OpenID Connect Discovery, registration by RFC 7591 or a URL client identifier, then response_type=code with PKCE S256, the scope the challenge named, and the resource indicator of RFC 8707. An offline_access the authorization server lists is asked for, which is what makes it hand out a refresh token (SEP-2207); a later 401 spends that refresh token instead of opening a browser, and only a challenge for a wider scope — a 403 a refresh may not answer — starts a second round, three at most. A resource whose metadata names another origin is refused before anything is sent.
Server-side, Bearer is the other half: the token the endpoint checks before it serves anything.