Labsco
rodaddy logo

mcp2cli

5

from rodaddy

CLI bridge that wraps MCP servers as bash-invokable commands, recovering ~11K tokens of context window per session https://github.com/rodaddy/mcp2cli

🔥🔥🔥🔥✓ VerifiedAccount requiredAdvanced setup

mcp2cli

CLI bridge that wraps MCP (Model Context Protocol) servers as bash-invokable commands. Instead of loading all MCP tool definitions into an LLM's system prompt (~13K+ tokens permanently), agents invoke tools via bash at zero context cost.

Inspired by Google Workspace CLI, which wrapped Google's complex APIs into simple CLI commands -- all the functionality, none of the hassle. mcp2cli does the same for MCP servers.

Commands

List Services

mcp2cli services

List Tools for a Service

mcp2cli <service> --help

Invoke a Tool

mcp2cli <service> <tool> --params '<json>'

The --params value must be valid JSON matching the tool's input schema.

Inspect Tool Schema

mcp2cli schema <service>.<tool>

Returns the JSON Schema for the tool's input parameters -- useful for discovering required fields.

Dry Run

mcp2cli <service> <tool> --params '{"query": "test"}' --dry-run

Validates input and shows what would be sent without executing the tool call.

Field Filtering

mcp2cli <service> <tool> --params '{}' --fields "id,name,status"

Extracts only the specified fields from the response -- reduces output noise for scripting.

Generate Skill Files

mcp2cli generate-skills <service>

Generates PAI skill files from MCP tool schemas, making tools discoverable by AI agents.

Daemon Management

mcp2cli daemon status    # check if daemon is running, connection pool stats
mcp2cli daemon stop      # graceful shutdown

Output Format

All responses are structured JSON on stdout. Logs go to stderr.

// Success
{ "success": true, "result": { "workflows": [...] } }

// Error
{ "error": true, "code": "TOOL_ERROR", "message": "Workflow not found", "reason": "..." }

This makes mcp2cli composable with jq, pipes, and scripting:

# Get workflow names
mcp2cli n8n n8n_list_workflows --params '{}' | jq '.result.workflows[].name'

# Check for errors
mcp2cli n8n n8n_get_workflow --params '{"id": "123"}' | jq 'if .error then .message else .result end'

Exit Codes

CodeMeaning
0Success
1Validation error (bad input, schema mismatch)
2Auth error (missing credentials, permission denied)
3Tool error (MCP tool returned an error)
4Connection error (daemon unreachable, transport failure)
5Internal error

Use exit codes for scripting:

mcp2cli n8n n8n_get_workflow --params '{"id": "123"}' 2>/dev/null
if [ $? -eq 4 ]; then
  echo "Connection failed -- is the MCP server configured?"
fi

Environment Variables

VariableDefaultDescription
MCP2CLI_LOG_LEVELsilentLog verbosity: silent, error, warn, info, debug
MCP2CLI_IDLE_TIMEOUT60Daemon idle timeout in seconds
MCP2CLI_STARTUP_TIMEOUT10000CLI wait time for daemon startup readiness in milliseconds
MCP2CLI_TOOL_TIMEOUT30000Tool call timeout in milliseconds
MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS60000CLI HTTP request timeout for explicit remote daemon calls in milliseconds
MCP2CLI_REMOTE_RETRIES3Remote request attempts for explicit remote daemon calls
MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS10000CLI HTTP timeout for each remote-local probe before falling back to the local daemon
MCP2CLI_REMOTE_FALLBACK_RETRIES1Remote probe attempts before remote-local calls fall back to the local daemon
MCP2CLI_POOL_MAX50Max concurrent MCP connections in the pool
MCP2CLI_LOG_DIR~/.cache/mcp2cli/logsDirectory for stderr capture logs
MCP2CLI_NO_DAEMON(unset)If set, bypass the daemon and connect directly
MCP2CLI_DEBUG(unset)If 1, print discarded stdout lines from MCP servers

Example:

MCP2CLI_LOG_LEVEL=debug mcp2cli n8n n8n_list_workflows --params '{}'
MCP2CLI_NO_DAEMON=1 mcp2cli n8n n8n_list_workflows --params '{}'

Architecture

CLI Entry (src/cli/index.ts)
  |-- Command Dispatch (services, schema, bootstrap, generate-skills, daemon)
  |-- Tool Call Handler -> Daemon Client (Unix socket)
  |                          \-- Daemon Server (src/daemon/server.ts)
  |                                |-- Connection Pool (src/daemon/pool.ts)
  |                                |     \-- MCP Transport (src/connection/transport.ts)
  |                                |-- Idle Timer (src/daemon/idle.ts)
  |                                \-- Health Endpoint (/health with memory stats)
  |-- Input Validation (src/validation/) -- 48 adversarial patterns
  |-- Schema Introspection (src/schema/)
  |-- Skill Generation (src/generation/)
  \-- Structured Logger (src/logger/) -- JSON on stderr

Key Design Decisions

Persistent daemon. MCP servers have a 2-5 second startup cost per connection. The daemon keeps connections alive in a pool, so subsequent calls return in milliseconds instead of seconds. The daemon auto-exits after the idle timeout (default 60s).

Connection pool with health checks. Connections are validated before use and recycled on failure. The pool enforces a max size to prevent resource exhaustion.

Structured JSON everywhere. stdout is always parseable JSON -- no mixed text output. Logs (when enabled) go to stderr as structured JSON lines. This makes mcp2cli reliable for scripting and piping.

Semantic exit codes. Different failure modes get different exit codes so callers can branch on the type of error without parsing output.

Input validation. All tool parameters are validated against the MCP schema before the call is dispatched. The validation layer handles 48 adversarial patterns (injection attempts, type coercion, overflow) to fail fast with clear errors.

Agent Integration

mcp2cli is designed to be called from AI agents via bash tool use. A typical agent workflow:

# Agent discovers available tools
mcp2cli n8n --help

# Agent reads the schema to understand parameters
mcp2cli schema n8n.n8n_get_workflow

# Agent invokes the tool
mcp2cli n8n n8n_get_workflow --params '{"id": "abc123"}'

This pattern keeps MCP tool definitions out of the agent's system prompt entirely. The agent only pays context cost when it actually needs to call a tool, and even then only for the specific tool's schema -- not all tools from all servers.

Multi-User Authentication

mcp2cli supports multi-user RBAC via ~/.config/mcp2cli/tokens.json. Each user or agent gets a bearer token with a role.

tokens.json

{
  "tokens": [
    {
      "id": "rico",
      "token": "your-admin-token-here",
      "role": "admin",
      "description": "Full admin access",
      "username": "rico",
      "password": "your-web-ui-password",
      "expiresAt": "2026-07-01T00:00:00.000Z"
    },
    {
      "id": "skippy",
      "token": "your-agent-token-here",
      "role": "agent",
      "description": "AI agent - tools + read, no config mutations",
      "expiresAt": "2026-07-01T00:00:00.000Z"
    },
    {
      "id": "viewer01",
      "token": "your-viewer-token-here",
      "role": "viewer",
      "description": "Read-only access"
    }
  ]
}

Generate secure tokens: openssl rand -base64 32

RBAC Roles

Permissionvieweragentadmin
List services, statusyesyesyes
Call tools, list tools, schemanoyesyes
Read credentialsnoyesyes
Add/update/remove servicesnonoyes
Write credentials, manage groupsnonoyes
Reload, import, shutdownnonoyes

The username/password fields enable web UI login at the daemon's root URL. Token-based auth (Bearer header) works for all API and CLI access.

The optional expiresAt field enables token expiry and refresh. Expired tokens are rejected. Near-expiry tokens from tokens.json can be rotated through POST /api/auth/refresh; the daemon writes the new token back to tokens.json and hot-reloads token file edits. Local CLI clients proactively refresh near-expiry admin tokens before daemon API calls.

Fallback Behavior

  • No tokens.json, no env token: Auth disabled, all requests treated as admin (backward compatible)
  • MCP2CLI_AUTH_TOKEN env var only: Legacy single-token mode, treated as admin
  • tokens.json exists: Full multi-user RBAC

Per-Identity Credential Management

Different users and agents can have their own API keys for backend services. When rico calls open-brain, he uses his key. When skippy calls it, the agents' shared key is used.

credentials.json

Create ~/.config/mcp2cli/credentials.json:

{
  "groups": {
    "ai_agents": ["skippy", "bilby", "nagatha", "claude"]
  },
  "credentials": {
    "rico": {
      "open-brain": { "headers": { "Authorization": "Bearer ricos-ob-key" } }
    },
    "ai_agents": {
      "open-brain": { "headers": { "Authorization": "Bearer agents-shared-ob-key" } },
      "n8n": { "env": { "N8N_API_KEY": "agents-n8n-key" } }
    }
  },
  "defaults": {
    "proxmox": { "headers": { "Authorization": "PVEAPIToken=shared-token" } }
  }
}

Resolution Chain

When a tool call comes in, credentials are resolved in priority order:

  1. User-specific -- credentials[userId][service]
  2. Group -- first matching group the user belongs to
  3. Defaults -- defaults[service]
  4. services.json -- whatever's baked into the service config (backward compatible)

For http/websocket services, credential headers are merged into the connection. For stdio services, credential env vars are merged into the process environment.

For identity-sensitive services, set requiresCredentials: true in services.json. If no user, group, or explicit default credential exists, the daemon rejects the call instead of using base service headers.

Credential CLI

# Set credentials for an identity on a service
mcp2cli credentials set rico open-brain --header "Authorization: Bearer my-key"

# Set env-based credentials (for stdio services)
mcp2cli credentials set rico n8n --env "N8N_API_KEY=my-n8n-key"

# Set a default credential (used when no user/group match)
mcp2cli credentials set-default proxmox --header "Authorization: PVEAPIToken=shared"

# List all credentials (values are redacted)
mcp2cli credentials list

# Show effective credential source for a user
mcp2cli credentials resolve skippy open-brain
# → {"exists": true, "source": "group"}

# Group management
mcp2cli credentials group add ai_agents skippy bilby nagatha
mcp2cli credentials group add-members ai_agents claude
mcp2cli credentials group remove-members ai_agents bilby
mcp2cli credentials group list

# Remove credentials
mcp2cli credentials remove rico open-brain
mcp2cli credentials remove-default proxmox
mcp2cli credentials group remove ai_agents

# Reload from disk after manual edits
mcp2cli credentials reload

# Populate Open Brain credentials from a Vaultwarden item
mcp2cli credentials bootstrap-open-brain --item "Open Brain - Per-User Tokens"

Open Brain Example

Open Brain (OBv2) is an HTTP MCP service where the bearer token controls namespace identity. Do not put an Open Brain Authorization header in services.json; store it only as a per-identity credential.

services.json -- base config for a hosted daemon (no credentials, endpoint only):

{
  "services": {
    "open-brain": {
      "backend": "http",
      "url": "http://open-brain.example.internal:3100/mcp",
      "source": "remote",
      "requiresCredentials": true,
      "preconnect": false
    }
  }
}

Use source: "remote" when the CLI is routing through a hosted mcp2cli daemon. requiresCredentials: true makes missing per-identity credentials fail closed, and preconnect: false prevents daemon startup from opening an unauthenticated base Open Brain connection.

credentials.json -- per-identity keys:

{
  "groups": {
    "ai_agents": ["skippy", "bilby", "claude"]
  },
  "credentials": {
    "rico": {
      "open-brain": { "headers": { "Authorization": "Bearer ricos-ob-api-key" } }
    },
    "ai_agents": {
      "open-brain": { "headers": { "Authorization": "Bearer agents-shared-ob-key" } }
    }
  }
}

Now when rico calls mcp2cli open-brain search_all --params '{"query": "kubernetes"}', his personal key is injected. When skippy calls the same tool, the agents' shared key is used. Each gets their own connection in the pool.

Vaultwarden bootstrap -- if the item Open Brain - Per-User Tokens has custom fields such as AUTH_TOKEN_USER_RICO, AUTH_TOKEN_USER_SKIPPY, and AUTH_TOKEN_USER_BILBY, run:

mcp2cli credentials bootstrap-open-brain

The field suffix is lowercased and used as the identity (AUTH_TOKEN_USER_RICO -> rico). Existing credentials are skipped unless --force is passed. The command prints counts and identity names only; it does not print bearer tokens.

Caller Identity Headers

Header and env values support ${caller.id} and ${caller.role} template variables. These are replaced with the authenticated caller's identity at call time.

In services.json -- inject identity headers for services whose backend trusts caller metadata instead of per-user bearer tokens:

{
  "services": {
    "example-service": {
      "backend": "http",
      "url": "https://example.internal/mcp",
      "headers": {
        "X-Agent-Id": "${caller.id}",
        "X-Role": "${caller.role}"
      }
    }
  }
}

When bilby calls the service, the request headers become X-Agent-Id: bilby and X-Role: agent.

In credentials.json -- combine per-identity keys with identity headers:

{
  "credentials": {
    "rico": {
      "open-brain": {
        "headers": {
          "Authorization": "Bearer ricos-ob-key",
          "X-Namespace": "${caller.id}"
        }
      }
    }
  }
}

Templates work in both headers and env values. Unknown variables (e.g., ${caller.email}) are left unexpanded.

Security

  • Redacted list output -- GET /api/credentials returns Bear*** not full values
  • IDOR protection -- agents can only resolve their own credentials, admin required for others
  • File permissions -- credentials.json is written with 0600 (owner read/write only)
  • Input validation -- header values reject CRLF injection, dangerous headers (Host, Transfer-Encoding) and env vars (PATH, LD_PRELOAD, NODE_OPTIONS) are blocked
  • Atomic writes -- temp file + rename prevents partial writes on crash
  • Pool invalidation -- changing credentials evicts stale connections automatically

Advanced Features

Schema Caching

Schemas are cached locally to avoid re-fetching on every invocation. Cached schemas live at ~/.cache/mcp2cli/schemas/ with a 24-hour TTL. Cache drift is detected via SHA-256 hashing -- if the upstream schema changes, the cache is automatically invalidated.

# Check cache status (age, TTL, drift)
mcp2cli cache status

# Clear all cached schemas
mcp2cli cache clear

# Clear cache for a specific service
mcp2cli cache clear n8n

# Bypass cache for a single schema lookup
mcp2cli schema n8n.n8n_list_workflows --fresh

Override the cache directory with MCP2CLI_CACHE_DIR.

Access Control

Restrict which tools are exposed per service using allowTools and blockTools in services.json. Both accept glob patterns.

{
  "services": {
    "n8n": {
      "description": "n8n workflow automation",
      "backend": "stdio",
      "command": "npx",
      "args": ["-y", "@anthropic/n8n-mcp"],
      "allowTools": ["n8n_list_*", "n8n_get_*"],
      "blockTools": ["n8n_delete_*"]
    }
  }
}

When both are present, allowTools is evaluated first (whitelist), then blockTools removes matches from the allowed set.

Search for tools across all services using cached schemas:

# Find all tools matching a pattern
mcp2cli grep "workflow"

# Regex patterns work
mcp2cli grep "delete|remove"

This searches cached schemas only -- no MCP connections are made.

WebSocket Transport

Connect to MCP servers over WebSocket. Supports optional stdio fallback and access control, same as HTTP.

{
  "services": {
    "remote-mcp": {
      "description": "Remote MCP server via WebSocket",
      "backend": "websocket",
      "url": "ws://mcp-gateway.local:3000/mcp",
      "fallback": {
        "command": "npx",
        "args": ["-y", "@anthropic/n8n-mcp"]
      }
    }
  }
}

WebSocket services benefit from the same circuit breaker and fallback behavior as HTTP services.

Batch Tool Calls

Execute multiple tool calls in a single invocation by piping NDJSON to mcp2cli batch. Each line is a JSON object with service, tool, and params fields:

# Sequential execution (default)
cat <<EOF | mcp2cli batch
{"service": "n8n", "tool": "n8n_list_workflows", "params": {}}
{"service": "n8n", "tool": "n8n_get_workflow", "params": {"id": "1"}}
EOF

# Parallel execution
cat <<EOF | mcp2cli batch --parallel
{"service": "n8n", "tool": "n8n_list_workflows", "params": {}}
{"service": "n8n", "tool": "n8n_get_workflow", "params": {"id": "1"}}
EOF

Output is NDJSON -- one result per line with the original service/tool for correlation:

{"service":"n8n","tool":"n8n_list_workflows","success":true,"result":{...}}
{"service":"n8n","tool":"n8n_get_workflow","success":true,"result":{...}}

Errors for individual calls are reported inline without aborting the batch.

Gateway Resilience

HTTP/SSE services can define a fallback stdio config. If the remote gateway is unreachable, mcp2cli transparently falls back to a local MCP server process.

{
  "services": {
    "n8n": {
      "description": "n8n via HTTP gateway with stdio fallback",
      "backend": "http",
      "url": "http://mcp-gateway:3000/n8n",
      "fallback": {
        "command": "npx",
        "args": ["-y", "@anthropic/n8n-mcp"]
      }
    }
  }
}

A circuit breaker protects against repeated failures: after 5 consecutive failures the circuit opens and routes directly to fallback for 60 seconds before re-probing the primary. Circuit state is persisted to ~/.cache/mcp2cli/circuit-breaker/ so it survives process restarts.

Output Formats

Control output format with the --format flag:

mcp2cli n8n n8n_list_workflows --params '{}' --format table
mcp2cli n8n n8n_list_workflows --params '{}' --format yaml
mcp2cli n8n n8n_list_workflows --params '{}' --format csv
mcp2cli n8n n8n_list_workflows --params '{}' --format ndjson
FormatDescription
jsonDefault. Structured JSON (unchanged from v1.0)
tableAligned columns -- human-readable terminal output
yamlYAML output
csvRFC 4180 CSV -- pipe to spreadsheets or csvtool
ndjsonOne JSON object per line -- for streaming pipelines

Error responses are always JSON regardless of the --format flag.

Skill Auto-Regeneration

Generated skill files can be previewed and kept in sync with upstream schema changes:

# Preview what would change without writing
mcp2cli generate-skills --diff n8n

# Regenerate (preserves manual sections)
mcp2cli generate-skills n8n

Manual edits inside MANUAL:START / MANUAL:END markers are preserved across regeneration. When schema drift is detected (via the caching layer), skill regeneration can be triggered automatically.

For Open Brain MCP changes, use the registry-backed path rather than directly refreshing a local daemon as the release proof:

# First land any required registry/config/process update in rodaddy/rtech-mcps.
# Then make mcp2cli pull the registry-backed service definition and refresh live schemas.
mcp2cli cache diff open-brain
mcp2cli cache warm open-brain
mcp2cli generate-skills open-brain --conflict=merge
mcp2cli open-brain --help

Local validation should use an isolated temporary MCP2CLI_HOME/config/cache so tests cannot mutate the operator's real local ~/.config/mcp2cli state. Final verification should run against the hosted daemon and prove the new tools are visible and callable there.

Additional Environment Variables

VariableDefaultDescription
MCP2CLI_CACHE_DIR~/.cache/mcp2cliBase directory for schema cache and circuit breaker state
MCP2CLI_TOKENS_FILE~/.config/mcp2cli/tokens.jsonPath to multi-user token config
MCP2CLI_TOKEN_REFRESH_WINDOW_MS86400000Refresh window for expiring tokens (default 24h)
MCP2CLI_TOKEN_TTL_MS2592000000Lifetime for refreshed tokens (default 30d)
MCP2CLI_CREDENTIALS_FILE~/.config/mcp2cli/credentials.jsonPath to per-identity credential mappings
MCP2CLI_IMPORT_TOKEN(unset)Bearer token used only for importUrl config imports
MCP2CLI_IMPORT_ALLOWED_HOSTS(required for importUrl)Comma-separated allowlist for importUrl hostnames
MCP2CLI_IMPORT_ALLOW_DNS(unset)Set to 1 to permit DNS hostnames for non-private importUrl targets
MCP2CLI_IMPORT_ALLOW_HTTP(unset)Set to 1 to permit non-HTTPS importUrl targets
MCP2CLI_IMPORT_ALLOW_PRIVATE(unset)Set to 1 to permit loopback/private/link-local importUrl targets
MCP2CLI_METRICS_INCLUDE_CALLER(unset)Set to 1 to include raw caller labels on public /metrics; disabled by default
MCP2CLI_VAULTWARDEN_TIMEOUT_MS10000Timeout for Vaultwarden-backed ${secret:...} lookups

Development

bun run dev -- <args>     # run without building
bun test                  # run test suite
bun run build             # compile to dist/mcp2cli