Crust logoCrust

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/mcp

The 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> mcp serves 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 a claude mcp add one-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 run and their permission flags, because Deno does not expose them through process.execArgv.
  • Use it to drop development-only flags such as inspectors or watch mode.
  • The working directory and environment such as NODE_OPTIONS are 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 mcp

Nothing 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:

DefinitionSchema
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 defaultlisted in required
Standard Schema or custom parseThe declared token type (string for schema args); the command validates and transforms when it runs
every toolraw: { 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 completed result that is plain JSON (finite numbers, strings, booleans, null, arrays, and plain objects of those) is returned as structuredContent plus a text block with the same JSON. Arrays and primitives are wrapped as { "result": value }.
  • Any other completed result (undefined, Date, Map, class instances, BigInt, NaN, cycles) and every handled outcome return the captured stdout as text. The result is copied once before serialization, so getters run once and toJSON is not called.
  • A failed outcome sets isError with <code>: <message> for a CrustError, <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

On this page