Skip to main content
Glama
ryanramage

paparam-mcp

by ryanramage

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

Use

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 CLI

Register it with a host

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

Or 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/list

What 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

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

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 / .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:

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 server

Tests

npm test          # both runtimes