# MCP server

> This feature is experimental.

ArgsBarg can expose your CLI to AI agents through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Each **leaf command** becomes an MCP tool; the full command tree is available as a schema resource. The server speaks JSON-RPC over stdio — one JSON object per line on stdin and stdout.

MCP is **opt-in**. Apps that do not set `mcpServer` on the program root behave exactly as before.

## Quick start

1. Add `mcpServer` to your program root:

```typescript
import pkg from "../package.json" with { type: "json" };

const cli = {
  key: "myapp",
  version: pkg.version,
  description: "My app.",
  mcpServer: { enabled: true },
  commands: [/* ... */],
} satisfies CliProgram;
```

`mcpServer: { enabled: true }` opts in. Omit `mcpServer` entirely to disable MCP. Empty `mcpServer: {}` is rejected at validation.

2. Run the MCP server:

```bash
myapp mcp
```

The process reads NDJSON requests from stdin and writes NDJSON responses to stdout. It stays alive until stdin closes.

3. Point your MCP client at that command. See [Client setup](#client-setup).

Optionally install an agent skill for discovery without MCP: see [docs/ai-skills.md](ai-skills.md).

The `examples/nested.ts` demo enables MCP — try:

```bash
bun run examples/nested.ts mcp
```

## Client setup

### `.agents` auto-install

When `mcpServer.enabled` is set, `configure install` merges a `mcpServers` entry into `~/.agents/mcp.json` per the https://dotagentsprotocol.com:

```bash
myapp configure install
```

### Manual client setup

Many clients do not read `~/.agents/mcp.json` yet. Copy the `mcpServers` entry from that file, or add:

```json
{
  "mcpServers": {
    "myapp": {
      "command": "myapp",
      "args": ["mcp"]
    }
  }
}
```

| Client | Config file |
| --- | --- |
| **Cursor** | `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project) |
| **Claude Code** | `~/.claude.json` under `mcpServers`, or project `.mcp.json` |
| **Claude Desktop** | See platform paths below |

Restart Cursor or reload MCP after editing. Restart Claude Desktop after config changes.

**Claude Desktop** config paths:

| Platform | Path |
| --- | --- |
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
| Linux | `~/.config/Claude/claude_desktop_config.json` |

You can also install a **`.mcpb`** bundle via **`mcp bundle`** (see [MCP Bundle](#mcp-bundle-mcp-bundle)).

### Other MCP hosts

Copy the `mcpServers` entry from `~/.agents/mcp.json` into the host's native MCP config. Any host that spawns a subprocess and wires stdin/stdout works the same way: the **command** is your app, and **`mcp`** starts the server.

## Configuration

Set `mcpServer` on the **program root only** (the `CliProgram` passed to `new Cli(program)`). Validation rejects `mcpServer` on nested nodes.

| Field | Default | Purpose |
| --- | --- | --- |
| `enabled` | *(required)* | Must be `true` when `mcpServer` is set |
| `schemaResourceUri` | `<sanitized root key>://schema` | URI for the built-in schema resource |
| `shellEnv` | on (opt-out with `false`) | Capture login-shell `env` at startup (`true` uses `$SHELL`, or pass a shell path) |
| `resources` | `[]` | Custom `CliMcpResource` entries (additive; schema resource is always included) |

MCP `serverInfo.name` and the default schema URI use the sanitized program `key` (non-alphanumeric characters become `_`). Program `version` comes from `CliProgram.version` (also used by the `version` built-in).

Example with optional fields:

```typescript
mcpServer: {
  enabled: true,
  shellEnv: false, // opt out of login-shell capture
}
```

## Tools

Every **user-defined leaf command** in your schema becomes one MCP tool. Built-ins (`completion`, `version`, `install`, `mcp`) are not exposed as tools.

### Tool names

Tool names are derived from the command path, with each segment sanitized (non-alphanumeric characters become `_`) and joined with `_`.

| CLI invocation | Tool name |
| --- | --- |
| `myapp deploy` | `deploy` |
| `myapp stat owner lookup` | `stat_owner_lookup` |
| `nested.ts read` | `read` |

### Tool descriptions

Each tool’s `description` includes the human CLI path and the leaf’s help text, separated by an em dash. Leaf **`notes`** are appended after a blank line (with `{argsbarg:program}` resolved). Tool arguments are defined in `inputSchema` (options and positionals with their descriptions).

| CLI path | MCP `description` (example) |
| --- | --- |
| `stat owner lookup` | `stat owner lookup — Resolve owner info.` |
| `read` | `read — Print the first line of each file.` |
| (root leaf app) | `{root.key} — Tiny demo.` |

### Per-leaf visibility

Set `mcpTool: { enabled: false }` on a **leaf command** to hide it from `tools/list` while keeping it in the CLI and in `docs cli-schema` output:

```typescript
{
  key: "debug",
  description: "Internal diagnostics.",
  mcpTool: { enabled: false },
  handler: () => { /* ... */ },
}
```

Omitted or `enabled: true` exposes the command (default). `mcpTool` is only valid on leaves — not on the program root or routing groups.

**Prefer fixing schema and handlers over `mcpTool` overrides** — standard option names (`yes`, `dry-run`, `json`), headless paths, and clear descriptions usually make MCP work without per-leaf config. See [cli-program.md](cli-program.md).

### Per-leaf tool metadata

```typescript
mcpTool: {
  enabled: true,
  description: "Custom tools/list text (overrides auto-generated path + help).",
}
```

Set **`outputSchema` on the leaf** (not under `mcpTool`) — see [cli-program.md — Structured stdout](cli-program.md#structured-stdout).

- **`description`** — when set, replaces the auto-generated `path — help` description entirely.

### Tool arguments

Each tool’s `inputSchema` is a JSON Schema object built from your CLI definition:

- **Options** — leaf-local flags only (declare on the command that uses them). Presence options are `boolean`; string, number, and **enum** options match their `CliOptionKind` (`Enum` uses JSON Schema `enum`). Required options are listed in `required`. `json`, `yes`, and `verbose` are omitted from MCP wire schemas (the framework handles them on invoke; mutating tools auto-receive `--yes`).
- **Positionals** — one property per `CliPositional` on the leaf. Single-slot positionals are `string`; varargs tails (`argMax: 0`) are `string[]`. Required positionals are listed in `required`. **Varargs must be a JSON array** — comma-separated strings are not accepted (use `format: comma-list` on an option when a single flag should accept `"a,b"` or `["a","b"]`).

Arguments are a **flat JSON object** keyed by option and positional names (same names as in your schema, including hyphenated option names like `"user-name"`).

Example for `nested.ts stat owner lookup`:

```json
{
  "path": "/path/to/file",
  "user-name": "alice",
  "json": true
}
```

This maps to argv: `stat owner lookup --json --user-name alice /path/to/file`.

Tool arguments use **long option names** only (`user-name`, not `-u`). Short aliases from your schema are not accepted in MCP tool calls.

### Tool results

On success (`isError: false`):

- **stdout** — first `content` text block with the handler’s captured stdout (raw, unchanged).
- **stderr** — when non-empty, a second `content` text block with trimmed stderr (no prefix). The block’s position signals stderr; hosts may label it themselves.
- **structuredContent** — when trimmed stdout is valid JSON, the parsed value is also returned per the [MCP tools spec](https://modelcontextprotocol.io/specification/draft/server/tools). Objects and arrays from flags like `--json` are the common case. JSON **primitives** (`true`, `42`, `"hello"`) are parsed too — a handler that prints the literal string `true` as human text would get `structuredContent: true`. Prefer objects for machine-readable output.

On failure (parse error, validation error, non-zero exit, thrown error), the message is returned as text content with `isError: true`. Handler stderr is included when present.

Help and `docs cli-schema` are not available through tool calls; use the schema resource or run the CLI directly for those.

## Schema and custom resources

The built-in schema resource (default URI `<sanitized-key>://schema`, e.g. `nested.ts` → `nested_ts://schema`) exposes your full CLI tree as JSON — the same output as `myapp docs cli-schema`. Override with `schemaResourceUri` if needed.

| Property | Value |
| --- | --- |
| Default URI | `<sanitized root key>://schema` |
| MIME type | `application/json` |
| Contents | `cliSchemaJson(root)` — handlers omitted, built-ins excluded |

### Auto docs topic resources

When docs is enabled (default) and **`mcpServer.enabled`** is true, each user key in **`docs.topics`** is also exposed as an MCP resource:

| Property | Value |
| --- | --- |
| URI | `<sanitized root key>://docs/<topicKey>` (e.g. `myapp://docs/readme`) |
| MIME type | `text/markdown` |
| Contents | Same body as `myapp docs <topicKey>` |

Built-in docs subcommands (`schema`, `api`, `skill`, `mcp`) are **not** auto-exposed — use the schema resource, `configure`, or CLI `docs` instead. `docs` subcommands remain hidden from MCP `tools/list`.

Custom `mcpServer.resources` URIs must not collide with the schema URI or any auto docs topic URI (validated at program compile time).

Add custom resources on the program root:

```typescript
mcpServer: {
  enabled: true,
  resources: [
    {
      uri: "myapp://config",
      name: "config",
      description: "Resolved app configuration.",
      mimeType: "application/json",
      load: () => JSON.stringify({ /* … */ }),
    },
  ],
},
```

URIs must be unique and must not equal `schemaResourceUri` or any auto docs topic URI (`<mcpId>://docs/<topicKey>`). `load()` runs synchronously at `resources/read` time.

## Invocation context

Handlers receive `ctx.invocation`: `"cli"` for normal `Cli.run()` dispatch, `"mcp"` for MCP `tools/call`.

MCP is always non-interactive. Commands that can mount Ink or prompts should implement a **headless fast path** (same path as non-TTY CLI with `--yes` / `--json`) — see [cli-program.md — Headless-capable handlers](cli-program.md#headless-capable-handlers).

Use `ctx.invocation` to branch subprocess behavior — MCP stdout is the JSON-RPC wire, so child processes must not inherit it:

```typescript
handler: async (ctx) => {
  const proc = Bun.spawn(["my-tool", ...ctx.args], {
    stdout: ctx.invocation === "mcp" ? "pipe" : "inherit",
    stderr: "inherit",
  });
  // capture proc.stdout when piping…
};
```

`Bun.spawn({ stdout: "inherit" })` under MCP corrupts the wire. Prefer `"pipe"` and let argsbarg return captured handler stdout in the tool result.

### `Cli.invoke` (public API)

`new Cli(root).invoke(argv)` runs a leaf handler without exiting the process — useful for tests and headless integrations. Returns `{ kind, exitCode, stdout, stderr }`. MCP tool dispatch uses this internally.

**Note:** Tool output is buffered until the handler completes. Live streaming (e.g. `tail -f`) is not supported yet; see [Design notes](#design-notes).

## Environment bootstrapping

MCP hosts (e.g. Cursor) often spawn your server with a minimal environment — missing `PATH` entries for Homebrew, nvm, rbenv, etc.

At server start (`Cli.serveMcp()`), before the NDJSON loop:

| Order | Source | Behavior |
| --- | --- | --- |
| 1 | `shellEnv` | Spawns `$SHELL -l -c env`; merges into `process.env` |
| 2 | App config file | Loads `program.appConfig` keys from flat JSON when unset in host env |

**`shellEnv` merge rules:**

- **`PATH`** — shell-only segments are **prepended** to the host `PATH` (always merged).
- **Other vars** — set only when absent from the host environment (host wins).
- On failure — one-line warning on **stderr**; server continues.

**App config (`program.appConfig`):**

- Default path: `~/.local/lib/<sanitized-key>/config`.
- JSON shape: flat object keyed by schema names — `{ "apiToken": "…" }`. Unknown keys rejected on load.
- Loaded at MCP startup; host `process.env` wins for mapped env vars already set.
- Missing required config does **not** exit the MCP server — enforced at `tools/call` with a helpful error.
- Configure interactively: `myapp configure` (see [configure.md](configure.md)).
- Built-in `configure get` / `configure set` when `program.appConfig.commands` is enabled (default). Hosts inject `user_config` → env at spawn; they never write the argsbarg config file.

Example:

```typescript
appConfig: {
  entries: {
    apiToken: { description: "Create at https://example.com/settings/tokens", env: "API_TOKEN", sensitive: true },
  },
},
mcpServer: {
  enabled: true,
},
```

## Protocol

- **Transport:** stdio, newline-delimited JSON (NDJSON).
- **JSON-RPC:** version `2.0`.
- **MCP protocol version:** `2024-11-05` (reported in `initialize`).

### Supported methods

| Method | Description |
| --- | --- |
| `initialize` | Returns capabilities (`tools`, `resources`) and `serverInfo`. |
| `notifications/initialized` | Acknowledged; no response (notification). |
| `ping` | Returns `{}`. |
| `tools/list` | Lists all tools with `name`, `description`, `inputSchema`, and optional `outputSchema`. |
| `tools/call` | Runs a leaf handler; params: `name`, `arguments` (object). |
| `resources/list` | Lists schema + custom resources. |
| `resources/read` | Returns resource body; params: `uri`. |

Requests without an `id` are treated as notifications and do not receive a response (except `notifications/initialized`, which is ignored after parsing).

### Manual smoke test

```bash
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | bun run examples/nested.ts mcp
```

You should get one JSON line on stdout with `result.capabilities` and `result.serverInfo`.

## MCP Bundle (`mcp bundle`)

When `mcpServer.enabled` is true, **`mcp bundle`** writes dist artifacts you opt into on the program root:

```bash
just build
./dist/myapp mcp bundle
# → dist/myapp.mcpb               (when mcpServer.mcpd: true)
# → dist/claude-plugin/myapp.zip  (when mcpServer.claudePlugin: true)
# → dist/cursor-plugin/myapp.zip  (when mcpServer.cursorPlugin: true)
```

Enable any combination of packaging flags:

```typescript
mcpServer: {
  enabled: true,
  mcpd: true,           // Claude Desktop `.mcpb`
  claudePlugin: true,   // Claude Code plugin zip
  cursorPlugin: true,   // Cursor plugin zip
},
```

Expects the compiled binary at **`dist/<program.key>`**. Stdout prints one path per artifact produced.

| Output | Purpose |
| --- | --- |
| **`dist/<key>.mcpb`** | Claude Desktop MCP Bundle — when `mcpd: true` (default **false**) |
| **`dist/claude-plugin/<name>.zip`** | Claude Code plugin zip — when `claudePlugin: true` (default **false**) |
| **`dist/cursor-plugin/<name>.zip`** | Cursor plugin zip — when `cursorPlugin: true` (default **false**) |

Manifest metadata is generated from your schema (`mcpServerId`, tools, `program.appConfig` user config for env-mapped entries). Optional pack-time fields live under **`mcpServer.bundle`** (`author`, `displayName`, `homepage`, `icon`, `license`, `longDescription`, `repository`, `skillsDir`).

**Claude Code plugin zip layout** (paths at archive root):

```
.claude-plugin/plugin.json   # includes "mcpServers": ".mcp.json"
.mcp.json
bin/myapp                    # executable (0755 preserved in the zip)
skills/<dirName>/...
```

**Cursor plugin zip layout** (paths at archive root):

```
.cursor-plugin/plugin.json   # Cursor plugin manifest
mcp.json                     # includes mcpServers with ${CURSOR_PLUGIN_ROOT}
bin/myapp                    # executable (0755 preserved in the zip)
skills/<dirName>/...
```

`plugin.json` and `mcp.json` configure Cursor and Claude to load the bundled MCP server when the plugin is enabled. The plugin zip preserves the executable bit on `bin/<key>`.

If the repository has a skill directory under `skills/<dirName>/` (or `mcpServer.bundle.skillsDir`), the plugin bundles that repository skill. Otherwise, it falls back to a generated **MCP routing stub** telling the agent to use the plugin's MCP toolset.

Load Claude plugin locally with `claude --plugin-dir ./dist/claude-plugin/myapp.zip`.
Unpack Cursor plugin locally into `~/.cursor/plugins/local/<name>`.

Bare **`myapp mcp`** still runs the stdio MCP server (unchanged for `configure` MCP targets and MCP hosts). Use **`configure install`** for Cursor, Claude Code, Claude Desktop, and OpenCode JSON config.

## Hidden commands and options

Set **`hidden: true`** on a command or option to omit it from help listings, `docs cli-schema` / `docs cli`, shell completions, and MCP `tools/list` / tool `inputSchema`. Hidden commands remain invocable; **`myapp hidden-cmd -h`** still works.

## Reserved names

When MCP is enabled:

- Do not declare top-level commands named **`completion`** or **`mcp`** — reserved for platform builtins.

Running `myapp mcp` without `mcpServer` on the root fails with an error (exit 1).

## Design notes

- **Zero extra dependencies** — hand-rolled NDJSON JSON-RPC on top of ArgsBarg’s existing parser and schema.
- **Same handlers** — tool calls run your real leaf handlers via an internal invoke path that captures stdout/stderr and does not exit the process, so the MCP server can handle many requests in one process.
- **User schema only** — tool dispatch uses your program root, not merged presentation builtins.
- **Buffered output** — MCP tool results are sent after the handler finishes. Incremental stdout (log tail, progress) is not streamed; a future release may add MCP progress notifications.

For the `docs cli-schema` export used by the resource, see [docs/bundled-docs.md](bundled-docs.md).
