diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3c56163 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# 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 server + - `mcp-cli info `: 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_*"]`) + - `disabledTools` takes precedence over `allowedTools` + - 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=1` to disable + +- **3-Subcommand Architecture** - `info`, `grep`, `call` + - Flexible format support: `server tool` and `server/tool` + - `call` always outputs raw JSON (for piping/scripting) + - `info`/`grep` always output human-readable format + +- **Improved Error Messages for LLMs** + - AMBIGUOUS_COMMAND: Shows both `call` and `info` options + - 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 servers + - `mcp-cli info ` requires a server argument + +- **Grep Output Format** + - Output now uses space-separated format: ` ` + - 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 diff --git a/README.md b/README.md index 1ab6ba5..4d1c6a3 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,13 @@ A lightweight, Bun-based CLI for interacting with [MCP (Model Context Protocol)] - πŸͺΆ **Lightweight** - Minimal dependencies, fast startup - πŸ“¦ **Single Binary** - Compile to standalone executable via `bun build --compile` -- πŸ”§ **Shell-Friendly** - JSON output for scripting, intuitive commands +- πŸ”§ **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 -- πŸ’‘ **Actionable Errors** - Structured error messages with recovery suggestions +- ⚑ **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](./comparison.jpeg) @@ -64,22 +67,25 @@ mcp-cli -d ```bash # View tool schema first -mcp-cli filesystem/read_file +mcp-cli info filesystem read_file # Call the tool -mcp-cli filesystem/read_file '{"path": "./README.md"}' +mcp-cli call filesystem read_file '{"path": "./README.md"}' ``` ## Usage ``` -mcp-cli [options] List all servers and tools (names only) -mcp-cli [options] grep Search tools by glob pattern -mcp-cli [options] Show server tools and parameters -mcp-cli [options] / Show tool schema (JSON input schema) -mcp-cli [options] / Call tool with arguments +mcp-cli [options] List all servers and tools +mcp-cli [options] info Show server tools and parameters +mcp-cli [options] info Show tool schema +mcp-cli [options] grep Search tools by glob pattern +mcp-cli [options] call Call tool (reads JSON from stdin if no args) +mcp-cli [options] call Call tool with JSON arguments ``` +**Both formats work:** `info ` or `info /` + > [!TIP] > Add `-d` to any command to include descriptions. @@ -89,8 +95,6 @@ mcp-cli [options] / Call tool with arguments |--------|-------------| | `-h, --help` | Show help message | | `-v, --version` | Show version number | -| `-j, --json` | Output as JSON (for scripting) | -| `-r, --raw` | Output raw text content | | `-d, --with-descriptions` | Include tool descriptions | | `-c, --config ` | Path to config file | @@ -98,7 +102,7 @@ mcp-cli [options] / Call tool with arguments | Stream | Content | |--------|---------| -| **stdout** | Tool results and data (text by default, JSON with `--json`) | +| **stdout** | Tool results and human-readable info | | **stderr** | Errors and diagnostics | ### Commands @@ -145,7 +149,7 @@ github/search_repositories - Search for GitHub repositories #### View Server Details ```bash -$ mcp-cli github +$ mcp-cli info github Server: github Transport: stdio Command: npx -y @modelcontextprotocol/server-github @@ -162,7 +166,10 @@ Tools (12): #### View Tool Schema ```bash -$ mcp-cli github/search_repositories +# Both formats work: +$ mcp-cli info github search_repositories +$ mcp-cli info github/search_repositories + Tool: search_repositories Server: github @@ -184,13 +191,13 @@ Input Schema: ```bash # With inline JSON -$ mcp-cli github/search_repositories '{"query": "mcp server", "per_page": 5}' +$ mcp-cli call github search_repositories '{"query": "mcp server", "per_page": 5}' -# JSON output for scripting -$ mcp-cli github/search_repositories '{"query": "mcp"}' --json | jq '.content[0].text' +# JSON output is default for call command +$ mcp-cli call github search_repositories '{"query": "mcp"}' | jq '.content[0].text' -# Read JSON from stdin (use '-' to indicate stdin) -$ echo '{"path": "./README.md"}' | mcp-cli filesystem/read_file - +# Read JSON from stdin (no '-' needed!) +$ echo '{"path": "./README.md"}' | mcp-cli call filesystem read_file ``` @@ -199,26 +206,70 @@ $ echo '{"path": "./README.md"}' | mcp-cli filesystem/read_file - For JSON arguments containing single quotes, special characters, or long text, use **stdin** to avoid shell escaping issues: ```bash -# Using a heredoc with '-' for stdin (recommended for complex JSON) -mcp-cli server/tool - < 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 '.' ``` -**Why stdin?** Shell interpretation of `{}`, quotes, and special characters requires careful escaping. Stdin bypasses shell parsing entirely, making it reliable for any JSON content. +**Tips for chaining:** +- Use `jq -r` for raw output (no quotes) +- Use `jq -e` for conditional checks (exit code 1 if false) +- Use `2>/dev/null` to suppress errors when testing +- Use `| jq -s '.'` to combine multiple JSON outputs ## Configuration @@ -250,6 +301,42 @@ The CLI uses `mcp_servers.json`, compatible with Claude Desktop, Gemini or VS Co **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`: + +```json +{ + "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:** +```json +// 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: @@ -271,6 +358,8 @@ The CLI searches for configuration in this order: | `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 @@ -292,49 +381,49 @@ Add this to your AI agent's system prompt for direct CLI access: ````xml ## MCP Servers -You have access to MCP (Model Context Protocol) servers via the `mcp-cli` cli. -MCP provides tools for interacting with external systems like GitHub, databases, and APIs. +You have access to MCP servers via the `mcp-cli` CLI. -Available Commands: +Commands: ```bash -mcp-cli # List all servers and tool names -mcp-cli # Show server tools and parameters -mcp-cli / # Get tool JSON schema and descriptions -mcp-cli / '' # Call tool with JSON arguments -mcp-cli grep "" # Search tools by name (glob pattern) +mcp-cli info # List all servers +mcp-cli info # Show server tools +mcp-cli info # Get tool schema +mcp-cli grep "" # Search tools +mcp-cli call # Call tool (stdin auto-detected) +mcp-cli call '{}' # Call with JSON args ``` -**Add `-d` to include tool descriptions** (e.g., `mcp-cli -d`) +**Both formats work:** `info ` or `info /` Workflow: -1. **Discover**: Run `mcp-cli` to see available servers and tools or `mcp-cli grep ""` to search for tools by name (glob pattern) -2. **Inspect**: Run `mcp-cli -d` or `mcp-cli /` to get the full JSON input schema if required context is missing. If there are more than 5 mcp servers defined don't use -d as it will print all tool descriptions and might exceed the context window. -3. **Execute**: Run `mcp-cli / ''` with correct arguments +1. **Discover**: `mcp-cli info` to see available servers +2. **Inspect**: `mcp-cli info ` to get the schema +3. **Execute**: `mcp-cli call '{}'` with arguments ### Examples ```bash -# With inline JSON -$ mcp-cli github/search_repositories '{"query": "mcp server", "per_page": 5}' +# Call with inline JSON +mcp-cli call github search_repositories '{"query": "mcp server"}' -# From stdin (use '-' to indicate stdin input) -$ echo '{"query": "mcp"}' | mcp-cli github/search_repositories - +# Pipe from stdin (no '-' needed) +echo '{"path": "./file"}' | mcp-cli call filesystem read_file -# Using a heredoc with '-' for stdin (recommended for complex JSON) -mcp-cli server/tool - < -d or `mcp-cli /` before calling any tool -3. **Quote JSON arguments**: Wrap JSON in single quotes to prevent shell interpretation +| 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 @@ -345,9 +434,53 @@ Create `mcp-cli/SKILL.md` in your skills directory. ## Architecture -### Connection Model +### Connection Pooling (Daemon) -The CLI uses a **lazy, on-demand connection strategy**. Server connections are only established when needed and closed immediately after use. +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:** +```bash +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 output +``` + +### Connection 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. ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -358,8 +491,8 @@ The CLI uses a **lazy, on-demand connection strategy**. Server connections are o β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ mcp-cli β”‚ β”‚ mcp-cli grep β”‚ β”‚ mcp-cli server/ β”‚ - β”‚ (list all) β”‚ β”‚ "*pattern*" β”‚ β”‚ tool '{...}' β”‚ + β”‚ mcp-cli info β”‚ β”‚ mcp-cli grep β”‚ β”‚ mcp-cli call β”‚ + β”‚ (list all) β”‚ β”‚ "*pattern*" β”‚ β”‚ server tool {} β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό @@ -381,78 +514,16 @@ The CLI uses a **lazy, on-demand connection strategy**. Server connections are o | Command | Servers Connected | |---------|-------------------| -| `mcp-cli` (list) | All N servers in parallel | +| `mcp-cli info` | All N servers in parallel | | `mcp-cli grep "*pattern*"` | All N servers in parallel | -| `mcp-cli server` | Only the specified server | -| `mcp-cli server/tool` | Only the specified server | -| `mcp-cli server/tool '{}'` | Only the specified server | - -### Concurrency Control - -For commands that connect to multiple servers (list, grep), the CLI uses a **worker pool** with concurrency limiting to prevent resource exhaustion. - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ 50 SERVERS CONFIGURED β”‚ -β”‚ β”Œβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” ... β”Œβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β” β”‚ -β”‚ β”‚ S1 β”‚ β”‚ S2 β”‚ β”‚ S3 β”‚ β”‚ S4 β”‚ β”‚ S5 β”‚ β”‚S48 β”‚ β”‚S49 β”‚ β”‚S50 β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ WORKER POOL (5 concurrent by default) β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Worker 1 Worker 2 Worker 3 Worker 4 Worker 5β”‚ β”‚ -β”‚ β”‚ β–Ό β–Ό β–Ό β–Ό β–Ό β”‚ β”‚ -β”‚ β”‚ [S1]β†’[S6]β†’ [S2]β†’[S7]β†’ [S3]β†’[S8]β†’ [S4]β†’[S9]β†’ [S5]β†’ β”‚ β”‚ -β”‚ β”‚ [S11]β†’... [S12]β†’... [S13]β†’... [S14]β†’... [S10]β†’ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ Total Time β‰ˆ (N / concurrency) Γ— average_connection_time β”‚ -β”‚ With 50 servers @ 5 concurrency: ~10 batches Γ— ~2s = ~20s β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -**Concurrency settings:** - -- Default: `5` concurrent connections -- Set via: `MCP_CONCURRENCY=10 mcp-cli` or export globally -- Results are **order-preserved** (sorted alphabetically for display) +| `mcp-cli info ` | Only the specified server | +| `mcp-cli info ` | Only the specified server | +| `mcp-cli call '{}'` | Only the specified server | -**Why limit concurrency?** - -1. **File descriptor limits** - Each stdio server spawns a subprocess with pipes -2. **Memory usage** - Each connection buffers data -3. **Server rate limits** - HTTP servers may throttle clients -4. **Predictable timing** - Linear scaling vs exponential resource usage ### Error Handling & Retry -The CLI includes **automatic retry with exponential backoff** for transient failures: - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ INITIAL ATTEMPT β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ FAILED? β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ YES β”‚ NO - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” SUCCESS - β”‚ TRANSIENT? β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ YES β”‚ NO - β–Ό β–Ό - RETRY with FAIL with - exponential error message - backoff - (1s β†’ 2s β†’ 4s, - max 3 retries) -``` +The CLI includes **automatic retry with exponential backoff** for transient failures. **Transient errors (auto-retried):** - Network: `ECONNREFUSED`, `ETIMEDOUT`, `ECONNRESET` @@ -515,11 +586,11 @@ bun link # Now you can use 'mcp-cli' anywhere mcp-cli --help -mcp-cli filesystem/read_file '{"path": "./README.md"}' +mcp-cli call filesystem read_file '{"path": "./README.md"}' # Or run directly during development bun run dev --help -bun run dev filesystem +bun run dev info filesystem ``` To unlink when done: @@ -541,20 +612,25 @@ Releases are automated via GitHub Actions. Use the release script: All errors include actionable recovery suggestions, optimized for both humans and AI agents: ``` -Error [CONFIG_NOT_FOUND]: Config file not found: /path/config.json - Suggestion: Create mcp_servers.json with: { "mcpServers": { "server-name": { "command": "..." } } } +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 filesystem, mcp-cli 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"}' - -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 filesystem' to see all available tools ``` ## License diff --git a/SKILL.md b/SKILL.md index 734b72d..effd740 100644 --- a/SKILL.md +++ b/SKILL.md @@ -11,65 +11,98 @@ Access MCP servers through the command line. MCP enables interaction with extern | Command | Output | |---------|--------| -| `mcp-cli` | List all servers and tool names | -| `mcp-cli ` | Show tools with parameters | -| `mcp-cli /` | Get tool JSON schema | -| `mcp-cli / ''` | Call tool with arguments | -| `mcp-cli grep ""` | Search tools by name | +| `mcp-cli` | List all servers and tools | +| `mcp-cli info ` | Show server tools and parameters | +| `mcp-cli info ` | Get tool JSON schema | +| `mcp-cli grep ""` | Search tools by name | +| `mcp-cli call ` | Call tool (reads JSON from stdin if no args) | +| `mcp-cli call ''` | Call tool with arguments | -**Add `-d` to include descriptions** (e.g., `mcp-cli filesystem -d`) +**Both formats work:** ` ` or `/` ## Workflow -1. **Discover**: `mcp-cli` β†’ see available servers and tools -2. **Explore**: `mcp-cli ` β†’ see tools with parameters -3. **Inspect**: `mcp-cli /` β†’ get full JSON input schema -4. **Execute**: `mcp-cli / ''` β†’ run with arguments +1. **Discover**: `mcp-cli` β†’ see available servers +2. **Explore**: `mcp-cli info ` β†’ see tools with parameters +3. **Inspect**: `mcp-cli info ` β†’ get full JSON schema +4. **Execute**: `mcp-cli call ''` β†’ run with arguments ## Examples ```bash -# List all servers and tool names +# List all servers mcp-cli -# See all tools with parameters -mcp-cli filesystem +# With descriptions +mcp-cli -d -# With descriptions (more verbose) -mcp-cli filesystem -d +# See server tools +mcp-cli info filesystem -# Get JSON schema for specific tool -mcp-cli filesystem/read_file +# Get tool schema (both formats work) +mcp-cli info filesystem read_file +mcp-cli info filesystem/read_file -# Call the tool -mcp-cli filesystem/read_file '{"path": "./README.md"}' +# 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*" -# JSON output for parsing -mcp-cli filesystem/read_file '{"path": "./README.md"}' --json - -# Complex JSON with quotes (use '-' for stdin input) -mcp-cli server/tool - < output.txt ``` +**jq tips:** `-r` raw output, `-e` exit 1 if false, `-s` slurp multiple inputs ## Options | Flag | Purpose | |------|---------| -| `-j, --json` | JSON output for scripting | -| `-r, --raw` | Raw text content | | `-d` | Include descriptions | +| `-c ` | 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 diff --git a/mcp_servers.json b/mcp_servers.json index dc569bf..d06e6f6 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -6,7 +6,10 @@ "-y", "@modelcontextprotocol/server-filesystem", "." - ] + ], + "env": { + "npm_config_registry": "https://registry.npmjs.org" + } }, "deepwiki": { "url": "https://mcp.deepwiki.com/mcp" diff --git a/scripts/generate-system-instructions.ts b/scripts/generate-system-instructions.ts new file mode 100644 index 0000000..35df767 --- /dev/null +++ b/scripts/generate-system-instructions.ts @@ -0,0 +1,124 @@ +#!/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 { + 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 ` 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(); diff --git a/src/client.ts b/src/client.ts index 5473825..50731cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,12 +11,20 @@ import { 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 @@ -27,6 +35,20 @@ export interface ConnectedClient { close: () => Promise; } +/** + * Unified connection interface that works with both daemon and direct connections + */ +export interface McpConnection { + listTools: () => Promise; + callTool: ( + toolName: string, + args: Record, + ) => Promise; + getInstructions: () => Promise; + close: () => Promise; + isDaemon: boolean; +} + export interface ServerInfo { name: string; version?: string; @@ -350,3 +372,95 @@ export async function callTool( 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 { + // 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 { + 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, + ): Promise { + // 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 { + return daemonConn.getInstructions(); + }, + async close(): Promise { + 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 { + const tools = await listTools(client); + // Apply tool filtering from config + return filterTools(tools, config); + }, + async callTool( + toolName: string, + args: Record, + ): Promise { + // 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 { + return client.getInstructions(); + }, + async close(): Promise { + await close(); + }, + isDaemon: false, + }; +} diff --git a/src/commands/call.ts b/src/commands/call.ts index 7317ce7..9edcaad 100644 --- a/src/commands/call.ts +++ b/src/commands/call.ts @@ -7,13 +7,11 @@ * - Errors always go to stderr */ -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { - callTool, - connectToServer, + type McpConnection, debug, + getConnection, getTimeoutMs, - listTools, safeClose, } from '../client.js'; import { @@ -31,12 +29,11 @@ import { toolExecutionError, toolNotFoundError, } from '../errors.js'; -import { formatJson, formatToolResult } from '../output.js'; +import { formatJson } from '../output.js'; export interface CallOptions { target: string; // "server/tool" args?: string; // JSON arguments - json: boolean; configPath?: string; } @@ -148,13 +145,10 @@ export async function callCommand(options: CallOptions): Promise { process.exit(ErrorCode.CLIENT_ERROR); } - let client: Client; - let close: () => Promise = async () => {}; // Initialize to noop to prevent undefined access + let connection: McpConnection; try { - const connection = await connectToServer(serverName, serverConfig); - client = connection.client; - close = connection.close; + connection = await getConnection(serverName, serverConfig); } catch (error) { console.error( formatCliError( @@ -165,20 +159,15 @@ export async function callCommand(options: CallOptions): Promise { } try { - const result = await callTool(client, toolName, args); + const result = await connection.callTool(toolName, args); - if (options.json) { - // Full JSON response - console.log(formatJson(result)); - } else { - // Default: extract text content (raw output) - console.log(formatToolResult(result)); - } + // Always output raw JSON for programmatic use + console.log(formatJson(result)); } catch (error) { // Try to get available tools for better error message let availableTools: string[] | undefined; try { - const tools = await listTools(client); + const tools = await connection.listTools(); availableTools = tools.map((t) => t.name); } catch { // Ignore - we'll show error without tool list @@ -197,6 +186,6 @@ export async function callCommand(options: CallOptions): Promise { } process.exit(ErrorCode.SERVER_ERROR); } finally { - await safeClose(close); + await safeClose(connection.close); } } diff --git a/src/commands/grep.ts b/src/commands/grep.ts index 29d57ee..24338b3 100644 --- a/src/commands/grep.ts +++ b/src/commands/grep.ts @@ -3,11 +3,11 @@ */ import { + type McpConnection, type ToolInfo, - connectToServer, debug, getConcurrencyLimit, - listTools, + getConnection, safeClose, } from '../client.js'; import { @@ -17,12 +17,11 @@ import { loadConfig, } from '../config.js'; import { ErrorCode } from '../errors.js'; -import { formatJson, formatSearchResults } from '../output.js'; +import { formatSearchResults } from '../output.js'; export interface GrepOptions { pattern: string; withDescriptions: boolean; - json: boolean; configPath?: string; } @@ -112,43 +111,38 @@ async function processWithConcurrency( } /** - * Search tools in a single server + * Search tools in a single server (uses daemon if enabled) */ async function searchServerTools( serverName: string, config: McpServersConfig, pattern: RegExp, ): Promise { + let connection: McpConnection | null = null; try { const serverConfig = getServerConfig(config, serverName); - const { client, close } = await connectToServer(serverName, serverConfig); - - try { - const tools = await listTools(client); - const results: SearchResult[] = []; - - for (const tool of tools) { - // Match against tool name, server/tool path, or description - const fullPath = `${serverName}/${tool.name}`; - const matchesName = pattern.test(tool.name); - const matchesPath = pattern.test(fullPath); - const matchesDescription = - tool.description && pattern.test(tool.description); - - if (matchesName || matchesPath || matchesDescription) { - results.push({ server: serverName, tool }); - } - } + connection = await getConnection(serverName, serverConfig); + + const tools = await connection.listTools(); + const results: SearchResult[] = []; - debug(`${serverName}: found ${results.length} matches`); - return { serverName, results }; - } finally { - await safeClose(close); + 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); + } } } @@ -207,18 +201,12 @@ export async function grepCommand(options: GrepOptions): Promise { 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; } - if (options.json) { - const jsonOutput = allResults.map((r) => ({ - server: r.server, - tool: r.tool.name, - description: r.tool.description, - inputSchema: r.tool.inputSchema, - })); - console.log(formatJson(jsonOutput)); - } else { - console.log(formatSearchResults(allResults, options.withDescriptions)); - } + // Human-readable output + console.log(formatSearchResults(allResults, options.withDescriptions)); } diff --git a/src/commands/info.ts b/src/commands/info.ts index 7da7d78..e88bea6 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -2,8 +2,7 @@ * Info command - Show server or tool details */ -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { connectToServer, getTool, listTools, safeClose } from '../client.js'; +import { type McpConnection, getConnection, safeClose } from '../client.js'; import { type McpServersConfig, type ServerConfig, @@ -16,15 +15,10 @@ import { serverConnectionError, toolNotFoundError, } from '../errors.js'; -import { - formatJson, - formatServerDetails, - formatToolSchema, -} from '../output.js'; +import { formatServerDetails, formatToolSchema } from '../output.js'; export interface InfoOptions { target: string; // "server" or "server/tool" - json: boolean; withDescriptions: boolean; configPath?: string; } @@ -63,13 +57,10 @@ export async function infoCommand(options: InfoOptions): Promise { process.exit(ErrorCode.CLIENT_ERROR); } - let client: Client; - let close: () => Promise = async () => {}; + let connection: McpConnection; try { - const connection = await connectToServer(serverName, serverConfig); - client = connection.client; - close = connection.close; + connection = await getConnection(serverName, serverConfig); } catch (error) { console.error( formatCliError( @@ -82,7 +73,7 @@ export async function infoCommand(options: InfoOptions): Promise { try { if (toolName) { // Show specific tool schema - const tools = await listTools(client); + const tools = await connection.listTools(); const tool = tools.find((t) => t.name === toolName); if (!tool) { @@ -95,45 +86,25 @@ export async function infoCommand(options: InfoOptions): Promise { process.exit(ErrorCode.CLIENT_ERROR); } - if (options.json) { - console.log( - formatJson({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - }), - ); - } else { - console.log(formatToolSchema(serverName, tool)); - } + // Human-readable output + console.log(formatToolSchema(serverName, tool)); } else { // Show server details - const tools = await listTools(client); + const tools = await connection.listTools(); + const instructions = await connection.getInstructions(); - if (options.json) { - console.log( - formatJson({ - name: serverName, - config: serverConfig, - tools: tools.map((t) => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - })), - }), - ); - } else { - console.log( - formatServerDetails( - serverName, - serverConfig, - tools, - options.withDescriptions, - ), - ); - } + // Human-readable output + console.log( + formatServerDetails( + serverName, + serverConfig, + tools, + options.withDescriptions, + instructions, + ), + ); } } finally { - await safeClose(close); + await safeClose(connection.close); } } diff --git a/src/commands/list.ts b/src/commands/list.ts index 9073288..82d1014 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -3,11 +3,11 @@ */ import { + type McpConnection, type ToolInfo, - connectToServer, debug, getConcurrencyLimit, - listTools, + getConnection, safeClose, } from '../client.js'; import { @@ -17,17 +17,17 @@ import { loadConfig, } from '../config.js'; import { ErrorCode } from '../errors.js'; -import { formatJson, formatServerList } from '../output.js'; +import { formatServerList } from '../output.js'; export interface ListOptions { withDescriptions: boolean; - json: boolean; configPath?: string; } interface ServerWithTools { name: string; tools: ToolInfo[]; + instructions?: string; error?: string; } @@ -61,23 +61,21 @@ async function processWithConcurrency( } /** - * Fetch tools from a single server + * Fetch tools from a single server (uses daemon if enabled) */ async function fetchServerTools( serverName: string, config: McpServersConfig, ): Promise { + let connection: McpConnection | null = null; try { const serverConfig = getServerConfig(config, serverName); - const { client, close } = await connectToServer(serverName, serverConfig); - - try { - const tools = await listTools(client); - debug(`${serverName}: loaded ${tools.length} tools`); - return { name: serverName, tools }; - } finally { - await safeClose(close); - } + 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}`); @@ -86,6 +84,10 @@ async function fetchServerTools( tools: [], error: errorMsg, }; + } finally { + if (connection) { + await safeClose(connection.close); + } } } @@ -129,6 +131,7 @@ export async function listCommand(options: ListOptions): Promise { // Convert errors to tool-like display for human output const displayServers = servers.map((s) => ({ name: s.name, + instructions: s.instructions, tools: s.error ? [ { @@ -140,18 +143,6 @@ export async function listCommand(options: ListOptions): Promise { : s.tools, })); - if (options.json) { - const jsonOutput = servers.map((s) => ({ - name: s.name, - tools: s.tools.map((t) => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - })), - error: s.error, - })); - console.log(formatJson(jsonOutput)); - } else { - console.log(formatServerList(displayServers, options.withDescriptions)); - } + // Human-readable output + console.log(formatServerList(displayServers, options.withDescriptions)); } diff --git a/src/config.ts b/src/config.ts index 0b936c4..99a4e25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,10 +15,26 @@ import { 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 { +export interface StdioServerConfig extends BaseServerConfig { command: string; args?: string[]; env?: Record; @@ -28,7 +44,7 @@ export interface StdioServerConfig { /** * HTTP server configuration (remote) */ -export interface HttpServerConfig { +export interface HttpServerConfig extends BaseServerConfig { url: string; headers?: Record; timeout?: number; @@ -40,6 +56,93 @@ export interface McpServersConfig { mcpServers: Record; } +// ============================================================================ +// 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( + 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 */ @@ -68,6 +171,7 @@ 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 @@ -138,6 +242,70 @@ export function getRetryDelayMs(): number { 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) diff --git a/src/daemon-client.ts b/src/daemon-client.ts new file mode 100644 index 0000000..d9b5c34 --- /dev/null +++ b/src/daemon-client.ts @@ -0,0 +1,350 @@ +/** + * 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; + callTool: ( + toolName: string, + args: Record, + ) => Promise; + getInstructions: () => Promise; + close: () => Promise; +} + +/** + * 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 { + 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 { + 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 { + 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 { + 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, + ): Promise { + 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 { + 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 { + // 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 { + 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 + } +} diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..944cc5c --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,416 @@ +/** + * MCP-CLI Daemon - Background worker that maintains persistent MCP connections + * + * This is spawned as a detached process and manages a Unix socket for IPC. + * It maintains the MCP server connection and forwards requests from CLI invocations. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname } from 'node:path'; +import { + type ConnectedClient, + callTool, + connectToServer, + listTools, +} from './client.js'; +import { + type ServerConfig, + debug, + getConfigHash, + getDaemonTimeoutMs, + getPidPath, + getSocketDir, + getSocketPath, +} from './config.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface DaemonRequest { + id: string; + type: 'listTools' | 'callTool' | 'ping' | 'close' | 'getInstructions'; + toolName?: string; + args?: Record; +} + +export interface DaemonResponse { + id: string; + success: boolean; + data?: unknown; + error?: { code: string; message: string }; +} + +interface PidFileContent { + pid: number; + configHash: string; + startedAt: string; +} + +// ============================================================================ +// PID File Management +// ============================================================================ + +/** + * Write PID file with config hash for stale detection + */ +export function writePidFile(serverName: string, configHash: string): void { + const pidPath = getPidPath(serverName); + const dir = dirname(pidPath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const content: PidFileContent = { + pid: process.pid, + configHash, + startedAt: new Date().toISOString(), + }; + + writeFileSync(pidPath, JSON.stringify(content), { mode: 0o600 }); +} + +/** + * Read PID file content + */ +export function readPidFile(serverName: string): PidFileContent | null { + const pidPath = getPidPath(serverName); + + if (!existsSync(pidPath)) { + return null; + } + + try { + const content = readFileSync(pidPath, 'utf-8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Remove PID file + */ +export function removePidFile(serverName: string): void { + const pidPath = getPidPath(serverName); + try { + if (existsSync(pidPath)) { + unlinkSync(pidPath); + } + } catch { + // Ignore errors during cleanup + } +} + +/** + * Remove socket file + */ +export function removeSocketFile(serverName: string): void { + const socketPath = getSocketPath(serverName); + try { + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + } catch { + // Ignore errors during cleanup + } +} + +/** + * Check if a process is running + */ +export function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Kill a process by PID + */ +export function killProcess(pid: number): boolean { + try { + process.kill(pid, 'SIGTERM'); + return true; + } catch { + return false; + } +} + +// ============================================================================ +// Daemon Worker +// ============================================================================ + +/** + * Main daemon entry point - run as detached background process + */ +export async function runDaemon( + serverName: string, + config: ServerConfig, +): Promise { + const socketPath = getSocketPath(serverName); + const configHash = getConfigHash(config); + const timeoutMs = getDaemonTimeoutMs(); + + let idleTimer: ReturnType | null = null; + let mcpClient: ConnectedClient | null = null; + let server: ReturnType | null = null; + const activeConnections = new Set(); + + // Cleanup function + const cleanup = async () => { + debug(`[daemon:${serverName}] Shutting down...`); + + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + + // Close all active socket connections + for (const conn of activeConnections) { + try { + (conn as { end: () => void }).end(); + } catch { + // Ignore + } + } + activeConnections.clear(); + + // Close MCP connection + if (mcpClient) { + try { + await mcpClient.close(); + } catch { + // Ignore + } + mcpClient = null; + } + + // Close socket server + if (server) { + try { + server.stop(); + } catch { + // Ignore + } + server = null; + } + + // Clean up files + removeSocketFile(serverName); + removePidFile(serverName); + + debug(`[daemon:${serverName}] Cleanup complete`); + }; + + // Reset idle timer + const resetIdleTimer = () => { + if (idleTimer) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(async () => { + debug(`[daemon:${serverName}] Idle timeout reached, shutting down`); + await cleanup(); + process.exit(0); + }, timeoutMs); + }; + + // Handle signals + process.on('SIGTERM', async () => { + await cleanup(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + await cleanup(); + process.exit(0); + }); + + // Ensure socket dir exists + const socketDir = getSocketDir(); + if (!existsSync(socketDir)) { + mkdirSync(socketDir, { recursive: true, mode: 0o700 }); + } + + // Remove stale socket if exists + removeSocketFile(serverName); + + // Write PID file + writePidFile(serverName, configHash); + + // Connect to MCP server + try { + debug(`[daemon:${serverName}] Connecting to MCP server...`); + mcpClient = await connectToServer(serverName, config); + debug(`[daemon:${serverName}] Connected to MCP server`); + } catch (error) { + console.error( + `[daemon:${serverName}] Failed to connect:`, + (error as Error).message, + ); + await cleanup(); + process.exit(1); + } + + // Handle incoming request + const handleRequest = async (data: Buffer): Promise => { + resetIdleTimer(); + + let request: DaemonRequest; + try { + request = JSON.parse(data.toString()); + } catch { + return { + id: 'unknown', + success: false, + error: { code: 'INVALID_REQUEST', message: 'Invalid JSON' }, + }; + } + + debug(`[daemon:${serverName}] Request: ${request.type} (${request.id})`); + + if (!mcpClient) { + return { + id: request.id, + success: false, + error: { code: 'NOT_CONNECTED', message: 'MCP client not connected' }, + }; + } + + try { + switch (request.type) { + case 'ping': + return { id: request.id, success: true, data: 'pong' }; + + case 'listTools': { + const tools = await listTools(mcpClient.client); + return { id: request.id, success: true, data: tools }; + } + + case 'callTool': { + if (!request.toolName) { + return { + id: request.id, + success: false, + error: { code: 'MISSING_TOOL', message: 'toolName required' }, + }; + } + const result = await callTool( + mcpClient.client, + request.toolName, + request.args ?? {}, + ); + return { id: request.id, success: true, data: result }; + } + + case 'getInstructions': { + const instructions = mcpClient.client.getInstructions(); + return { id: request.id, success: true, data: instructions }; + } + + case 'close': + // Graceful shutdown requested + setTimeout(async () => { + await cleanup(); + process.exit(0); + }, 100); + return { id: request.id, success: true, data: 'closing' }; + + default: + return { + id: request.id, + success: false, + error: { + code: 'UNKNOWN_TYPE', + message: `Unknown request type: ${request.type}`, + }, + }; + } + } catch (error) { + const err = error as Error; + return { + id: request.id, + success: false, + error: { code: 'EXECUTION_ERROR', message: err.message }, + }; + } + }; + + // Start Unix socket server + try { + server = Bun.listen({ + unix: socketPath, + socket: { + open(socket) { + activeConnections.add(socket); + debug(`[daemon:${serverName}] Client connected`); + }, + async data(socket, data) { + const response = await handleRequest(data); + socket.write(`${JSON.stringify(response)}\n`); + }, + close(socket) { + activeConnections.delete(socket); + debug(`[daemon:${serverName}] Client disconnected`); + }, + error(socket, error) { + debug(`[daemon:${serverName}] Socket error: ${error.message}`); + activeConnections.delete(socket); + }, + }, + }); + + debug(`[daemon:${serverName}] Listening on ${socketPath}`); + + // Start idle timer + resetIdleTimer(); + + // Signal readiness by writing to stdout (parent will read this) + console.log('DAEMON_READY'); + } catch (error) { + console.error( + `[daemon:${serverName}] Failed to start socket server:`, + (error as Error).message, + ); + await cleanup(); + process.exit(1); + } +} + +// ============================================================================ +// Entry point when run directly +// ============================================================================ + +// Check if running as daemon process +if (process.argv[2] === '--daemon') { + const serverName = process.argv[3]; + const configJson = process.argv[4]; + + if (!serverName || !configJson) { + console.error('Usage: daemon.ts --daemon '); + process.exit(1); + } + + let config: ServerConfig; + try { + config = JSON.parse(configJson); + } catch { + console.error('Invalid config JSON'); + process.exit(1); + } + + runDaemon(serverName, config).catch((error) => { + console.error('Daemon failed:', error); + process.exit(1); + }); +} diff --git a/src/errors.ts b/src/errors.ts index 00fd641..7883799 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -202,6 +202,19 @@ export function toolExecutionError( }; } +export function toolDisabledError( + toolName: string, + serverName: string, +): CliError { + return { + code: ErrorCode.CLIENT_ERROR, + type: 'TOOL_DISABLED', + message: `Tool "${toolName}" is disabled by configuration`, + details: `Server "${serverName}" has allowedTools/disabledTools filtering configured`, + suggestion: `Check your mcp_servers.json config. Remove "${toolName}" from disabledTools or add it to allowedTools.`, + }; +} + // ============================================================================ // Argument Errors // ============================================================================ @@ -229,17 +242,38 @@ export function invalidJsonArgsError( type: 'INVALID_JSON_ARGUMENTS', message: 'Invalid JSON in tool arguments', details: parseError ? `Parse error: ${parseError}` : `Input: ${truncated}`, - suggestion: - 'Arguments must be valid JSON. Use single quotes around JSON: \'{"key": "value"}\'', + suggestion: `Use valid JSON: '{"path": "./file.txt"}'. Run 'mcp-cli info ' for the schema.`, }; } export function unknownOptionError(option: string): CliError { + // Provide context-aware suggestions for common mistakes + let suggestion: string; + + const optionLower = option.toLowerCase().replace(/^-+/, ''); + + if (['server', 's'].includes(optionLower)) { + suggestion = `Server is a positional argument. Use 'mcp-cli info '`; + } else if (['tool', 't'].includes(optionLower)) { + suggestion = `Tool is a positional argument. Use 'mcp-cli call '`; + } else if (['args', 'arguments', 'a', 'input'].includes(optionLower)) { + suggestion = `Pass JSON directly: 'mcp-cli call '{\"key\": \"value\"}''`; + } else if (['pattern', 'p', 'search', 'query'].includes(optionLower)) { + suggestion = `Use 'mcp-cli grep \"*pattern*\"'`; + } else if (['call', 'run', 'exec'].includes(optionLower)) { + suggestion = `Use 'call' as a subcommand, not option: 'mcp-cli call '`; + } else if (['info', 'list', 'get'].includes(optionLower)) { + suggestion = `Use 'info' as a subcommand, not option: 'mcp-cli info '`; + } else { + suggestion = + 'Valid options: -c/--config, -j/--json, -d/--with-descriptions, -r/--raw'; + } + return { code: ErrorCode.CLIENT_ERROR, type: 'UNKNOWN_OPTION', message: `Unknown option: ${option}`, - suggestion: "Run 'mcp-cli --help' to see available options", + suggestion, }; } @@ -247,10 +281,106 @@ export function missingArgumentError( command: string, argument: string, ): CliError { + // Provide command-specific format examples + let suggestion: string; + + switch (command) { + case 'call': + if (argument.includes('server')) { + suggestion = `Use 'mcp-cli call '{\"key\": \"value\"}''`; + } else { + suggestion = `Use 'mcp-cli call '{\"key\": \"value\"}''`; + } + break; + case 'grep': + suggestion = `Use 'mcp-cli grep \"*pattern*\"'`; + break; + case '-c/--config': + suggestion = `Use 'mcp-cli -c /path/to/mcp_servers.json'`; + break; + default: + suggestion = `Run 'mcp-cli --help' for usage examples`; + } + return { code: ErrorCode.CLIENT_ERROR, type: 'MISSING_ARGUMENT', message: `Missing required argument for ${command}: ${argument}`, - suggestion: `Run 'mcp-cli --help' for usage examples`, + suggestion, + }; +} + +// ============================================================================ +// Subcommand Errors +// ============================================================================ + +/** + * Error when user provides ambiguous command like "mcp-cli server tool" + */ +export function ambiguousCommandError( + serverName: string, + toolName: string, + hasArgs?: boolean, +): CliError { + const cmd = hasArgs + ? `mcp-cli call ${serverName} ${toolName} ''` + : `mcp-cli call ${serverName} ${toolName}`; + return { + code: ErrorCode.CLIENT_ERROR, + type: 'AMBIGUOUS_COMMAND', + message: 'Ambiguous command: did you mean to call a tool or view info?', + details: `Received: mcp-cli ${serverName} ${toolName}${hasArgs ? ' ...' : ''}`, + suggestion: `Use '${cmd}' to execute, or 'mcp-cli info ${serverName} ${toolName}' to view schema`, + }; +} + +/** + * Error when user uses unknown subcommand with smart suggestions + */ +export function unknownSubcommandError(subcommand: string): CliError { + // Map common aliases to correct subcommands + const suggestions: Record = { + run: 'call', + execute: 'call', + exec: 'call', + invoke: 'call', + list: 'info', + ls: 'info', + get: 'info', + show: 'info', + describe: 'info', + search: 'grep', + find: 'grep', + query: 'grep', + }; + + const suggested = suggestions[subcommand.toLowerCase()]; + const validCommands = 'info, grep, call'; + + return { + code: ErrorCode.CLIENT_ERROR, + type: 'UNKNOWN_SUBCOMMAND', + message: `Unknown subcommand: "${subcommand}"`, + details: `Valid subcommands: ${validCommands}`, + suggestion: suggested + ? `Did you mean 'mcp-cli ${suggested}'?` + : `Use 'mcp-cli --help' to see available commands`, + }; +} + +/** + * Error when too many positional arguments provided + */ +export function tooManyArgumentsError( + command: string, + received: number, + max: number, +): CliError { + return { + code: ErrorCode.CLIENT_ERROR, + type: 'TOO_MANY_ARGUMENTS', + message: `Too many arguments for ${command}`, + details: `Received ${received} arguments, maximum is ${max}`, + suggestion: `Run 'mcp-cli --help' for correct usage`, }; } diff --git a/src/index.ts b/src/index.ts index e634437..088deed 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,10 +4,11 @@ * * Commands: * mcp-cli List all servers and tools - * mcp-cli grep Search tools by glob pattern - * mcp-cli Show server details - * mcp-cli / Show tool schema - * mcp-cli / Call tool with arguments + * mcp-cli info Show server details + * mcp-cli info Show tool schema + * mcp-cli grep Search tools by glob pattern + * mcp-cli call Call tool (reads JSON from stdin if no args) + * mcp-cli call {} Call tool with JSON args */ import { callCommand } from './commands/call.js'; @@ -19,32 +20,96 @@ import { DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY_MS, DEFAULT_TIMEOUT_SECONDS, + listServerNames, + loadConfig, } from './config.js'; import { ErrorCode, + ambiguousCommandError, formatCliError, missingArgumentError, + tooManyArgumentsError, unknownOptionError, + unknownSubcommandError, } from './errors.js'; import { VERSION } from './version.js'; interface ParsedArgs { - command: 'list' | 'grep' | 'info' | 'call' | 'help' | 'version'; - target?: string; + command: 'list' | 'info' | 'grep' | 'call' | 'help' | 'version'; + server?: string; + tool?: string; pattern?: string; args?: string; - json: boolean; withDescriptions: boolean; configPath?: string; } +/** + * Known subcommands + */ +const SUBCOMMANDS = ['info', 'grep', 'call'] as const; + +/** + * Check if a string looks like a subcommand (not a server name) + */ +function isKnownSubcommand(arg: string): boolean { + return SUBCOMMANDS.includes(arg as (typeof SUBCOMMANDS)[number]); +} + +/** + * Check if a string looks like it could be an unknown subcommand + * (common aliases that users might try) + */ +function isPossibleSubcommand(arg: string): boolean { + const aliases = [ + 'run', + 'execute', + 'exec', + 'invoke', + 'list', + 'ls', + 'get', + 'show', + 'describe', + 'search', + 'find', + 'query', + ]; + return aliases.includes(arg.toLowerCase()); +} + +/** + * Parse server/tool from either "server/tool" or "server tool" format + */ +function parseServerTool(args: string[]): { server: string; tool?: string } { + if (args.length === 0) { + return { server: '' }; + } + + const first = args[0]; + + // Check for slash format: server/tool + if (first.includes('/')) { + const slashIndex = first.indexOf('/'); + return { + server: first.substring(0, slashIndex), + tool: first.substring(slashIndex + 1) || undefined, + }; + } + + // Space format: server tool + return { + server: first, + tool: args[1], + }; +} + /** * Parse command line arguments */ function parseArgs(args: string[]): ParsedArgs { const result: ParsedArgs = { - command: 'list', - json: false, + command: 'info', withDescriptions: false, }; @@ -64,11 +129,6 @@ function parseArgs(args: string[]): ParsedArgs { result.command = 'version'; return result; - case '-j': - case '--json': - result.json = true; - break; - case '-d': case '--with-descriptions': result.withDescriptions = true; @@ -77,6 +137,12 @@ function parseArgs(args: string[]): ParsedArgs { case '-c': case '--config': result.configPath = args[++i]; + if (!result.configPath) { + console.error( + formatCliError(missingArgumentError('-c/--config', 'path')), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } break; default: @@ -89,33 +155,189 @@ function parseArgs(args: string[]): ParsedArgs { } } - // Determine command from positional arguments + // No positional args = list all servers if (positional.length === 0) { result.command = 'list'; - } else if (positional[0] === 'grep') { + return result; + } + + const firstArg = positional[0]; + + // ========================================================================= + // Explicit subcommand routing + // ========================================================================= + + if (firstArg === 'info') { + result.command = 'info'; + const remaining = positional.slice(1); + const { server, tool } = parseServerTool(remaining); + + // info requires a server argument - show available servers in error + if (!server) { + // Try to load config synchronously to show available servers + let availableServers: string[] = []; + const configPaths = [ + result.configPath, + process.env.MCP_CONFIG_PATH, + './mcp_servers.json', + `${process.env.HOME}/.mcp_servers.json`, + `${process.env.HOME}/.config/mcp/mcp_servers.json`, + ].filter(Boolean) as string[]; + + const fs = require('node:fs'); + for (const cfgPath of configPaths) { + try { + const content = fs.readFileSync(cfgPath, 'utf-8'); + const config = JSON.parse(content); + if (config.mcpServers) { + availableServers = Object.keys(config.mcpServers); + break; + } + } catch { + // Try next path + } + } + + const serverList = + availableServers.length > 0 + ? availableServers.join(', ') + : '(none found)'; + + console.error( + 'Error [MISSING_ARGUMENT]: Missing required argument for info: server', + ); + console.error(` Available servers: ${serverList}`); + console.error( + ` Suggestion: Use 'mcp-cli info ' to see server details, or just 'mcp-cli' to list all`, + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + + result.server = server; + result.tool = tool; + return result; + } + + if (firstArg === 'grep') { result.command = 'grep'; result.pattern = positional[1]; if (!result.pattern) { console.error(formatCliError(missingArgumentError('grep', 'pattern'))); process.exit(ErrorCode.CLIENT_ERROR); } - } else if (positional[0].includes('/')) { - // server/tool format - result.target = positional[0]; - if (positional.length > 1) { - result.command = 'call'; - // Support '-' to indicate stdin (Unix convention) - const argsValue = positional.slice(1).join(' '); - result.args = argsValue === '-' ? undefined : argsValue; + if (positional.length > 2) { + console.error( + formatCliError(tooManyArgumentsError('grep', positional.length - 1, 1)), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + return result; + } + + if (firstArg === 'call') { + result.command = 'call'; + const remaining = positional.slice(1); + + if (remaining.length === 0) { + console.error( + formatCliError(missingArgumentError('call', 'server and tool')), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + + // Parse server/tool from remaining args + const { server, tool } = parseServerTool(remaining); + result.server = server; + + if (!tool) { + // Check if it was slash format without tool + if (remaining[0].includes('/') && !remaining[0].split('/')[1]) { + console.error(formatCliError(missingArgumentError('call', 'tool'))); + process.exit(ErrorCode.CLIENT_ERROR); + } + // Space format with only server + if (remaining.length < 2) { + console.error(formatCliError(missingArgumentError('call', 'tool'))); + process.exit(ErrorCode.CLIENT_ERROR); + } + } + + result.tool = tool; + + // Determine where args start + let argsStartIndex: number; + if (remaining[0].includes('/')) { + // slash format: call server/tool '{}' β†’ args at index 1 + argsStartIndex = 1; } else { - result.command = 'info'; + // space format: call server tool '{}' β†’ args at index 2 + argsStartIndex = 2; + } + + // Collect remaining args as JSON (support '-' for stdin) + const jsonArgs = remaining.slice(argsStartIndex); + if (jsonArgs.length > 0) { + const argsValue = jsonArgs.join(' '); + result.args = argsValue === '-' ? undefined : argsValue; + } + + return result; + } + + // ========================================================================= + // Check for unknown subcommand (common aliases) + // ========================================================================= + + if (isPossibleSubcommand(firstArg)) { + console.error(formatCliError(unknownSubcommandError(firstArg))); + process.exit(ErrorCode.CLIENT_ERROR); + } + + // ========================================================================= + // Slash format without subcommand β†’ error (require explicit subcommand) + // ========================================================================= + + if (firstArg.includes('/')) { + const parts = firstArg.split('/'); + const serverName = parts[0]; + const toolName = parts[1] || ''; + const hasArgs = positional.length > 1; + console.error( + formatCliError(ambiguousCommandError(serverName, toolName, hasArgs)), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + + // ========================================================================= + // Ambiguous command detection: server tool without subcommand + // ========================================================================= + + if (positional.length >= 2) { + const serverName = positional[0]; + const possibleTool = positional[1]; + + // Check if second arg looks like a tool name (not JSON) + const looksLikeJson = + possibleTool.startsWith('{') || possibleTool.startsWith('['); + const looksLikeToolName = /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(possibleTool); + + if (!looksLikeJson && looksLikeToolName) { + const hasArgs = positional.length > 2; + console.error( + formatCliError( + ambiguousCommandError(serverName, possibleTool, hasArgs), + ), + ); + process.exit(ErrorCode.CLIENT_ERROR); } - } else { - // Just server name - result.command = 'info'; - result.target = positional[0]; } + // ========================================================================= + // Default: single server name β†’ info + // ========================================================================= + + result.command = 'info'; + result.server = firstArg; return result; } @@ -127,40 +349,42 @@ function printHelp(): void { mcp-cli v${VERSION} - A lightweight CLI for MCP servers Usage: - mcp-cli [options] List all servers and tools - mcp-cli [options] grep Search tools by glob pattern - mcp-cli [options] Show server tools and parameters - mcp-cli [options] / Show tool schema and description - mcp-cli [options] / Call tool with arguments + mcp-cli [options] List all servers and tools + mcp-cli [options] info Show server details + mcp-cli [options] info Show tool schema + mcp-cli [options] grep Search tools by glob pattern + mcp-cli [options] call Call tool (reads JSON from stdin if no args) + mcp-cli [options] call Call tool with JSON arguments + +Formats (both work): + mcp-cli info server tool Space-separated + mcp-cli info server/tool Slash-separated + mcp-cli call server tool '{}' Space-separated + mcp-cli call server/tool '{}' Slash-separated Options: -h, --help Show this help message -v, --version Show version number - -j, --json Output as JSON (for scripting) -d, --with-descriptions Include tool descriptions -c, --config Path to mcp_servers.json config file Output: - stdout Tool results and data (default: text, --json for JSON) - stderr Errors and diagnostics - -Environment Variables: - MCP_CONFIG_PATH Path to config file (alternative to -c) - MCP_DEBUG Enable debug output - MCP_TIMEOUT Request timeout in seconds (default: ${DEFAULT_TIMEOUT_SECONDS}) - MCP_CONCURRENCY Max parallel server connections (default: ${DEFAULT_CONCURRENCY}) - MCP_MAX_RETRIES Max retry attempts for transient errors (default: ${DEFAULT_MAX_RETRIES}) - MCP_RETRY_DELAY Base retry delay in milliseconds (default: ${DEFAULT_RETRY_DELAY_MS}) - MCP_STRICT_ENV Set to "false" to warn on missing env vars (default: true) + mcp-cli/info/grep Human-readable text to stdout + call Raw JSON to stdout (for piping) + Errors Always to stderr Examples: - mcp-cli # List all servers - mcp-cli -d # List with descriptions - mcp-cli grep "*file*" # Search for file tools - mcp-cli filesystem # Show server tools - mcp-cli filesystem/read_file # Show tool schema - mcp-cli filesystem/read_file '{"path":"./README.md"}' # Call tool - echo '{"path":"./file"}' | mcp-cli server/tool - # Read JSON from stdin + mcp-cli # List all servers + mcp-cli -d # List with descriptions + mcp-cli grep "*file*" # Search for file tools + mcp-cli info filesystem # Show server tools + mcp-cli info filesystem read_file # Show tool schema + mcp-cli call filesystem read_file '{}' # Call tool + cat input.json | mcp-cli call server tool # Read from stdin (no '-' needed) + +Environment Variables: + MCP_NO_DAEMON=1 Disable connection caching (force fresh connections) + MCP_DAEMON_TIMEOUT=N Set daemon idle timeout in seconds (default: 60) Config File: The CLI looks for mcp_servers.json in: @@ -171,6 +395,15 @@ Config File: `); } +/** + * Build target string from server and tool + */ +function buildTarget(server?: string, tool?: string): string { + if (!server) return ''; + if (!tool) return server; + return `${server}/${tool}`; +} + /** * Main entry point */ @@ -189,24 +422,22 @@ async function main(): Promise { case 'list': await listCommand({ withDescriptions: args.withDescriptions, - json: args.json, configPath: args.configPath, }); break; - case 'grep': - await grepCommand({ - pattern: args.pattern ?? '', + case 'info': + // info always has a server (validated in parseArgs) + await infoCommand({ + target: buildTarget(args.server, args.tool), withDescriptions: args.withDescriptions, - json: args.json, configPath: args.configPath, }); break; - case 'info': - await infoCommand({ - target: args.target ?? '', - json: args.json, + case 'grep': + await grepCommand({ + pattern: args.pattern ?? '', withDescriptions: args.withDescriptions, configPath: args.configPath, }); @@ -214,9 +445,8 @@ async function main(): Promise { case 'call': await callCommand({ - target: args.target ?? '', + target: buildTarget(args.server, args.tool), args: args.args, - json: args.json, configPath: args.configPath, }); break; @@ -232,8 +462,13 @@ process.on('SIGTERM', () => { }); // Run -main().catch((error) => { - // Error message already formatted by command handlers - console.error(error.message); - process.exit(ErrorCode.CLIENT_ERROR); -}); +main() + .then(() => { + // Use setImmediate to let stdout flush before exiting + setImmediate(() => process.exit(0)); + }) + .catch((error) => { + // Error message already formatted by command handlers + console.error(error.message); + setImmediate(() => process.exit(ErrorCode.CLIENT_ERROR)); + }); diff --git a/src/output.ts b/src/output.ts index 743ce3b..69a6195 100644 --- a/src/output.ts +++ b/src/output.ts @@ -37,7 +37,7 @@ function color(text: string, colorCode: string): string { * Format server list for display */ export function formatServerList( - servers: Array<{ name: string; tools: ToolInfo[] }>, + servers: Array<{ name: string; tools: ToolInfo[]; instructions?: string }>, withDescriptions: boolean, ): string { const lines: string[] = []; @@ -45,6 +45,19 @@ export function formatServerList( 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)}`); @@ -69,11 +82,15 @@ export function formatSearchResults( const lines: string[] = []; for (const result of results) { - const path = `${color(result.server, colors.cyan)}/${color(result.tool.name, colors.green)}`; - if (withDescriptions && result.tool.description) { - lines.push(`${path} - ${color(result.tool.description, colors.dim)}`); + 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(path); + lines.push(`${server} ${tool}`); } } @@ -88,6 +105,7 @@ export function formatServerDetails( config: ServerConfig, tools: ToolInfo[], withDescriptions = false, + instructions?: string, ): string { const lines: string[] = []; @@ -105,6 +123,17 @@ export function formatServerDetails( ); } + 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)}`); diff --git a/tests/cli-errors.test.ts b/tests/cli-errors.test.ts new file mode 100644 index 0000000..4d0ba8f --- /dev/null +++ b/tests/cli-errors.test.ts @@ -0,0 +1,382 @@ +/** + * Integration tests for CLI error handling + * + * Tests the 22 LLM error cases from the implementation plan + * by invoking the actual CLI with wrong/confusing arguments. + */ + +import { describe, test, expect } from 'bun:test'; +import { join } from 'node:path'; +import { $ } from 'bun'; + +describe('CLI Error Handling Tests', () => { + const cliPath = join(import.meta.dir, '..', 'src', 'index.ts'); + + async function runCli( + args: string[] + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + // Disable daemon for tests for deterministic behavior + const result = await $`MCP_NO_DAEMON=1 bun run ${cliPath} ${args}`.nothrow(); + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + exitCode: result.exitCode, + }; + } catch (error: any) { + return { + stdout: error.stdout?.toString() || '', + stderr: error.stderr?.toString() || '', + exitCode: error.exitCode || 1, + }; + } + } + + describe('Ambiguous command errors', () => { + // Case 1: mcp-cli server tool (without subcommand) + test('errors on "mcp-cli server tool" pattern', async () => { + const result = await runCli(['someserver', 'sometool']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('AMBIGUOUS_COMMAND'); + expect(result.stderr).toContain('call'); + expect(result.stderr).toContain('info'); + }); + + // Case 2: mcp-cli server tool '{}' (without subcommand) + test('errors on "mcp-cli server tool json" pattern', async () => { + const result = await runCli(['someserver', 'sometool', '{}']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('AMBIGUOUS_COMMAND'); + }); + }); + + describe('Unknown subcommand errors', () => { + // Case 3: mcp-cli run server tool + test('suggests "call" for "run"', async () => { + const result = await runCli(['run', 'server', 'tool']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('UNKNOWN_SUBCOMMAND'); + expect(result.stderr).toContain('call'); + }); + + // Case 4: mcp-cli execute server/tool + test('suggests "call" for "execute"', async () => { + const result = await runCli(['execute', 'server/tool']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('call'); + }); + + // Case 5: mcp-cli get server + test('suggests "info" for "get"', async () => { + const result = await runCli(['get', 'server']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('info'); + }); + + // Case 6: mcp-cli list + test('suggests "info" for "list"', async () => { + const result = await runCli(['list']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('info'); + }); + + // Case 7: mcp-cli search "*file*" + test('suggests "grep" for "search"', async () => { + const result = await runCli(['search', '*file*']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('grep'); + }); + + // Case 8: mcp-cli find "*file*" + test('suggests "grep" for "find"', async () => { + const result = await runCli(['find', '*file*']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('grep'); + }); + }); + + describe('Missing argument errors', () => { + // Case 9: mcp-cli call (missing server and tool) + test('errors on "call" with no args', async () => { + const result = await runCli(['call']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MISSING_ARGUMENT'); + expect(result.stderr).toContain('server'); + }); + + // Case 10: mcp-cli call server (missing tool) + test('errors on "call server" without tool', async () => { + const result = await runCli(['call', 'server']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MISSING_ARGUMENT'); + expect(result.stderr).toContain('tool'); + }); + + // Case 11: mcp-cli grep (missing pattern) + test('errors on "grep" without pattern', async () => { + const result = await runCli(['grep']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MISSING_ARGUMENT'); + expect(result.stderr).toContain('pattern'); + }); + }); + + describe('Unknown option errors', () => { + // Case 12: mcp-cli info --server fs + test('errors on unknown "--server" option', async () => { + const result = await runCli(['info', '--server', 'fs']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('UNKNOWN_OPTION'); + }); + + // Case 13: mcp-cli call server tool --args '{}' + test('errors on unknown "--args" option', async () => { + const result = await runCli(['call', 'server', 'tool', '--args', '{}']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('UNKNOWN_OPTION'); + }); + + // Case 19: mcp-cli --call server tool + test('errors on "--call" as option', async () => { + const result = await runCli(['--call', 'server', 'tool']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('UNKNOWN_OPTION'); + }); + + // Case 20: mcp-cli -c (missing config path) + test('errors on "-c" without path', async () => { + const result = await runCli(['-c']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MISSING_ARGUMENT'); + }); + }); + + describe('Valid commands still work', () => { + test('info command works', async () => { + const result = await runCli(['info']); + // May fail on server connection, but should not error on parsing + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + expect(result.stderr).not.toContain('UNKNOWN_SUBCOMMAND'); + }); + + test('grep command works', async () => { + const result = await runCli(['grep', '*']); + expect(result.stderr).not.toContain('UNKNOWN_SUBCOMMAND'); + }); + + test('call with slash format works', async () => { + const result = await runCli(['call', 'server/tool', '{}']); + // Will fail on server, but should not error on parsing + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + + test('slash format without subcommand errors (backward compat removed)', async () => { + const result = await runCli(['server/tool', '{}']); + // Backward compat removed - now errors as AMBIGUOUS + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('AMBIGUOUS_COMMAND'); + }); + }); + + describe('Help and version', () => { + test('--help shows new command structure', async () => { + const result = await runCli(['--help']); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('info'); + expect(result.stdout).toContain('grep'); + expect(result.stdout).toContain('call'); + }); + + test('-h works', async () => { + const result = await runCli(['-h']); + expect(result.exitCode).toBe(0); + }); + + test('--version works', async () => { + const result = await runCli(['--version']); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/mcp-cli v\d+\.\d+\.\d+/); + }); + }); + + describe('Invalid JSON arguments (LLM mistakes)', () => { + // Case 14: Unquoted keys + test('errors on unquoted JSON keys: {path:x}', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', '{path:"test"}']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid JSON'); + }); + + // Case 15: Key=value format + test('errors on key=value format', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', 'path=./README.md']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid JSON'); + }); + + // LLMs often forget quotes around strings + test('errors on unquoted string values', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', '{"path": test}']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid JSON'); + }); + + // Trailing commas + test('errors on trailing comma in JSON', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', '{"path": "test",}']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid JSON'); + }); + + // Single quotes instead of double + test('errors on single-quoted JSON', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', "{'path': 'test'}"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid JSON'); + }); + + // Just the value without braces - JSON.parse accepts bare strings + test('bare value parses as string', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', '"./README.md"']); + // JSON.parse("\"./README.md\"") = "./README.md" which is a string, not an object + // Tool will fail validation but CLI parses it + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + + // Completely wrong format + test('errors on plain text argument', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', 'just plain text']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid JSON'); + }); + }); + + describe('Malformed target paths (LLM mistakes)', () => { + // Case 21: Too many slashes + test('handles triple slash path', async () => { + const result = await runCli(['call', 'server/tool/extra']); + expect(result.exitCode).toBe(1); + // Should error on server not found (server is "server") + }); + + // Empty tool name + test('errors on trailing slash with no tool', async () => { + const result = await runCli(['call', 'filesystem/']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MISSING_ARGUMENT'); + }); + + // Double slash - first part is empty, second is tool name + test('handles double slash as tool with empty server', async () => { + const result = await runCli(['call', 'filesystem//read_file']); + // server="filesystem", tool="" initially, then /read_file as extra + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + + // Spaces in server name - treated as ambiguous because it looks like "call server tool" + test('errors on spaced server name', async () => { + const result = await runCli(['call', 'file', 'system', 'read_file']); + expect(result.exitCode).toBe(1); + // Will error on server not found or too many args + }); + }); + + describe('Half-complete arguments (LLM mistakes)', () => { + // Forgot the JSON + test('call without JSON still works (stdin mode)', async () => { + const result = await runCli(['call', 'filesystem/read_file']); + // Should try to read stdin or error on missing args + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + + // Just "call" alone + test('call alone errors properly', async () => { + const result = await runCli(['call']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MISSING_ARGUMENT'); + }); + + // Info with partial target + test('info with just slash searches for empty server', async () => { + const result = await runCli(['info', '/']); + // "/" parses as server="", tool="" + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + }); + + describe('Common LLM command variations', () => { + // LLMs might add "mcp" prefix + test('errors on mcp as first arg', async () => { + const result = await runCli(['mcp', 'filesystem', 'read_file']); + expect(result.exitCode).toBe(1); + // Should error - mcp is not a known subcommand + }); + + // LLMs might try kubectl-style + test('errors on kubectl-style "describe"', async () => { + const result = await runCli(['describe', 'filesystem']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('info'); + }); + + // LLMs might try docker-style "exec" + test('errors on docker-style "exec"', async () => { + const result = await runCli(['exec', 'filesystem', 'read_file']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('call'); + }); + + // LLMs might try "invoke" + test('errors on "invoke"', async () => { + const result = await runCli(['invoke', 'filesystem', 'read_file']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('call'); + }); + + // LLMs might try "show" + test('errors on "show"', async () => { + const result = await runCli(['show', 'filesystem']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('info'); + }); + + // LLMs might use "ls" + test('errors on "ls"', async () => { + const result = await runCli(['ls']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('info'); + }); + + // LLMs might use "query" + test('errors on "query"', async () => { + const result = await runCli(['query', '*file*']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('grep'); + }); + }); + + describe('Edge case argument combinations', () => { + // Multiple JSON-like arguments + test('handles multiple JSON arguments', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', '{}', '{}']); + // Should use the combined args or first one + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + + // Empty string argument + test('handles empty JSON object', async () => { + const result = await runCli(['call', 'filesystem', 'read_file', '{}']); + // Will fail on tool execution but not on parsing + expect(result.stderr).not.toContain('AMBIGUOUS_COMMAND'); + }); + + // Very long server name + test('handles very long server name', async () => { + const longName = 'a'.repeat(100); + const result = await runCli(['info', longName]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('not found'); + }); + }); +}); + diff --git a/tests/errors.test.ts b/tests/errors.test.ts index ec12898..dab1e0d 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -17,6 +17,9 @@ import { invalidJsonArgsError, unknownOptionError, missingArgumentError, + ambiguousCommandError, + unknownSubcommandError, + tooManyArgumentsError, ErrorCode, } from '../src/errors'; @@ -173,11 +176,21 @@ describe('errors', () => { expect(error.details).toContain('Unexpected token'); }); - test('unknownOptionError shows help suggestion', () => { + test('unknownOptionError shows valid options for unknown flag', () => { const error = unknownOptionError('--bad'); expect(error.type).toBe('UNKNOWN_OPTION'); expect(error.message).toContain('--bad'); - expect(error.suggestion).toContain('--help'); + expect(error.suggestion).toContain('-c/--config'); + }); + + test('unknownOptionError shows context for --server', () => { + const error = unknownOptionError('--server'); + expect(error.suggestion).toContain('positional argument'); + }); + + test('unknownOptionError shows context for --args', () => { + const error = unknownOptionError('--args'); + expect(error.suggestion).toContain('JSON directly'); }); test('missingArgumentError includes command and argument', () => { @@ -196,4 +209,48 @@ describe('errors', () => { expect(ErrorCode.AUTH_ERROR).toBe(4); }); }); + + describe('subcommand errors', () => { + test('ambiguousCommandError shows both options', () => { + const error = ambiguousCommandError('server', 'tool'); + expect(error.type).toBe('AMBIGUOUS_COMMAND'); + expect(error.details).toContain('server tool'); + expect(error.suggestion).toContain('call server tool'); + expect(error.suggestion).toContain('info server tool'); + }); + + test('ambiguousCommandError handles args case', () => { + const error = ambiguousCommandError('server', 'tool', true); + expect(error.details).toContain('...'); + expect(error.suggestion).toContain(''); + }); + + test('unknownSubcommandError suggests call for run/execute', () => { + const error = unknownSubcommandError('run'); + expect(error.type).toBe('UNKNOWN_SUBCOMMAND'); + expect(error.suggestion).toContain('call'); + }); + + test('unknownSubcommandError suggests info for list/get', () => { + const error = unknownSubcommandError('list'); + expect(error.suggestion).toContain('info'); + }); + + test('unknownSubcommandError suggests grep for search/find', () => { + const error = unknownSubcommandError('search'); + expect(error.suggestion).toContain('grep'); + }); + + test('unknownSubcommandError shows help for unknown alias', () => { + const error = unknownSubcommandError('unknown'); + expect(error.suggestion).toContain('--help'); + }); + + test('tooManyArgumentsError shows counts', () => { + const error = tooManyArgumentsError('grep', 5, 1); + expect(error.type).toBe('TOO_MANY_ARGUMENTS'); + expect(error.details).toContain('5'); + expect(error.details).toContain('1'); + }); + }); }); diff --git a/tests/filter.test.ts b/tests/filter.test.ts new file mode 100644 index 0000000..85b2434 --- /dev/null +++ b/tests/filter.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for tool filtering (allowedTools/disabledTools) + */ + +import { describe, test, expect } from 'bun:test'; +import { filterTools, isToolAllowed } from '../src/config.js'; + +describe('Tool Filtering', () => { + const sampleTools = [ + { name: 'read_file', description: 'Read a file' }, + { name: 'write_file', description: 'Write a file' }, + { name: 'delete_file', description: 'Delete a file' }, + { name: 'list_directory', description: 'List directory contents' }, + { name: 'search_files', description: 'Search files' }, + ]; + + describe('filterTools', () => { + test('returns all tools when no filtering configured', () => { + const config = { command: 'test' }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(5); + }); + + test('filters to only allowed tools (exact match)', () => { + const config = { + command: 'test', + allowedTools: ['read_file', 'list_directory'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(2); + expect(result.map(t => t.name)).toContain('read_file'); + expect(result.map(t => t.name)).toContain('list_directory'); + }); + + test('filters to only allowed tools (wildcard)', () => { + const config = { + command: 'test', + allowedTools: ['*file*'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(4); + expect(result.map(t => t.name)).toContain('read_file'); + expect(result.map(t => t.name)).toContain('write_file'); + expect(result.map(t => t.name)).toContain('delete_file'); + expect(result.map(t => t.name)).toContain('search_files'); + }); + + test('filters to only allowed tools (prefix wildcard)', () => { + const config = { + command: 'test', + allowedTools: ['read_*'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('read_file'); + }); + + test('excludes disabled tools (exact match)', () => { + const config = { + command: 'test', + disabledTools: ['delete_file'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(4); + expect(result.map(t => t.name)).not.toContain('delete_file'); + }); + + test('excludes disabled tools (wildcard)', () => { + const config = { + command: 'test', + disabledTools: ['*file'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(2); + expect(result.map(t => t.name)).toContain('list_directory'); + expect(result.map(t => t.name)).toContain('search_files'); + }); + + test('disabledTools takes precedence over allowedTools', () => { + const config = { + command: 'test', + allowedTools: ['*file*'], + disabledTools: ['write_file', 'delete_file'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(2); + expect(result.map(t => t.name)).toContain('read_file'); + expect(result.map(t => t.name)).toContain('search_files'); + expect(result.map(t => t.name)).not.toContain('write_file'); + expect(result.map(t => t.name)).not.toContain('delete_file'); + }); + + test('combines allowedTools and disabledTools', () => { + const config = { + command: 'test', + allowedTools: ['read_file', 'write_file', 'delete_file'], + disabledTools: ['delete_file'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(2); + expect(result.map(t => t.name)).toContain('read_file'); + expect(result.map(t => t.name)).toContain('write_file'); + }); + + test('pattern matching is case-insensitive', () => { + const config = { + command: 'test', + allowedTools: ['READ_FILE'], + }; + const result = filterTools(sampleTools, config); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('read_file'); + }); + + test('supports ? wildcard for single character', () => { + const tools = [ + { name: 'file1' }, + { name: 'file2' }, + { name: 'file10' }, + ]; + const config = { + command: 'test', + allowedTools: ['file?'], + }; + const result = filterTools(tools, config); + expect(result).toHaveLength(2); + expect(result.map(t => t.name)).toContain('file1'); + expect(result.map(t => t.name)).toContain('file2'); + }); + }); + + describe('isToolAllowed', () => { + test('returns true when no filtering configured', () => { + const config = { command: 'test' }; + expect(isToolAllowed('any_tool', config)).toBe(true); + }); + + test('returns true for allowed tool', () => { + const config = { + command: 'test', + allowedTools: ['read_file'], + }; + expect(isToolAllowed('read_file', config)).toBe(true); + }); + + test('returns false for non-allowed tool', () => { + const config = { + command: 'test', + allowedTools: ['read_file'], + }; + expect(isToolAllowed('write_file', config)).toBe(false); + }); + + test('returns false for disabled tool', () => { + const config = { + command: 'test', + disabledTools: ['delete_file'], + }; + expect(isToolAllowed('delete_file', config)).toBe(false); + }); + + test('returns true for tool not in disabled list', () => { + const config = { + command: 'test', + disabledTools: ['delete_file'], + }; + expect(isToolAllowed('read_file', config)).toBe(true); + }); + + test('disabled takes precedence over allowed', () => { + const config = { + command: 'test', + allowedTools: ['*file*'], + disabledTools: ['write_file'], + }; + expect(isToolAllowed('write_file', config)).toBe(false); + expect(isToolAllowed('read_file', config)).toBe(true); + }); + + test('supports wildcard patterns', () => { + const config = { + command: 'test', + allowedTools: ['read_*'], + }; + expect(isToolAllowed('read_file', config)).toBe(true); + expect(isToolAllowed('read_directory', config)).toBe(true); + expect(isToolAllowed('write_file', config)).toBe(false); + }); + }); +}); diff --git a/tests/grep.test.ts b/tests/grep.test.ts index c4ef76a..36d17e3 100644 --- a/tests/grep.test.ts +++ b/tests/grep.test.ts @@ -134,18 +134,21 @@ describe('globToRegex', () => { expect(regex.test('list_directory')).toBe(false); }); - test('matches by server/tool path', () => { - const regex = globToRegex('filesystem/*'); - expect(regex.test('filesystem/read_file')).toBe(true); - expect(regex.test('github/search_repos')).toBe(false); + test('matches tool names only (not server/tool paths)', () => { + // Since grep now only matches tool names, patterns with slashes + // are for the regex function itself, not how grep uses it + const regex = globToRegex('read_*'); + expect(regex.test('read_file')).toBe(true); + expect(regex.test('read_directory')).toBe(true); + expect(regex.test('write_file')).toBe(false); }); - test('matches tools across all servers', () => { - const regex = globToRegex('**search*'); + test('matches search-related tools', () => { + const regex = globToRegex('*search*'); expect(regex.test('search')).toBe(true); expect(regex.test('search_repos')).toBe(true); - expect(regex.test('github/search_repos')).toBe(true); - expect(regex.test('elasticsearch/search')).toBe(true); + expect(regex.test('full_text_search')).toBe(true); + expect(regex.test('find_files')).toBe(false); }); }); }); diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 6b57731..21f4722 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -30,6 +30,7 @@ describe('CLI Integration Tests', () => { await writeFile(join(subDir, 'nested.txt'), 'Nested content'); // Create config pointing to the temp directory + // Note: npm_config_registry override ensures npx uses public npm registry configPath = join(tempDir, 'mcp_servers.json'); await writeFile( configPath, @@ -38,6 +39,9 @@ describe('CLI Integration Tests', () => { filesystem: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', tempDir], + env: { + npm_config_registry: 'https://registry.npmjs.org', + }, }, }, }) @@ -55,8 +59,9 @@ describe('CLI Integration Tests', () => { const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts'); try { + // Disable daemon for tests for deterministic behavior const result = - await $`bun run ${cliPath} -c ${configPath} ${args}`.nothrow(); + await $`MCP_NO_DAEMON=1 bun run ${cliPath} -c ${configPath} ${args}`.nothrow(); return { stdout: result.stdout.toString(), stderr: result.stderr.toString(), @@ -112,15 +117,6 @@ describe('CLI Integration Tests', () => { expect(result.stdout.length).toBeGreaterThan(100); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli(['--json']); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].name).toBe('filesystem'); - expect(Array.isArray(parsed[0].tools)).toBe(true); - }); }); describe('grep command', () => { @@ -128,7 +124,8 @@ describe('CLI Integration Tests', () => { const result = await runCli(['grep', '*file*']); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/filesystem\/(read_file|write_file)/); + // Should find file-related tools (space-separated format: server tool) + expect(result.stdout).toContain('read_file '); }); test('searches with descriptions', async () => { @@ -138,28 +135,19 @@ describe('CLI Integration Tests', () => { expect(result.stdout).toContain('filesystem'); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli(['grep', '*read*', '--json']); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0].server).toBeDefined(); - expect(parsed[0].tool).toBeDefined(); - }); test('shows message for no matches', async () => { const result = await runCli(['grep', '*nonexistent_xyz_123*']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('No tools found'); + expect(result.stdout).toContain('Tip:'); }); }); describe('info command (server)', () => { test('shows server details', async () => { - const result = await runCli(['filesystem']); + const result = await runCli(['info', 'filesystem']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Server:'); @@ -168,18 +156,9 @@ describe('CLI Integration Tests', () => { expect(result.stdout).toContain('Tools'); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli(['filesystem', '--json']); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.name).toBe('filesystem'); - expect(parsed.tools).toBeDefined(); - expect(Array.isArray(parsed.tools)).toBe(true); - }); test('errors on unknown server', async () => { - const result = await runCli(['nonexistent_server']); + const result = await runCli(['info', 'nonexistent_server']); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('not found'); @@ -188,7 +167,7 @@ describe('CLI Integration Tests', () => { describe('info command (tool)', () => { test('shows tool schema', async () => { - const result = await runCli(['filesystem/read_file']); + const result = await runCli(['info', 'filesystem', 'read_file']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Tool:'); @@ -198,17 +177,9 @@ describe('CLI Integration Tests', () => { expect(result.stdout).toContain('Input Schema:'); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli(['filesystem/read_file', '--json']); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.name).toBe('read_file'); - expect(parsed.inputSchema).toBeDefined(); - }); test('errors on unknown tool', async () => { - const result = await runCli(['filesystem/nonexistent_tool']); + const result = await runCli(['info', 'filesystem', 'nonexistent_tool']); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('not found'); @@ -218,7 +189,9 @@ describe('CLI Integration Tests', () => { describe('call command', () => { test('calls read_file tool', async () => { const result = await runCli([ - 'filesystem/read_file', + 'call', + 'filesystem', + 'read_file', JSON.stringify({ path: testFilePath }), ]); @@ -228,7 +201,9 @@ describe('CLI Integration Tests', () => { test('calls list_directory tool', async () => { const result = await runCli([ - 'filesystem/list_directory', + 'call', + 'filesystem', + 'list_directory', JSON.stringify({ path: tempDir }), ]); @@ -237,22 +212,12 @@ describe('CLI Integration Tests', () => { expect(result.stdout).toContain('subdir'); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli([ - 'filesystem/read_file', - JSON.stringify({ path: testFilePath }), - '--json', - ]); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.content).toBeDefined(); - expect(Array.isArray(parsed.content)).toBe(true); - }); test('handles tool errors gracefully', async () => { const result = await runCli([ - 'filesystem/read_file', + 'call', + 'filesystem', + 'read_file', JSON.stringify({ path: '/nonexistent/path/file.txt' }), ]); @@ -262,7 +227,7 @@ describe('CLI Integration Tests', () => { }); test('handles invalid JSON arguments', async () => { - const result = await runCli(['filesystem/read_file', 'not valid json']); + const result = await runCli(['call', 'filesystem', 'read_file', 'not valid json']); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Invalid JSON'); @@ -270,7 +235,7 @@ describe('CLI Integration Tests', () => { test('calls tool with no arguments', async () => { // list_directory might work with default path - const result = await runCli(['filesystem/list_directory', '{}']); + const result = await runCli(['call', 'filesystem', 'list_directory', '{}']); // May succeed or fail depending on server implementation // We just verify it doesn't crash @@ -337,8 +302,9 @@ describe('HTTP Transport Integration Tests', () => { const cliPath = join(import.meta.dir, '..', '..', 'src', 'index.ts'); try { + // Disable daemon for tests const result = - await $`bun run ${cliPath} -c ${configPath} ${args}`.nothrow(); + await $`MCP_NO_DAEMON=1 bun run ${cliPath} -c ${configPath} ${args}`.nothrow(); return { stdout: result.stdout.toString(), stderr: result.stderr.toString(), @@ -361,20 +327,11 @@ describe('HTTP Transport Integration Tests', () => { expect(result.stdout).toContain('deepwiki'); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli(['--json']); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].name).toBe('deepwiki'); - expect(Array.isArray(parsed[0].tools)).toBe(true); - }); }); describe('info command with HTTP server', () => { test('shows HTTP server details', async () => { - const result = await runCli(['deepwiki']); + const result = await runCli(['info', 'deepwiki']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Server:'); @@ -383,15 +340,6 @@ describe('HTTP Transport Integration Tests', () => { expect(result.stdout).toContain('HTTP'); }); - test('outputs JSON with --json flag', async () => { - const result = await runCli(['deepwiki', '--json']); - - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed.name).toBe('deepwiki'); - expect(parsed.config.url).toBe('https://mcp.deepwiki.com/mcp'); - expect(parsed.tools).toBeDefined(); - }); }); describe('grep command with HTTP server', () => { diff --git a/tests/output.test.ts b/tests/output.test.ts index f7f8639..518713f 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -80,7 +80,7 @@ describe('output', () => { expect(output).toContain('find'); }); - test('includes descriptions when requested', () => { + test('always includes descriptions when available', () => { const results = [ { server: 'test', @@ -92,11 +92,12 @@ describe('output', () => { }, ]; + // Descriptions are always shown in grep output (regardless of -d flag) const withDesc = formatSearchResults(results, true); expect(withDesc).toContain('Tool description'); const withoutDesc = formatSearchResults(results, false); - expect(withoutDesc).not.toContain('Tool description'); + expect(withoutDesc).toContain('Tool description'); }); });