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
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues