// The MCP server tree: the author's tools, resources and prompts as annotated
// functions under `internal/tools`, registered by the code moonmcp generates from
// them, and an entry point serving them over Streamable HTTP or stdio.
///|
let mcp_tools_src : String =
#|///|
#|/// Each annotated function is offered by the server; its parameters become the
#|/// tool's input schema. Edit freely: `tools.g.mbt` is regenerated on build.
#|#mcp.tool(description="Say hello to someone")
#|fn hello(name : String) -> String {
#| "hello, " + name
#|}
#|
#|///|
#|#mcp.resource("about://server", description="What this server is")
#|fn about() -> String {
#| "an MCP server scaffolded by mctl"
#|}
#|
#|///|
#|#mcp.prompt(description="Ask for a greeting in a given tone")
#|fn greeting(name : String, tone? : String) -> String {
#| match tone {
#| Some(t) => "Greet \{name} in a \{t} tone."
#| None => "Greet \{name}."
#| }
#|}
///|
/// What moonmcp's generator writes for `mcp_tools_src`, so a fresh project
/// compiles before its first build regenerates it.
let mcp_generated_src : String =
#|// Generated by moonmcp from tools.mbt. Edit those files, not this one.
#|
#|///|
#|pub fn register(mcp : @moonmcp.Server) -> Unit {
#| mcp.tool(
#| "hello",
#| args => ToJson::to_json(hello(@moonmcp.decode(args, "name"))),
#| input_schema={
#| "type": "object",
#| "properties": { "name": { "type": "string" } },
#| "required": ["name"],
#| },
#| description="Say hello to someone",
#| )
#| mcp.resource(
#| "about://server",
#| () => about(),
#| description="What this server is",
#| )
#| mcp.prompt(
#| "greeting",
#| args => {
#| greeting(
#| @moonmcp.decode(args, "name"),
#| tone?=@moonmcp.decode_option(args, "tone"),
#| )
#| },
#| description="Ask for a greeting in a given tone",
#| arguments=[
#| { name: "name", title: None, description: None, required: Some(true) },
#| { name: "tone", title: None, description: None, required: Some(false) },
#| ],
#| )
#|}
///|
let mcp_test_src : String =
#|///|
#|async test "hello answers a tools/call" {
#| let mcp = @moonmcp.Server::new("test")
#| @tools.register(mcp)
#| let call : Json = {
#| "jsonrpc": "2.0",
#| "id": 1,
#| "method": "tools/call",
#| "params": {
#| "name": "hello",
#| "arguments": { "name": "moonbit" },
#| "_meta": {
#| "io.modelcontextprotocol/protocolVersion": "2026-07-28",
#| "io.modelcontextprotocol/clientCapabilities": {},
#| },
#| },
#| }
#| guard mcp.handle(call)
#| is Some({ "result": { "content": [{ "text": String(text), .. }], .. }, .. }) else {
#| fail("no text in the reply")
#| }
#| assert_eq(text, "hello, moonbit")
#|}
///|
fn mcp_main(module_ : String) -> String {
let src =
#|///|
#|/// The %svc MCP server: over Streamable HTTP on mooncat, or over stdio when
#|/// started with `--stdio` by a client that runs it as a subprocess.
#|async fn main {
#| let svc = @svc.ServiceContext::new(@config.Config::new())
#| let mcp = @moonmcp.Server::new("%svc", version="0.1.0")
#| @tools.register(mcp)
#| if @env.args().contains("--stdio") {
#| mcp.run_stdio()
#| } else {
#| let cfg = svc.config
#| println("%svc serving MCP on http://" + cfg.host + ":" + cfg.port.to_string() + "/mcp")
#| @mooncat.serve(mcp.to_asgi(), host=cfg.host, port=cfg.port)
#| }
#|}
src.replace_all(old="%svc", new=module_) + "\n"
}
///|
/// The MCP server tree for module `module_` under `dir`: the shared module,
/// configuration and context, `internal/tools` with the annotated functions,
/// their generated registration and a test, and the build rule that keeps the
/// registration in step with the functions.
pub fn generate_mcp_tree(
module_ : String,
style? : @style.Style,
dir~ : String = "",
) -> Array[TreeFile] {
let st = style.unwrap_or(@style.Style::gozero())
let tools_pkg = module_ + "/internal/tools"
let mcp = ("moonbitstack/moonmcp", "")
let out = base_tree(
module_,
st,
dir,
deps=["moonbitstack/moonmcp", "moonbitstack/mooncat"],
imports=[
mcp,
("moonbitstack/mooncat", ""),
("moonbitlang/core/env", ""),
(tools_pkg, ""),
],
main=mcp_main(module_),
).map(f => {
if f.path == at(dir, "moon.mod") {
{
..f,
content: f.content +
"\nrule(name: \"mcp\", command: \"moonx moonbitstack/moonmcp/cmd/mcp@" +
pins.get("moonbitstack/moonmcp").unwrap() +
" -C \\\"$mod_dir\\\" \\\"$input\\\" -o \\\"$output\\\"\")\n",
}
} else {
f
}
})
let tests = pkg_file([("moonbitlang/async", "")]).replace(
old="}\n",
new="} for \"test\"\n",
)
out.push({
path: at(dir, "internal/tools/moon.pkg"),
content: pkg_file([mcp]) +
"\n" +
tests +
"\ndev_build(rule: \"mcp\", input: \"tools.mbt\", output: \"tools.g.mbt\")\n\nformatter(ignore: [ \"tools.g.mbt\" ])\n",
regen: Once,
})
out.push({
path: at(dir, "internal/tools/tools.mbt"),
content: mcp_tools_src + "\n",
regen: Once,
})
out.push({
path: at(dir, "internal/tools/tools.g.mbt"),
content: mcp_generated_src + "\n",
regen: Once,
})
out.push({
path: at(dir, "internal/tools/tools_test.mbt"),
content: mcp_test_src + "\n",
regen: Once,
})
out
}
///|
/// Scaffold an MCP server under `/`: annotated tools, a resource and a
/// prompt, a test calling the tool, and an entry point serving them over
/// Streamable HTTP or, with `--stdio`, over stdio.
pub fn scaffold_mcp(name : String) -> Array[@plugin.GenFile] {
let files = tree_plan(generate_mcp_tree(name, dir=name), _ => false)
let readme_src =
#|# %name
#|
#|An MCP server scaffolded by mctl.
#|
#|```bash
#|moon test --target native # the tests
#|moon run --target native . # serve on http://0.0.0.0:12000/mcp
#|moon run --target native . -- --stdio # serve over stdio
#|```
#|
#|Tools, resources and prompts are the annotated functions in
#|`internal/tools/tools.mbt`; `tools.g.mbt` is regenerated from them on every
#|build.
files.push({
path: name + "/README.md",
content: readme_src.replace_all(old="%name", new=name) + "\n",
})
files
}