Skip to main content
Glama
ryanramage

paparam-mcp

by ryanramage
README.md
# paparam-mcp

**Expose any [paparam] CLI as an MCP server.**

A CLI built with paparam already describes its whole surface precisely: every
flag's type, every choice, every default, every required argument. That is
exactly what an MCP tool definition needs — so the tool list is **derived**, not
hand-written, and a model calls

```jsonc
{ "name": "launch", "arguments": { "vehicle": "MRDN-7", "mode": "orbit" } }
```

against a real schema instead of guessing at an argv string.

Every call is validated by **your own command** (`parse` with `run: false`)
before anything executes, so a bad call comes back as an error the model can read
and correct — not a half-run command.

Built on [`paparam-reflect`], which is its only dependency. Runs on [Bare] and on
Node.

## Install

```sh
npm install paparam-mcp
```

## Use

```js
const { command, flag, arg } = require('paparam')
const { isMcpMode, runMcp } = require('paparam-mcp')

const cmd = command(
  'orbital',
  command(
    'launch',
    arg('<vehicle>', 'hull id'),
    flag('--mode|-m [mode]', 'flight profile').choices(['orbit', 'suborbital']),
    flag('--crewed', 'fly with crew aboard').default(true),
    (c) => {
      /* your real runner */
    }
  )
)

if (isMcpMode(Bare.argv)) await runMcp(cmd, { name: 'orbital', version: '1.0.0' })
else cmd.parse(Bare.argv.slice(2))
```

That's the whole integration. `--mcp` is one more branch on the same ladder
[`bare-tui-paparam`] already establishes:

```js
if (isMcpMode(Bare.argv))
  await runMcp(cmd, opts) // an agent host drives it
else if (isMenuMode(Bare.argv))
  await runMenu(cmd) // a human picks from a menu
else cmd.parse(Bare.argv.slice(2)) // the normal CLI
```

### Register it with a host

```sh
claude mcp add orbital -- bare ./cli.js --mcp
```

Or in a config file:

```json
{ "mcpServers": { "orbital": { "command": "bare", "args": ["./cli.js", "--mcp"] } } }
```

### Try it with the Inspector

Pass the server through a config file — the Inspector's own argument parser will
otherwise swallow your `--mcp`:

```sh
npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server orbital --method tools/list
```

## What gets generated

From the definition above:

```jsonc
{
  "name": "launch",
  "title": "launch",
  "description": "launch a vehicle\n\nRuns: launch <vehicle>",
  "inputSchema": {
    "type": "object",
    "properties": {
      "vehicle": { "type": "string", "description": "hull id" },
      "mode": {
        "type": "string",
        "description": "flight profile",
        "enum": ["orbit", "suborbital"]
      },
      "crewed": { "type": "boolean", "description": "fly with crew aboard", "default": true }
    },
    "required": ["vehicle"],
    "additionalProperties": false
  },
  "annotations": {}
}
```

Choices become `enum`. `.default()` becomes `default`. Required positionals
become `required`. Hidden flags never appear. `.hide()` on a command keeps it out
entirely — it is the zero-config way to keep something away from the model.

**Resources** are served too: `paparam://help` (the full reference),
`paparam://help/<command>` (one command's help) and `paparam://catalog.json`. A
host can read the manual without spending a tool call.

**A prompt**, `use_<name>`, seeds a conversation with the whole command reference
plus your `supplement`.

## How many tools?

A host has to fit every tool from every connected server into the model's
context, so a 60-subcommand CLI that insists on 60 tools is a bad neighbour.
Two shapes, picked automatically:

| commands | mode    | tools                                              |
| -------- | ------- | -------------------------------------------------- |
| ≤ 25     | `tools` | one typed tool per command                         |
| \> 25    | `meta`  | `list_commands`, `describe_command`, `run_command` |

Per-command is much better for the model — real enums, real defaults, no
discovery round-trips — so it is the default wherever it fits. Meta mode costs
two extra calls but keeps the tool list at three. Execution goes through the
identical path either way.

Override with `mode: 'tools' | 'meta' | 'auto'` and `threshold`.

## Options

```js
runMcp(cmd, {
  name: 'orbital', // server identity reported to the host
  version: '1.0.0',
  instructions: '…', // guidance for the model, in the discovery response
  supplement: '…', // extra grounding folded into the prompt

  mode: 'auto', // 'tools' | 'meta' | 'auto'
  threshold: 25, // where 'auto' switches
  prefix: 'orbital', // prepended to every tool name

  include: ['launch'], // strict allowlist, by command label
  exclude: ['scuttle'], // or a denylist
  expose: (item) => true, // or a predicate

  annotations: {
    // per-command behaviour hints, by label
    scuttle: { destructiveHint: true }
  },

  validateSubmission, // (result, { argv, command }) => error|null
  onCall, // (result, { argv, item }) => any — runs INSTEAD of parse
  dryRun: false // validate and report argv, never run
})
```

### Annotations

`readOnlyHint` / `idempotentHint` are set automatically for commands whose leaf
verb is `list`, `show`, `status`, `get`, … and `destructiveHint` for `delete`,
`reset`, `purge`, `scuttle`-like verbs. Anything unrecognised is left
**unannotated on purpose**: MCP already treats a tool with no `readOnlyHint` as
potentially destructive, which is the safe reading. Your `annotations` map always
wins.

### validateSubmission

Runs after paparam accepts the argv and before anything executes. Return a string
to reject; the model sees it and can retry.

`result` is what `parse()` returns — the deepest matched `Command` — so read
`result.name`, `result.args` and `result.flags` directly:

```js
validateSubmission: (result) => {
  if (result.name === 'launch' && result.flags.crewed !== false && !result.args.window) {
    return 'a crewed launch needs an explicit window'
  }
  return null
}
```

### onCall

By default the server runs `command.parse(argv)` and returns whatever the runner
printed. Supply `onCall` to take over — return a string for text, or an object to
become `structuredContent`. This is the right hook when a command should return
structured data rather than terminal output.

## Writing runners that behave as MCP tools

**On stdio, stdout _is_ the JSON-RPC wire.** Anything a runner prints to it would
corrupt the protocol stream. So:

- `console.log` / `.info` / `.warn` / `.error` are **captured** for the duration
  of a call and returned as the tool's result — which is where a model can
  actually see them. `warn` and `error` are also mirrored to real stderr.
- A runner that writes **directly** to `Bare.stdio.out` or `process.stdout`
  bypasses that and will corrupt the stream. Use `onCall` for those.
- `Bare.exit()` inside a runner takes the server down with it.
- Calls are handled **one at a time**, because console capture swaps a global.

## Two protocol eras

MCP changed shape in revision `2026-07-28`: protocol version and client identity
moved into per-request `_meta`, `server/discover` became mandatory, and results
carry a `resultType`. Revisions up to `2025-11-25` instead open with an
`initialize` handshake.

This server answers **both**, which is what the spec's dual-era guidance
prescribes — a request carrying modern `_meta` is served statelessly under the
new rules, an `initialize` request selects legacy semantics. You don't have to
care which your host speaks.

## Testing your own server

`createMcpServer` is transport-free — `handle(message)` takes a parsed JSON-RPC
object and resolves to the response. No pipes, no subprocesses:

```js
const { createMcpServer } = require('paparam-mcp')
const server = createMcpServer(cmd, { name: 'orbital' })

const res = await server.handle({
  jsonrpc: '2.0',
  id: 1,
  method: 'tools/call',
  params: { name: 'launch', arguments: { vehicle: 'MRDN-7' } }
})
```

## When you don't want a server at all

Not every agent speaks MCP, and MCP tool definitions sit in the model's context
on **every** request. A paparam CLI is already installed and already
self-describing, so an agent can just run it — it only needs to know what
exists. [`paparam-reflect`] generates that reference from the same command
definition:

```js
const { isDocsMode, printAgentDocs } = require('paparam-reflect')

if (isDocsMode(Bare.argv)) printAgentDocs(cmd, { invocation: 'orbital' })
else if (isMcpMode(Bare.argv)) await runMcp(cmd, opts)
else cmd.parse(Bare.argv.slice(2))
```

On the example below that is 318 tokens against 565 for the equivalent
`tools/list`, or 106 at `--agent-docs=index` (where the agent drills into any one
command with the CLI's own `--help`). Same source of truth, different delivery —
pick per host, or ship both.

## Example

[`examples/orbital-mcp.js`](examples/orbital-mcp.js) — a mission-control CLI
wiring all of the above.

```sh
bare examples/orbital-mcp.js status --verbose   # the normal CLI
bare examples/orbital-mcp.js --agent-docs       # the markdown reference
bare examples/orbital-mcp.js --mcp              # the MCP server
```

## Tests

```sh
npm test          # both runtimes
```

[paparam]: https://github.com/holepunchto/paparam
[Bare]: https://bare.pears.com
[`paparam-reflect`]: https://github.com/holepunchto/paparam-reflect
[`bare-tui-paparam`]: https://github.com/holepunchto/bare-tui-paparam