
Mcp Cli
- 386 installs
- 1.2k repo stars
- Updated February 5, 2026
- philschmid/mcp-cli
Helps with ai & agent building tasks.
About
mcp-cli is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- mcp-cli
- AI & Agent Building
- AI-coding skill
Mcp Cli by the numbers
- 386 all-time installs (skills.sh)
- +64 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,019 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/philschmid/mcp-cli --skill mcp-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 386 |
|---|---|
| repo stars | ★ 1.2k |
| Last updated | February 5, 2026 |
| Repository | philschmid/mcp-cli ↗ |
What it does
Helps with ai & agent building tasks.
Files
MCP-CLI
Access MCP servers through the command line. MCP enables interaction with external systems like GitHub, filesystems, databases, and APIs.
Commands
| Command | Output |
|---|---|
mcp-cli | List all servers and tools |
mcp-cli info <server> | Show server tools and parameters |
mcp-cli info <server> <tool> | Get tool JSON schema |
mcp-cli grep "<pattern>" | Search tools by name |
mcp-cli call <server> <tool> | Call tool (reads JSON from stdin if no args) |
mcp-cli call <server> <tool> '<json>' | Call tool with arguments |
Both formats work: <server> <tool> or <server>/<tool>
Workflow
1. Discover: mcp-cli → see available servers 2. Explore: mcp-cli info <server> → see tools with parameters 3. Inspect: mcp-cli info <server> <tool> → get full JSON schema 4. Execute: mcp-cli call <server> <tool> '<json>' → run with arguments
Examples
# List all servers
mcp-cli
# With descriptions
mcp-cli -d
# See server tools
mcp-cli info filesystem
# Get tool schema (both formats work)
mcp-cli info filesystem read_file
mcp-cli info filesystem/read_file
# Call tool
mcp-cli call filesystem read_file '{"path": "./README.md"}'
# Pipe from stdin (no '-' needed!)
cat args.json | mcp-cli call filesystem read_file
# Search for tools
mcp-cli grep "*file*"
# Output is raw text (pipe-friendly)
mcp-cli call filesystem read_file '{"path": "./file"}' | head -10Advanced Chaining
# Chain: search files → read first match
mcp-cli call filesystem search_files '{"path": ".", "pattern": "*.md"}' \
| head -1 \
| xargs -I {} mcp-cli call filesystem read_file '{"path": "{}"}'
# Loop: process multiple files
mcp-cli call filesystem list_directory '{"path": "./src"}' \
| while read f; do mcp-cli call filesystem read_file "{\"path\": \"$f\"}"; done
# Conditional: check before reading
mcp-cli call filesystem list_directory '{"path": "."}' \
| grep -q "README" \
&& mcp-cli call filesystem read_file '{"path": "./README.md"}'
# Multi-server aggregation
{
mcp-cli call github search_repositories '{"query": "mcp", "per_page": 3}'
mcp-cli call filesystem list_directory '{"path": "."}'
}
# Save to file
mcp-cli call github get_file_contents '{"owner": "x", "repo": "y", "path": "z"}' > output.txtNote: call outputs raw text content directly (no jq needed for text extraction)
Options
| Flag | Purpose |
|---|---|
-d | Include descriptions |
-c <path> | Specify config file |
Common Errors
| Wrong Command | Error | Fix |
|---|---|---|
mcp-cli server tool | AMBIGUOUS_COMMAND | Use call server tool or info server tool |
mcp-cli run server tool | UNKNOWN_SUBCOMMAND | Use call instead of run |
mcp-cli list | UNKNOWN_SUBCOMMAND | Use info instead of list |
mcp-cli call server | MISSING_ARGUMENT | Add tool name |
mcp-cli call server tool {bad} | INVALID_JSON | Use valid JSON with quotes |
Exit Codes
0: Success1: Client error (bad args, missing config)2: Server error (tool failed)3: Network error
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run typecheck
- name: Lint
run: bun run lint
test:
name: Test
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run unit tests
run: bun test tests/config.test.ts tests/output.test.ts tests/client.test.ts tests/errors.test.ts tests/grep.test.ts
- name: Run integration tests
run: bun test --timeout 60000 tests/integration/
name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
name: Test before release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Run unit tests
run: bun test tests/config.test.ts tests/output.test.ts tests/client.test.ts tests/errors.test.ts
- name: Run integration tests
run: bun test --timeout 60000 tests/integration/
build:
name: Build binaries
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build Linux x64
run: bun build --compile --minify --target=bun-linux-x64 src/index.ts --outfile dist/mcp-cli-linux-x64
- name: Build macOS x64
run: bun build --compile --minify --target=bun-darwin-x64 src/index.ts --outfile dist/mcp-cli-darwin-x64
- name: Build macOS ARM64
run: bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile dist/mcp-cli-darwin-arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: binaries
path: dist/
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: binaries
path: dist/
- name: Generate checksums
run: |
cd dist
sha256sum * > checksums.txt
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: ${{ steps.version.outputs.VERSION }}
generate_release_notes: true
files: |
dist/mcp-cli-linux-x64
dist/mcp-cli-darwin-x64
dist/mcp-cli-darwin-arm64
dist/checksums.txt
# Dependencies
node_modules/
# Build output
dist/
# Bun lockfile binary (use bun.lock instead)
*.lockb
# OS files
.DS_Store
Thumbs.db
# Editor directories
.idea/
.vscode/
*.swp
*.swo
*~
# Environment files
.env
.env.local
.env.*.local
# Test coverage
coverage/
# TypeScript build info
*.tsbuildinfo
# Logs
logs/
*.log
npm-debug.log*
bun-debug.log*
# Temporary files
tmp/
temp/
*.tmp
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": [
"node_modules",
"dist"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always"
}
}
}{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "mcp-cli",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.3.5",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
}
}
Changelog
[0.3.0] - 2026-01-22
Added
- Server Instructions Support - Display MCP server instructions in output
mcp-cli(list all): Shows first line of instructions per servermcp-cli info <server>: Shows full instructions under "Instructions:" heading
- Tool Filtering - Restrict tools per server via config
allowedTools: Glob patterns for tools to allow (e.g.,["read_*", "list_*"])disabledTools: Glob patterns for tools to exclude (e.g.,["delete_*"])disabledToolstakes precedence overallowedTools- Filtering applies globally to all CLI operations (info, grep, call)
- Connection Daemon - Lazy-spawn connection pooling
- Per-server daemon keeps MCP connections warm
- 60s idle timeout (configurable via
MCP_DAEMON_TIMEOUT) - Automatic config hash invalidation
MCP_NO_DAEMON=1to disable
- 3-Subcommand Architecture -
info,grep,call - Flexible format support:
server toolandserver/tool calloutputs raw text content (CLI-friendly, pipe to grep/head/etc.)info/grepoutput human-readable format
- Improved Error Messages for LLMs
- AMBIGUOUS_COMMAND: Shows both
callandinfooptions - UNKNOWN_SUBCOMMAND: Smart mapping (run→call, list→info, search→grep)
- MISSING_ARGUMENT: Shows available servers list
- INVALID_JSON: Schema hint with example
- Advanced Chaining Examples - New documentation section
- Search and read pipelines with jq
- Multi-file processing with loops
- Conditional execution with
jq -e - Multi-server aggregation
- Error handling patterns
- Generate System Instructions Script -
scripts/generate-system-instructions.ts
Changed
- CLI Command Structure
mcp-cli(no args) lists all serversmcp-cli info <server>requires a server argument
- Grep Output Format
- Output now uses space-separated format:
<server> <tool> <description> - Descriptions are always shown when available
- Pattern now matches tool name only (not server name or description)
Removed
- Backward Compatibility Syntax -
mcp-cli server/tool [args]now errors with helpful message - `--json` and `--raw` options - Output format now automatic based on command
#!/bin/bash
# Install script for mcp-cli
# Usage: curl -fsSL https://raw.githubusercontent.com/philschmid/mcp-cli/main/install.sh | bash
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
# Cleanup on exit
TMP_FILE=""
TMP_CHECKSUM=""
cleanup() {
if [ -n "$TMP_FILE" ] && [ -f "$TMP_FILE" ]; then
rm -f "$TMP_FILE"
fi
if [ -n "$TMP_CHECKSUM" ] && [ -f "$TMP_CHECKSUM" ]; then
rm -f "$TMP_CHECKSUM"
fi
}
trap cleanup EXIT
# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
linux)
case "$ARCH" in
x86_64) BINARY="mcp-cli-linux-x64" ;;
aarch64) BINARY="mcp-cli-linux-arm64" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
;;
darwin)
case "$ARCH" in
x86_64) BINARY="mcp-cli-darwin-x64" ;;
arm64) BINARY="mcp-cli-darwin-arm64" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
;;
*)
echo -e "${RED}Unsupported OS: $OS${NC}"
exit 1
;;
esac
# Installation directory - prefer ~/.local/bin (no sudo needed)
if [ -z "${INSTALL_DIR:-}" ]; then
if [ -w "/usr/local/bin" ]; then
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
fi
fi
GITHUB_REPO="philschmid/mcp-cli"
# Print banner
echo ""
echo -e "${BOLD}Installing mcp-cli${NC}"
echo ""
echo -e " ${BOLD}Platform${NC}: $OS/$ARCH"
echo -e " ${BOLD}Binary${NC}: $BINARY"
echo -e " ${BOLD}Location${NC}: $INSTALL_DIR/mcp-cli"
echo ""
# Check for existing installation
if command -v mcp-cli &> /dev/null; then
EXISTING_VERSION=$(mcp-cli --version 2>/dev/null || echo "unknown")
echo -e "${YELLOW}Note: Updating existing installation ($EXISTING_VERSION)${NC}"
echo ""
fi
# Get latest release URL
DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/latest/download/$BINARY"
CHECKSUM_URL="https://github.com/$GITHUB_REPO/releases/latest/download/checksums.txt"
# Download binary
echo -e "${BLUE}Downloading...${NC}"
TMP_FILE=$(mktemp)
if ! curl -fsSL "$DOWNLOAD_URL" -o "$TMP_FILE"; then
echo -e "${RED}Failed to download binary. Check if releases exist at:${NC}"
echo " https://github.com/$GITHUB_REPO/releases"
exit 1
fi
# Verify checksum (if available)
TMP_CHECKSUM=$(mktemp)
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_CHECKSUM" 2>/dev/null; then
# Extract checksum for our binary
EXPECTED_CHECKSUM=$(grep "$BINARY" "$TMP_CHECKSUM" | awk '{print $1}')
if [ -n "$EXPECTED_CHECKSUM" ]; then
echo -e "${BLUE}Verifying checksum...${NC}"
# Calculate actual checksum
if command -v sha256sum &> /dev/null; then
ACTUAL_CHECKSUM=$(sha256sum "$TMP_FILE" | awk '{print $1}')
elif command -v shasum &> /dev/null; then
ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_FILE" | awk '{print $1}')
else
echo -e "${YELLOW}Warning: Could not verify checksum (no sha256sum/shasum found)${NC}"
ACTUAL_CHECKSUM=""
fi
if [ -n "$ACTUAL_CHECKSUM" ]; then
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
echo -e "${RED}Checksum verification failed!${NC}"
echo "Expected: $EXPECTED_CHECKSUM"
echo "Actual: $ACTUAL_CHECKSUM"
exit 1
fi
echo -e "${GREEN}✓${NC} Checksum verified"
fi
fi
fi
# Make executable
chmod +x "$TMP_FILE"
# Create install directory if needed
if [ ! -d "$INSTALL_DIR" ]; then
echo -e "${BLUE}Creating $INSTALL_DIR...${NC}"
mkdir -p "$INSTALL_DIR"
fi
# Install
echo -e "${BLUE}Installing...${NC}"
if [ -w "$INSTALL_DIR" ]; then
mv "$TMP_FILE" "$INSTALL_DIR/mcp-cli"
else
echo -e "${YELLOW}Requires sudo to install to $INSTALL_DIR${NC}"
sudo mv "$TMP_FILE" "$INSTALL_DIR/mcp-cli"
fi
TMP_FILE="" # Clear so cleanup doesn't try to delete
# Success message
echo ""
echo -e "${GREEN}✓ mcp-cli installed successfully!${NC}"
echo ""
# Check if in PATH and show version
if command -v mcp-cli &> /dev/null; then
mcp-cli --version
else
# Not in PATH - show setup instructions
echo -e "${YELLOW}Add mcp-cli to your PATH:${NC}"
echo ""
SHELL_NAME=$(basename "$SHELL")
case "$SHELL_NAME" in
bash)
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
;;
zsh)
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
;;
fish)
echo " fish_add_path ~/.local/bin"
;;
*)
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
;;
esac
echo ""
fi
echo "Get started:"
echo " mcp-cli --help"
echo ""
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"."
],
"env": {
"npm_config_registry": "https://registry.npmjs.org"
}
},
"deepwiki": {
"url": "https://mcp.deepwiki.com/mcp"
}
}
}{
"name": "mcp-cli",
"version": "0.3.0",
"description": "A lightweight CLI for interacting with MCP (Model Context Protocol) servers",
"type": "module",
"main": "src/index.ts",
"bin": {
"mcp-cli": "src/index.ts"
},
"scripts": {
"dev": "bun run src/index.ts",
"build": "bun build --compile --minify src/index.ts --outfile dist/mcp-cli",
"build:linux": "bun build --compile --minify --target=bun-linux-x64 src/index.ts --outfile dist/mcp-cli-linux-x64",
"build:linux-arm": "bun build --compile --minify --target=bun-linux-arm64 src/index.ts --outfile dist/mcp-cli-linux-arm64",
"build:macos": "bun build --compile --minify --target=bun-darwin-x64 src/index.ts --outfile dist/mcp-cli-darwin-x64",
"build:macos-arm": "bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile dist/mcp-cli-darwin-arm64",
"build:windows": "bun build --compile --minify --target=bun-windows-x64 src/index.ts --outfile dist/mcp-cli-windows-x64.exe",
"build:all": "bun run build:linux && bun run build:linux-arm && bun run build:macos && bun run build:macos-arm && bun run build:windows",
"test": "bun test",
"test:integration": "bun test --timeout 30000 tests/integration",
"typecheck": "tsc --noEmit",
"lint": "bunx --bun @biomejs/biome check src/",
"lint:fix": "bunx --bun @biomejs/biome check --write src/",
"format": "bunx --bun @biomejs/biome format --write src/"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.3.5",
"typescript": "^5.9.3"
},
"keywords": [
"mcp",
"model-context-protocol",
"cli",
"ai",
"tools",
"agents"
],
"author": "Philipp Schmid",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/philschmid/mcp-cli"
},
"bugs": {
"url": "https://github.com/philschmid/mcp-cli/issues"
},
"homepage": "https://github.com/philschmid/mcp-cli#readme",
"engines": {
"bun": ">=1.0.0"
}
}
mcp-cli
A lightweight, Bun-based CLI for interacting with MCP (Model Context Protocol) servers.
Features
- 🪶 Lightweight - Minimal dependencies, fast startup
- 📦 Single Binary - Compile to standalone executable via
bun build --compile - 🔧 Shell-Friendly - JSON output for call, pipes with
jq, chaining support - 🤖 Agent-Optimized - Designed for AI coding agents (Gemini CLI, Claude Code, etc.)
- 🔌 Universal - Supports both stdio and HTTP MCP servers
- ⚡ Connection Pooling - Lazy-spawn daemon keeps connections warm (60s idle timeout)
- � Tool Filtering - Allow/disable specific tools per server via config
- 📋 Server Instructions - Display MCP server instructions in output
- �💡 Actionable Errors - Structured error messages with available servers and recovery suggestions
!mcp-cli
Quick Start
1. Installation
curl -fsSL https://raw.githubusercontent.com/philschmid/mcp-cli/main/install.sh | bashor
# requires bun install
bun install -g https://github.com/philschmid/mcp-cli2. Create a config file
Create mcp_servers.json in your current directory or ~/.config/mcp/:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"."
]
},
"deepwiki": {
"url": "https://mcp.deepwiki.com/mcp"
}
}
}3. Discover available tools
# List all servers and tools
mcp-cli
# With descriptions
mcp-cli -d4. Call a tool
# View tool schema first
mcp-cli info filesystem read_file
# Call the tool
mcp-cli call filesystem read_file '{"path": "./README.md"}'Usage
mcp-cli [options] List all servers and tools
mcp-cli [options] info <server> Show server tools and parameters
mcp-cli [options] info <server> <tool> Show tool schema
mcp-cli [options] grep <pattern> Search tools by glob pattern
mcp-cli [options] call <server> <tool> Call tool (reads JSON from stdin if no args)
mcp-cli [options] call <server> <tool> <json> Call tool with JSON argumentsBoth formats work: info <server> <tool> or info <server>/<tool>
[!TIP]
Add -d to any command to include descriptions.Options
| Option | Description |
|---|---|
-h, --help | Show help message |
-v, --version | Show version number |
-d, --with-descriptions | Include tool descriptions |
-c, --config <path> | Path to config file |
Output
| Stream | Content |
|---|---|
| stdout | Tool results and human-readable info |
| stderr | Errors and diagnostics |
Commands
List Servers
# Basic listing
$ mcp-cli
github
• search_repositories
• get_file_contents
• create_or_update_file
filesystem
• read_file
• write_file
• list_directory
# With descriptions
$ mcp-cli --with-descriptions
github
• search_repositories - Search for GitHub repositories
• get_file_contents - Get contents of a file or directory
filesystem
• read_file - Read the contents of a file
• write_file - Write content to a fileSearch Tools
# Find file-related tools across all servers
$ mcp-cli grep "*file*"
github/get_file_contents
github/create_or_update_file
filesystem/read_file
filesystem/write_file
# Search with descriptions
$ mcp-cli grep "*search*" -d
github/search_repositories - Search for GitHub repositoriesView Server Details
$ mcp-cli info github
Server: github
Transport: stdio
Command: npx -y @modelcontextprotocol/server-github
Tools (12):
search_repositories
Search for GitHub repositories
Parameters:
• query (string, required) - Search query
• page (number, optional) - Page number
...View Tool Schema
# Both formats work:
$ mcp-cli info github search_repositories
$ mcp-cli info github/search_repositories
Tool: search_repositories
Server: github
Description:
Search for GitHub repositories
Input Schema:
{
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"page": { "type": "number" }
},
"required": ["query"]
}Call a Tool
# With inline JSON
$ mcp-cli call github search_repositories '{"query": "mcp server", "per_page": 5}'
# JSON output is default for call command
$ mcp-cli call github search_repositories '{"query": "mcp"}' | jq '.content[0].text'
# Read JSON from stdin (no '-' needed!)
$ echo '{"path": "./README.md"}' | mcp-cli call filesystem read_file
Complex Commands
For JSON arguments containing single quotes, special characters, or long text, use stdin to avoid shell escaping issues:
# Using a heredoc (no '-' needed with call subcommand)
mcp-cli call server tool <<EOF
{"content": "Text with 'single quotes' and \"double quotes\""}
EOF
# From a file
cat args.json | mcp-cli call server tool
# Using jq to build complex JSON
jq -n '{query: "mcp", filters: ["active", "starred"]}' | mcp-cli call github searchWhy stdin? Shell interpretation of {}, quotes, and special characters requires careful escaping. Stdin bypasses shell parsing entirely.
Advanced Chaining Examples
Chain multiple MCP calls together using pipes and shell tools:
# 1. Search and read: Find files matching pattern, then read the first one
mcp-cli call filesystem search_files '{"path": "src/", "pattern": "*.ts"}' \
| jq -r '.content[0].text | split("\n")[0]' \
| xargs -I {} mcp-cli call filesystem read_file '{"path": "{}"}'
# 2. Process multiple results: Read all matching files
mcp-cli call filesystem search_files '{"path": ".", "pattern": "*.md"}' \
| jq -r '.content[0].text | split("\n")[]' \
| while read file; do
echo "=== $file ==="
mcp-cli call filesystem read_file "{\"path\": \"$file\"}" | jq -r '.content[0].text'
done
# 3. Extract and transform: Get repo info, extract URLs
mcp-cli call github search_repositories '{"query": "mcp server", "per_page": 5}' \
| jq -r '.content[0].text | fromjson | .items[].html_url'
# 4. Conditional execution: Check file exists before reading
mcp-cli call filesystem list_directory '{"path": "."}' \
| jq -e '.content[0].text | contains("README.md")' \
&& mcp-cli call filesystem read_file '{"path": "./README.md"}'
# 5. Save output to file
mcp-cli call github get_file_contents '{"owner": "user", "repo": "project", "path": "src/main.ts"}' \
| jq -r '.content[0].text' > main.ts
# 6. Error handling in scripts
if result=$(mcp-cli call filesystem read_file '{"path": "./config.json"}' 2>/dev/null); then
echo "$result" | jq '.content[0].text | fromjson'
else
echo "File not found, using defaults"
fi
# 7. Aggregate results from multiple servers
{
mcp-cli call github search_repositories '{"query": "mcp", "per_page": 3}'
mcp-cli call filesystem list_directory '{"path": "./src"}'
} | jq -s '.'Tips for chaining:
- Use
jq -rfor raw output (no quotes) - Use
jq -efor conditional checks (exit code 1 if false) - Use
2>/dev/nullto suppress errors when testing - Use
| jq -s '.'to combine multiple JSON outputs
Configuration
Config File Format
The CLI uses mcp_servers.json, compatible with Claude Desktop, Gemini or VS Code:
{
"mcpServers": {
"local-server": {
"command": "node",
"args": ["./server.js"],
"env": {
"API_KEY": "${API_KEY}"
},
"cwd": "/path/to/directory"
},
"remote-server": {
"url": "https://mcp.example.com",
"headers": {
"Authorization": "Bearer ${TOKEN}"
}
}
}
}Environment Variable Substitution: Use ${VAR_NAME} syntax anywhere in the config. Values are substituted at load time. By default, missing environment variables cause an error with a clear message. Set MCP_STRICT_ENV=false to use empty values instead (with a warning).
Tool Filtering
Restrict which tools are available from a server using allowedTools and disabledTools:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
"allowedTools": ["read_file", "list_directory"],
"disabledTools": ["delete_file"]
}
}
}Rules:
allowedTools: Only tools matching these patterns are available (supports glob:*,?)disabledTools: Tools matching these patterns are excluded- `disabledTools` takes precedence over
allowedTools - Filtering applies globally to all CLI operations (info, grep, call)
Examples:
// Only allow read operations
"allowedTools": ["read_*", "list_*", "search_*"]
// Allow all except destructive operations
"disabledTools": ["delete_*", "write_*", "create_*"]
// Combine: allow file operations but disable delete
"allowedTools": ["*file*"],
"disabledTools": ["delete_file"]Config Resolution
The CLI searches for configuration in this order:
1. MCP_CONFIG_PATH environment variable 2. -c/--config command line argument 3. ./mcp_servers.json (current directory) 4. ~/.mcp_servers.json 5. ~/.config/mcp/mcp_servers.json
Environment Variables
| Variable | Description | Default |
|---|---|---|
MCP_CONFIG_PATH | Path to config file | (none) |
MCP_DEBUG | Enable debug output | false |
MCP_TIMEOUT | Request timeout (seconds) | 1800 (30 min) |
MCP_CONCURRENCY | Servers processed in parallel (not a limit on total) | 5 |
MCP_MAX_RETRIES | Retry attempts for transient errors (0 = disable) | 3 |
MCP_RETRY_DELAY | Base retry delay (milliseconds) | 1000 |
MCP_STRICT_ENV | Error on missing ${VAR} in config | true |
MCP_NO_DAEMON | Disable connection caching (force fresh connections) | false |
MCP_DAEMON_TIMEOUT | Idle timeout for cached connections (seconds) | 60 |
Using with AI Agents
mcp-cli is designed to give AI coding agents access to MCP (Model Context Protocol) servers. MCP enables AI models to interact with external tools, APIs, and data sources through a standardized protocol.
Why MCP + CLI?
Traditional MCP integration loads full tool schemas into the AI's context window, consuming thousands of tokens. The CLI approach:
- On-demand loading: Only fetch schemas when needed
- Token efficient: Minimal context overhead
- Shell composable: Chain with
jq, pipes, and scripts - Scriptable: AI can write shell scripts for complex workflows
Option 1: System Prompt Integration
Add this to your AI agent's system prompt for direct CLI access:
````xml
MCP Servers
You have access to MCP servers via the mcp-cli CLI.
Commands:
mcp-cli info # List all servers
mcp-cli info <server> # Show server tools
mcp-cli info <server> <tool> # Get tool schema
mcp-cli grep "<pattern>" # Search tools
mcp-cli call <server> <tool> # Call tool (stdin auto-detected)
mcp-cli call <server> <tool> '{}' # Call with JSON argsBoth formats work: info <server> <tool> or info <server>/<tool>
Workflow:
1. Discover: mcp-cli info to see available servers 2. Inspect: mcp-cli info <server> <tool> to get the schema 3. Execute: mcp-cli call <server> <tool> '{}' with arguments
Examples
# Call with inline JSON
mcp-cli call github search_repositories '{"query": "mcp server"}'
# Pipe from stdin (no '-' needed)
echo '{"path": "./file"}' | mcp-cli call filesystem read_file
# Heredoc for complex JSON
mcp-cli call server tool <<EOF
{"content": "Text with 'quotes'"}
EOFCommon Errors
| Wrong | Error | Fix |
|---|---|---|
mcp-cli server tool | AMBIGUOUS | Use call server tool |
mcp-cli run server tool | UNKNOWN_SUBCOMMAND | Use call |
mcp-cli list | UNKNOWN_SUBCOMMAND | Use info |
````
Option 2: Agents Skill
For Code Agents that support Agents Skills, like Gemini CLI, OpenCode or Claude Code. you can use the mcp-cli skill to interface with MCP servers. The Skill is available at SKILL.md
Create mcp-cli/SKILL.md in your skills directory.
Architecture
Connection Pooling (Daemon)
By default, the CLI uses lazy-spawn connection pooling to avoid repeated MCP server startup latency:
┌────────────────────────────────────────────────────────────────────┐
│ First CLI Call │
│ $ mcp-cli info server │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ Check: /tmp/mcp-cli-{uid}/server.sock exists? │
└────────────────────────────────────────────────────────────────────┘
│ │
│ NO │ YES
▼ ▼
┌─────────────────────────┐ ┌───────────────────────────────────┐
│ Fork background daemon │ │ Connect to existing socket │
│ ├─ Connect to MCP server│ │ ├─ Send request via IPC │
│ ├─ Create Unix socket │ │ ├─ Receive response │
│ └─ Start 60s idle timer │ │ └─ Daemon resets idle timer │
└─────────────────────────┘ └───────────────────────────────────┘
│ │
└────────────────┬───────────────────┘
▼
┌────────────────────────────────────────────────────────────────────┐
│ On idle timeout (60s): Daemon self-terminates, cleans up files │
└────────────────────────────────────────────────────────────────────┘Key features:
- Automatic: No manual start/stop needed
- Per-server: Each MCP server gets its own daemon
- Stale detection: Config changes trigger re-spawn
- Fast fallback: 5s spawn timeout, then direct connection
Control via environment:
MCP_NO_DAEMON=1 mcp-cli info # Force fresh connection
MCP_DAEMON_TIMEOUT=120 mcp-cli # 2 minute idle timeout
MCP_DEBUG=1 mcp-cli info # See daemon debug outputConnection Model (Direct)
When daemon is disabled (MCP_NO_DAEMON=1), the CLI uses a lazy, on-demand connection strategy. Server connections are only established when needed and closed immediately after use.
┌─────────────────────────────────────────────────────────────────┐
│ USER REQUEST │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ mcp-cli info │ │ mcp-cli grep │ │ mcp-cli call │
│ (list all) │ │ "*pattern*" │ │ server tool {} │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Connect to ALL │ │ Connect to ALL │ │ Connect to ONE │
│ servers (N) │ │ servers (N) │ │ server only │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
List tools Search tools Execute tool
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ CLOSE CONNECTIONS │
└─────────────────────────────────────────────────────────────┘When are servers connected?
| Command | Servers Connected |
|---|---|
mcp-cli info | All N servers in parallel |
mcp-cli grep "*pattern*" | All N servers in parallel |
mcp-cli info <server> | Only the specified server |
mcp-cli info <server> <tool> | Only the specified server |
mcp-cli call <server> <tool> '{}' | Only the specified server |
Error Handling & Retry
The CLI includes automatic retry with exponential backoff for transient failures.
Transient errors (auto-retried):
- Network:
ECONNREFUSED,ETIMEDOUT,ECONNRESET - HTTP:
502,503,504,429
Non-transient errors (fail immediately):
- Config: Invalid JSON, missing fields
- Auth:
401,403 - Tool: Validation errors, not found
Development
Prerequisites
- Bun >= 1.0.0
Setup
bun install https://github.com/philschmid/mcp-cliCommands
# Run in development
bun run dev
# Type checking
bun run typecheck
# Linting
bun run lint
bun run lint:fix
# Run all tests (unit + integration)
bun test
# Run only unit tests (fast)
bun test tests/config.test.ts tests/output.test.ts tests/client.test.ts
# Run integration tests (requires MCP server, ~35s)
bun test tests/integration/
# Build single executable
bun run build
# Build for all platforms
bun run build:allLocal Testing
Test the CLI locally without compiling by using bun link:
# Link the package globally (run once)
bun link
# Now you can use 'mcp-cli' anywhere
mcp-cli --help
mcp-cli call filesystem read_file '{"path": "./README.md"}'
# Or run directly during development
bun run dev --help
bun run dev info filesystemTo unlink when done:
bun unlinkReleasing
Releases are automated via GitHub Actions. Use the release script:
./scripts/release.sh 0.2.0Error Messages
All errors include actionable recovery suggestions, optimized for both humans and AI agents:
Error [AMBIGUOUS_COMMAND]: Ambiguous command: did you mean to call a tool or view info?
Details: Received: mcp-cli filesystem read_file
Suggestion: Use 'mcp-cli call filesystem read_file' to execute, or 'mcp-cli info filesystem read_file' to view schema
Error [UNKNOWN_SUBCOMMAND]: Unknown subcommand: "run"
Details: Valid subcommands: info, grep, call
Suggestion: Did you mean 'mcp-cli call'?
Error [SERVER_NOT_FOUND]: Server "github" not found in config
Details: Available servers: filesystem, sqlite
Suggestion: Use one of: mcp-cli info filesystem, mcp-cli info sqlite
Error [TOOL_NOT_FOUND]: Tool "search" not found in server "filesystem"
Details: Available tools: read_file, write_file, list_directory (+5 more)
Suggestion: Run 'mcp-cli info filesystem' to see all available tools
Error [INVALID_JSON_ARGUMENTS]: Invalid JSON in tool arguments
Details: Parse error: Unexpected identifier "test"
Suggestion: Arguments must be valid JSON. Use single quotes: '{"key": "value"}'License
MIT License - see LICENSE for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
#!/usr/bin/env bun
/**
* Example: Generate System Instructions
*
* This script generates a system prompt snippet containing all available
* MCP servers with their instructions and tools.
*
* Usage:
* bun run scripts/generate-system-instructions.ts
* bun run scripts/generate-system-instructions.ts -c /path/to/config.json
*/
import { getConnection, safeClose, getConcurrencyLimit, type McpConnection } from '../src/client.js';
import { loadConfig, listServerNames, getServerConfig, type McpServersConfig } from '../src/config.js';
interface ServerInfo {
name: string;
instructions?: string;
tools: string[];
error?: string;
}
async function fetchServerInfo(serverName: string, config: McpServersConfig): Promise<ServerInfo> {
let connection: McpConnection | null = null;
try {
const serverConfig = getServerConfig(config, serverName);
connection = await getConnection(serverName, serverConfig);
const tools = await connection.listTools();
const instructions = await connection.getInstructions();
return {
name: serverName,
instructions,
tools: tools.map(t => t.name),
};
} catch (error) {
return {
name: serverName,
tools: [],
error: (error as Error).message,
};
} finally {
if (connection) {
await safeClose(connection.close);
}
}
}
function formatSystemInstructions(servers: ServerInfo[]): string {
const lines: string[] = [];
lines.push('# Available MCP Servers');
lines.push('');
lines.push('You have access to the following MCP servers via `mcp-cli`:');
lines.push('');
for (const server of servers) {
lines.push(`## ${server.name}`);
if (server.error) {
lines.push(` (Error: ${server.error})`);
lines.push('');
continue;
}
if (server.instructions) {
lines.push('');
lines.push('**Instructions:**');
lines.push(server.instructions);
}
lines.push('');
lines.push('**Tools:**');
for (const tool of server.tools) {
lines.push(`- ${tool}`);
}
lines.push('');
}
lines.push('---');
lines.push('');
lines.push('Use `mcp-cli info <server> <tool>` to see tool schema before calling.');
return lines.join('\n');
}
async function main() {
const configPath = process.argv.includes('-c')
? process.argv[process.argv.indexOf('-c') + 1]
: undefined;
try {
const config = await loadConfig(configPath);
const serverNames = listServerNames(config);
if (serverNames.length === 0) {
console.error('No servers configured');
process.exit(1);
}
console.error(`Fetching info from ${serverNames.length} servers...`);
// Fetch all servers in parallel
const servers = await Promise.all(
serverNames.map(name => fetchServerInfo(name, config))
);
// Sort alphabetically
servers.sort((a, b) => a.name.localeCompare(b.name));
// Output the formatted system instructions
console.log(formatSystemInstructions(servers));
process.exit(0);
} catch (error) {
console.error(`Error: ${(error as Error).message}`);
process.exit(1);
}
}
main();
#!/bin/bash
# Release script for mcp-cli
# Usage: ./scripts/release.sh <version>
# Example: ./scripts/release.sh 0.1.0
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if version argument is provided
if [ -z "$1" ]; then
echo -e "${RED}Error: Version number required${NC}"
echo "Usage: ./scripts/release.sh <version>"
echo "Example: ./scripts/release.sh 0.1.0"
exit 1
fi
VERSION=$1
# Validate version format (semver)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: Invalid version format${NC}"
echo "Version must be in format: X.Y.Z (e.g., 0.1.0)"
exit 1
fi
# Check if we're on main branch
CURRENT_BRANCH=$(git branch --show-current)
if [ "$CURRENT_BRANCH" != "main" ]; then
echo -e "${YELLOW}Warning: Not on main branch (current: $CURRENT_BRANCH)${NC}"
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Check for uncommitted changes
if ! git diff-index --quiet HEAD --; then
echo -e "${RED}Error: Uncommitted changes detected${NC}"
echo "Please commit or stash your changes first."
exit 1
fi
# Check if tag already exists
if git tag -l "v$VERSION" | grep -q "v$VERSION"; then
echo -e "${RED}Error: Tag v$VERSION already exists${NC}"
exit 1
fi
echo -e "${GREEN}Preparing release v$VERSION${NC}"
# Update version in package.json
echo "Updating package.json..."
if command -v jq &> /dev/null; then
jq ".version = \"$VERSION\"" package.json > package.json.tmp && mv package.json.tmp package.json
else
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json
rm -f package.json.bak
fi
# Update version in src/version.ts (used by compiled binary)
echo "Updating src/version.ts..."
cat > src/version.ts << EOF
/**
* Version constant - single source of truth
* This file is auto-updated by scripts/release.sh
*/
export const VERSION = '$VERSION';
EOF
# Run tests before releasing
echo "Running tests..."
bun run typecheck
bun run lint
bun test tests/config.test.ts tests/output.test.ts tests/client.test.ts tests/errors.test.ts
echo -e "${GREEN}Tests passed!${NC}"
# Commit version bump
echo "Committing version bump..."
git add package.json src/version.ts
git commit -m "Release v$VERSION"
# Create tag
echo "Creating tag v$VERSION..."
git tag -a "v$VERSION" -m "Release v$VERSION"
# Push changes and tag
echo "Pushing to origin..."
git push origin main
git push origin "v$VERSION"
echo ""
echo -e "${GREEN}✓ Release v$VERSION created successfully!${NC}"
echo ""
echo "GitHub Actions will now:"
echo " 1. Run the full test suite"
echo " 2. Build binaries for Linux and macOS"
echo " 3. Create the GitHub release"
echo ""
echo "Monitor the release at:"
echo " https://github.com/philschmid/mcp-cli/actions"
/**
* MCP Client - Connection management for MCP servers
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import {
type HttpServerConfig,
type ServerConfig,
type StdioServerConfig,
debug,
filterTools,
getConcurrencyLimit,
getMaxRetries,
getRetryDelayMs,
getTimeoutMs,
isDaemonEnabled,
isHttpServer,
isToolAllowed,
} from './config.js';
import {
type DaemonConnection,
cleanupOrphanedDaemons,
getDaemonConnection,
} from './daemon-client.js';
import { VERSION } from './version.js';
// Re-export config utilities for convenience
export { debug, getTimeoutMs, getConcurrencyLimit };
export interface ConnectedClient {
client: Client;
close: () => Promise<void>;
}
/**
* Unified connection interface that works with both daemon and direct connections
*/
export interface McpConnection {
listTools: () => Promise<ToolInfo[]>;
callTool: (
toolName: string,
args: Record<string, unknown>,
) => Promise<unknown>;
getInstructions: () => Promise<string | undefined>;
close: () => Promise<void>;
isDaemon: boolean;
}
export interface ServerInfo {
name: string;
version?: string;
protocolVersion?: string;
}
export interface ToolInfo {
name: string;
description?: string;
inputSchema: Record<string, unknown>;
}
/**
* Retry configuration
*/
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
totalBudgetMs: number;
}
/**
* Get retry config respecting MCP_TIMEOUT budget
*/
function getRetryConfig(): RetryConfig {
const totalBudgetMs = getTimeoutMs();
const maxRetries = getMaxRetries();
const baseDelayMs = getRetryDelayMs();
// Reserve at least 5s for the final attempt
const retryBudgetMs = Math.max(0, totalBudgetMs - 5000);
return {
maxRetries,
baseDelayMs,
maxDelayMs: Math.min(10000, retryBudgetMs / 2),
totalBudgetMs,
};
}
/**
* Check if an error is transient and worth retrying
* Uses error codes when available, falls back to message matching
*/
export function isTransientError(error: Error): boolean {
// Check error code first (more reliable than message matching)
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code) {
const transientCodes = [
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'EPIPE',
'ENETUNREACH',
'EHOSTUNREACH',
'EAI_AGAIN',
];
if (transientCodes.includes(nodeError.code)) {
return true;
}
}
// Fallback to message matching for errors without codes
const message = error.message;
// HTTP transient errors - require status code at start or with HTTP context
// Pattern: "502", "502 Bad Gateway", "HTTP 502", "status 502", "status code 502"
if (/^(502|503|504|429)\b/.test(message)) return true;
if (/\b(http|status(\s+code)?)\s*(502|503|504|429)\b/i.test(message))
return true;
if (
/\b(502|503|504|429)\s+(bad gateway|service unavailable|gateway timeout|too many requests)/i.test(
message,
)
)
return true;
// Generic network terms - more specific patterns
if (/network\s*(error|fail|unavailable|timeout)/i.test(message)) return true;
if (/connection\s*(reset|refused|timeout)/i.test(message)) return true;
if (/\btimeout\b/i.test(message)) return true;
return false;
}
/**
* Calculate delay with exponential backoff and jitter
*/
function calculateDelay(attempt: number, config: RetryConfig): number {
const exponentialDelay = config.baseDelayMs * 2 ** attempt;
const cappedDelay = Math.min(exponentialDelay, config.maxDelayMs);
// Add jitter (±25%)
const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
return Math.round(cappedDelay + jitter);
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Execute a function with retry logic for transient failures
* Respects overall timeout budget from MCP_TIMEOUT
*/
async function withRetry<T>(
fn: () => Promise<T>,
operationName: string,
config: RetryConfig = getRetryConfig(),
): Promise<T> {
let lastError: Error | undefined;
const startTime = Date.now();
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
// Check if we've exceeded the total timeout budget
const elapsed = Date.now() - startTime;
if (elapsed >= config.totalBudgetMs) {
debug(`${operationName}: timeout budget exhausted after ${elapsed}ms`);
break;
}
try {
return await fn();
} catch (error) {
lastError = error as Error;
const remainingBudget = config.totalBudgetMs - (Date.now() - startTime);
const shouldRetry =
attempt < config.maxRetries &&
isTransientError(lastError) &&
remainingBudget > 1000; // At least 1s remaining
if (shouldRetry) {
const delay = Math.min(
calculateDelay(attempt, config),
remainingBudget - 1000,
);
debug(
`${operationName} failed (attempt ${attempt + 1}/${config.maxRetries + 1}): ${lastError.message}. Retrying in ${delay}ms...`,
);
await sleep(delay);
} else {
throw lastError;
}
}
}
throw lastError;
}
/**
* Safely close a connection, logging but not throwing on error
*/
export async function safeClose(close: () => Promise<void>): Promise<void> {
try {
await close();
} catch (err) {
debug(`Failed to close connection: ${(err as Error).message}`);
}
}
/**
* Connect to an MCP server with retry logic
* Captures stderr from stdio servers to include in error messages
*/
export async function connectToServer(
serverName: string,
config: ServerConfig,
): Promise<ConnectedClient> {
// Collect stderr for better error messages
const stderrChunks: string[] = [];
return withRetry(async () => {
const client = new Client(
{
name: 'mcp-cli',
version: VERSION,
},
{
capabilities: {},
},
);
let transport: StdioClientTransport | StreamableHTTPClientTransport;
if (isHttpServer(config)) {
transport = createHttpTransport(config);
} else {
transport = createStdioTransport(config);
// Capture stderr for debugging - attach BEFORE connect
// Always stream stderr immediately so auth prompts are visible
const stderrStream = transport.stderr;
if (stderrStream) {
stderrStream.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderrChunks.push(text);
// Always stream stderr immediately so users can see auth prompts
process.stderr.write(`[${serverName}] ${text}`);
});
}
}
try {
await client.connect(transport);
} catch (error) {
// Enhance error with captured stderr
const stderrOutput = stderrChunks.join('').trim();
if (stderrOutput) {
const err = error as Error;
err.message = `${err.message}\n\nServer stderr:\n${stderrOutput}`;
}
throw error;
}
// For successful connections, forward stderr to console
if (!isHttpServer(config)) {
const stderrStream = (transport as StdioClientTransport).stderr;
if (stderrStream) {
stderrStream.on('data', (chunk: Buffer) => {
process.stderr.write(chunk);
});
}
}
return {
client,
close: async () => {
await client.close();
},
};
}, `connect to ${serverName}`);
}
/**
* Create HTTP transport for remote servers
*/
function createHttpTransport(
config: HttpServerConfig,
): StreamableHTTPClientTransport {
const url = new URL(config.url);
return new StreamableHTTPClientTransport(url, {
requestInit: {
headers: config.headers,
},
});
}
/**
* Create stdio transport for local servers
* Uses stderr: 'pipe' to capture server output for debugging
*/
function createStdioTransport(config: StdioServerConfig): StdioClientTransport {
// Merge process.env with config.env, filtering out undefined values
const mergedEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
mergedEnv[key] = value;
}
}
if (config.env) {
Object.assign(mergedEnv, config.env);
}
return new StdioClientTransport({
command: config.command,
args: config.args,
env: mergedEnv,
cwd: config.cwd,
stderr: 'pipe', // Capture stderr for better error messages
});
}
/**
* List all tools from a connected client with retry logic
*/
export async function listTools(client: Client): Promise<ToolInfo[]> {
return withRetry(async () => {
const result = await client.listTools();
return result.tools.map((tool: Tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema as Record<string, unknown>,
}));
}, 'list tools');
}
/**
* Get a specific tool by name
*/
export async function getTool(
client: Client,
toolName: string,
): Promise<ToolInfo | undefined> {
const tools = await listTools(client);
return tools.find((t) => t.name === toolName);
}
/**
* Call a tool with arguments and retry logic
*/
export async function callTool(
client: Client,
toolName: string,
args: Record<string, unknown>,
): Promise<unknown> {
return withRetry(async () => {
const result = await client.callTool(
{
name: toolName,
arguments: args,
},
undefined,
{ timeout: getTimeoutMs() },
);
return result;
}, `call tool ${toolName}`);
}
// ============================================================================
// Unified Connection Interface (Daemon + Direct)
// ============================================================================
/**
* Get a unified connection to an MCP server
*
* If daemon mode is enabled (default), tries to use a cached daemon connection.
* Falls back to direct connection if daemon fails or is disabled.
*
* @param serverName - Name of the server from config
* @param config - Server configuration
* @returns McpConnection with listTools, callTool, and close methods
*/
export async function getConnection(
serverName: string,
config: ServerConfig,
): Promise<McpConnection> {
// Clean up any orphaned daemons on first call
await cleanupOrphanedDaemons();
// Try daemon connection if enabled
if (isDaemonEnabled()) {
try {
const daemonConn = await getDaemonConnection(serverName, config);
if (daemonConn) {
debug(`Using daemon connection for ${serverName}`);
return {
async listTools(): Promise<ToolInfo[]> {
const data = await daemonConn.listTools();
const tools = data as ToolInfo[];
// Apply tool filtering from config
return filterTools(tools, config);
},
async callTool(
toolName: string,
args: Record<string, unknown>,
): Promise<unknown> {
// Check if tool is allowed before calling
if (!isToolAllowed(toolName, config)) {
throw new Error(
`Tool "${toolName}" is disabled by configuration`,
);
}
return daemonConn.callTool(toolName, args);
},
async getInstructions(): Promise<string | undefined> {
return daemonConn.getInstructions();
},
async close(): Promise<void> {
await daemonConn.close();
},
isDaemon: true,
};
}
} catch (err) {
debug(
`Daemon connection failed for ${serverName}: ${(err as Error).message}, falling back to direct`,
);
}
}
// Fall back to direct connection
debug(`Using direct connection for ${serverName}`);
const { client, close } = await connectToServer(serverName, config);
return {
async listTools(): Promise<ToolInfo[]> {
const tools = await listTools(client);
// Apply tool filtering from config
return filterTools(tools, config);
},
async callTool(
toolName: string,
args: Record<string, unknown>,
): Promise<unknown> {
// Check if tool is allowed before calling
if (!isToolAllowed(toolName, config)) {
throw new Error(`Tool "${toolName}" is disabled by configuration`);
}
return callTool(client, toolName, args);
},
async getInstructions(): Promise<string | undefined> {
return client.getInstructions();
},
async close(): Promise<void> {
await close();
},
isDaemon: false,
};
}
/**
* Call command - Execute a tool with arguments
*
* Output behavior:
* - Default: Raw text content to stdout (CLI-friendly)
* - With --json: Full JSON response to stdout
* - Errors always go to stderr
*/
import {
type McpConnection,
debug,
getConnection,
getTimeoutMs,
safeClose,
} from '../client.js';
import {
type McpServersConfig,
type ServerConfig,
getServerConfig,
loadConfig,
} from '../config.js';
import {
ErrorCode,
formatCliError,
invalidJsonArgsError,
invalidTargetError,
serverConnectionError,
toolExecutionError,
toolNotFoundError,
} from '../errors.js';
import { formatJson, formatToolResult } from '../output.js';
export interface CallOptions {
target: string; // "server/tool"
args?: string; // JSON arguments
configPath?: string;
}
/**
* Parse target into server and tool name
*/
function parseTarget(target: string): { server: string; tool: string } {
const slashIndex = target.indexOf('/');
if (slashIndex === -1) {
throw new Error(formatCliError(invalidTargetError(target)));
}
return {
server: target.substring(0, slashIndex),
tool: target.substring(slashIndex + 1),
};
}
/**
* Parse JSON arguments from string or stdin
*/
async function parseArgs(
argsString?: string,
): Promise<Record<string, unknown>> {
let jsonString: string;
if (argsString) {
jsonString = argsString;
} else if (!process.stdin.isTTY) {
// Read from stdin with timeout - use timer cleanup to prevent memory leak
const timeoutMs = getTimeoutMs();
const chunks: Buffer[] = [];
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const readPromise = (async () => {
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf-8').trim();
})();
const timeoutPromise = new Promise<string>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`stdin read timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
jsonString = await Promise.race([readPromise, timeoutPromise]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
} else {
// No arguments provided
return {};
}
if (!jsonString) {
return {};
}
try {
return JSON.parse(jsonString);
} catch (e) {
throw new Error(
formatCliError(invalidJsonArgsError(jsonString, (e as Error).message)),
);
}
}
/**
* Execute the call command
*/
export async function callCommand(options: CallOptions): Promise<void> {
let config: McpServersConfig;
try {
config = await loadConfig(options.configPath);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
let serverName: string;
let toolName: string;
try {
const parsed = parseTarget(options.target);
serverName = parsed.server;
toolName = parsed.tool;
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
let serverConfig: ServerConfig;
try {
serverConfig = getServerConfig(config, serverName);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
let args: Record<string, unknown>;
try {
args = await parseArgs(options.args);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
let connection: McpConnection;
try {
connection = await getConnection(serverName, serverConfig);
} catch (error) {
console.error(
formatCliError(
serverConnectionError(serverName, (error as Error).message),
),
);
process.exit(ErrorCode.NETWORK_ERROR);
}
try {
const result = await connection.callTool(toolName, args);
// Extract text content from MCP response for CLI-friendly output
// Uses formatToolResult which extracts text from MCP content array
console.log(formatToolResult(result));
} catch (error) {
// Try to get available tools for better error message
let availableTools: string[] | undefined;
try {
const tools = await connection.listTools();
availableTools = tools.map((t) => t.name);
} catch {
// Ignore - we'll show error without tool list
}
const errMsg = (error as Error).message;
// Check if it's a "tool not found" type error
if (errMsg.includes('not found') || errMsg.includes('unknown tool')) {
console.error(
formatCliError(toolNotFoundError(toolName, serverName, availableTools)),
);
} else {
console.error(
formatCliError(toolExecutionError(toolName, serverName, errMsg)),
);
}
process.exit(ErrorCode.SERVER_ERROR);
} finally {
await safeClose(connection.close);
}
}
/**
* Grep command - Search tools by pattern
*/
import {
type McpConnection,
type ToolInfo,
debug,
getConcurrencyLimit,
getConnection,
safeClose,
} from '../client.js';
import {
type McpServersConfig,
getServerConfig,
listServerNames,
loadConfig,
} from '../config.js';
import { ErrorCode } from '../errors.js';
import { formatSearchResults } from '../output.js';
export interface GrepOptions {
pattern: string;
withDescriptions: boolean;
configPath?: string;
}
interface SearchResult {
server: string;
tool: ToolInfo;
}
interface ServerSearchResult {
serverName: string;
results: SearchResult[];
error?: string;
}
/**
* Convert glob pattern to regex
* Handles: * (any chars), ? (single char), ** (globstar)
*
* Examples:
* - "*file*" matches "read_file", "file_utils"
* - "**test**" matches "test", "my_test_tool", "testing"
* - "server/*" matches "server/tool" but not "server/sub/tool"
* - "server/**" matches "server/tool" and "server/sub/tool"
*/
export function globToRegex(pattern: string): RegExp {
let escaped = '';
let i = 0;
while (i < pattern.length) {
const char = pattern[i];
if (char === '*' && pattern[i + 1] === '*') {
// ** (globstar) - match anything including slashes (zero or more chars)
escaped += '.*';
i += 2;
// Skip any immediately following * (e.g., *** becomes .*)
while (pattern[i] === '*') {
i++;
}
} else if (char === '*') {
// * - match any chars except slash (zero or more)
escaped += '[^/]*';
i += 1;
} else if (char === '?') {
// ? - match single char (not slash)
escaped += '[^/]';
i += 1;
} else if ('[.+^${}()|\\]'.includes(char)) {
// Escape special regex chars
escaped += `\\${char}`;
i += 1;
} else {
escaped += char;
i += 1;
}
}
return new RegExp(`^${escaped}$`, 'i');
}
/**
* Process items with limited concurrency, preserving order
*/
async function processWithConcurrency<T, R>(
items: T[],
processor: (item: T, index: number) => Promise<R>,
maxConcurrency: number,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let currentIndex = 0;
async function worker(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++;
results[index] = await processor(items[index], index);
}
}
// Start workers up to concurrency limit
const workers = Array.from(
{ length: Math.min(maxConcurrency, items.length) },
() => worker(),
);
await Promise.all(workers);
return results;
}
/**
* Search tools in a single server (uses daemon if enabled)
*/
async function searchServerTools(
serverName: string,
config: McpServersConfig,
pattern: RegExp,
): Promise<ServerSearchResult> {
let connection: McpConnection | null = null;
try {
const serverConfig = getServerConfig(config, serverName);
connection = await getConnection(serverName, serverConfig);
const tools = await connection.listTools();
const results: SearchResult[] = [];
for (const tool of tools) {
// Match against tool name only (not server name or description)
if (pattern.test(tool.name)) {
results.push({ server: serverName, tool });
}
}
debug(`${serverName}: found ${results.length} matches`);
return { serverName, results };
} catch (error) {
const errorMsg = (error as Error).message;
debug(`${serverName}: connection failed - ${errorMsg}`);
return { serverName, results: [], error: errorMsg };
} finally {
if (connection) {
await safeClose(connection.close);
}
}
}
/**
* Execute the grep command
*/
export async function grepCommand(options: GrepOptions): Promise<void> {
let config: McpServersConfig;
try {
config = await loadConfig(options.configPath);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
const pattern = globToRegex(options.pattern);
const serverNames = listServerNames(config);
if (serverNames.length === 0) {
console.error(
'Warning: No servers configured. Add servers to mcp_servers.json',
);
return;
}
const concurrencyLimit = getConcurrencyLimit();
debug(
`Searching ${serverNames.length} servers for pattern "${options.pattern}" (concurrency: ${concurrencyLimit})`,
);
// Process servers in parallel with concurrency limit
const serverResults = await processWithConcurrency(
serverNames,
(serverName) => searchServerTools(serverName, config, pattern),
concurrencyLimit,
);
const allResults: SearchResult[] = [];
const failedServers: string[] = [];
for (const result of serverResults) {
allResults.push(...result.results);
if (result.error) {
failedServers.push(result.serverName);
}
}
// Show failed servers warning
if (failedServers.length > 0) {
console.error(
`Warning: ${failedServers.length} server(s) failed to connect: ${failedServers.join(', ')}`,
);
}
if (allResults.length === 0) {
console.log(`No tools found matching "${options.pattern}"`);
console.log(' Tip: Pattern matches tool names only (not server names)');
console.log(` Tip: Use '*' for wildcards, e.g. '*file*' or 'read_*'`);
console.log(` Tip: Run 'mcp-cli' to list all available tools`);
return;
}
// Human-readable output
console.log(formatSearchResults(allResults, options.withDescriptions));
}
/**
* Info command - Show server or tool details
*/
import { type McpConnection, getConnection, safeClose } from '../client.js';
import {
type McpServersConfig,
type ServerConfig,
getServerConfig,
loadConfig,
} from '../config.js';
import {
ErrorCode,
formatCliError,
serverConnectionError,
toolNotFoundError,
} from '../errors.js';
import { formatServerDetails, formatToolSchema } from '../output.js';
export interface InfoOptions {
target: string; // "server" or "server/tool"
withDescriptions: boolean;
configPath?: string;
}
/**
* Parse target into server and optional tool name
*/
function parseTarget(target: string): { server: string; tool?: string } {
const parts = target.split('/');
if (parts.length === 1) {
return { server: parts[0] };
}
return { server: parts[0], tool: parts.slice(1).join('/') };
}
/**
* Execute the info command
*/
export async function infoCommand(options: InfoOptions): Promise<void> {
let config: McpServersConfig;
try {
config = await loadConfig(options.configPath);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
const { server: serverName, tool: toolName } = parseTarget(options.target);
let serverConfig: ServerConfig;
try {
serverConfig = getServerConfig(config, serverName);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
let connection: McpConnection;
try {
connection = await getConnection(serverName, serverConfig);
} catch (error) {
console.error(
formatCliError(
serverConnectionError(serverName, (error as Error).message),
),
);
process.exit(ErrorCode.NETWORK_ERROR);
}
try {
if (toolName) {
// Show specific tool schema
const tools = await connection.listTools();
const tool = tools.find((t) => t.name === toolName);
if (!tool) {
const availableTools = tools.map((t) => t.name);
console.error(
formatCliError(
toolNotFoundError(toolName, serverName, availableTools),
),
);
process.exit(ErrorCode.CLIENT_ERROR);
}
// Human-readable output
console.log(formatToolSchema(serverName, tool));
} else {
// Show server details
const tools = await connection.listTools();
const instructions = await connection.getInstructions();
// Human-readable output
console.log(
formatServerDetails(
serverName,
serverConfig,
tools,
options.withDescriptions,
instructions,
),
);
}
} finally {
await safeClose(connection.close);
}
}
/**
* List command - List all servers and their tools
*/
import {
type McpConnection,
type ToolInfo,
debug,
getConcurrencyLimit,
getConnection,
safeClose,
} from '../client.js';
import {
type McpServersConfig,
getServerConfig,
listServerNames,
loadConfig,
} from '../config.js';
import { ErrorCode } from '../errors.js';
import { formatServerList } from '../output.js';
export interface ListOptions {
withDescriptions: boolean;
configPath?: string;
}
interface ServerWithTools {
name: string;
tools: ToolInfo[];
instructions?: string;
error?: string;
}
/**
* Process items with limited concurrency, preserving order
* Uses a worker pool pattern where each worker grabs the next item from a shared index
*/
async function processWithConcurrency<T, R>(
items: T[],
processor: (item: T, index: number) => Promise<R>,
maxConcurrency: number,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let currentIndex = 0;
async function worker(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++;
results[index] = await processor(items[index], index);
}
}
// Start workers up to concurrency limit
const workers = Array.from(
{ length: Math.min(maxConcurrency, items.length) },
() => worker(),
);
await Promise.all(workers);
return results;
}
/**
* Fetch tools from a single server (uses daemon if enabled)
*/
async function fetchServerTools(
serverName: string,
config: McpServersConfig,
): Promise<ServerWithTools> {
let connection: McpConnection | null = null;
try {
const serverConfig = getServerConfig(config, serverName);
connection = await getConnection(serverName, serverConfig);
const tools = await connection.listTools();
const instructions = await connection.getInstructions();
debug(`${serverName}: loaded ${tools.length} tools`);
return { name: serverName, tools, instructions };
} catch (error) {
const errorMsg = (error as Error).message;
debug(`${serverName}: connection failed - ${errorMsg}`);
return {
name: serverName,
tools: [],
error: errorMsg,
};
} finally {
if (connection) {
await safeClose(connection.close);
}
}
}
/**
* Execute the list command
*/
export async function listCommand(options: ListOptions): Promise<void> {
let config: McpServersConfig;
try {
config = await loadConfig(options.configPath);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}
const serverNames = listServerNames(config);
if (serverNames.length === 0) {
console.error(
'Warning: No servers configured. Add servers to mcp_servers.json',
);
return;
}
const concurrencyLimit = getConcurrencyLimit();
debug(
`Processing ${serverNames.length} servers with concurrency ${concurrencyLimit}`,
);
// Process servers in parallel with concurrency limit
const servers = await processWithConcurrency(
serverNames,
(name) => fetchServerTools(name, config),
concurrencyLimit,
);
// Sort by name to ensure consistent output order
servers.sort((a, b) => a.name.localeCompare(b.name));
// Convert errors to tool-like display for human output
const displayServers = servers.map((s) => ({
name: s.name,
instructions: s.instructions,
tools: s.error
? [
{
name: `<error: ${s.error}>`,
description: undefined,
inputSchema: {},
},
]
: s.tools,
}));
// Human-readable output
console.log(formatServerList(displayServers, options.withDescriptions));
}
/**
* MCP-CLI Configuration Types and Loader
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import {
ErrorCode,
configInvalidJsonError,
configMissingFieldError,
configNotFoundError,
configSearchError,
formatCliError,
serverNotFoundError,
} from './errors.js';
/**
* Base server configuration with tool filtering
*
* Tool Filtering Rules:
* - If allowedTools is specified, only tools matching those patterns are available
* - If disabledTools is specified, tools matching those patterns are excluded
* - disabledTools takes precedence over allowedTools (a tool in both lists is disabled)
* - Patterns support glob syntax (e.g., "read_*", "*file*")
*/
export interface BaseServerConfig {
/** Glob patterns for tools to allow (if empty/undefined, all tools are allowed) */
allowedTools?: string[];
/** Glob patterns for tools to exclude (takes precedence over allowedTools) */
disabledTools?: string[];
}
/**
* stdio server configuration (local process)
*/
export interface StdioServerConfig extends BaseServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
}
/**
* HTTP server configuration (remote)
*/
export interface HttpServerConfig extends BaseServerConfig {
url: string;
headers?: Record<string, string>;
timeout?: number;
}
export type ServerConfig = StdioServerConfig | HttpServerConfig;
export interface McpServersConfig {
mcpServers: Record<string, ServerConfig>;
}
// ============================================================================
// Tool Filtering
// ============================================================================
/**
* Simple glob pattern matcher for tool names
* Supports * (any characters) and ? (single character)
*/
function matchesPattern(name: string, pattern: string): boolean {
// Convert glob pattern to regex
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
.replace(/\*/g, '.*') // * matches any characters
.replace(/\?/g, '.'); // ? matches single character
return new RegExp(`^${regexPattern}$`, 'i').test(name);
}
/**
* Check if a tool name matches any of the given patterns
*/
function matchesAnyPattern(name: string, patterns: string[]): boolean {
return patterns.some((pattern) => matchesPattern(name, pattern));
}
/**
* Filter tools based on allowedTools and disabledTools configuration
*
* Rules:
* - If allowedTools is specified, only tools matching those patterns are available
* - If disabledTools is specified, tools matching those patterns are excluded
* - disabledTools takes precedence over allowedTools
*
* @param tools - Array of tools with name property
* @param config - Server config with optional allowedTools/disabledTools
* @returns Filtered array of tools
*/
export function filterTools<T extends { name: string }>(
tools: T[],
config: ServerConfig,
): T[] {
const { allowedTools, disabledTools } = config;
return tools.filter((tool) => {
// First check if tool is in disabledTools (takes precedence)
if (disabledTools && disabledTools.length > 0) {
if (matchesAnyPattern(tool.name, disabledTools)) {
return false;
}
}
// Then check if allowedTools is specified
if (allowedTools && allowedTools.length > 0) {
return matchesAnyPattern(tool.name, allowedTools);
}
// No filtering specified, allow all
return true;
});
}
/**
* Check if a specific tool is allowed by the config
*
* @param toolName - Name of the tool to check
* @param config - Server config with optional allowedTools/disabledTools
* @returns true if tool is allowed, false otherwise
*/
export function isToolAllowed(toolName: string, config: ServerConfig): boolean {
const { allowedTools, disabledTools } = config;
// First check if tool is in disabledTools (takes precedence)
if (disabledTools && disabledTools.length > 0) {
if (matchesAnyPattern(toolName, disabledTools)) {
return false;
}
}
// Then check if allowedTools is specified
if (allowedTools && allowedTools.length > 0) {
return matchesAnyPattern(toolName, allowedTools);
}
// No filtering specified, allow all
return true;
}
/**
* Check if a server config is HTTP-based
*/
export function isHttpServer(config: ServerConfig): config is HttpServerConfig {
return 'url' in config;
}
/**
* Check if a server config is stdio-based
*/
export function isStdioServer(
config: ServerConfig,
): config is StdioServerConfig {
return 'command' in config;
}
// ============================================================================
// Environment Variables & Runtime Configuration
// ============================================================================
/**
* Default configuration values - centralized to avoid inline magic numbers
*/
export const DEFAULT_TIMEOUT_SECONDS = 1800; // 30 minutes
export const DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_SECONDS * 1000;
export const DEFAULT_CONCURRENCY = 5;
export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_RETRY_DELAY_MS = 1000; // 1 second base delay
export const DEFAULT_DAEMON_TIMEOUT_SECONDS = 60; // 60 seconds idle timeout
/**
* Debug logging utility - only logs when MCP_DEBUG is set
*/
export function debug(message: string): void {
if (process.env.MCP_DEBUG) {
console.error(`[mcp-cli] ${message}`);
}
}
/**
* Get configured timeout in milliseconds
* @env MCP_TIMEOUT - timeout in seconds (default: 1800 = 30 minutes)
*/
export function getTimeoutMs(): number {
const envTimeout = process.env.MCP_TIMEOUT;
if (envTimeout) {
const seconds = Number.parseInt(envTimeout, 10);
if (!Number.isNaN(seconds) && seconds > 0) {
return seconds * 1000;
}
}
return DEFAULT_TIMEOUT_MS;
}
/**
* Get concurrency limit for parallel server connections
* @env MCP_CONCURRENCY - max parallel connections (default: 5)
*/
export function getConcurrencyLimit(): number {
const envConcurrency = process.env.MCP_CONCURRENCY;
if (envConcurrency) {
const limit = Number.parseInt(envConcurrency, 10);
if (!Number.isNaN(limit) && limit > 0) {
return limit;
}
}
return DEFAULT_CONCURRENCY;
}
/**
* Get max retry attempts for transient failures
* @env MCP_MAX_RETRIES - max retry attempts (default: 3, use 0 to disable retries)
*/
export function getMaxRetries(): number {
const envRetries = process.env.MCP_MAX_RETRIES;
if (envRetries) {
const retries = Number.parseInt(envRetries, 10);
if (!Number.isNaN(retries) && retries >= 0) {
return retries;
}
}
return DEFAULT_MAX_RETRIES;
}
/**
* Get base delay for retry backoff in milliseconds
* @env MCP_RETRY_DELAY - base delay in milliseconds (default: 1000)
*/
export function getRetryDelayMs(): number {
const envDelay = process.env.MCP_RETRY_DELAY;
if (envDelay) {
const delay = Number.parseInt(envDelay, 10);
if (!Number.isNaN(delay) && delay > 0) {
return delay;
}
}
return DEFAULT_RETRY_DELAY_MS;
}
// ============================================================================
// Daemon Configuration
// ============================================================================
/**
* Check if daemon mode is enabled
* @env MCP_NO_DAEMON - set to "1" to disable daemon, force fresh connections
*/
export function isDaemonEnabled(): boolean {
return process.env.MCP_NO_DAEMON !== '1';
}
/**
* Get daemon idle timeout in milliseconds
* @env MCP_DAEMON_TIMEOUT - timeout in seconds (default: 60)
*/
export function getDaemonTimeoutMs(): number {
const envTimeout = process.env.MCP_DAEMON_TIMEOUT;
if (envTimeout) {
const seconds = Number.parseInt(envTimeout, 10);
if (!Number.isNaN(seconds) && seconds > 0) {
return seconds * 1000;
}
}
return DEFAULT_DAEMON_TIMEOUT_SECONDS * 1000;
}
/**
* Get the socket directory for daemon connections
* Uses platform-appropriate temp directory
*/
export function getSocketDir(): string {
const uid = process.getuid?.() ?? 'unknown';
// macOS uses /var/folders which is auto-cleaned, Linux uses /tmp
const base = process.platform === 'darwin' ? '/tmp' : '/tmp';
return join(base, `mcp-cli-${uid}`);
}
/**
* Get socket path for a specific server
*/
export function getSocketPath(serverName: string): string {
return join(getSocketDir(), `${serverName}.sock`);
}
/**
* Get PID file path for a specific server daemon
*/
export function getPidPath(serverName: string): string {
return join(getSocketDir(), `${serverName}.pid`);
}
/**
* Generate a hash of server config for stale detection
* Returns consistent hash for identical configs
*/
export function getConfigHash(config: ServerConfig): string {
const str = JSON.stringify(config, Object.keys(config).sort());
// Simple hash using Bun's native hashing
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(str);
return hasher.digest('hex').slice(0, 16); // First 16 chars is enough
}
/**
* Check if strict environment variable mode is enabled
* @env MCP_STRICT_ENV - set to "false" to warn instead of error (default: true)
*/
function isStrictEnvMode(): boolean {
const value = process.env.MCP_STRICT_ENV?.toLowerCase();
return value !== 'false' && value !== '0';
}
/**
* Substitute environment variables in a string
* Supports ${VAR_NAME} syntax
*
* By default (strict mode), throws an error when referenced env var is not set.
* Set MCP_STRICT_ENV=false to warn instead of error.
*/
function substituteEnvVars(value: string): string {
const missingVars: string[] = [];
const result = value.replace(/\$\{([^}]+)\}/g, (match, varName) => {
const envValue = process.env[varName];
if (envValue === undefined) {
missingVars.push(varName);
return '';
}
return envValue;
});
if (missingVars.length > 0) {
const varList = missingVars.map((v) => `\${${v}}`).join(', ');
const message = `Missing environment variable${missingVars.length > 1 ? 's' : ''}: ${varList}`;
if (isStrictEnvMode()) {
throw new Error(
formatCliError({
code: ErrorCode.CLIENT_ERROR,
type: 'MISSING_ENV_VAR',
message: message,
details: 'Referenced in config but not set in environment',
suggestion: `Set the variable(s) before running: export ${missingVars[0]}="value" or set MCP_STRICT_ENV=false to use empty values`,
}),
);
}
// Non-strict mode: warn but continue
console.error(`[mcp-cli] Warning: ${message}`);
}
return result;
}
/**
* Recursively substitute environment variables in an object
*/
function substituteEnvVarsInObject<T>(obj: T): T {
if (typeof obj === 'string') {
return substituteEnvVars(obj) as T;
}
if (Array.isArray(obj)) {
return obj.map(substituteEnvVarsInObject) as T;
}
if (obj && typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = substituteEnvVarsInObject(value);
}
return result as T;
}
return obj;
}
/**
* Get default config search paths
*/
function getDefaultConfigPaths(): string[] {
const paths: string[] = [];
const home = homedir();
// Current directory
paths.push(resolve('./mcp_servers.json'));
// Home directory variants
paths.push(join(home, '.mcp_servers.json'));
paths.push(join(home, '.config', 'mcp', 'mcp_servers.json'));
return paths;
}
/**
* Load and parse MCP servers configuration
*/
export async function loadConfig(
explicitPath?: string,
): Promise<McpServersConfig> {
let configPath: string | undefined;
// Check explicit path from argument or environment
if (explicitPath) {
configPath = resolve(explicitPath);
} else if (process.env.MCP_CONFIG_PATH) {
configPath = resolve(process.env.MCP_CONFIG_PATH);
}
// If explicit path provided, it must exist
if (configPath) {
if (!existsSync(configPath)) {
throw new Error(formatCliError(configNotFoundError(configPath)));
}
} else {
// Search default paths
const searchPaths = getDefaultConfigPaths();
for (const path of searchPaths) {
if (existsSync(path)) {
configPath = path;
break;
}
}
if (!configPath) {
throw new Error(formatCliError(configSearchError()));
}
}
// Read and parse config
const file = Bun.file(configPath);
const content = await file.text();
let config: McpServersConfig;
try {
config = JSON.parse(content);
} catch (e) {
throw new Error(
formatCliError(configInvalidJsonError(configPath, (e as Error).message)),
);
}
// Validate structure
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
throw new Error(formatCliError(configMissingFieldError(configPath)));
}
// Warn if no servers are configured
if (Object.keys(config.mcpServers).length === 0) {
console.error(
'[mcp-cli] Warning: No servers configured in mcpServers. Add server configurations to use MCP tools.',
);
}
// Validate individual server configs
for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
if (!serverConfig || typeof serverConfig !== 'object') {
throw new Error(
formatCliError({
code: ErrorCode.CLIENT_ERROR,
type: 'CONFIG_INVALID_SERVER',
message: `Invalid server configuration for "${serverName}"`,
details: 'Server config must be an object',
suggestion: `Use { "command": "..." } for stdio or { "url": "..." } for HTTP`,
}),
);
}
const hasCommand = 'command' in serverConfig;
const hasUrl = 'url' in serverConfig;
if (!hasCommand && !hasUrl) {
throw new Error(
formatCliError({
code: ErrorCode.CLIENT_ERROR,
type: 'CONFIG_INVALID_SERVER',
message: `Server "${serverName}" missing required field`,
details: `Must have either "command" (for stdio) or "url" (for HTTP)`,
suggestion: `Add "command": "npx ..." for local servers or "url": "https://..." for remote servers`,
}),
);
}
if (hasCommand && hasUrl) {
throw new Error(
formatCliError({
code: ErrorCode.CLIENT_ERROR,
type: 'CONFIG_INVALID_SERVER',
message: `Server "${serverName}" has both "command" and "url"`,
details:
'A server must be either stdio (command) or HTTP (url), not both',
suggestion: `Remove one of "command" or "url"`,
}),
);
}
}
// Substitute environment variables
config = substituteEnvVarsInObject(config);
return config;
}
/**
* Get a specific server config by name
*/
export function getServerConfig(
config: McpServersConfig,
serverName: string,
): ServerConfig {
const server = config.mcpServers[serverName];
if (!server) {
const available = Object.keys(config.mcpServers);
throw new Error(formatCliError(serverNotFoundError(serverName, available)));
}
return server;
}
/**
* List all server names
*/
export function listServerNames(config: McpServersConfig): string[] {
return Object.keys(config.mcpServers);
}
/**
* MCP-CLI Daemon Client - IPC client for communicating with daemon workers
*
* Handles spawning daemons, detecting stale connections, and forwarding requests.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import {
type ServerConfig,
debug,
getConfigHash,
getSocketDir,
getSocketPath,
} from './config.js';
import {
type DaemonRequest,
type DaemonResponse,
isProcessRunning,
killProcess,
readPidFile,
removePidFile,
removeSocketFile,
} from './daemon.js';
// ============================================================================
// Daemon Connection
// ============================================================================
/**
* Represents a daemon connection for a specific server
*/
export interface DaemonConnection {
serverName: string;
listTools: () => Promise<unknown>;
callTool: (
toolName: string,
args: Record<string, unknown>,
) => Promise<unknown>;
getInstructions: () => Promise<string | undefined>;
close: () => Promise<void>;
}
/**
* Generate a unique request ID
*/
function generateRequestId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Send a request to the daemon and wait for response
*/
async function sendRequest(
socketPath: string,
request: DaemonRequest,
): Promise<DaemonResponse> {
return new Promise((resolve, reject) => {
const socket = Bun.connect({
unix: socketPath,
socket: {
open(socket) {
socket.write(JSON.stringify(request));
},
data(socket, data) {
try {
const response = JSON.parse(data.toString().trim());
socket.end();
resolve(response);
} catch (err) {
socket.end();
reject(new Error('Invalid response from daemon'));
}
},
error(socket, error) {
reject(error);
},
close() {
// Connection closed
},
connectError(socket, error) {
reject(error);
},
},
});
// Timeout after 5 seconds (fast fallback to direct connection)
setTimeout(() => {
reject(new Error('Daemon request timeout'));
}, 5000);
});
}
/**
* Check if daemon is running and has matching config
*/
function isDaemonValid(serverName: string, config: ServerConfig): boolean {
const socketPath = getSocketPath(serverName);
const pidInfo = readPidFile(serverName);
// No PID file = no daemon
if (!pidInfo) {
debug(`[daemon-client] No PID file for ${serverName}`);
return false;
}
// Check if process is actually running
if (!isProcessRunning(pidInfo.pid)) {
debug(`[daemon-client] Process ${pidInfo.pid} not running, cleaning up`);
removePidFile(serverName);
removeSocketFile(serverName);
return false;
}
// Check if config matches
const currentHash = getConfigHash(config);
if (pidInfo.configHash !== currentHash) {
debug(
`[daemon-client] Config hash mismatch for ${serverName}, killing old daemon`,
);
killProcess(pidInfo.pid);
removePidFile(serverName);
removeSocketFile(serverName);
return false;
}
// Check if socket exists
if (!existsSync(socketPath)) {
debug(`[daemon-client] Socket missing for ${serverName}, cleaning up`);
killProcess(pidInfo.pid);
removePidFile(serverName);
return false;
}
return true;
}
/**
* Spawn a new daemon process for a server
*/
async function spawnDaemon(
serverName: string,
config: ServerConfig,
): Promise<boolean> {
debug(`[daemon-client] Spawning daemon for ${serverName}`);
// Find the daemon script path
const daemonScript = join(import.meta.dir, 'daemon.ts');
const configJson = JSON.stringify(config);
// Spawn detached process
const proc = Bun.spawn({
cmd: ['bun', 'run', daemonScript, '--daemon', serverName, configJson],
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env },
});
// Wait for daemon to signal readiness or fail
return new Promise((resolve) => {
let resolved = false;
const reader = proc.stdout.getReader();
const checkReady = async () => {
try {
const { value, done } = await reader.read();
if (done) {
if (!resolved) {
resolved = true;
resolve(false);
}
return;
}
const text = new TextDecoder().decode(value);
if (text.includes('DAEMON_READY')) {
if (!resolved) {
resolved = true;
// Don't await the process, let it run detached
proc.unref();
resolve(true);
}
} else {
// Keep reading
checkReady();
}
} catch {
if (!resolved) {
resolved = true;
resolve(false);
}
}
};
checkReady();
// Timeout after 5 seconds (fast fallback to direct connection)
setTimeout(() => {
if (!resolved) {
resolved = true;
debug(`[daemon-client] Daemon spawn timeout for ${serverName}`);
resolve(false);
}
}, 5000);
// Check for early exit
proc.exited.then((code) => {
if (!resolved && code !== 0) {
resolved = true;
debug(`[daemon-client] Daemon exited with code ${code}`);
resolve(false);
}
});
});
}
/**
* Get or create a daemon connection for a server
* Returns null if daemon mode fails (caller should fallback to direct connection)
*/
export async function getDaemonConnection(
serverName: string,
config: ServerConfig,
): Promise<DaemonConnection | null> {
const socketPath = getSocketPath(serverName);
// Check if valid daemon exists
if (!isDaemonValid(serverName, config)) {
// Spawn new daemon
const spawned = await spawnDaemon(serverName, config);
if (!spawned) {
debug(`[daemon-client] Failed to spawn daemon for ${serverName}`);
return null;
}
// Wait a bit for socket to be ready
await new Promise((r) => setTimeout(r, 100));
}
// Verify socket exists
if (!existsSync(socketPath)) {
debug(`[daemon-client] Socket not found after spawn for ${serverName}`);
return null;
}
// Test connection with ping
try {
const pingResponse = await sendRequest(socketPath, {
id: generateRequestId(),
type: 'ping',
});
if (!pingResponse.success) {
debug(`[daemon-client] Ping failed for ${serverName}`);
return null;
}
} catch (error) {
debug(
`[daemon-client] Connection test failed for ${serverName}: ${(error as Error).message}`,
);
return null;
}
debug(`[daemon-client] Connected to daemon for ${serverName}`);
// Return connection interface
return {
serverName,
async listTools(): Promise<unknown> {
const response = await sendRequest(socketPath, {
id: generateRequestId(),
type: 'listTools',
});
if (!response.success) {
throw new Error(response.error?.message ?? 'listTools failed');
}
return response.data;
},
async callTool(
toolName: string,
args: Record<string, unknown>,
): Promise<unknown> {
const response = await sendRequest(socketPath, {
id: generateRequestId(),
type: 'callTool',
toolName,
args,
});
if (!response.success) {
throw new Error(response.error?.message ?? 'callTool failed');
}
return response.data;
},
async getInstructions(): Promise<string | undefined> {
const response = await sendRequest(socketPath, {
id: generateRequestId(),
type: 'getInstructions',
});
if (!response.success) {
throw new Error(response.error?.message ?? 'getInstructions failed');
}
return response.data as string | undefined;
},
async close(): Promise<void> {
// Just disconnect, don't tell daemon to close (let it idle timeout)
debug(`[daemon-client] Disconnecting from ${serverName} daemon`);
},
};
}
/**
* Clean up any orphaned daemon processes and sockets
* Call this on CLI startup
*/
export async function cleanupOrphanedDaemons(): Promise<void> {
const socketDir = getSocketDir();
if (!existsSync(socketDir)) {
return;
}
try {
const files = await Array.fromAsync(new Bun.Glob('*.pid').scan(socketDir));
for (const file of files) {
const serverName = file.replace('.pid', '');
const pidInfo = readPidFile(serverName);
if (pidInfo && !isProcessRunning(pidInfo.pid)) {
debug(`[daemon-client] Cleaning up orphaned daemon: ${serverName}`);
removePidFile(serverName);
removeSocketFile(serverName);
}
}
} catch {
// Ignore errors during cleanup scan
}
}
/**
* Output formatting utilities
*/
import type { ToolInfo } from './client.js';
import type { ServerConfig } from './config.js';
import { isHttpServer } from './config.js';
// ANSI color codes
const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
};
/**
* Check if output should be colorized
*/
function shouldColorize(): boolean {
return process.stdout.isTTY && !process.env.NO_COLOR;
}
/**
* Apply color if terminal supports it
*/
function color(text: string, colorCode: string): string {
if (!shouldColorize()) return text;
return `${colorCode}${text}${colors.reset}`;
}
/**
* Format server list for display
*/
export function formatServerList(
servers: Array<{ name: string; tools: ToolInfo[]; instructions?: string }>,
withDescriptions: boolean,
): string {
const lines: string[] = [];
for (const server of servers) {
lines.push(color(server.name, colors.bold + colors.cyan));
// Show instructions if available (first line only in list view, or all if short)
if (server.instructions) {
const instructionLines = server.instructions.split('\n');
const firstLine = instructionLines[0].slice(0, 100);
const suffix =
instructionLines.length > 1 || instructionLines[0].length > 100
? '...'
: '';
lines.push(
` ${color(`Instructions: ${firstLine}${suffix}`, colors.dim)}`,
);
}
for (const tool of server.tools) {
if (withDescriptions && tool.description) {
lines.push(` • ${tool.name} - ${color(tool.description, colors.dim)}`);
} else {
lines.push(` • ${tool.name}`);
}
}
lines.push(''); // Empty line between servers
}
return lines.join('\n').trimEnd();
}
/**
* Format search results
*/
export function formatSearchResults(
results: Array<{ server: string; tool: ToolInfo }>,
withDescriptions: boolean,
): string {
const lines: string[] = [];
for (const result of results) {
const server = color(result.server, colors.cyan);
const tool = color(result.tool.name, colors.green);
// Always show description if available (grep is for discovery)
if (result.tool.description) {
lines.push(
`${server} ${tool} ${color(result.tool.description, colors.dim)}`,
);
} else {
lines.push(`${server} ${tool}`);
}
}
return lines.join('\n');
}
/**
* Format server details
*/
export function formatServerDetails(
serverName: string,
config: ServerConfig,
tools: ToolInfo[],
withDescriptions = false,
instructions?: string,
): string {
const lines: string[] = [];
lines.push(
`${color('Server:', colors.bold)} ${color(serverName, colors.cyan)}`,
);
if (isHttpServer(config)) {
lines.push(`${color('Transport:', colors.bold)} HTTP`);
lines.push(`${color('URL:', colors.bold)} ${config.url}`);
} else {
lines.push(`${color('Transport:', colors.bold)} stdio`);
lines.push(
`${color('Command:', colors.bold)} ${config.command} ${(config.args || []).join(' ')}`,
);
}
if (instructions) {
lines.push('');
lines.push(`${color('Instructions:', colors.bold)}`);
// Indent multi-line instructions
const indentedInstructions = instructions
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
lines.push(indentedInstructions);
}
lines.push('');
lines.push(`${color(`Tools (${tools.length}):`, colors.bold)}`);
for (const tool of tools) {
lines.push(` ${color(tool.name, colors.green)}`);
if (withDescriptions && tool.description) {
lines.push(` ${color(tool.description, colors.dim)}`);
}
// Show parameters from schema
const schema = tool.inputSchema as {
properties?: Record<string, { type?: string; description?: string }>;
required?: string[];
};
if (schema.properties) {
lines.push(` ${color('Parameters:', colors.yellow)}`);
for (const [name, prop] of Object.entries(schema.properties)) {
const required = schema.required?.includes(name)
? 'required'
: 'optional';
const type = prop.type || 'any';
const desc =
withDescriptions && prop.description ? ` - ${prop.description}` : '';
lines.push(` • ${name} (${type}, ${required})${desc}`);
}
}
lines.push('');
}
return lines.join('\n').trimEnd();
}
/**
* Format tool schema
*/
export function formatToolSchema(serverName: string, tool: ToolInfo): string {
const lines: string[] = [];
lines.push(
`${color('Tool:', colors.bold)} ${color(tool.name, colors.green)}`,
);
lines.push(
`${color('Server:', colors.bold)} ${color(serverName, colors.cyan)}`,
);
lines.push('');
if (tool.description) {
lines.push(`${color('Description:', colors.bold)}`);
lines.push(` ${tool.description}`);
lines.push('');
}
lines.push(`${color('Input Schema:', colors.bold)}`);
lines.push(JSON.stringify(tool.inputSchema, null, 2));
return lines.join('\n');
}
/**
* Format tool call result
*/
export function formatToolResult(result: unknown): string {
if (typeof result === 'object' && result !== null) {
const r = result as { content?: Array<{ type: string; text?: string }> };
// Handle MCP tool result format
if (r.content && Array.isArray(r.content)) {
const textParts = r.content
.filter((c) => c.type === 'text' && c.text)
.map((c) => c.text);
if (textParts.length > 0) {
return textParts.join('\n');
}
}
}
// Fallback to JSON
return JSON.stringify(result, null, 2);
}
/**
* Format as JSON
*/
export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
/**
* Format error message
*/
export function formatError(message: string): string {
return color(`Error: ${message}`, '\x1b[31m'); // Red
}
/**
* Version constant - single source of truth
* This file is auto-updated by scripts/release.sh
*/
export const VERSION = '0.3.0';
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": [
"bun"
],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"lib": [
"ESNext"
],
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*",
"tests/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}