paparam-mcp
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
{ "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
npm install paparam-mcpUse
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:
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 CLIRegister it with a host
claude mcp add orbital -- bare ./cli.js --mcpOr in a config file:
{ "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:
npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server orbital --method tools/listWhat gets generated
From the definition above:
{
"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 |
| one typed tool per command |
> 25 |
|
|
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
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:
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/.errorare captured for the duration of a call and returned as the tool's result — which is where a model can actually see them.warnanderrorare also mirrored to real stderr.A runner that writes directly to
Bare.stdio.outorprocess.stdoutbypasses that and will corrupt the stream. UseonCallfor 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:
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:
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 — a mission-control CLI
wiring all of the above.
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 serverTests
npm test # both runtimes