MCP
Serve a Crust CLI as MCP tools over stdio
@crustjs/mcp turns the Command Snapshot into an MCP tool manifest and routes every tool call through typed app.run(). No per-command glue: the same definitions that drive parsing, help, and completion drive the JSON Schema each tool advertises.
Install
npm install @crustjs/mcpThe package has a peer dependency on @crustjs/core and depends on @modelcontextprotocol/sdk. Only the stdio transport is supported.
mcpExtension(options)
import { type AnyCrust, Crust, defineCommand } from "@crustjs/core";
import { mcpExtension } from "@crustjs/mcp";
const deploy = defineCommand("deploy", { description: "Deploy the app" }, (command) =>
command
.args({ name: "target", type: "string", required: true, choices: ["staging", "prod"] })
.flags({ name: "dry-run", type: "boolean" })
.action(({ args, flags }) => ({ target: args.target, dryRun: flags["dry-run"] === true })),
);
// `app` is read when `mcp` runs, so the callback may return the variable being
// assigned; the return annotation breaks the type-inference cycle.
export const app = new Crust("my-cli", { description: "Manage deployments", version: "1.2.3" })
.add(deploy)
.add(defineCommand("wipe", { description: "Delete everything" }, (c) => c.action(() => {})))
.extend(mcpExtension({ app: (): AnyCrust => app, exclude: [["wipe"]] }));mcpExtension() owns two commands:
<cli> mcpserves the application over stdio until the client disconnects. Stdout carries protocol frames, so the Command Action writes nothing else to it; command output is captured per call instead.<cli> mcp config [--client claude|cursor|vscode]prints the client configuration snippet on stdout, and for Claude also prints aclaude mcp addone-liner on stderr.
app is a callback because the Extension is installed while the application is still being assembled; it runs when mcp runs. Returning the variable being assigned needs an explicit (): AnyCrust => return annotation, or TypeScript reports the variable as referenced in its own initializer. mcpExtension.id is the Extension identity.
Prop
Type
Client configuration
mcp config derives the launch command from the running process. A compiled executable launches itself with args: ["mcp"]; a Node or Bun source entry or Node bundle relaunches through process.execPath with process.execArgv before the entry path. Claude Code, Claude Desktop, and Cursor read mcpServers; VS Code's .vscode/mcp.json reads servers.
Set launch: { command, args } to override it; args must include mcp.
- Deno source entries need the override, with
runand their permission flags, because Deno does not expose them throughprocess.execArgv. - Use it to drop development-only flags such as inspectors or watch mode.
- The working directory and environment such as
NODE_OPTIONSare not preserved. Use absolute paths and configure the client's environment separately.
{
"mcpServers": {
"my-cli": { "type": "stdio", "command": "/usr/local/bin/my-cli", "args": ["mcp"] }
}
}Or run: claude mcp add my-cli -- /usr/local/bin/my-cli mcpNothing is written into client configuration files; paste the snippet or run the one-liner.
Tools
A command becomes a tool when it is visible (not hidden, nor under a hidden command), has a Command Action, is not the mcp command, and is not under an exclude path. The root is a tool only when it has an action.
The tool name is the canonical command path joined with _ (deploy api becomes deploy_api); the root uses its own name, and aliases never become tools. That join is not injective, so toolsFromSnapshot throws when two commands map to one name (a_b and a b, or the root and a subcommand named after it), naming both paths. It also throws when a name falls outside the MCP tool-name rule (1–128 characters of A-Z a-z 0-9 _ - .).
Each tool's inputSchema is one flat object built from the snapshot:
| Definition | Schema |
|---|---|
type: "string" / "path" | { type: "string" } |
type: "number" / "boolean" | { type: "number" } / { type: "boolean" } |
type: "url" | { type: "string", format: "uri" }; the string becomes a URL before run() |
type: "json" | {} (any JSON) |
choices | { type: "string", enum } |
variadic / multiple | { type: "array", items } |
required without default | listed in required |
Standard Schema or custom parse | The declared token type (string for schema args); the command validates and transforms when it runs |
| every tool | raw: { type: "array", items: { type: "string" } }, delivered to the action as rawArgs |
Snapshots carry no schemas or parse functions, so schema-owned requiredness is invisible: schema-backed definitions are advertised as optional, while a custom parse definition keeps its declared required. An arg and a flag with the same name, or a definition named raw, are rejected. Defaults are advertised as JSON copies; unserializable defaults are omitted from the manifest but still apply when the command runs.
Results
The outcome of app.run() decides the tool result:
- A
completedresult that is plain JSON (finite numbers, strings, booleans,null, arrays, and plain objects of those) is returned asstructuredContentplus a text block with the same JSON. Arrays and primitives are wrapped as{ "result": value }. - Any other
completedresult (undefined,Date,Map, class instances,BigInt,NaN, cycles) and everyhandledoutcome return the captured stdout as text. The result is copied once before serialization, so getters run once andtoJSONis not called. - A
failedoutcome setsisErrorwith<code>: <message>for aCrustError,<name>: <message>for other errors.
Unknown tool names are protocol errors (InvalidParams), which is also how hidden and excluded commands present.
Headless primitives
import { createMcpServer, serveStdio } from "@crustjs/mcp";
const server = await createMcpServer(app, { exclude: [["wipe"]] });
await serveStdio(server);createMcpServer(app, options?) returns the SDK's low-level Server with tools/list and tools/call handlers, named after the root command with its version (or 0.0.0). Each call runs app.run(path, input, { signal }) with its own captured output. Client cancellation aborts only that call and disconnecting aborts all of them; actions must honor signal to stop. serveStdio(server) connects the stdio transport and resolves when the client disconnects.
Over stdio, protocol frames own the process's stdout. Output written through the invocation's
stdout / stderr is captured per call, but direct console.log or process.stdout.write calls
corrupt the protocol stream. Keep them out of code that runs under mcp, or write to stderr.
Prop
Type
toolsFromSnapshot(snapshot, options?)
import { toolsFromSnapshot } from "@crustjs/mcp";
// Exclusions are per manifest or server, not part of the snapshot: without this
// option `wipe` would be listed too.
const tools = toolsFromSnapshot(await app.snapshot(), { exclude: [["wipe"]] });
console.log(tools.map((tool) => tool.name)); // => ["deploy"]
console.log(tools[0]?.inputSchema);
// => {
// type: "object",
// properties: {
// target: { type: "string", enum: ["staging", "prod"] },
// "dry-run": { type: "boolean" },
// raw: { type: "array", items: { type: "string" }, description: "Passthrough values, as if written after `--`" },
// },
// required: ["target"],
// }The pure manifest, for tests or tooling that ships tool definitions without a server. Pass await app.snapshot() so Extension-contributed commands are included. Each entry carries the tool name, description, inputSchema, and the canonical path for app.run().
Prop
Type