Guide

Build a server, step by step

A short walk through moonmcp. Register a tool by hand, then write the same tool as an annotation. Add resources, prompts, pick a transport, and point a client at it.

MCP 2026-07-28 · 2025-11-25 · 2024-11-05 · Streamable HTTP · stdio

Install

moonmcp is a MoonBit package. Add it to a project, then build a server in the same language your tools are written in.

moon add moonbitstack/moonmcp

The code below builds the two example servers in src/examples/ — the demo one, and the annotated one — a capability at a time. Every MoonBit block on this page is compiled by scripts/check_docs.py, so none of them can drift from the package.

Register a tool, by hand

A tool has a name, a handler that reads its arguments and returns JSON, and an input schema the client can show.

let mcp = @server.Server::new("demo", version="1.0.0",
  instructions="Arithmetic and greetings, for trying a client out.")
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")
    }
    { "sum": Json(a + b) }
  },
  description="Add two numbers",
  input_schema={
    "type": "object",
    "properties": { "a": { "type": "number" }, "b": { "type": "number" } },
    "required": ["a", "b"],
  },
)

The handler receives the call's arguments as a Map[String, Json]. Reading a number out of that map, and re-reading an optional one as a parameter's type, is what decode and decode_option do for you when you annotate instead. Raise @exceptions.Tool and the error returns as an isError result.

A call the client makes lands back as exactly the JSON you returned, wrapped and marked as a result:

call
tools/call · { "a": 2, "b": 3 }
result
{ "content": [ { "type": "text", "text": "{\"sum\":5}" } ], "structuredContent": { "sum": 5 }, "isError": false }

Annotate, instead

The same work can be written as plain functions. #mcp.tool reads the argument types and builds the input schema; the generator, wired into moonx dev_build, writes the registration you would otherwise write by hand.

#mcp.tool(description="Add two numbers")
fn add(a : Double, b : Double) -> Double {
  a + b
}

#mcp.tool(description="Greet someone, in Chinese when asked")
fn greet(name : String, language? : String) -> String {
  match language {
    Some("zh") => "你好," + name
    _ => "hello, " + name
  }
}

#mcp.tool(name="word_count", description="Count the words of a text")
fn count(text : String, unique~ : Bool = false) -> Int32 {
  let words = text
    .split(" ")
    .filter(w => !w.is_empty())
    .map(w => w.to_owned())
    .collect()
  if unique {
    let seen : Map[String, Bool] = Map([])
    for w in words {
      seen[w] = true
    }
    seen.length()
  } else {
    words.length()
  }
}

Each parameter becomes a property of the schema, typed from its MoonBit type. An optional parameter (language? : String) and a labelled one with a default (unique~ : Bool = false) are left out of required, and a missing one arrives as None. The tool's name may differ from the function's.

A project wires the generator once. The package that holds the functions asks for the generated file:

dev_build(rule: "mcp", input: "tools.mbt", output: "tools.g.mbt")
formatter(ignore: [ "tools.g.mbt" ])

The rule itself goes in moon.mod next to the imports; the README shows the line. From then on moon check, moon build and moon run regenerate tools.g.mbt whenever tools.mbt changes, and that file holds the one register(mcp) the program calls before it serves.

Resources & templates

A resource has a URI and a reader. A template puts {id} in the URI; each matching read runs the function with the variable read as its MoonBit type.

let mcp = @server.Server::new("demo", version="1.0.0")
mcp.resource(
  "config://app",
  () => "{\"theme\":\"dark\"}",
  name="app config",
  mime_type="application/json",
)
mcp.template("users://{id}/profile", params => {
  guard params.get("id") is Some(String(id)) else { "unknown" }
  "profile of user " + id
})

Reading either one returns the body, with the URI and mime type wrapped for the wire:

read
resources/read · config://app
result
{ "contents": [ { "uri": "config://app", "mimeType": "application/json", "text": "{\"theme\":\"dark\"}" } ] }

Prompts

A prompt is a renderer that turns arguments into the messages a client hands to its model. Its parameters become the prompt's arguments, and an optional one may be left out of the call.

#mcp.prompt(description="Ask for a code review")
fn review(code : String, focus? : String) -> String {
  let what = match focus {
    Some(f) => " Focus on \{f}."
    None => ""
  }
  "Review this code.\{what}\n\n\{code}"
}

Calling it returns the rendered messages:

get
prompts/get · { "code": "fn f() { 1 }" }
result
{ "description": "Ask for a code review", "messages": [ { "role": "user", "content": { "type": "text", "text": "Review this code.\n\nfn f() { 1 }" } } ] }

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.

Streamable HTTP
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.

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

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 look

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