diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7e34b..6949991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `mcpc close @session`, `mcpc restart @session`, and `mcpc shell @session` command-first syntax as alternatives to `mcpc @session close/restart/shell` - E2E tests now run under the Bun runtime (in addition to Node.js); use `./test/e2e/run.sh --runtime bun` or `npm run test:e2e:bun` ### Changed +- **Breaking:** CLI syntax redesigned to command-first style. All commands now start with a verb; MCP operations require a named session. + + | Before | After | + |-----------------------------------------------|-------| + | `mcpc tools-list` | `mcpc connect @name` then `mcpc @name tools-list` | + | `mcpc connect @name` | `mcpc connect @name` | + | `mcpc login` | `mcpc login ` | + | `mcpc logout` | `mcpc logout ` | + | `mcpc --clean=sessions` | `mcpc clean sessions` | + | `mcpc --config file.json entry connect @name` | `mcpc connect file.json:entry @name` | + + Direct one-shot URL access (e.g. `mcpc mcp.apify.com tools-list`) is removed; create a session first with `mcpc connect`. + - `@napi-rs/keyring` native addon is now loaded lazily: `mcpc` starts and works normally even when `libsecret` (Linux) or the addon itself is missing; a one-time warning is emitted and credentials fall back to `~/.mcpc/credentials.json` (mode 0600) ## [0.1.10] - 2026-03-01 diff --git a/CLAUDE.md b/CLAUDE.md index 76101a8..f7e9f69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,23 +53,22 @@ npm run format # List all active sessions and saved authentication profiles mcpc -# Use a local server package referenced by MCP config file -mcpc --config ~/.vscode/mcp.json filesystem tools-list - # Login to OAuth-enabled MCP server and save authentication for future use -mcpc mcp.apify.com login +mcpc login mcp.apify.com -# Show information about a remote MCP server and open interactive shell -mcpc mcp.apify.com -mcpc mcp.apify.com shell +# Create a persistent session +mcpc connect mcp.apify.com @test +mcpc @test # show session info +mcpc @test tools-list # list available tools +mcpc @test tools-call search-actors query:="web crawler" +mcpc @test shell # interactive shell # Use JSON mode for scripting -mcpc --json mcp.apify.com tools-list +mcpc --json @test tools-list -# Create a persistent session (or reconnect if it exists but bridge is dead) -mcpc mcp.apify.com connect @test -mcpc @test tools-call search-actors query:="web crawler" -mcpc @test shell +# Use a local server package referenced by MCP config file +mcpc connect ~/.vscode/mcp.json:filesystem @fs +mcpc @fs tools-list ``` ## Design Principles @@ -81,7 +80,6 @@ mcpc @test shell - Be forgiving, always help users make progress (great errors + guidance) - Be consistent with the [MCP specification](https://modelcontextprotocol.io/specification/latest), with `--json` strictly - Minimal and portable (few deps, cross-platform) -- Keep backwards compatibility as much as possible - No slop! ## Architecture @@ -138,22 +136,22 @@ mcpc/ - Bridge lifecycle: start/connect/stop, auto-restart on crash - Interactive shell using Node.js `readline` with command history (`~/.mcpc/history`, last 1000 commands) - Configuration file loading (standard MCP JSON format, compatible with Claude Desktop) -- Credential management via OS keychain (`keytar` package) +- Credential management via OS keychain (`@napi-rs/keyring` package) **CLI Command Structure:** - All MCP commands use hyphenated format: `tools-list`, `tools-call`, `resources-read`, etc. - `mcpc` - List all sessions and authentication profiles -- `mcpc ` - Show server info, instructions, and capabilities - `mcpc @` - Show session info, server capabilities, and authentication details -- `mcpc help` - Alias for `mcpc ` -- `mcpc ` - Execute MCP command -- Session creation: `mcpc connect @ [--profile ]` -- Authentication: `mcpc login [--profile ]` and `mcpc logout [--profile ]` +- `mcpc @ ` - Execute MCP command (e.g., `mcpc @apify tools-list`) +- `mcpc connect @` - Create a named persistent session +- `mcpc login [--profile ]` - Login via OAuth and save auth profile +- `mcpc logout [--profile ]` - Delete an authentication profile +- `mcpc clean [sessions|profiles|logs|all ...]` - Clean up mcpc data +- `mcpc help [command]` - Show help for a specific command -**Target Types:** -- `@` - Named session (e.g., `@apify`) - persistent connection via bridge -- `` - Server URL (e.g., `mcp.apify.com` or `https://mcp.apify.com`) - URL scheme optional, defaults to `https://` -- `` - Config file entry (requires `--config` flag) - local or remote server +**Server formats for `connect`, `login`, `logout`:** +- `` - Remote HTTP server (e.g., `mcp.apify.com` or `https://mcp.apify.com`) - scheme optional, defaults to `https://` +- `:` - Config file entry (e.g., `~/.vscode/mcp.json:filesystem`) **Output Utilities** (`src/cli/output.ts`): - `logTarget(target, outputMode)` - Shows `[Using session: @name]` prefix (human mode only) @@ -164,7 +162,7 @@ mcpc/ ### Session Lifecycle -1. User creates session: `mcpc mcp.apify.com connect @apify` +1. User creates session: `mcpc connect mcp.apify.com @apify` 2. CLI creates entry in `sessions.json`, spawns bridge process 3. Bridge creates Unix socket at `~/.mcpc/bridges/apify.sock` 4. Bridge performs MCP initialization: @@ -366,7 +364,7 @@ Environment variable substitution supported: `${VAR_NAME}` - **Node.js:** β‰₯18.0.0 (for native `fetch` API) - **Bun:** β‰₯1.0.0 (alternative runtime) - **OS support:** macOS, Linux, Windows -- **Linux dependency:** `libsecret` (for OS keychain access via `keytar`) +- **Linux dependency:** `libsecret` (for OS keychain access via `@napi-rs/keyring`) ## Authentication Architecture @@ -390,13 +388,13 @@ Environment variable substitution supported: `${VAR_NAME}` **CLI Commands:** ```bash # Login and save authentication profile -mcpc login [--profile ] +mcpc login [--profile ] # Logout and delete authentication profile -mcpc logout [--profile ] +mcpc logout [--profile ] # Create session with specific profile -mcpc connect @ --profile +mcpc connect @ --profile ``` **Authentication Behavior:** @@ -415,7 +413,7 @@ On failure, the error message includes instructions on how to login. This ensure - You can mix authenticated sessions and public access on the same server **OAuth Flow:** -1. User runs `mcpc login --profile personal` +1. User runs `mcpc login --profile personal` 2. CLI discovers OAuth metadata via `WWW-Authenticate` header or well-known URIs 3. CLI creates local HTTP callback server on `http://localhost:/callback` 4. CLI opens browser to authorization URL with PKCE challenge @@ -484,7 +482,7 @@ All state files are stored in `~/.mcpc/` directory (unless overridden by `MCPC_H - `@modelcontextprotocol/sdk` - Official MCP SDK for client/server implementation - `commander` - Command-line argument parsing and CLI framework - `chalk` - Terminal string styling and colors -- `keytar` - OS keychain integration for secure credential storage +- `@napi-rs/keyring` - OS keychain integration for secure credential storage - `proper-lockfile` - File locking for concurrent session access - `@inquirer/input`, `@inquirer/select` - Interactive prompts for login flows - `ora` - Spinner animations for progress indication @@ -530,7 +528,7 @@ When implementing features: 7. **Protocol compliance** - Follow MCP specification strictly; handle all notification types 8. **Session management** - Always clean up resources; handle orphaned processes; provide reconnection 9. **Hyphenated commands** - All MCP commands use hyphens: `tools-list`, `resources-read`, `prompts-list` -10. **Target-first syntax** - Commands follow `mcpc ` pattern consistently +10. **Command-first syntax** - Top-level commands come first (`connect`, `login`, `clean`); MCP operations always go through a named session (`mcpc @session `) 11. **JSON field naming** - Use consistent field names in JSON output: - `sessionName` (not `name`) for session identifiers - `server` (not `target`) for server URLs/addresses @@ -599,7 +597,7 @@ Bridge logs location: `~/.mcpc/logs/bridge-.log` - Authentication profiles (reusable credentials) - Token refresh with automatic persistence - Integration with session management -- **Keychain Integration**: OS keychain via `keytar` for secure credential storage +- **Keychain Integration**: OS keychain via `@napi-rs/keyring` for secure credential storage ### 🚧 Deferred / Nice-to-have - **Package Resolution**: Find and run local MCP packages automatically @@ -608,23 +606,19 @@ Bridge logs location: `~/.mcpc/logs/bridge-.log` ### πŸ“‹ Implementation Approach -`mcpc` implements a **hybrid architecture** supporting both direct connections and persistent sessions: - -**Direct Connection** (for one-off commands without sessions): -- CLI creates `McpClient` on-demand via `withMcpClient()` helper -- Connect β†’ Execute β†’ Close for each command -- Used when target is a URL or config entry (not a session name) -- Good for ephemeral usage and scripts +All MCP operations go through named sessions. Sessions are persistent bridge processes that maintain the MCP connection. -**Bridge Process Architecture** (for persistent sessions): +**Bridge Process Architecture:** - Persistent bridge maintains MCP connection and state - CLI communicates via Unix socket IPC - Supports sessions, notifications, caching, and better performance - Used when target is a session name (e.g., `@apify`) - Bridge handles automatic reconnection and error recovery -This hybrid approach provides flexibility: use direct connections for quick one-off commands, -or create sessions for interactive use and long-running workflows. +**Session workflow:** +1. `mcpc connect @name` β€” creates session and starts bridge +2. `mcpc @name ` β€” all MCP operations routed through the bridge +3. `mcpc @name close` β€” tears down session and bridge ## References diff --git a/README.md b/README.md index d35aa4c..1d28181 100644 --- a/README.md +++ b/README.md @@ -84,21 +84,21 @@ dbus-run-session -- bash -c "echo -n 'password' | gnome-keyring-daemon --unlock mcpc # Login to remote MCP server and save OAuth credentials for future use -mcpc mcp.apify.com login +mcpc login mcp.apify.com -# Show information about a remote MCP server -mcpc mcp.apify.com - -# Use JSON mode for scripting -mcpc mcp.apify.com tools-list --json - -# Create and use persistent MCP session -mcpc mcp.apify.com connect @test +# Create a persistent session and interact with it +mcpc connect mcp.apify.com @test +mcpc @test # show server info +mcpc @test tools-list mcpc @test tools-call search-actors keywords:="website crawler" mcpc @test shell -# Interact with a local MCP server package (stdio) referenced from config file -mcpc --config ~/.vscode/mcp.json filesystem tools-list +# Use JSON mode for scripting +mcpc --json @test tools-list + +# Use a local MCP server package (stdio) referenced from config file +mcpc connect ./.vscode/mcp.json:filesystem @fs +mcpc @fs tools-list ``` ## Usage @@ -106,68 +106,53 @@ mcpc --config ~/.vscode/mcp.json filesystem tools-list ``` -Usage: mcpc [options] [command] +Usage: mcpc [options] [<@session>] [] Universal command-line client for the Model Context Protocol (MCP). Options: - -j, --json Output in JSON format for scripting - -c, --config Path to MCP config JSON file (e.g. ".vscode/mcp.json") - -H, --header
HTTP header for remote MCP server (can be repeated) - -v, --version Output the version number - --verbose Enable debug logging - --profile OAuth profile for the server ("default" if not provided) - --schema Validate tool/prompt schema against expected schema - --schema-mode Schema validation mode: strict, compatible (default), ignore - --timeout Request timeout in seconds (default: 300) - --proxy <[host:]port> Start proxy MCP server for session (with "connect" command) - --proxy-bearer-token Require authentication for access to proxy server - --x402 Enable x402 auto-payment using the configured wallet - --clean[=types] Clean up mcpc data (types: sessions, logs, profiles, all) - -h, --help Display general help - -Targets: - @ Named persistent session (e.g. "@apify") - Entry in MCP config file specified by --config (e.g. "fs") - Remote MCP server URL (e.g. "mcp.apify.com") - -Management commands: - login Create OAuth profile with credentials for remote server - logout Remove OAuth profile for remote server - connect @ Connect to server and create named persistent session - restart Kill and restart a session - close Close a session - -MCP server commands: - help Show server info ("help" can be omitted) - shell Open interactive shell - tools-list [--full] Send "tools/list" MCP request... - tools-get - tools-call [arg1:=val1 arg2:=val2 ... | | [arg1:=val1 arg2:=val2 ... | | - resources-subscribe - resources-unsubscribe - resources-templates-list - logging-set-level - ping - -EXPERIMENTAL: x402 payment commands (no target needed): - x402 init Create a new x402 wallet - x402 import Import wallet from private key - x402 info Show wallet info - x402 sign -r Sign payment from PAYMENT-REQUIRED header - x402 remove Remove the wallet - -Run "mcpc" without to show available sessions and profiles. + -j, --json Output in JSON format for scripting + -H, --header
HTTP header (can be repeated) + --verbose Enable debug logging + --profile OAuth profile for the server ("default" if not provided) + --schema Validate tool/prompt schema against expected schema + --schema-mode Schema validation mode: strict, compatible (default), ignore + --timeout Request timeout in seconds (default: 300) + -v, --version Output the version number + -h, --help Display help + +Commands: + connect <@session> Connect to an MCP server and start a new named @session + login Authenticate to server using OAuth and save the profile + logout Delete an authentication profile for a server + clean [resources...] Clean up mcpc data (sessions, profiles, logs, all) + x402 [subcommand] [args...] Configure an x402 payment wallet (EXPERIMENTAL) + help [command] Show help for a specific command + +Session commands (after connecting): + <@session> Show MCP server info and capabilities + <@session> shell Open interactive shell + <@session> close Close the session + <@session> restart Kill and restart the session + <@session> tools-list List MCP tools + <@session> tools-get + <@session> tools-call [arg:=val ... | | prompts-list + <@session> prompts-get [arg:=val ... | | resources-list + <@session> resources-read + <@session> resources-subscribe + <@session> resources-unsubscribe + <@session> resources-templates-list + <@session> logging-set-level + <@session> ping + +Run "mcpc" without arguments to show active sessions and OAuth profiles. ``` ### General actions -When `` is omitted, `mcpc` provides general actions: +With no arguments, `mcpc` lists all active sessions and saved OAuth profiles: ```bash # List all sessions and OAuth profiles (also in JSON mode) @@ -178,46 +163,31 @@ mcpc --json mcpc --help mcpc --version -# Clean expired sessions and old log files (see below for details) -mcpc --clean +# Clean stale sessions and old log files +mcpc clean ``` -### Targets - -To connect and interact with an MCP server, you need to specify a ``, which can be one of (in this order of precedence): - -- **Entry in a config file** (e.g. `--config .vscode/mcp.json filesystem`) - see [Config file](#mcp-server-config-file) -- **Remote MCP server URL** (e.g. `https://mcp.apify.com`) -- **Named session** (e.g. `@apify`) - see [Sessions](#sessions) - -`mcpc` automatically selects the transport protocol based on the server (stdio or Streamable HTTP), -connects, and enables you to interact with it. +### Server formats -**URL handling:** +The `connect`, `login`, and `logout` commands accept a `` argument in these formats: -- URLs without a scheme (e.g. `mcp.apify.com`) default to `https://` -- `localhost` and `127.0.0.1` addresses without a scheme default to `http://` (for local dev/proxy servers) -- To override the default, specify the scheme explicitly (e.g. `http://example.com`) +- **Remote URL** (e.g. `mcp.apify.com` or `https://mcp.apify.com`) β€” scheme defaults to `https://` +- **Config file entry** (e.g. `~/.vscode/mcp.json:filesystem`) β€” `file:entry-name` syntax ### MCP commands -When `` is provided, `mcpc` sends MCP requests to the target server: +All MCP commands go through a named session created with `connect`: ```bash -# Server from config file (stdio) -mcpc --config .vscode/mcp.json fileSystem -mcpc --config .vscode/mcp.json fileSystem tools-list -mcpc --config .vscode/mcp.json fileSystem tools-call list_directory path:=/ - -# Remote server (Streamable HTTP) -mcpc mcp.apify.com\?tools=docs -mcpc mcp.apify.com\?tools=docs tools-list -mcpc mcp.apify.com\?tools=docs tools-call search-apify-docs query:="What are Actors?" - -# Session -mcpc mcp.apify.com\?tools=docs connect @apify +# Connect to a remote server and create a session +mcpc connect mcp.apify.com @apify mcpc @apify tools-list mcpc @apify tools-call search-apify-docs query:="What are Actors?" + +# Connect to a local server via config file entry +mcpc connect ~/.vscode/mcp.json:filesystem @fs +mcpc @fs tools-list +mcpc @fs tools-call list_directory path:=/ ``` See [MCP feature support](#mcp-feature-support) for details about all supported MCP features and commands. @@ -228,18 +198,18 @@ The `tools-call` and `prompts-get` commands accept arguments as positional param ```bash # Key:=value pairs (auto-parsed: tries JSON, falls back to string) -mcpc tools-call greeting:="hello world" count:=10 enabled:=true -mcpc tools-call config:='{"key":"value"}' items:='[1,2,3]' +mcpc @session tools-call greeting:="hello world" count:=10 enabled:=true +mcpc @session tools-call config:='{"key":"value"}' items:='[1,2,3]' # Force string type with JSON quotes -mcpc tools-call id:='"123"' flag:='"true"' +mcpc @session tools-call id:='"123"' flag:='"true"' # Inline JSON object (if first arg starts with { or [) -mcpc tools-call '{"greeting":"hello world","count":10}' +mcpc @session tools-call '{"greeting":"hello world","count":10}' # Read from stdin (automatic when no positional args and input is piped) -echo '{"greeting":"hello","count":10}' | mcpc tools-call -cat args.json | mcpc tools-call +echo '{"greeting":"hello","count":10}' | mcpc @session tools-call +cat args.json | mcpc @session tools-call ``` **Rules:** @@ -286,8 +256,7 @@ mcpc @server tools-call search "query:=hello world" `mcpc` provides an interactive shell for discovery and testing of MCP servers. ```bash -mcpc mcp.apify.com shell # Direct connection -mcpc @apify shell # Use existing session +mcpc @apify shell ``` Shell commands: `help`, `exit`/`quit`/Ctrl+D, Ctrl+C to cancel. @@ -317,7 +286,7 @@ which then serve as unique reference in commands. ```bash # Create a persistent session -mcpc mcp.apify.com\?tools=docs connect @apify +mcpc connect mcp.apify.com @apify # List all sessions and OAuth profiles mcpc @@ -327,10 +296,10 @@ mcpc @apify tools-list mcpc @apify shell # Restart the session (kills and restarts the bridge process) -mcpc @apify restart +mcpc @apify restart # or: mcpc restart @apify # Close the session, terminates bridge process -mcpc @apify close +mcpc @apify close # or: mcpc close @apify # ...now session name "@apify" is forgotten and available for future use ``` @@ -371,14 +340,14 @@ and any future attempts to use them will fail. To **remove the session from the list**, you need to explicitly close it: ```bash -mcpc @apify close +mcpc @apify close # or: mcpc close @apify ``` You can restart a session anytime, which kills the bridge process and opens new connection with new `MCP-Session-Id`, by running: ```bash -mcpc @apify restart +mcpc @apify restart # or: mcpc restart @apify ``` ## Authentication @@ -391,11 +360,7 @@ For local servers (stdio) or remote servers (Streamable HTTP) which do not requi `mcpc` can be used without authentication: ```bash -# One-shot command -mcpc mcp.apify.com\?tools=docs tools-list - -# Session command -mcpc mcp.apify.com\?tools=docs connect @test +mcpc connect mcp.apify.com @test mcpc @test tools-list ``` @@ -407,11 +372,8 @@ All headers are stored securely in the OS keychain for the session, but they are running a one-shot command or connecting new session. ```bash -# One-time command with Bearer token -mcpc --header "Authorization: Bearer ${APIFY_TOKEN}" https://mcp.apify.com tools-list - -# Create session with Bearer token (saved to keychain for this session only) -mcpc --header "Authorization: Bearer ${APIFY_TOKEN}" https://mcp.apify.com connect @apify +# Create session with Bearer token (token saved to keychain for this session only) +mcpc connect https://mcp.apify.com @apify --header "Authorization: Bearer ${APIFY_TOKEN}" # Use the session (Bearer token is loaded from keychain automatically) mcpc @apify tools-list @@ -443,25 +405,25 @@ Key concepts: ```bash # Login to server and save 'default' authentication profile for future use -mcpc mcp.apify.com login +mcpc login mcp.apify.com # Use named authentication profile instead of 'default' -mcpc mcp.apify.com login --profile work +mcpc login mcp.apify.com --profile work # Create two sessions using the two different credentials -mcpc https://mcp.apify.com connect @apify-personal -mcpc https://mcp.apify.com connect @apify-work --profile work +mcpc connect mcp.apify.com @apify-personal +mcpc connect mcp.apify.com @apify-work --profile work # Both sessions now work independently mcpc @apify-personal tools-list # Uses personal account mcpc @apify-work tools-list # Uses work account # Re-authenticate existing profile (e.g., to refresh or change scopes) -mcpc mcp.apify.com login --profile work +mcpc login mcp.apify.com --profile work # Delete "default" and "work" authentication profiles -mcpc mcp.apify.com logout -mcpc mcp.apify.com logout --profile work +mcpc logout mcp.apify.com +mcpc logout mcp.apify.com --profile work ``` ### Authentication precedence @@ -505,16 +467,13 @@ This flow ensures: # With specific profile - always authenticated: # - Uses 'work' if it exists # - Fails if it doesn't exist -mcpc mcp.apify.com connect @apify-work --profile work +mcpc connect mcp.apify.com @apify-work --profile work # Without profile - opportunistic authentication: # - Uses 'default' if it exists # - Tries unauthenticated if 'default' doesn't exist # - Fails if the server requires authentication -mcpc mcp.apify.com connect @apify-personal - -# Public server - no authentication needed: -mcpc mcp.apify.com\?tools=docs tools-list +mcpc connect mcp.apify.com @apify-personal ``` ## MCP proxy @@ -526,25 +485,21 @@ See also [AI sandboxes](#ai-sandboxes). ```bash # Human authenticates to a remote server -mcpc mcp.apify.com login +mcpc login mcp.apify.com # Create authenticated session with proxy server on localhost:8080 -mcpc mcp.apify.com connect @open-relay --proxy 8080 +mcpc connect mcp.apify.com @open-relay --proxy 8080 # Now any MCP client can connect to proxy like to a regular MCP server # The client has NO access to the original OAuth tokens or HTTP headers # Note: localhost/127.0.0.1 URLs default to http:// (no scheme needed) -mcpc localhost:8080 tools-list -mcpc 127.0.0.1:8080 tools-call search-actors keywords:="web scraper" - -# Or create a new session from the proxy for convenience -mcpc localhost:8080 connect @sandboxed +mcpc connect localhost:8080 @sandboxed mcpc @sandboxed tools-call search-actors keywords:="web scraper" # Optionally protect proxy with bearer token for better security (stored in OS keychain) -mcpc mcp.apify.com connect @secure-relay --proxy 8081 --proxy-bearer-token secret123 +mcpc connect mcp.apify.com @secure-relay --proxy 8081 --proxy-bearer-token secret123 # To use the proxy, caller needs to pass the bearer token in HTTP header -mcpc localhost:8081 connect @sandboxed2 --header "Authorization: Bearer secret123" +mcpc connect localhost:8081 @sandboxed2 --header "Authorization: Bearer secret123" ``` **Proxy options for `connect` command:** @@ -565,13 +520,13 @@ mcpc localhost:8081 connect @sandboxed2 --header "Authorization: Bearer secret12 ```bash # Localhost only (default, most secure) -mcpc mcp.apify.com connect @relay --proxy 8080 +mcpc connect mcp.apify.com @relay --proxy 8080 # Bind to all interfaces (allows network access - use with caution!) -mcpc mcp.apify.com connect @relay --proxy 0.0.0.0:8080 +mcpc connect mcp.apify.com @relay --proxy 0.0.0.0:8080 # Bind to specific interface -mcpc mcp.apify.com connect @relay --proxy 192.168.1.100:8080 +mcpc connect mcp.apify.com @relay --proxy 192.168.1.100:8080 ``` When listing sessions, proxy info is displayed prominently: @@ -664,8 +619,8 @@ it's always a good idea to run them in a code sandbox with limited access to you The [proxy MCP server](#mcp-proxy) feature provides a security boundary for AI agents: -1. **Human creates authentication profile**: `mcpc mcp.apify.com login --profile ai-access` -2. **Human creates session**: `mcpc mcp.apify.com connect @ai-sandbox --profile ai-access --proxy 8080` +1. **Human creates authentication profile**: `mcpc login mcp.apify.com --profile ai-access` +2. **Human creates session**: `mcpc connect mcp.apify.com @ai-sandbox --profile ai-access --proxy 8080` 3. **AI runs inside a sandbox**: If sandbox has access limited to `localhost:8080`, it can only interact with the MCP server through the `@ai-sandbox` session, without access to the original OAuth credentials, HTTP headers, or `mcpc` configuration. @@ -732,7 +687,7 @@ Pass the `--x402` flag when connecting to a session or running direct commands: ```bash # Create a session with x402 payment support -mcpc mcp.apify.com connect @apify --x402 +mcpc connect mcp.apify.com @apify --x402 # The session now automatically handles 402 responses mcpc @apify tools-call expensive-tool query:="hello" @@ -904,7 +859,7 @@ When connected via a [session](#sessions), `mcpc` automatically handles `list_ch notifications for tools, resources, and prompts. The bridge process tracks when each notification type was last received. In [shell mode](#interactive-shell), notifications are displayed in real-time. -The timestamps are available in JSON output of `mcpc --json` under the `_mcpc.notifications` +The timestamps are available in JSON output of `mcpc @session --json` under the `_mcpc.notifications` field - see [Server instructions](#server-instructions). #### Server logs @@ -958,14 +913,12 @@ You can configure `mcpc` using a config file, environment variables, or command- `mcpc` supports the ["standard"](https://gofastmcp.com/integrations/mcp-json-configuration) MCP server JSON config file, compatible with Claude Desktop, VS Code, and other MCP clients. -You can point to an existing config file with `--config`: +Use the `file:entry` syntax to reference a server from a config file: ```bash -# One-shot command to an MCP server configured in Visual Studio Code -mcpc --config .vscode/mcp.json apify tools-list - -# Open a session to a server specified in the custom config file -mcpc --config .vscode/mcp.json apify connect @my-apify +# Open a session to a server specified in the Visual Studio Code config +mcpc connect .vscode/mcp.json:apify @my-apify +mcpc @my-apify tools-list ``` **Example MCP config JSON file:** @@ -1010,14 +963,12 @@ For **stdio servers:** **Using servers from config file:** -When `--config` is provided, you can reference servers by name: +Reference servers by their name using the `file:entry` syntax: ```bash -# With config file, use server names directly -mcpc --config .vscode/mcp.json filesystem tools-list - -# Create a named session from server in config -mcpc --config .vscode/mcp.json filesystem connect @fs +# Create a named session from a server in the config +mcpc connect .vscode/mcp.json:filesystem @fs +mcpc @fs tools-list mcpc @fs tools-call search ``` @@ -1060,20 +1011,19 @@ Config files support environment variable substitution using `${VAR_NAME}` synta ### Cleanup -You can clean up the `mcpc` state and data using the `--clean` option: +You can clean up the `mcpc` state and data using the `clean` command: ```bash # Safe non-destructive cleanup: remove expired sessions, delete old orphaned logs -mcpc --clean +mcpc clean -# Clean specific resources (comma-separated) -mcpc --clean=sessions # Kill bridges, delete all sessions -mcpc --clean=profiles # Delete all authentication profiles -mcpc --clean=logs # Delete all log files -mcpc --clean=sessions,logs # Clean multiple resource types +# Clean specific resources +mcpc clean sessions # Kill bridges, delete all sessions +mcpc clean profiles # Delete all authentication profiles +mcpc clean logs # Delete all log files # Nuclear option: remove everything -mcpc --clean=all # Delete all sessions, profiles, logs, and sockets +mcpc clean all # Delete all sessions, profiles, logs, and sockets ``` ## Security @@ -1152,12 +1102,12 @@ The main `mcpc` process doesn't save log files, but supports [verbose mode](#ver **"Session not found"** - List existing sessions: `mcpc` -- Create new session if expired: `mcpc @ close` and `mcpc connect @` +- Create new session if expired: `mcpc @ close` and `mcpc connect @` **"Authentication failed"** - List saved OAuth profiles: `mcpc` -- Re-authenticate: `mcpc login [--profile ]` +- Re-authenticate: `mcpc login [--profile ]` - For bearer tokens: provide `--header "Authorization: Bearer ${TOKEN}"` again ## Development diff --git a/docs/TODOs.md b/docs/TODOs.md index 1a5a088..70fe1d0 100644 --- a/docs/TODOs.md +++ b/docs/TODOs.md @@ -2,41 +2,124 @@ # TODOs -- mcpc @apify tools-get fetch-actor-details => should print also "object" properties in human mode +## Bugs ! + +Unauthenticated session to sentry MCP keeps showing as live, but it should be expired. + +$ mcpc @dumy** ξ‚² βœ” +[@dumy β†’ https://mcp.sentry.dev/mcp (HTTP)] + +Error: Authentication required by server. + +To authenticate, run: +mcpc https://mcp.sentry.dev/mcp login + +Then recreate the session: +mcpc https://mcp.sentry.dev/mcp session @dumy + +$ mcpc ξ‚² 4 ✘ +MCP sessions: +@fss β†’ npx -y @modelcontextprotocol/server-filesystem /Users/jancurn/Projects/mcpc (stdio) ● live +@fs β†’ npx -y @modelcontextprotocol/server-filesystem /Users/jancurn/Projects/mcpc (stdio) ● live +@dumy β†’ https://mcp.sentry.dev/mcp (HTTP) ● live + +Available OAuth profiles: +mcp.notion.com / default, refreshed 1 weeks ago +mcp.apify.com / default, created 58m ago + +Run "mcpc --help" for usage information. + + + +- mcpc @session --timeout ... / mcpc @session --timeout ... has no effect + +- createSessionProgram() advertises --header and --profile options for mcpc @session ..., but these values are never applied: withMcpClient()/SessionClient ignore headers/profile overrides and always use the session’s stored config. This is misleading for users and makes it easy to think a command is authenticated/modified when it isn’t. Either wire these options into session execution (e.g. by updating/restarting the session/bridge) or remove them from the session program/help. + +- parseServerArg() splits config entries using the first : (arg.indexOf(':')). This breaks Windows paths with drive letters (e.g. C:\Users\me\mcp.json:filesystem), which would be parsed as file=C entry=\Users\.... Consider special-casing ^[A-Za-z]:[\\/] and/or using lastIndexOf(':') for the file/entry delimiter to keep Windows paths working +- parseServerArg() treats any string containing : (that wasn’t recognized as a URL) as a config file:entry. This will misclassify inputs like example.com:foo (invalid host:port) as a config file named example.com. Consider tightening the config heuristic (e.g. require the left side to look like a file path or have a known config extension) and return null for ambiguous/invalid host:port inputs. + +- validateOptions() relies on KNOWN_OPTIONS, but several options used by subcommands are missing (e.g. --scope on login, -r/--payment-required, --amount, --expiry for x402 sign, and session flags like -o/--output, --max-size). This will cause valid commands to fail early with "Unknown option" before routing to the correct Commander program. Either expand KNOWN_OPTIONS to cover all CLI flags (including subcommand-specific ones) or change validation to only check global options (e.g. only scan args before the first non-option command token +- login introduces a --scope option here, but the pre-parse validateOptions() step uses KNOWN_OPTIONS from parser.ts, which currently does not include --scope. As a result, mcpc login --scope ... will fail early with "Unknown option: --scope" before Commander runs. Add --scope to the known options list or make option validation command-aware. + +## x402 +- sign -r Sign payment from PAYMENT-REQUIRED header - why the "-r" is needed? + +## NEW + +- mcp-cli inspiration +Add glob-based tool search across all servers like `mcpc grep *mail*` or `mcpc grep *@session/mail*`. + Consider making `tools-list` more succinct for discovery. + Use https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool for inspiration/compatibility? + $ mcpc grep "*file*" + $ mcpc grep "@github/*" + $ mcpc grep -F "anything really" + +RETURNS + @github/create_or_update_file + @filesystem/read_file + @filesystem/write_file + +Then we can have +$ mcpc call @github/get_file_contents arg:="yes" + +or maybe just? +$ mcpc @session/tool arg:="yes" +support also (undocumented) +$ mcpc @session:tool + + + + +## Later + +$ mcpc @apify tools-call search-apify-docs query:="test" +Should skip `structuredContent` in results if there is `content` with "type": "text", and print it as text. AI agents can use --json + - `--capabilities '{"tools":...,"prompts":...}"` to limit access to selected MCP features and tools, for both proxy and normal session, for simplicity. The command could work on the fly, to give agents less room to wiggle. - Implement resources-subscribe/resources-unsubscribe, --o file command properly, --max-size automatically update the -o file on changes, without it just keep track of changed files in - bridge process' cache, and report in resources-list/resources-read operation - -- Ensure "logging-set-level" works well + bridge process' cache, and report in resources-list/resources-read operatio -- mcp-cli inspiration - - Add glob-based tool search across all servers like `mcpc grep *mail*`. Consider making `tools-list` more succinct for discovery. - - Use https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool for inspiration/compatibility? - - Consider adding support for something like `mcp-cli @session/tool [args]` to make it easier to use +- MAYBE LATER: +Connects to all entries from file - the +NOW: $ mcpc connect ~/.vscode/mcp.json:puppeteer @puppeteer +$ mcpc connect ~/.vscode/mcp.json +$ mcpc connect -## Later - add support for OAuth `--client-id XXX` and `--client-secret YYY` for servers that don't have DCR !!! and equally, we should add `--header XXX` to save logins via HTTP header - +## Code mode - Emit tools to dirs ("codegen" variant?) - see https://cursor.com/blog/dynamic-context-discovery - generate skills file too? - feature: enable generation of TypeScript stubs based on the server schema, with access to session and schema validation, for TS code mode. For simplicity they an just "mcpc" command, later we can use IPC for more efficiency. -- Support for Markdown generation with shebang? + +# Misc + +- Ensure "logging-set-level" works well - Restart of expires OAuth session is too many steps - why not add "mcpc login" to refresh? -- Tool list refresh - how about printing it to stderr on first time after it happens? then the agent/user would notice and tools-list again +- Tool list server refresh - let's print it to stderr on first time after it happens, so the agent/user would notice there are new tools + + +## Nice to have + +- Add support for "mcpc close @session", "mcpc restart @session" and "mcpc shell @session" - add to docs + +- mcpc @apify tools-get fetch-actor-details => should print also "object" properties in human mode + +- Add ASCII diagrams to README to help explain major concepts: tool calling, auth, bridge process, etc. + +- "login" and "logout" commands could work also with file:entry, just use the remote server URL - maybe introduce new session status: auth failed or unauthed -- nit: show also header / open auth statuses for HTTP servers? -- ux: consider forking "alive" session state to "alive" and "diconnected", to indicate the remove server is not responding but bridge + ux: consider forking "alive" session state to "alive" and "disconnected", to indicate the remote server is not responding but bridge runs fine. We can use lastSeenAt + ping interval info for that, or status of last ping. - ux: Be even more forgiving with `args:=x`, when we know from tools/prompt schema the text is compatible with `x` even if the exact type is not - just re-type it dynamically to make it work. @@ -49,9 +132,6 @@ - Auto-discovery of existing MCP configs like mcporter - Show protocolVersion also for stdio - but for that we need to update the SDK to save it! See setProtocolVersion -## E2E test scenarios - +- nit: show also header / open auth statuses for HTTP servers? -# Questions -- mcpc mcp.apify.com shell --- do we also open "virtual" session, how does it work exactly? Let's explain this in README. diff --git a/package-lock.json b/package-lock.json index cf75a7e..83f53a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2755,7 +2754,6 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2854,7 +2852,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3369,7 +3366,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3954,7 +3950,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5484,7 +5479,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5841,7 +5835,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6692,7 +6685,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7737,7 +7729,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12199,7 +12190,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12538,7 +12528,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13180,7 +13169,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -13311,7 +13299,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 5d70264..080f636 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -73,8 +73,8 @@ async function checkPortAvailable(host: string, port: number): Promise * If session already exists with crashed bridge, reconnects it automatically */ export async function connectSession( - name: string, target: string, + name: string, options: { outputMode: OutputMode; verbose?: boolean; diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts index f2d6b86..1f11bbe 100644 --- a/src/cli/helpers.ts +++ b/src/cli/helpers.ts @@ -3,97 +3,18 @@ * Provides target resolution and MCP client management */ -import { createMcpClient } from '../core/factory.js'; import type { IMcpClient, OutputMode, ServerConfig } from '../lib/types.js'; -import { - ClientError, - NetworkError, - AuthError, - McpError, - isAuthenticationError, - createServerAuthError, -} from '../lib/errors.js'; +import { ClientError } from '../lib/errors.js'; import { normalizeServerUrl, isValidSessionName, getServerHost } from '../lib/utils.js'; import { setVerbose, createLogger } from '../lib/logger.js'; import { loadConfig, getServerConfig, validateServerConfig } from '../lib/config.js'; -import { OAuthProvider } from '../lib/auth/oauth-provider.js'; -import { OAuthTokenManager } from '../lib/auth/oauth-token-manager.js'; import { getAuthProfile, listAuthProfiles } from '../lib/auth/profiles.js'; -import { readKeychainOAuthTokenInfo, readKeychainOAuthClientInfo } from '../lib/auth/keychain.js'; import { logTarget } from './output.js'; -import { getWallet } from '../lib/wallets.js'; -import { createX402FetchMiddleware } from '../lib/x402/fetch-middleware.js'; -import { createRequire } from 'module'; -const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.json') as { - version: string; -}; import { DEFAULT_AUTH_PROFILE } from '../lib/auth/oauth-utils.js'; import { parseHeaderFlags } from './parser.js'; const logger = createLogger('cli'); -/** - * Create an OAuthProvider for a server URL if auth profile exists - * Returns undefined if no auth profile or tokens are available - */ -async function createAuthProviderForServer( - url: string, - profileName: string = DEFAULT_AUTH_PROFILE -): Promise { - try { - // Check if auth profile exists - const profile = await getAuthProfile(url, profileName); - if (!profile) { - logger.debug(`No auth profile found for ${url} (profile: ${profileName})`); - return undefined; - } - - // Load tokens from keychain - const tokens = await readKeychainOAuthTokenInfo(url, profileName); - if (!tokens?.refreshToken) { - logger.debug(`No refresh token in keychain for profile: ${profileName}`); - return undefined; - } - - // Load client info from keychain - const clientInfo = await readKeychainOAuthClientInfo(url, profileName); - if (!clientInfo?.clientId) { - logger.warn(`OAuth client ID not found in keychain for profile: ${profileName}`); - return undefined; - } - - // Create token manager with tokens from keychain - const tokenManagerOptions: ConstructorParameters[0] = { - serverUrl: url, - profileName, - clientId: clientInfo.clientId, - refreshToken: tokens.refreshToken, - accessToken: tokens.accessToken, - }; - if (tokens.expiresAt !== undefined) { - tokenManagerOptions.accessTokenExpiresAt = tokens.expiresAt; - } - const tokenManager = new OAuthTokenManager(tokenManagerOptions); - - // Create and return OAuthProvider in runtime mode - logger.debug(`Created OAuthProvider for profile: ${profileName}`); - return new OAuthProvider({ - serverUrl: url, - profileName, - tokenManager, - clientId: clientInfo.clientId, - }); - } catch (error) { - // Re-throw AuthError (expired token, refresh failed, etc.) - if (error instanceof AuthError) { - throw error; - } - // Log other errors but don't fail the connection - logger.warn(`Failed to create auth provider: ${(error as Error).message}`); - return undefined; - } -} - /** * Resolve which auth profile to use for an HTTP server * Returns the profile name to use, or undefined if no profile is available @@ -121,7 +42,7 @@ export async function resolveAuthProfile( throw new ClientError( `Authentication profile "${specifiedProfile}" not found for ${host}.\n\n` + `To create this profile, run:\n` + - ` mcpc ${target} login --profile ${specifiedProfile}` + ` mcpc login ${target} --profile ${specifiedProfile}` ); } return specifiedProfile; @@ -147,8 +68,8 @@ export async function resolveAuthProfile( // Profiles exist but no default - suggest using --profile const profileNames = serverProfiles.map((p) => p.name).join(', '); const commandHint = context?.sessionName - ? `mcpc ${target} connect ${context.sessionName} --profile ` - : `mcpc ${target} --profile `; + ? `mcpc connect ${target} ${context.sessionName} --profile ` + : `mcpc login ${target} --profile `; throw new ClientError( `No default authentication profile for ${host}.\n\n` + `Available profiles: ${profileNames}\n\n` + @@ -210,11 +131,9 @@ export async function resolveTarget( url = normalizeServerUrl(target); } catch (error) { throw new ClientError( + // TODO: or config file? `Failed to resolve target: ${target}\n` + - `Target must be one of:\n` + - ` - Named session (@name)\n` + - ` - Server URL (e.g., mcp.apify.com or https://mcp.apify.com)\n` + - ` - Entry in JSON config file specified by --config flag\n\n` + + `Target must be a server URL (e.g., mcp.apify.com or https://mcp.apify.com)\n\n` + `Error: ${(error as Error).message}` ); } @@ -239,179 +158,52 @@ export interface McpClientContext { } /** - * Execute an operation with an MCP client - * Handles connection, execution, and cleanup - * Automatically detects and uses sessions (targets starting with @) - * Logs the target prefix before executing the operation + * Execute an operation with an MCP client via a named session + * The target must be a valid session name (starts with @) * - * @param target - Target string (URL, @session, package, etc.) - * @param options - CLI options (verbose, config, headers, etc.) + * @param target - Session name (e.g. @apify) + * @param options - CLI options (verbose, outputMode, etc.) * @param callback - Async function that receives the connected client and context */ export async function withMcpClient( target: string, options: { outputMode?: OutputMode; - config?: string; - headers?: string[]; - timeout?: number; verbose?: boolean; hideTarget?: boolean; - profile?: string; - x402?: boolean; }, callback: (client: IMcpClient, context: McpClientContext) => Promise ): Promise { - // Check if this is a session target (@name, not @scope/package) - if (isValidSessionName(target)) { - const { withSessionClient } = await import('../lib/session-client.js'); - const { getSession } = await import('../lib/sessions.js'); - - logger.debug('Using session:', target); - - // Get session data to include in context - // TODO: getSession() is called also in withSessionClient() => createSessionClient() => ensureBridgeReady() - // if we could reuse it, we'd save extra file lock and read operation - const session = await getSession(target); - const context: McpClientContext = { - sessionName: session?.name, - profileName: session?.profileName, - serverConfig: session?.server, - }; - - // Log target prefix (unless hidden) - if (options.outputMode) { - await logTarget(target, { - outputMode: options.outputMode, - hide: options.hideTarget, - }); - } - - // Use session client (SessionClient implements IMcpClient interface) - return await withSessionClient(target, (client) => callback(client, context)); + if (!isValidSessionName(target)) { + throw new ClientError( + `Invalid session name: ${target}\n` + + `Session names must start with @ (e.g. @apify).\n\n` + + `To create a session, run:\n` + + ` mcpc connect @my-session` + ); } - // Regular direct connection - const serverConfig = await resolveTarget(target, options); + const { withSessionClient } = await import('../lib/session-client.js'); + const { getSession } = await import('../lib/sessions.js'); - logger.debug('Resolved target:', { target, serverConfig }); + logger.debug('Using session:', target); - // Create and connect client - const clientConfig: Parameters[0] = { - clientInfo: { name: 'mcpc', version: mcpcVersion }, - serverConfig, - capabilities: { - // Declare client capabilities - roots: { listChanged: true }, - sampling: {}, - }, - autoConnect: true, + // Get session data to include in context + const session = await getSession(target); + const context: McpClientContext = { + sessionName: session?.name, + profileName: session?.profileName, + serverConfig: session?.server, }; - // Only include verbose if it's true - if (options.verbose) { - clientConfig.verbose = true; - } - - // For HTTP transports, resolve auth profile and create authProvider - let profileName: string | undefined; - if (serverConfig.url) { - profileName = await resolveAuthProfile(serverConfig.url, target, options.profile); - const authProvider = await createAuthProviderForServer(serverConfig.url, profileName); - if (authProvider) { - clientConfig.authProvider = authProvider; - logger.debug(`Using auth profile: ${profileName}`); - } - - // Set up x402 fetch middleware for automatic payment signing - if (options.x402) { - const wallet = await getWallet(); - if (!wallet) { - throw new ClientError('x402 wallet not found. Create one with: mcpc x402 init'); - } - logger.debug(`Using x402 wallet: ${wallet.address}`); - clientConfig.customFetch = createX402FetchMiddleware(fetch, { - wallet: { privateKey: wallet.privateKey, address: wallet.address }, - // No getToolByName for direct connections β€” proactive signing requires - // a tools list cache which direct connections don't maintain. - // The 402 fallback will still work. - }); - } - } - - let client: IMcpClient; - try { - client = await createMcpClient(clientConfig); - } catch (error) { - // Check if this is an authentication error from the server (check before McpError guard) - const errorMessage = (error as Error).message || ''; - if (isAuthenticationError(errorMessage)) { - throw createServerAuthError(target, { originalError: error as Error }); - } - // NetworkError from mcp-client.ts β€” re-throw with server URL in message - if (error instanceof NetworkError) { - const serverUrl = serverConfig.url ?? target; - const causeMsg = error.message.replace(/^Failed to connect to MCP server: /, ''); - throw new NetworkError( - `Failed to connect to MCP server "${serverUrl}": ${causeMsg}`, - error.details - ); - } - if (error instanceof McpError) throw error; - throw new NetworkError(`Failed to connect to ${serverConfig.url ?? target}: ${errorMessage}`, { - originalError: error, + // Log target prefix (unless hidden) + if (options.outputMode) { + await logTarget(target, { + outputMode: options.outputMode, + hide: options.hideTarget, }); } - try { - logger.debug('Connected successfully'); - - // Log target prefix (unless hidden) - if (options.outputMode) { - // Get protocol version for display - const serverDetails = await client.getServerDetails(); - await logTarget(target, { - outputMode: options.outputMode, - hide: options.hideTarget, - profileName, - serverConfig, - protocolVersion: serverDetails.protocolVersion, - }); - } - - // Execute callback with connected client and context - const context: McpClientContext = { serverConfig, profileName }; - const result = await callback(client, context); - - return result; - } catch (error) { - logger.error('MCP operation failed:', error); - - if ( - error instanceof NetworkError || - error instanceof ClientError || - error instanceof AuthError - ) { - throw error; - } - - // Check if this is an authentication error from the server - const errorMessage = (error as Error).message || ''; - if (isAuthenticationError(errorMessage)) { - throw createServerAuthError(target, { originalError: error as Error }); - } - - throw new NetworkError(`Failed to communicate with MCP server: ${(error as Error).message}`, { - originalError: error, - }); - } finally { - // Always clean up - try { - logger.debug('Closing connection...'); - await client.close(); - logger.debug('Connection closed'); - } catch (error) { - logger.warn('Error closing connection:', error); - } - } + // Use session client (SessionClient implements IMcpClient interface) + return await withSessionClient(target, (client) => callback(client, context)); } diff --git a/src/cli/index.ts b/src/cli/index.ts index eacc45d..396cb7d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,7 +13,7 @@ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { Command } from 'commander'; import { setVerbose, setJsonMode, closeFileLogger } from '../lib/index.js'; -import { isMcpError, formatHumanError, NetworkError } from '../lib/index.js'; +import { isMcpError, formatHumanError, ClientError } from '../lib/index.js'; import { formatJson, formatJsonError, rainbow } from './output.js'; import * as tools from './commands/tools.js'; import * as resources from './commands/resources.js'; @@ -26,15 +26,16 @@ import { handleX402Command } from './commands/x402.js'; import { clean } from './commands/clean.js'; import type { OutputMode } from '../lib/index.js'; import { - findTarget, extractOptions, - hasCommandAfterTarget, getVerboseFromEnv, getJsonFromEnv, validateOptions, - validateCleanTypes, validateArgValues, + parseServerArg, + hasSubcommand, + optionTakesValue, KNOWN_COMMANDS, + KNOWN_SESSION_COMMANDS, } from './parser.js'; import { createRequire } from 'module'; const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.json') as { @@ -49,7 +50,6 @@ setGlobalDispatcher(new EnvHttpProxyAgent()); */ interface HandlerOptions { outputMode: OutputMode; - config?: string; headers?: string[]; timeout?: number; verbose?: boolean; @@ -81,13 +81,20 @@ function getOptionsFromCommand(command: Command): HandlerOptions { }; // Only include optional properties if they're present - if (opts.config) options.config = opts.config; if (opts.header) { // Commander stores repeated options as arrays, but single values as strings // Always convert to array for consistent handling options.headers = Array.isArray(opts.header) ? opts.header : [opts.header]; } - if (opts.timeout) options.timeout = parseInt(opts.timeout, 10); + if (opts.timeout) { + const timeout = parseInt(opts.timeout as string, 10); + if (isNaN(timeout) || timeout <= 0) { + throw new Error( + `Invalid --timeout value: "${opts.timeout as string}". Must be a positive number (seconds).` + ); + } + options.timeout = timeout; + } if (opts.profile) options.profile = opts.profile; if (verbose) options.verbose = verbose; if (opts.x402) options.x402 = true; @@ -95,7 +102,9 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.schemaMode) { const mode = opts.schemaMode as string; if (mode !== 'strict' && mode !== 'compatible' && mode !== 'ignore') { - throw new Error(`Invalid schema mode: ${mode}. Must be 'strict', 'compatible', or 'ignore'.`); + throw new Error( + `Invalid --schema-mode value: "${mode}". Valid modes are: strict, compatible, ignore` + ); } options.schemaMode = mode; } @@ -135,7 +144,7 @@ async function main(): Promise { // Check for help flag if (args.includes('--help') || args.includes('-h')) { - const program = createProgram(); + const program = createTopLevelProgram(); await program.parseAsync(process.argv); return; } @@ -150,112 +159,131 @@ async function main(): Promise { process.exit(1); } - // Handle --clean option (global command, no target needed) - const cleanArg = args.find((arg) => arg === '--clean' || arg.startsWith('--clean=')); - if (cleanArg) { - const options = extractOptions(args); - if (options.verbose) setVerbose(true); - if (options.json) setJsonMode(true); - - // Parse --clean value: --clean or --clean=all,sessions,profiles,logs - const cleanValue = cleanArg.includes('=') ? cleanArg.split('=')[1] : ''; - const cleanTypes = cleanValue ? cleanValue.split(',').map((s) => s.trim()) : []; - - // Validate clean types (argument validation - always plain text) - try { - validateCleanTypes(cleanTypes); - } catch (error) { - console.error(formatHumanError(error as Error, false)); - process.exit(1); + // Find the first non-option argument to determine routing + let firstNonOption: string | undefined; + let firstNonOptionIndex = -1; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg) continue; + if (arg.startsWith('-')) { + if (optionTakesValue(arg) && !arg.includes('=') && i + 1 < args.length) { + i++; // skip value + } + continue; } - - await clean({ - outputMode: options.json ? 'json' : 'human', - sessions: cleanTypes.includes('sessions'), - profiles: cleanTypes.includes('profiles'), - logs: cleanTypes.includes('logs'), - all: cleanTypes.includes('all'), - }); - - await closeFileLogger(); - return; + firstNonOption = arg; + firstNonOptionIndex = i; + break; } - // Find the target - const targetInfo = findTarget(args); - - // If no target found, list sessions - if (!targetInfo) { + // No args β†’ list sessions + if (!firstNonOption) { const { json } = extractOptions(args); if (json) setJsonMode(true); await sessions.listSessionsAndAuthProfiles({ outputMode: json ? 'json' : 'human' }); if (!json) { console.log('\nRun "mcpc --help" for usage information.\n'); } - await closeFileLogger(); return; } - const { target, targetIndex } = targetInfo; + // Session command: @name [subcommand] + if (firstNonOption.startsWith('@')) { + const session = firstNonOption; + const modifiedArgs = [ + ...process.argv.slice(0, 2), + ...args.slice(0, firstNonOptionIndex), + ...args.slice(firstNonOptionIndex + 1), + ]; - // Build modified argv without the target - const modifiedArgs = [ - ...process.argv.slice(0, 2), - ...args.slice(0, targetIndex), - ...args.slice(targetIndex + 1), - ]; + try { + await handleSessionCommands(session, modifiedArgs); + } catch (error) { + if (isMcpError(error)) { + const opts = extractOptions(args); + const outputMode: OutputMode = opts.json ? 'json' : 'human'; + if (outputMode === 'json') { + console.error(formatJsonError(error, error.code)); + } else { + console.error(formatHumanError(error, opts.verbose)); + } + process.exit(error.code); + } + throw error; + } finally { + await closeFileLogger(); + } - // Handle x402 as a top-level command (not a server target) - if (target === 'x402') { - const x402Args = args.slice(targetIndex + 1); - await handleX402Command(x402Args); - await closeFileLogger(); - return; + // Flush stdout before exiting + await flushStdout(); + process.exit(0); } - // Handle commands - try { - await handleCommands(target, modifiedArgs); - } catch (error) { - if (isMcpError(error)) { - const opts = extractOptions(args); - const outputMode: OutputMode = opts.json ? 'json' : 'human'; - if (outputMode === 'json') { - console.error(formatJsonError(error, error.code)); - } else { - console.error(formatHumanError(error, opts.verbose)); - if (error instanceof NetworkError && KNOWN_COMMANDS.includes(target)) { - console.error(`\nDid you mean "mcpc ${target}" ?`); - console.error(`Run "mcpc --help" for usage information.\n`); - process.exit(error.code); + // Top-level commands: login, logout, connect, clean, help, x402 + if (KNOWN_COMMANDS.includes(firstNonOption)) { + // Handle x402 separately (legacy standalone handler) + if (firstNonOption === 'x402') { + const x402Args = args.slice(firstNonOptionIndex + 1); + await handleX402Command(x402Args); + await closeFileLogger(); + return; + } + + try { + const program = createTopLevelProgram(); + await program.parseAsync(process.argv); + } catch (error) { + if (isMcpError(error)) { + const opts = extractOptions(args); + const outputMode: OutputMode = opts.json ? 'json' : 'human'; + if (outputMode === 'json') { + console.error(formatJsonError(error, error.code)); + } else { + console.error(formatHumanError(error, opts.verbose)); } + process.exit(error.code); } - process.exit(error.code); + throw error; + } finally { + await closeFileLogger(); } - throw error; - } finally { - await closeFileLogger(); + return; } - // Flush stdout before exiting. When stdout is a pipe, Node.js uses async I/O - // and process.exit() would discard any data still in the stream buffer. - // This caused silent truncation at 64KB (the kernel pipe buffer size). - await new Promise((resolve) => { - if (process.stdout.writableFinished) { - resolve(); + // Unknown command β€” provide helpful error + const opts = extractOptions(args); + const outputMode: OutputMode = opts.json ? 'json' : 'human'; + + const allCommands = [...KNOWN_COMMANDS, ...KNOWN_SESSION_COMMANDS]; + if (allCommands.includes(firstNonOption)) { + // It's a session subcommand used without @session + if (outputMode === 'json') { + console.error( + formatJsonError(new Error(`Missing session target for command: ${firstNonOption}`), 1) + ); } else { - process.stdout.once('finish', resolve); - process.stdout.end(); + console.error(`Error: Missing session target for command: ${firstNonOption}`); + console.error(`\nDid you mean: mcpc <@session> ${firstNonOption}`); + console.error(`Run "mcpc --help" for usage information.\n`); } - }); - - // Explicit exit to avoid waiting for stdio child processes to close - // (the MCP SDK's StdioClientTransport keeps handles in the event loop) - process.exit(0); + } else { + if (outputMode === 'json') { + console.error(formatJsonError(new Error(`Unknown command: ${firstNonOption}`), 1)); + } else { + console.error(`Error: Unknown command: ${firstNonOption}`); + console.error(`Run "mcpc --help" for usage information.\n`); + } + } + await closeFileLogger(); + process.exit(1); } -function createProgram(): Command { +/** + * Create the top-level Commander program with global commands + * (login, logout, connect, clean, help) + */ +function createTopLevelProgram(): Command { const program = new Command(); // Configure help output width to avoid wrapping (default is 80) @@ -265,114 +293,317 @@ function createProgram(): Command { getErrHelpWidth: () => 100, }); + // Strip [options] from the commands list (options are shown per-command via `mcpc help `) + program.configureHelp({ + subcommandTerm: (cmd) => + `${cmd.name()} ${cmd.usage()}`.replace(/^\[options\]\s*|\s*\[options\]/g, '').trim(), + }); + + // Use raw Markdown URL for pipes (AI agents), GitHub UI for TTY (humans) + const docsUrl = process.stdout.isTTY + ? `https://github.com/apify/mcpc/tree/v${mcpcVersion}` + : `https://raw.githubusercontent.com/apify/mcpc/v${mcpcVersion}/README.md`; + program .name('mcpc') .description( `${rainbow('Universal')} command-line client for the Model Context Protocol (MCP).` ) - .usage('[options] [command]') - .helpOption('-h, --help', 'Display general help') + .usage('[options] [<@session>] []') .option('-j, --json', 'Output in JSON format for scripting') - .option('-c, --config ', 'Path to MCP config JSON file (e.g. ".vscode/mcp.json")') - .option('-H, --header
', 'HTTP header for remote MCP server (can be repeated)') - .version(mcpcVersion, '-v, --version', 'Output the version number') + .option('-H, --header
', 'HTTP header (can be repeated)') .option('--verbose', 'Enable debug logging') .option('--profile ', 'OAuth profile for the server ("default" if not provided)') .option('--schema ', 'Validate tool/prompt schema against expected schema') .option('--schema-mode ', 'Schema validation mode: strict, compatible (default), ignore') .option('--timeout ', 'Request timeout in seconds (default: 300)') - .option('--proxy <[host:]port>', 'Start proxy MCP server for session (with "connect" command)') - .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') - .option('--x402', 'Enable x402 auto-payment using the configured wallet') - .option('--clean[=types]', 'Clean up mcpc data (types: sessions, logs, profiles, all)'); - - // Add help text to match README - // Use raw Markdown URL for pipes (AI agents), GitHub UI for TTY (humans) - const docsUrl = process.stdout.isTTY - ? `https://github.com/apify/mcpc/tree/v${mcpcVersion}` - : `https://raw.githubusercontent.com/apify/mcpc/v${mcpcVersion}/README.md`; + .version(mcpcVersion, '-v, --version', 'Output the version number') + .helpOption('-h, --help', 'Display help'); program.addHelpText( 'after', ` -Targets: - @ Named persistent session (e.g. "@apify") - Entry in MCP config file specified by --config (e.g. "fs") - Remote MCP server URL (e.g. "mcp.apify.com") - -Management commands: - login Create OAuth profile with credentials for remote server - logout Remove OAuth profile for remote server - connect @ Connect to server and create named persistent session - restart Kill and restart a session - close Close a session - -MCP server commands: - help Show server info ("help" can be omitted) - shell Open interactive shell - tools-list [--full] Send "tools/list" MCP request... - tools-get - tools-call [arg1:=val1 arg2:=val2 ... | | [arg1:=val1 arg2:=val2 ... | | - resources-subscribe - resources-unsubscribe - resources-templates-list - logging-set-level - ping - -EXPERIMENTAL: x402 payment commands (no target needed): - x402 init Create a new x402 wallet - x402 import Import wallet from private key - x402 info Show wallet info - x402 sign -r Sign payment from PAYMENT-REQUIRED header - x402 remove Remove the wallet - -Run "mcpc" without to show available sessions and profiles. +Session commands (after connecting): + <@session> Show MCP server info and capabilities + <@session> shell Open interactive shell + <@session> close Close the session + <@session> restart Kill and restart the session + <@session> tools-list List MCP tools + <@session> tools-get + <@session> tools-call [arg:=val ... | | prompts-list + <@session> prompts-get [arg:=val ... | | resources-list + <@session> resources-read + <@session> resources-subscribe + <@session> resources-unsubscribe + <@session> resources-templates-list + <@session> logging-set-level + <@session> ping + +Run "mcpc" without arguments to show active sessions and OAuth profiles. Full docs: ${docsUrl}` ); - return program; -} + // connect command: mcpc connect @ + program + .command('connect [server] [@session]') + .usage(' <@session>') + .description('Connect to an MCP server and start a new named @session') + .option('--profile ', 'OAuth profile to use ("default" if skipped)') + .option('--proxy <[host:]port>', 'Start proxy MCP server for session') + .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') + .option('--x402', 'Enable x402 auto-payment using the configured wallet') + .addHelpText( + 'after', + ` +Server formats: + mcp.apify.com Remote HTTP server (https:// added automatically) + ~/.vscode/mcp.json:puppeteer Config file entry (file:entry) +` + ) + .action(async (server, sessionName, opts, command) => { + if (!server) { + throw new ClientError( + 'Missing required argument: server\n\nExample: mcpc connect mcp.apify.com @myapp' + ); + } + if (!sessionName) { + throw new ClientError( + 'Missing required argument: @session\n\nExample: mcpc connect mcp.apify.com @myapp' + ); + } + const globalOpts = getOptionsFromCommand(command); + const parsed = parseServerArg(server); + + if (!parsed) { + throw new ClientError( + `Invalid server: "${server}"\n\n` + + `Expected a URL (e.g. mcp.apify.com) or a config file entry (e.g. ~/.vscode/mcp.json:filesystem)` + ); + } -async function handleCommands(target: string, args: string[]): Promise { - const program = createProgram(); - program.argument('', 'Target (session @name, MCP config entry, or server URL)'); + if (parsed.type === 'config') { + // Config file entry: pass entry name as target with config file path + await sessions.connectSession(parsed.entry, sessionName, { + ...globalOpts, + config: parsed.file, + proxy: opts.proxy, + proxyBearerToken: opts.proxyBearerToken, + x402: opts.x402, + }); + } else { + await sessions.connectSession(server, sessionName, { + ...globalOpts, + proxy: opts.proxy, + proxyBearerToken: opts.proxyBearerToken, + x402: opts.x402, + }); + } + }); - // Check if no command provided - show server info and instructions - if (!hasCommandAfterTarget(args)) { - const options = extractOptions(args); - if (options.verbose) setVerbose(true); - if (options.json) setJsonMode(true); + // close command: mcpc close @ + program + .command('close [@session]', { hidden: true }) + .usage('<@session>') + .description('Close a session') + .action(async (sessionName, _opts, command) => { + if (!sessionName) { + throw new ClientError('Missing required argument: @session\n\nExample: mcpc close @myapp'); + } + await sessions.closeSession(sessionName, getOptionsFromCommand(command)); + }); - await sessions.showServerDetails(target, { - outputMode: options.json ? 'json' : 'human', - ...(options.verbose && { verbose: true }), - ...(options.config && { config: options.config }), - ...(options.headers && { headers: options.headers }), - ...(options.timeout !== undefined && { timeout: options.timeout }), + // restart command: mcpc restart @ + program + .command('restart [@session]', { hidden: true }) + .usage('<@session>') + .description('Restart a session') + .action(async (sessionName, _opts, command) => { + if (!sessionName) { + throw new ClientError( + 'Missing required argument: @session\n\nExample: mcpc restart @myapp' + ); + } + await sessions.restartSession(sessionName, getOptionsFromCommand(command)); + }); + + // shell command: mcpc shell @ + program + .command('shell [@session]', { hidden: true }) + .usage('<@session>') + .description('Open interactive shell for a session') + .action(async (sessionName) => { + if (!sessionName) { + throw new ClientError('Missing required argument: @session\n\nExample: mcpc shell @myapp'); + } + await sessions.openShell(sessionName); }); - return; - } + // login command: mcpc login + program + .command('login [server]') + .usage('') + .description('Authenticate to server using OAuth and save the profile') + .option('--profile ', 'Profile name (default: "default")') + .option('--scope ', 'OAuth scope(s) to request') + .action(async (server, opts, command) => { + if (!server) { + throw new ClientError( + 'Missing required argument: server\n\nExample: mcpc login mcp.apify.com' + ); + } + await auth.login(server, { + profile: opts.profile, + scope: opts.scope, + ...getOptionsFromCommand(command), + }); + }); + + // logout command: mcpc logout + program + .command('logout [server]') + .usage('') + .description('Delete an authentication profile for a server') + .option('--profile ', 'Profile name (default: "default")') + .action(async (server, opts, command) => { + if (!server) { + throw new ClientError( + 'Missing required argument: server\n\nExample: mcpc logout mcp.apify.com' + ); + } + await auth.logout(server, { + profile: opts.profile, + ...getOptionsFromCommand(command), + }); + }); + + // clean command: mcpc clean [resources...] + program + .command('clean [resources...]') + .description('Clean up mcpc data (sessions, profiles, logs, all)') + .addHelpText( + 'after', + ` +Resources: + sessions Remove stale/crashed session records + profiles Remove authentication profiles + logs Remove bridge log files + all Remove all of the above + +Without arguments, performs safe cleanup of stale data only. +` + ) + .action(async (resources: string[], _opts, command) => { + const globalOpts = getOptionsFromCommand(command); + + // Validate clean types + const VALID_CLEAN_TYPES = ['sessions', 'profiles', 'logs', 'all']; + for (const r of resources) { + if (!VALID_CLEAN_TYPES.includes(r)) { + throw new ClientError( + `Invalid clean resource: "${r}". Valid resources are: ${VALID_CLEAN_TYPES.join(', ')}` + ); + } + } + + await clean({ + outputMode: globalOpts.outputMode, + sessions: resources.includes('sessions'), + profiles: resources.includes('profiles'), + logs: resources.includes('logs'), + all: resources.includes('all'), + }); + }); + + // x402 command: mcpc x402 + // Note: x402 is handled before Commander in main() β€” this registration exists only for help text + program + .command('x402 [subcommand] [args...]') + .description('Configure an x402 payment wallet (EXPERIMENTAL)') + .addHelpText( + 'after', + ` +Subcommands: + init Create a new x402 wallet + import Import wallet from private key + info Show wallet info + sign -r Sign payment from PAYMENT-REQUIRED header + remove Remove the wallet +` + ) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .action(() => {}); + + // help command: mcpc help [command] + program + .command('help [command]') + .description('Show help for a specific command') + .action((cmdName?: string) => { + if (!cmdName) { + program.outputHelp(); + return; + } + + // Check top-level commands + const topLevelCmd = program.commands.find( + (c) => c.name() === cmdName || c.aliases().includes(cmdName) + ); + if (topLevelCmd) { + topLevelCmd.outputHelp(); + return; + } + + // Check session subcommands + const dummyProgram = new Command(); + registerSessionCommands(dummyProgram, '@dummy'); + const sessionCmd = dummyProgram.commands.find( + (c) => c.name() === cmdName || c.aliases().includes(cmdName) + ); + if (sessionCmd) { + sessionCmd.outputHelp(); + return; + } + + console.error(`Unknown command: ${cmdName}`); + console.error(`Run "mcpc --help" for usage information.`); + process.exit(1); + }); + + // Default action (no args) β€” list sessions + program.action(async () => { + const opts = program.opts(); + const json = opts.json || getJsonFromEnv(); + if (json) setJsonMode(true); + await sessions.listSessionsAndAuthProfiles({ outputMode: json ? 'json' : 'human' }); + if (!json) { + console.log('\nRun "mcpc --help" for usage information.\n'); + } + }); + + return program; +} + +/** + * Register all session subcommands on a Commander program + * Extracted so it can be reused for both execution and help lookup + */ +function registerSessionCommands(program: Command, session: string): void { // Help command program .command('help') .description('Show server instructions and available capabilities') .action(async (_options, command) => { - await sessions.showHelp(target, getOptionsFromCommand(command)); + await sessions.showHelp(session, getOptionsFromCommand(command)); }); // Shell command program .command('shell') - .description('Interactive shell for the target') + .description('Interactive shell for the session') .action(async () => { - await sessions.openShell(target); + await sessions.openShell(session); }); // Close command @@ -380,7 +611,7 @@ async function handleCommands(target: string, args: string[]): Promise { .command('close') .description('Close the session') .action(async (_options, command) => { - await sessions.closeSession(target, getOptionsFromCommand(command)); + await sessions.closeSession(session, getOptionsFromCommand(command)); }); // Restart command @@ -388,56 +619,16 @@ async function handleCommands(target: string, args: string[]): Promise { .command('restart') .description('Restart the session (stop and start the bridge)') .action(async (_options, command) => { - await sessions.restartSession(target, getOptionsFromCommand(command)); - }); - - // Connect command: mcpc connect @ - // Creates a new session or reconnects if session exists but bridge has crashed - program - .command('connect ') - .description('Create or reconnect a named session to an MCP server') - .action(async (name, _options, command) => { - const opts = command.optsWithGlobals(); - await sessions.connectSession(name, target, { - ...getOptionsFromCommand(command), - proxy: opts.proxy, - proxyBearerToken: opts.proxyBearerToken, - x402: opts.x402, - }); - }); - - // Authentication commands - program - .command('login') - .description('Login to a server using OAuth and save authentication profile') - .option('--profile ', 'Profile name (default: default)') - .option('--scope ', 'OAuth scope(s) to request') - .action(async (options, command) => { - await auth.login(target, { - profile: options.profile, - scope: options.scope, - ...getOptionsFromCommand(command), - }); + await sessions.restartSession(session, getOptionsFromCommand(command)); }); - program - .command('logout') - .description('Delete an authentication profile') - .option('--profile ', 'Profile name (default: default)') - .action(async (options, command) => { - await auth.logout(target, { - profile: options.profile, - ...getOptionsFromCommand(command), - }); - }); - - // Tools commands (keep these short aliases undocumented, they serve just as fallback) + // Tools commands program .command('tools') .description('List available tools (shorthand for tools-list)') .option('--full', 'Show full tool details including complete input schema') .action(async (_options, command) => { - await tools.listTools(target, getOptionsFromCommand(command)); + await tools.listTools(session, getOptionsFromCommand(command)); }); program @@ -445,39 +636,39 @@ async function handleCommands(target: string, args: string[]): Promise { .description('List available tools') .option('--full', 'Show full tool details including complete input schema') .action(async (_options, command) => { - await tools.listTools(target, getOptionsFromCommand(command)); + await tools.listTools(session, getOptionsFromCommand(command)); }); program .command('tools-get ') .description('Get information about a specific tool') .action(async (name, _options, command) => { - await tools.getTool(target, name, getOptionsFromCommand(command)); + await tools.getTool(session, name, getOptionsFromCommand(command)); }); program .command('tools-call [args...]') .description('Call a tool with arguments (key:=value pairs or JSON)') .action(async (name, args, _options, command) => { - await tools.callTool(target, name, { + await tools.callTool(session, name, { args, ...getOptionsFromCommand(command), }); }); - // Resources commands (keep these short aliases undocumented, they serve just as fallback) + // Resources commands program .command('resources') .description('List available resources (shorthand for resources-list)') .action(async (_options, command) => { - await resources.listResources(target, getOptionsFromCommand(command)); + await resources.listResources(session, getOptionsFromCommand(command)); }); program .command('resources-list') .description('List available resources') .action(async (_options, command) => { - await resources.listResources(target, getOptionsFromCommand(command)); + await resources.listResources(session, getOptionsFromCommand(command)); }); program @@ -486,9 +677,8 @@ async function handleCommands(target: string, args: string[]): Promise { .option('-o, --output ', 'Write resource to file') .option('--max-size ', 'Maximum resource size in bytes') .action(async (uri, options, command) => { - await resources.getResource(target, uri, { + await resources.getResource(session, uri, { output: options.output, - raw: options.raw, maxSize: options.maxSize, ...getOptionsFromCommand(command), }); @@ -498,43 +688,43 @@ async function handleCommands(target: string, args: string[]): Promise { .command('resources-subscribe ') .description('Subscribe to resource updates') .action(async (uri, _options, command) => { - await resources.subscribeResource(target, uri, getOptionsFromCommand(command)); + await resources.subscribeResource(session, uri, getOptionsFromCommand(command)); }); program .command('resources-unsubscribe ') .description('Unsubscribe from resource updates') .action(async (uri, _options, command) => { - await resources.unsubscribeResource(target, uri, getOptionsFromCommand(command)); + await resources.unsubscribeResource(session, uri, getOptionsFromCommand(command)); }); program .command('resources-templates-list') .description('List available resource templates') .action(async (_options, command) => { - await resources.listResourceTemplates(target, getOptionsFromCommand(command)); + await resources.listResourceTemplates(session, getOptionsFromCommand(command)); }); - // Prompts commands (keep these short aliases undocumented, they serve just as fallback) + // Prompts commands program .command('prompts') .description('List available prompts (shorthand for prompts-list)') .action(async (_options, command) => { - await prompts.listPrompts(target, getOptionsFromCommand(command)); + await prompts.listPrompts(session, getOptionsFromCommand(command)); }); program .command('prompts-list') .description('List available prompts') .action(async (_options, command) => { - await prompts.listPrompts(target, getOptionsFromCommand(command)); + await prompts.listPrompts(session, getOptionsFromCommand(command)); }); program .command('prompts-get [args...]') .description('Get a prompt by name with arguments (key:=value pairs or JSON)') .action(async (name, args, _options, command) => { - await prompts.getPrompt(target, name, { + await prompts.getPrompt(session, name, { args, ...getOptionsFromCommand(command), }); @@ -547,7 +737,7 @@ async function handleCommands(target: string, args: string[]): Promise { 'Set server logging level (debug, info, notice, warning, error, critical, alert, emergency)' ) .action(async (level, _options, command) => { - await logging.setLogLevel(target, level, getOptionsFromCommand(command)); + await logging.setLogLevel(session, level, getOptionsFromCommand(command)); }); // Server commands @@ -555,8 +745,59 @@ async function handleCommands(target: string, args: string[]): Promise { .command('ping') .description('Ping the MCP server to check if it is alive') .action(async (_options, command) => { - await utilities.ping(target, getOptionsFromCommand(command)); + await utilities.ping(session, getOptionsFromCommand(command)); + }); +} + +/** + * Create a Commander program for session subcommands + * Separate from top-level program to avoid command name conflicts + */ +function createSessionProgram(): Command { + const program = new Command(); + + program.configureOutput({ + outputError: (str, write) => write(str), + getOutHelpWidth: () => 100, + getErrHelpWidth: () => 100, + }); + + program + .name('mcpc <@session>') + .helpOption('-h, --help', 'Display help') + .option('-j, --json', 'Output in JSON format for scripting and code mode') + .option('-H, --header
', 'Custom HTTP header (can be repeated)') + .option('--verbose', 'Enable debug logging') + .option('--profile ', 'OAuth profile override') + .option('--schema ', 'Validate tool/prompt schema against expected schema') + .option('--schema-mode ', 'Schema validation mode: strict, compatible (default), ignore') + .option('--timeout ', 'Request timeout in seconds (default: 300)'); + + return program; +} + +/** + * Handle commands for a session target (@name) + */ +async function handleSessionCommands(session: string, args: string[]): Promise { + // Check if no subcommand provided - show server info + if (!hasSubcommand(args)) { + const options = extractOptions(args); + if (options.verbose) setVerbose(true); + if (options.json) setJsonMode(true); + + await sessions.showServerDetails(session, { + outputMode: options.json ? 'json' : 'human', + ...(options.verbose && { verbose: true }), + ...(options.timeout !== undefined && { timeout: options.timeout }), }); + return; + } + + const program = createSessionProgram(); + + // Register all session subcommands + registerSessionCommands(program, session); // Parse and execute try { @@ -584,6 +825,20 @@ async function handleCommands(target: string, args: string[]): Promise { } } +/** + * Flush stdout before exiting to prevent truncation with pipes + */ +async function flushStdout(): Promise { + await new Promise((resolve) => { + if (process.stdout.writableFinished) { + resolve(); + } else { + process.stdout.once('finish', resolve); + process.stdout.end(); + } + }); +} + // Run main function main().catch(async (error) => { console.error('Fatal error:', error); diff --git a/src/cli/parser.ts b/src/cli/parser.ts index f74fc71..df24a82 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -31,8 +31,6 @@ export function getJsonFromEnv(): boolean { // Options that take a value (not boolean flags) const OPTIONS_WITH_VALUES = [ - '-c', - '--config', '-H', '--header', '--timeout', @@ -54,25 +52,36 @@ const KNOWN_OPTIONS = [ '-h', '--help', '--verbose', - '--clean', '--full', '--x402', ]; -// Valid --clean types -const VALID_CLEAN_TYPES = ['sessions', 'profiles', 'logs', 'all']; +// Valid --schema-mode values +const VALID_SCHEMA_MODES = ['strict', 'compatible', 'ignore']; /** - * All known MCP subcommands (used to detect when user forgets to specify a target) + * All known top-level commands */ export const KNOWN_COMMANDS = [ 'help', - 'shell', 'login', 'logout', 'connect', 'close', 'restart', + 'shell', + 'clean', + 'x402', +]; + +/** + * All known session subcommands (used in help and error messages) + */ +export const KNOWN_SESSION_COMMANDS = [ + 'help', + 'shell', + 'close', + 'restart', 'tools', 'tools-list', 'tools-get', @@ -88,12 +97,8 @@ export const KNOWN_COMMANDS = [ 'prompts-get', 'logging-set-level', 'ping', - 'x402', ]; -// Valid --schema-mode values -const VALID_SCHEMA_MODES = ['strict', 'compatible', 'ignore']; - /** * Check if an option always takes a value */ @@ -102,6 +107,25 @@ export function optionTakesValue(arg: string): boolean { return OPTIONS_WITH_VALUES.includes(optionName); } +/** + * Check if there is a non-option argument in args starting from index 2 + * (index 0 = node, index 1 = script path β€” mirrors process.argv format) + */ +export function hasSubcommand(args: string[]): boolean { + for (let i = 2; i < args.length; i++) { + const arg = args[i]; + if (!arg) continue; + if (arg.startsWith('-')) { + if (optionTakesValue(arg) && !arg.includes('=')) { + i++; // skip value + } + continue; + } + return true; + } + return false; +} + /** * Check if an option is known */ @@ -112,7 +136,9 @@ function isKnownOption(arg: string): boolean { } /** - * Validate that all options in args are known + * Validate that all global options (before the first command token) are known. + * Stops at the first non-option argument so subcommand-specific options + * (e.g. --scope, --payment-required, -o/--output) are never checked here. * @throws ClientError if unknown option is found */ export function validateOptions(args: string[]): void { @@ -120,7 +146,6 @@ export function validateOptions(args: string[]): void { const arg = args[i]; if (!arg) continue; - // Only check arguments that start with - if (arg.startsWith('-')) { if (!isKnownOption(arg)) { throw new ClientError(`Unknown option: ${arg}`); @@ -129,26 +154,17 @@ export function validateOptions(args: string[]): void { if (optionTakesValue(arg) && !arg.includes('=') && i + 1 < args.length) { i++; } + } else { + // Stop at the first non-option argument (command token). + // Options after this point are subcommand-specific and are handled by Commander. + break; } } } /** - * Validate --clean types - * @throws ClientError if invalid clean type is found - */ -export function validateCleanTypes(types: string[]): void { - for (const type of types) { - if (type && !VALID_CLEAN_TYPES.includes(type)) { - throw new ClientError( - `Invalid --clean type: "${type}". Valid types are: ${VALID_CLEAN_TYPES.join(', ')}` - ); - } - } -} - -/** - * Validate argument values (--schema-mode, --timeout, etc.) + * Validate argument values (--schema-mode, --timeout, etc.) for global options only. + * Stops at the first non-option argument so subcommand-specific options are ignored. * @throws ClientError if invalid value is found */ export function validateArgValues(args: string[]): void { @@ -157,6 +173,11 @@ export function validateArgValues(args: string[]): void { const nextArg = args[i + 1]; if (!arg) continue; + if (!arg.startsWith('-')) { + // Stop at the first non-option argument (command token) + break; + } + // Validate --schema-mode value if (arg === '--schema-mode' && nextArg) { if (!VALID_SCHEMA_MODES.includes(nextArg)) { @@ -176,14 +197,6 @@ export function validateArgValues(args: string[]): void { } } - // Validate --config file exists - if ((arg === '--config' || arg === '-c') && nextArg) { - const configPath = resolvePath(nextArg); - if (!existsSync(configPath)) { - throw new ClientError(`Config file not found: ${nextArg}`); - } - } - // Validate --schema file exists if (arg === '--schema' && nextArg) { const schemaPath = resolvePath(nextArg); @@ -203,37 +216,11 @@ export function validateArgValues(args: string[]): void { } } -/** - * Find the first non-option argument (the target) - * Returns { target, targetIndex } or undefined if no target found - */ -export function findTarget(args: string[]): { target: string; targetIndex: number } | undefined { - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (!arg) continue; - - // Skip options and their values - if (arg.startsWith('-')) { - // If option takes a value and value is not inline (no =), skip next arg - if (optionTakesValue(arg) && !arg.includes('=') && i + 1 < args.length) { - i++; // Skip the value - } - continue; - } - - // Found first non-option argument - return { target: arg, targetIndex: i }; - } - - return undefined; -} - /** * Extract option values from args * Environment variables MCPC_VERBOSE and MCPC_JSON are used as defaults */ export function extractOptions(args: string[]): { - config?: string; headers?: string[]; timeout?: number; profile?: string; @@ -246,11 +233,6 @@ export function extractOptions(args: string[]): { json: args.includes('--json') || args.includes('-j') || getJsonFromEnv(), }; - // Extract --config - const configIndex = args.findIndex((arg) => arg === '--config' || arg === '-c'); - const config = - configIndex >= 0 && configIndex + 1 < args.length ? args[configIndex + 1] : undefined; - // Extract --header (can be repeated) const headers: string[] = []; for (let i = 0; i < args.length; i++) { @@ -277,7 +259,6 @@ export function extractOptions(args: string[]): { return { ...options, - ...(config && { config }), ...(headers.length > 0 && { headers }), ...(timeout !== undefined && { timeout }), ...(profile && { profile }), @@ -286,26 +267,55 @@ export function extractOptions(args: string[]): { } /** - * Check if there's a command after the target in args + * Returns true if str is a valid URL with a non-empty host */ -export function hasCommandAfterTarget(args: string[]): boolean { - // Start from index 2 (skip node and script path) - for (let i = 2; i < args.length; i++) { - const arg = args[i]; - if (!arg) continue; +function isValidUrlWithHost(str: string): boolean { + try { + return new URL(str).host.length > 0; + } catch { + return false; + } +} - // Skip options and their values - if (arg.startsWith('-')) { - if (optionTakesValue(arg) && !arg.includes('=')) { - i++; // Skip the value - } - continue; +/** + * Parse a server argument into a URL or config file entry. + * + * 1. URL: arg (as-is, or prefixed with https:// or http://) is a valid URL with a non-empty host. + * Args that start with a path character (/, ~, .) skip the prefix check to avoid false positives + * (e.g. https://~/ or https:///// parse with unusual hosts but are clearly file paths). + * 2. Config entry: colon present with non-empty text on both sides β†’ file:entry + * 3. Otherwise: returns null (caller should report an error) + */ +export function parseServerArg( + arg: string +): { type: 'url'; url: string } | { type: 'config'; file: string; entry: string } | null { + // Step 1a: try arg as-is (covers full URLs like https://... or ftp://...) + if (isValidUrlWithHost(arg)) { + return { type: 'url', url: arg }; + } + + // Step 1b: try adding https:// / http:// prefix for bare hostnames and host:port combos. + // Skip if arg starts with a path character β€” those are file paths, not hostnames. + // Skip if arg ends with ':' β€” dangling colon is not a valid hostname. + const startsWithPathChar = arg.startsWith('/') || arg.startsWith('~') || arg.startsWith('.'); + if (!startsWithPathChar && !arg.endsWith(':')) { + if (isValidUrlWithHost('https://' + arg) || isValidUrlWithHost('http://' + arg)) { + return { type: 'url', url: arg }; } + } - // Found a non-option arg (this is a command) - return true; + // Step 2: config file entry β€” colon with non-empty text on both sides + const colonIndex = arg.indexOf(':'); + if (colonIndex > 0 && colonIndex < arg.length - 1) { + return { + type: 'config', + file: arg.substring(0, colonIndex), + entry: arg.substring(colonIndex + 1), + }; } - return false; + + // Step 3: unrecognised + return null; } /** diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8258c50..5aa223f 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -103,13 +103,13 @@ export function createServerAuthError( options?: { sessionName?: string; originalError?: Error } ): AuthError { const sessionHint = options?.sessionName - ? `Then recreate the session:\n mcpc ${target} session ${options.sessionName}` + ? `Then recreate the session:\n mcpc connect ${target} ${options.sessionName}` : `Then run your command again.`; return new AuthError( `Authentication required by server.\n\n` + `To authenticate, run:\n` + - ` mcpc ${target} login\n\n` + + ` mcpc login ${target}\n\n` + sessionHint, options?.originalError ? { originalError: options.originalError } : undefined ); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f9035e6..8d398e2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,7 +6,7 @@ import { createHash } from 'crypto'; import { homedir } from 'os'; import { join, resolve, isAbsolute } from 'path'; -import { mkdir, access, constants, stat } from 'fs/promises'; +import { mkdir, access, constants } from 'fs/promises'; import { ClientError } from './errors.js'; /** @@ -125,30 +125,6 @@ export async function fileExists(filepath: string): Promise { } } -/** - * Check if a path is a file - */ -export async function isFile(filepath: string): Promise { - try { - const stats = await stat(filepath); - return stats.isFile(); - } catch { - return false; - } -} - -/** - * Check if a path is a directory - */ -export async function isDirectory(filepath: string): Promise { - try { - const stats = await stat(filepath); - return stats.isDirectory(); - } catch { - return false; - } -} - /** * Validate if a string is a valid URL with http:// or https:// scheme */ diff --git a/test/e2e/lib/framework.sh b/test/e2e/lib/framework.sh index b2b9d61..270f448 100755 --- a/test/e2e/lib/framework.sh +++ b/test/e2e/lib/framework.sh @@ -184,27 +184,12 @@ MCPC="${E2E_RUNTIME:-node} $PROJECT_ROOT/dist/cli/index.js" # Run mcpc and capture output # Sets: STDOUT, STDERR, EXIT_CODE -# Note: Automatically adds --header for test server connections to satisfy auth requirement run_mcpc() { local stdout_file="$TEST_TMP/stdout.$$.$RANDOM" local stderr_file="$TEST_TMP/stderr.$$.$RANDOM" - # Build command args, adding test header if connecting to test server - local -a args=() - local first_arg="${1:-}" - if [[ -n "${TEST_SERVER_URL:-}" && "$first_arg" == "$TEST_SERVER_URL" ]]; then - # Add dummy header to satisfy auth credential requirement - args=("$first_arg" "--header" "X-Test: true") - shift - for arg in "$@"; do - args+=("$arg") - done - else - args=("$@") - fi - set +e - $MCPC ${args[@]+"${args[@]}"} >"$stdout_file" 2>"$stderr_file" + $MCPC "$@" >"$stdout_file" 2>"$stderr_file" EXIT_CODE=$? set -e @@ -344,12 +329,17 @@ session_name() { # Create a session and track it for cleanup # Usage: create_session [session-suffix] +# Automatically adds X-Test header when connecting to TEST_SERVER_URL create_session() { local target="$1" local suffix="${2:-default}" local session=$(session_name "$suffix") - run_mcpc "$target" connect "$session" + if [[ -n "${TEST_SERVER_URL:-}" && "$target" == "$TEST_SERVER_URL" ]]; then + run_mcpc connect "$target" "$session" --header "X-Test: true" + else + run_mcpc connect "$target" "$session" + fi if [[ $EXIT_CODE -eq 0 ]]; then _SESSIONS_CREATED+=("$session") fi diff --git a/test/e2e/run.sh b/test/e2e/run.sh index f1fc1f1..62cbce1 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -118,37 +118,27 @@ done # Suites directory SUITES_DIR="$SCRIPT_DIR/suites" -# Find all test files matching patterns +# Find all test files matching patterns (outputs newline-separated paths) find_tests() { - local tests=() - if [[ ${#PATTERNS[@]} -eq 0 ]]; then # No pattern - find all tests in suites/ - while IFS= read -r -d '' test; do - tests+=("$test") - done < <(find "$SUITES_DIR" -name "*.test.sh" -print0 | sort -z) + find "$SUITES_DIR" -name "*.test.sh" | sort else for pattern in "${PATTERNS[@]}"; do if [[ -f "$SUITES_DIR/$pattern" ]]; then # Specific file - tests+=("$SUITES_DIR/$pattern") + echo "$SUITES_DIR/$pattern" elif [[ -d "$SUITES_DIR/$pattern" ]]; then # Directory - find all tests in it - while IFS= read -r -d '' test; do - tests+=("$test") - done < <(find "$SUITES_DIR/$pattern" -name "*.test.sh" -print0 | sort -z) + find "$SUITES_DIR/$pattern" -name "*.test.sh" | sort elif [[ -d "$SUITES_DIR/${pattern%/}" ]]; then # Directory without trailing slash - while IFS= read -r -d '' test; do - tests+=("$test") - done < <(find "$SUITES_DIR/${pattern%/}" -name "*.test.sh" -print0 | sort -z) + find "$SUITES_DIR/${pattern%/}" -name "*.test.sh" | sort else echo "Warning: No tests match pattern: $pattern" >&2 fi done fi - - printf '%s\n' "${tests[@]}" } # Get test name from path (relative to suites dir, without .test.sh) @@ -158,11 +148,11 @@ test_name() { echo "${rel%.test.sh}" } -# Collect tests (compatible with bash 3.x on macOS) +# Collect tests TESTS=() while IFS= read -r test; do [[ -n "$test" ]] && TESTS+=("$test") -done < <(find_tests) +done <<< "$(find_tests)" if [[ ${#TESTS[@]} -eq 0 ]]; then echo "No tests found" >&2 @@ -362,9 +352,9 @@ fi # Check for setup requirements (tests that were skipped due to missing configuration) SETUP_FILES=() -while IFS= read -r -d '' setup_file; do - SETUP_FILES+=("$setup_file") -done < <(find "$RUN_DIR" -name ".setup_required" -print0 2>/dev/null) +while IFS= read -r setup_file; do + [[ -n "$setup_file" ]] && SETUP_FILES+=("$setup_file") +done <<< "$(find "$RUN_DIR" -name ".setup_required" 2>/dev/null)" if [[ ${#SETUP_FILES[@]} -gt 0 ]]; then echo "" diff --git a/test/e2e/suites/auth/oauth-remote.test.sh b/test/e2e/suites/auth/oauth-remote.test.sh index 297d7a5..570d8c7 100755 --- a/test/e2e/suites/auth/oauth-remote.test.sh +++ b/test/e2e/suites/auth/oauth-remote.test.sh @@ -51,8 +51,8 @@ OAuth E2E tests require authentication profiles to be configured. To set up the required profiles, run: - mcpc $REMOTE_SERVER login --profile $PROFILE1 - mcpc $REMOTE_SERVER login --profile $PROFILE2 + mcpc login $REMOTE_SERVER --profile $PROFILE1 + mcpc login $REMOTE_SERVER --profile $PROFILE2 You'll need a free Apify account: https://console.apify.com/sign-up EOF @@ -78,7 +78,7 @@ OAuth E2E tests require authentication profiles to be configured. To set up the required profiles, run: - mcpc $REMOTE_SERVER login --profile $PROFILE2 + mcpc login $REMOTE_SERVER --profile $PROFILE2 You'll need a free Apify account: https://console.apify.com/sign-up EOF @@ -103,89 +103,6 @@ if [[ -f "$USER_PROFILES" ]]; then fi test_pass -# ============================================================================= -# Test: One-shot commands (direct connection, no session) -# ============================================================================= - -test_case "one-shot: server info with OAuth profile" -run_mcpc "$REMOTE_SERVER" --profile "$PROFILE1" -assert_success -assert_contains "$STDOUT" "Apify" -assert_contains "$STDOUT" "Capabilities:" -test_pass - -test_case "one-shot: ping with OAuth" -run_mcpc "$REMOTE_SERVER" ping --profile "$PROFILE1" -assert_success -assert_contains "$STDOUT" "Ping successful" -test_pass - -test_case "one-shot: ping --json returns valid JSON" -run_mcpc --json "$REMOTE_SERVER" ping --profile "$PROFILE1" -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '.durationMs' -test_pass - -test_case "one-shot: tools-list with OAuth" -# Note: Using run_mcpc instead of run_xmcpc because remote server output -# may vary between calls (non-deterministic ordering, dynamic data) -run_mcpc "$REMOTE_SERVER" tools-list --profile "$PROFILE1" -assert_success -assert_not_empty "$STDOUT" -test_pass - -test_case "one-shot: tools-list --json returns valid array" -run_mcpc --json "$REMOTE_SERVER" tools-list --profile "$PROFILE1" -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '. | type == "array"' -assert_json "$STDOUT" '. | length > 0' -test_pass - -test_case "one-shot: resources-list with OAuth" -run_mcpc "$REMOTE_SERVER" resources-list --profile "$PROFILE1" -assert_success -# May have resources or be empty, just check it doesn't error -test_pass - -test_case "one-shot: resources-list --json returns valid array" -run_mcpc --json "$REMOTE_SERVER" resources-list --profile "$PROFILE1" -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '. | type == "array"' -test_pass - -test_case "one-shot: prompts-list with OAuth" -run_mcpc "$REMOTE_SERVER" prompts-list --profile "$PROFILE1" -assert_success -# May have prompts or be empty, just check it doesn't error -test_pass - -test_case "one-shot: prompts-list --json returns valid array" -run_mcpc --json "$REMOTE_SERVER" prompts-list --profile "$PROFILE1" -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '. | type == "array"' -test_pass - -test_case "one-shot: help shows available commands" -run_mcpc "$REMOTE_SERVER" help --profile "$PROFILE1" -assert_success -assert_contains "$STDOUT" "Available commands:" -test_pass - -test_case "one-shot: different profile works independently" -if [[ "$SINGLE_PROFILE_MODE" == "true" ]]; then - test_skip "Single profile mode enabled" -else - # Verify that using a different profile also works - run_mcpc "$REMOTE_SERVER" ping --profile "$PROFILE2" - assert_success - assert_contains "$STDOUT" "Ping successful" - test_pass -fi - # ============================================================================= # Test: Session with OAuth profile # ============================================================================= @@ -193,7 +110,7 @@ fi test_case "create session with OAuth profile (verbose)" SESSION1=$(session_name "oauth1") # Create session with verbose mode to check for credential leaks -run_mcpc --verbose "$REMOTE_SERVER" connect "$SESSION1" --profile "$PROFILE1" +run_mcpc --verbose connect "$REMOTE_SERVER" "$SESSION1" --profile "$PROFILE1" assert_success _SESSIONS_CREATED+=("$SESSION1") @@ -248,7 +165,7 @@ if [[ "$SINGLE_PROFILE_MODE" == "true" ]]; then else SESSION2=$(session_name "oauth2") # Create session with verbose mode to check for credential leaks - run_mcpc --verbose "$REMOTE_SERVER" connect "$SESSION2" --profile "$PROFILE2" + run_mcpc --verbose connect "$REMOTE_SERVER" "$SESSION2" --profile "$PROFILE2" assert_success _SESSIONS_CREATED+=("$SESSION2") @@ -394,30 +311,6 @@ if [[ -f "$BRIDGE_LOG" ]]; then fi test_pass -test_case "verbose direct command does not leak OAuth tokens" -# Test direct connection (no session) with verbose mode -run_mcpc --verbose "$REMOTE_SERVER" ping --profile "$PROFILE1" -assert_success - -ALL_OUTPUT="$STDOUT$STDERR" - -# Check for token leaks in direct mode -if echo "$ALL_OUTPUT" | grep -iE 'Bearer [A-Za-z0-9_-]{20,}' >/dev/null 2>&1; then - test_fail "Verbose direct command output contains Bearer token" - exit 1 -fi - -if echo "$ALL_OUTPUT" | grep -iE '"access_token"\s*:\s*"[^"]{20,}"' >/dev/null 2>&1; then - test_fail "Verbose direct command output contains access_token" - exit 1 -fi - -if echo "$ALL_OUTPUT" | grep -iE 'Authorization:\s*[A-Za-z]+\s+[A-Za-z0-9_-]{20,}' >/dev/null 2>&1; then - test_fail "Verbose direct command output contains Authorization header" - exit 1 -fi -test_pass - # ============================================================================= # Test: Close sessions # ============================================================================= diff --git a/test/e2e/suites/basic/auth-errors.test.sh b/test/e2e/suites/basic/auth-errors.test.sh index 21ac758..42424d4 100755 --- a/test/e2e/suites/basic/auth-errors.test.sh +++ b/test/e2e/suites/basic/auth-errors.test.sh @@ -7,18 +7,24 @@ test_init "basic/auth-errors" # Start test server with auth required start_test_server REQUIRE_AUTH=true -# Test: tools-list without auth fails with 401 +# Create a session without proper auth credentials (no auth header) +# Session creation may or may not succeed depending on timing +AUTH_SESSION=$(session_name "auth") +run_mcpc connect "$TEST_SERVER_URL" "$AUTH_SESSION" +# Don't assert here - session creation might fail immediately (auth error) or succeed +# Either way, subsequent commands on the session should fail + +# Test: tools-list without auth fails test_case "tools-list without auth fails" -run_xmcpc "$TEST_SERVER_URL" tools-list +run_xmcpc "$AUTH_SESSION" tools-list assert_failure # Should contain some indication of auth failure (401, unauthorized, etc.) -# Just verify we get an error - exact message depends on implementation assert_not_empty "$STDERR" "should have error message" test_pass # Test: JSON error output for auth failure test_case "auth failure returns JSON error" -run_mcpc "$TEST_SERVER_URL" tools-list --json +run_mcpc "$AUTH_SESSION" tools-list --json assert_failure assert_json_valid "$STDERR" test_pass @@ -26,7 +32,7 @@ test_pass # Test: auth error with session test_case "session without auth fails on first use" SESSION=$(session_name "auth-fail") -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" # Session creation might succeed (just stores config) # But using it should fail due to auth run_xmcpc "$SESSION" tools-list @@ -38,22 +44,25 @@ run_mcpc "$SESSION" close 2>/dev/null || true # Test: tools-call without auth fails test_case "tools-call without auth fails" -run_xmcpc "$TEST_SERVER_URL" tools-call echo '{"message":"test"}' +run_xmcpc "$AUTH_SESSION" tools-call echo '{"message":"test"}' assert_failure test_pass # Test: resources-list without auth fails test_case "resources-list without auth fails" -run_xmcpc "$TEST_SERVER_URL" resources-list +run_xmcpc "$AUTH_SESSION" resources-list assert_failure test_pass # Test: prompts-list without auth fails test_case "prompts-list without auth fails" -run_xmcpc "$TEST_SERVER_URL" prompts-list +run_xmcpc "$AUTH_SESSION" prompts-list assert_failure test_pass +# Clean up auth session +run_mcpc "$AUTH_SESSION" close 2>/dev/null || true + # ============================================================================= # Test: OAuth-enabled remote server without profile hints at login # ============================================================================= @@ -61,16 +70,17 @@ test_pass # Use mcp.slack.com which requires OAuth authentication OAUTH_SERVER="https://mcp.slack.com/mcp" -test_case "OAuth server without profile shows login hint" -run_mcpc "$OAUTH_SERVER" tools-list +test_case "OAuth server session creation without profile shows login hint" +SESSION=$(session_name "oauth-noprof") +run_mcpc connect "$OAUTH_SERVER" "$SESSION" assert_failure # Should hint at login command assert_contains "$STDERR" "login" -assert_contains "$STDERR" "authenticate" test_pass -test_case "OAuth server without profile (JSON) shows login hint" -run_mcpc --json "$OAUTH_SERVER" tools-list +test_case "OAuth server session creation without profile (JSON) shows login hint" +SESSION=$(session_name "oauth-noprof2") +run_mcpc --json connect "$OAUTH_SERVER" "$SESSION" assert_failure assert_json_valid "$STDERR" # JSON error should also contain login hint @@ -81,12 +91,4 @@ if [[ -z "$error_msg" ]] || ! echo "$error_msg" | grep -qi "login"; then fi test_pass -test_case "OAuth server session creation without profile shows login hint" -SESSION=$(session_name "oauth-noprof") -run_mcpc "$OAUTH_SERVER" connect "$SESSION" -assert_failure -# Should hint at login command -assert_contains "$STDERR" "login" -test_pass - test_done diff --git a/test/e2e/suites/basic/bun.test.sh b/test/e2e/suites/basic/bun.test.sh index b55aa03..661df43 100755 --- a/test/e2e/suites/basic/bun.test.sh +++ b/test/e2e/suites/basic/bun.test.sh @@ -52,29 +52,35 @@ assert_json_valid "$STDOUT" assert_json "$STDOUT" '.version' test_pass -# Test: tools-list via direct connection -test_case "bun: tools-list (direct connection)" -run_xmcpc "$TEST_SERVER_URL" tools-list +# Create a session to use for session-based tests +BUN_SESSION=$(session_name "bun") +run_mcpc connect "$TEST_SERVER_URL" "$BUN_SESSION" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$BUN_SESSION") + +# Test: tools-list via session +test_case "bun: tools-list (session)" +run_xmcpc "$BUN_SESSION" tools-list assert_success assert_contains "$STDOUT" "echo" test_pass -# Test: tools-call via direct connection -test_case "bun: tools-call (direct connection)" -run_mcpc "$TEST_SERVER_URL" tools-call echo 'message:=hello from bun' +# Test: tools-call via session +test_case "bun: tools-call (session)" +run_mcpc "$BUN_SESSION" tools-call echo 'message:=hello from bun' assert_success assert_contains "$STDOUT" "hello from bun" test_pass -# Test: resources-list via direct connection -test_case "bun: resources-list (direct connection)" -run_xmcpc "$TEST_SERVER_URL" resources-list +# Test: resources-list via session +test_case "bun: resources-list (session)" +run_xmcpc "$BUN_SESSION" resources-list assert_success test_pass -# Test: JSON mode via direct connection -test_case "bun: tools-list --json (direct connection)" -run_mcpc --json "$TEST_SERVER_URL" tools-list +# Test: JSON mode via session +test_case "bun: tools-list --json (session)" +run_mcpc --json "$BUN_SESSION" tools-list assert_success assert_json_valid "$STDOUT" test_pass @@ -88,7 +94,7 @@ test_pass test_case "bun: session with bearer token (keychain write)" SESSION=$(session_name "bearer") -run_mcpc "$TEST_SERVER_URL" --header "Authorization: Bearer testtoken-bun-$$" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" --header "Authorization: Bearer testtoken-bun-$$" assert_success test_pass diff --git a/test/e2e/suites/basic/config-env-vars.test.sh b/test/e2e/suites/basic/config-env-vars.test.sh index b99748f..f367dbb 100755 --- a/test/e2e/suites/basic/config-env-vars.test.sh +++ b/test/e2e/suites/basic/config-env-vars.test.sh @@ -31,7 +31,7 @@ EOF # Test that we can connect using the config SESSION=$(session_name "env-url") -run_mcpc --config "$CONFIG_FILE" env-test connect "$SESSION" +run_mcpc connect "$CONFIG_FILE:env-test" "$SESSION" assert_success _SESSIONS_CREATED+=("$SESSION") @@ -70,7 +70,7 @@ EOF # Test that we can connect using the config SESSION=$(session_name "env-hdr") -run_mcpc --config "$CONFIG_FILE" header-test connect "$SESSION" +run_mcpc connect "$CONFIG_FILE:header-test" "$SESSION" assert_success _SESSIONS_CREATED+=("$SESSION") @@ -109,7 +109,7 @@ EOF # Should still connect (empty string is valid header value) SESSION=$(session_name "env-miss") -run_mcpc --config "$CONFIG_FILE" missing-test connect "$SESSION" +run_mcpc connect "$CONFIG_FILE:missing-test" "$SESSION" assert_success _SESSIONS_CREATED+=("$SESSION") @@ -139,7 +139,7 @@ cat > "$CONFIG_FILE" </dev/null 2>&1 +_SESSIONS_CREATED=("${_SESSIONS_CREATED[@]/$SESSION}") test_pass # ============================================================================= @@ -24,9 +30,15 @@ test_pass test_case "HTTPS_PROXY does not affect HTTP connections" # HTTPS_PROXY points to a dead port; HTTP_PROXY points to working proxy # Since MCP server URL is HTTP, only HTTP_PROXY should be used β€” should succeed -HTTPS_PROXY="http://127.0.0.1:1" HTTP_PROXY="$PROXY_URL" run_mcpc "$TEST_SERVER_URL" tools-list +SESSION=$(session_name "proxy-https") +HTTPS_PROXY="http://127.0.0.1:1" HTTP_PROXY="$PROXY_URL" run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$SESSION") +run_mcpc "$SESSION" tools-list assert_success assert_contains "$STDOUT" "echo" +run_mcpc "$SESSION" close >/dev/null 2>&1 +_SESSIONS_CREATED=("${_SESSIONS_CREATED[@]/$SESSION}") test_pass # ============================================================================= @@ -34,8 +46,19 @@ test_pass # ============================================================================= test_case "invalid proxy causes connection failure" -HTTP_PROXY="http://127.0.0.1:1" run_xmcpc "$TEST_SERVER_URL" tools-list -assert_failure +SESSION=$(session_name "proxy-broken") +HTTP_PROXY="http://127.0.0.1:1" run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" +if [[ $EXIT_CODE -ne 0 ]]; then + # Connect itself failed due to proxy β€” valid failure path + assert_failure +else + # Connect returned 0 but bridge couldn't establish the MCP session through the broken proxy; + # subsequent CLI commands may restart the bridge without the proxy env var, so we can't + # assert tools-list fails. Instead verify that connect didn't show server capabilities + # (confirming the bridge never fully initialized through the broken proxy). + assert_not_contains "$STDOUT" "Capabilities:" "Bridge should not fully initialize through a broken proxy" + run_mcpc "$SESSION" close 2>/dev/null || true +fi test_pass test_done diff --git a/test/e2e/suites/basic/env-vars.test.sh b/test/e2e/suites/basic/env-vars.test.sh index a6d55ad..689a4a6 100755 --- a/test/e2e/suites/basic/env-vars.test.sh +++ b/test/e2e/suites/basic/env-vars.test.sh @@ -19,7 +19,7 @@ cp "$MCPC_HOME_DIR/profiles.json" "$CUSTOM_HOME/profiles.json" # Create a session with custom home SESSION=$(session_name "env-home") -MCPC_HOME_DIR="$CUSTOM_HOME" run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +MCPC_HOME_DIR="$CUSTOM_HOME" run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success # Verify sessions.json exists in custom home @@ -79,7 +79,7 @@ test_pass # Test: MCPC_VERBOSE=1 enables verbose mode test_case "MCPC_VERBOSE=1 enables verbose output" SESSION2=$(session_name "env-verbose") -run_mcpc "$TEST_SERVER_URL" connect "$SESSION2" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION2" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION2") diff --git a/test/e2e/suites/basic/errors.test.sh b/test/e2e/suites/basic/errors.test.sh index b2f9581..5b52587 100755 --- a/test/e2e/suites/basic/errors.test.sh +++ b/test/e2e/suites/basic/errors.test.sh @@ -23,15 +23,15 @@ run_mcpc @test invalid-command-$RANDOM assert_failure test_pass -# Test: missing required argument for session command (Commander.js handles this) -test_case "missing required argument for session" -run_mcpc example.com session +# Test: connect command missing required arguments (Commander.js handles this) +test_case "connect command missing required arguments" +run_mcpc connect assert_failure test_pass -# Test: invalid URL scheme +# Test: invalid URL scheme passed to connect test_case "invalid URL scheme" -run_xmcpc "ftp://example.com" tools-list +run_mcpc connect "ftp://example.com" "@test-$RANDOM" assert_failure test_pass @@ -98,11 +98,10 @@ assert_failure assert_contains "$STDERR" "Unknown option" test_pass -# Test: invalid --clean type should fail -test_case "invalid --clean type fails" -run_mcpc --clean=invalid +# Test: invalid clean resource type should fail +test_case "invalid clean resource type fails" +run_mcpc clean invalid-type-$RANDOM assert_failure -assert_contains "$STDERR" "Invalid --clean type" test_pass # Test: option that looks like --clean but isn't should fail @@ -114,35 +113,35 @@ test_pass # Test: invalid --header format (missing colon) test_case "invalid --header format fails" -run_mcpc example.com tools-list --header "InvalidHeader" +run_mcpc connect example.com "@test-$RANDOM" --header "InvalidHeader" assert_failure assert_contains "$STDERR" "Invalid header format" test_pass # Test: invalid --schema-mode value test_case "invalid --schema-mode fails" -run_mcpc example.com tools-list --schema-mode invalid +run_mcpc @nonexistent tools-list --schema-mode invalid assert_failure assert_contains "$STDERR" "Invalid --schema-mode" test_pass # Test: non-numeric --timeout value test_case "non-numeric --timeout fails" -run_mcpc example.com tools-list --timeout notanumber +run_mcpc @nonexistent tools-list --timeout notanumber assert_failure assert_contains "$STDERR" "Invalid --timeout" test_pass -# Test: non-existent --config file -test_case "non-existent --config file fails" -run_mcpc --config /nonexistent/config-$RANDOM.json fs tools-list +# Test: non-existent config file via connect command +test_case "non-existent config file fails" +run_mcpc connect "/nonexistent/config-$RANDOM.json:fs" "@test-session-$RANDOM" assert_failure assert_contains "$STDERR" "not found" test_pass # Test: non-existent --schema file test_case "non-existent --schema file fails" -run_mcpc example.com tools-list --schema /nonexistent/schema-$RANDOM.json +run_mcpc @nonexistent tools-list --schema /nonexistent/schema-$RANDOM.json assert_failure assert_contains "$STDERR" "not found" test_pass diff --git a/test/e2e/suites/basic/header-security.test.sh b/test/e2e/suites/basic/header-security.test.sh index 0b8877e..5fa3e6e 100755 --- a/test/e2e/suites/basic/header-security.test.sh +++ b/test/e2e/suites/basic/header-security.test.sh @@ -22,7 +22,7 @@ test_case "secret header not visible in ps aux" SESSION=$(session_name "sec-hdr") # Create session with secret header -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" --header "$SECRET_HEADER: Bearer $SECRET_VALUE" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" --header "$SECRET_HEADER: Bearer $SECRET_VALUE" assert_success _SESSIONS_CREATED+=("$SESSION") @@ -121,7 +121,8 @@ test_case "multiple headers all redacted" SESSION2=$(session_name "sec-multi") ANOTHER_SECRET="another-secret-$(date +%s)" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION2" \ +run_mcpc connect "$TEST_SERVER_URL" "$SESSION2" \ + --header "X-Test: true" \ --header "X-Api-Key: $SECRET_VALUE" \ --header "X-Secret-Token: $ANOTHER_SECRET" \ --header "X-Public: public-value" @@ -158,7 +159,7 @@ SESSION3=$(session_name "sec-verb") VERBOSE_SECRET="verbose-secret-$(date +%s)" # Create session with verbose mode -run_mcpc --verbose "$TEST_SERVER_URL" connect "$SESSION3" --header "X-Api-Key: $VERBOSE_SECRET" +run_mcpc --verbose connect "$TEST_SERVER_URL" "$SESSION3" --header "X-Test: true" --header "X-Api-Key: $VERBOSE_SECRET" assert_success _SESSIONS_CREATED+=("$SESSION3") diff --git a/test/e2e/suites/basic/hidden-commands.test.sh b/test/e2e/suites/basic/hidden-commands.test.sh new file mode 100755 index 0000000..03c0c9c --- /dev/null +++ b/test/e2e/suites/basic/hidden-commands.test.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Test: hidden top-level commands (shell, close, restart) +# These commands are hidden from --help but must remain fully functional + +source "$(dirname "$0")/../../lib/framework.sh" +test_init "basic/hidden-commands" + +start_test_server + +# ============================================================================= +# shell, close, restart are NOT shown in --help +# ============================================================================= + +test_case "shell not listed in --help" +run_mcpc --help +assert_success +assert_not_contains "$STDOUT" " shell " "shell should be hidden from help" +test_pass + +test_case "close not listed in --help" +run_mcpc --help +assert_not_contains "$STDOUT" " close " "close should be hidden from help" +test_pass + +test_case "restart not listed in --help" +run_mcpc --help +assert_not_contains "$STDOUT" " restart " "restart should be hidden from help" +test_pass + +# ============================================================================= +# mcpc close @session (top-level form) +# ============================================================================= + +test_case "mcpc close @session closes the session" +SESSION=$(session_name "close") +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" +assert_success +run_mcpc close "$SESSION" +assert_success +# Session should no longer exist +run_mcpc --json +assert_success +session_exists=$(echo "$STDOUT" | jq -r ".sessions[] | select(.name == \"$SESSION\") | .name") +assert_empty "$session_exists" "session should not exist after close" +test_pass + +test_case "mcpc close missing @session errors" +run_mcpc close +assert_failure +test_pass + +# ============================================================================= +# mcpc restart @session (top-level form) +# ============================================================================= + +test_case "mcpc restart @session restarts the session" +SESSION=$(session_name "restart") +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$SESSION") +# Get initial PID +run_mcpc --json +INITIAL_PID=$(echo "$STDOUT" | jq -r ".sessions[] | select(.name == \"$SESSION\") | .pid") +assert_not_empty "$INITIAL_PID" +# Restart +run_mcpc restart "$SESSION" +assert_success +assert_contains "$STDOUT" "restarted" +# Bridge PID should change +run_mcpc --json +NEW_PID=$(echo "$STDOUT" | jq -r ".sessions[] | select(.name == \"$SESSION\") | .pid") +assert_not_empty "$NEW_PID" +if [[ "$INITIAL_PID" == "$NEW_PID" ]]; then + test_fail "Bridge PID did not change after restart (still $INITIAL_PID)" + exit 1 +fi +test_pass + +test_case "mcpc restart missing @session errors" +run_mcpc restart +assert_failure +test_pass + +# ============================================================================= +# mcpc shell @session (top-level form) +# ============================================================================= + +test_case "mcpc shell @session exits cleanly on EOF" +SESSION2=$(session_name "shell") +run_mcpc connect "$TEST_SERVER_URL" "$SESSION2" --header "X-Test: true" +assert_success +_SESSIONS_CREATED+=("$SESSION2") +# Send EOF immediately; readline closes, shell exits 0 +echo -n "" | run_mcpc shell "$SESSION2" +assert_success +test_pass + +test_case "mcpc shell missing @session errors" +run_mcpc shell +assert_failure +test_pass + +test_done diff --git a/test/e2e/suites/basic/human-output.test.sh b/test/e2e/suites/basic/human-output.test.sh index e08638d..eb6c0fb 100755 --- a/test/e2e/suites/basic/human-output.test.sh +++ b/test/e2e/suites/basic/human-output.test.sh @@ -13,7 +13,7 @@ SESSION=$(session_name "human-out") # Create session for testing test_case "setup: create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/basic/json-schema.test.sh b/test/e2e/suites/basic/json-schema.test.sh index 1a58b66..a6dfa7f 100755 --- a/test/e2e/suites/basic/json-schema.test.sh +++ b/test/e2e/suites/basic/json-schema.test.sh @@ -14,7 +14,7 @@ SESSION=$(session_name "json-schema") # Create session for testing test_case "setup: create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/basic/output-invariants.test.sh b/test/e2e/suites/basic/output-invariants.test.sh index 0053636..85bdde9 100755 --- a/test/e2e/suites/basic/output-invariants.test.sh +++ b/test/e2e/suites/basic/output-invariants.test.sh @@ -19,7 +19,7 @@ test_pass test_case "--verbose doesn't change stdout for session list" # Create a session first so we have something to list INVARIANT_SESSION=$(session_name "invariant") -run_mcpc --config "$(create_fs_config "$TEST_TMP")" fs connect "$INVARIANT_SESSION" >/dev/null 2>&1 +run_mcpc connect "$(create_fs_config "$TEST_TMP"):fs" "$INVARIANT_SESSION" >/dev/null 2>&1 _SESSIONS_CREATED+=("$INVARIANT_SESSION") # Test the invariant - with isolated home, this is deterministic @@ -33,7 +33,7 @@ test_pass test_case "--verbose doesn't change stdout for mcpc --json" # Create a session for this test INVARIANT_SESSION2=$(session_name "inv-json") -run_mcpc --config "$(create_fs_config "$TEST_TMP")" fs connect "$INVARIANT_SESSION2" >/dev/null 2>&1 +run_mcpc connect "$(create_fs_config "$TEST_TMP"):fs" "$INVARIANT_SESSION2" >/dev/null 2>&1 _SESSIONS_CREATED+=("$INVARIANT_SESSION2") # Test the invariant with JSON mode @@ -84,7 +84,7 @@ test_pass # Test: session creation with --json returns only valid JSON to stdout test_case "session create --json returns only valid JSON" SESSION=$(session_name "json-test") -run_mcpc --config "$(create_fs_config "$TEST_TMP")" fs connect "$SESSION" --json +run_mcpc connect "$(create_fs_config "$TEST_TMP"):fs" "$SESSION" --json assert_success _SESSIONS_CREATED+=("$SESSION") assert_json_valid "$STDOUT" "session create --json should return only valid JSON to stdout" diff --git a/test/e2e/suites/basic/prompts.test.sh b/test/e2e/suites/basic/prompts.test.sh index ecfc9ff..820772e 100755 --- a/test/e2e/suites/basic/prompts.test.sh +++ b/test/e2e/suites/basic/prompts.test.sh @@ -13,7 +13,7 @@ SESSION=$(session_name "prmpt") # Create session for testing test_case "setup: create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/basic/remote-open.test.sh b/test/e2e/suites/basic/remote-open.test.sh index a2f6569..d481da4 100755 --- a/test/e2e/suites/basic/remote-open.test.sh +++ b/test/e2e/suites/basic/remote-open.test.sh @@ -45,45 +45,13 @@ cat > "$profiles_file" << EOF EOF test_pass -# ============================================================================= -# Test: Direct connection without authentication -# ============================================================================= - -test_case "connect to open remote server" -# Note: Using run_mcpc instead of run_xmcpc because remote server output -# may vary between calls (non-deterministic ordering, dynamic data) -run_mcpc "$REMOTE_SERVER" -assert_success -assert_contains "$STDOUT" "Apify" -test_pass - -test_case "tools-list returns tools" -run_mcpc "$REMOTE_SERVER" tools-list -assert_success -assert_not_empty "$STDOUT" -test_pass - -test_case "tools-list --json returns valid JSON array" -run_mcpc --json "$REMOTE_SERVER" tools-list -assert_success -assert_json_valid "$STDOUT" -# JSON output is a direct array of tools -assert_json "$STDOUT" '. | type == "array"' -assert_json "$STDOUT" '. | length > 0' -test_pass - -test_case "ping succeeds" -run_mcpc "$REMOTE_SERVER" ping -assert_success -test_pass - # ============================================================================= # Test: Session with open server # ============================================================================= test_case "create session without authentication" SESSION=$(session_name "open") -run_mcpc "$REMOTE_SERVER" connect "$SESSION" +run_mcpc connect "$REMOTE_SERVER" "$SESSION" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/basic/resources.test.sh b/test/e2e/suites/basic/resources.test.sh index 77b63a0..57a37f5 100755 --- a/test/e2e/suites/basic/resources.test.sh +++ b/test/e2e/suites/basic/resources.test.sh @@ -13,7 +13,7 @@ SESSION=$(session_name "res") # Create session for testing test_case "setup: create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/basic/schema-validation.test.sh b/test/e2e/suites/basic/schema-validation.test.sh index 7ad40c2..ef801e9 100755 --- a/test/e2e/suites/basic/schema-validation.test.sh +++ b/test/e2e/suites/basic/schema-validation.test.sh @@ -13,7 +13,7 @@ SESSION=$(session_name "schema") # Create session for testing test_case "setup: create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/sessions/bridge-resilience.test.sh b/test/e2e/suites/sessions/bridge-resilience.test.sh index dc85968..c3b6ebf 100755 --- a/test/e2e/suites/sessions/bridge-resilience.test.sh +++ b/test/e2e/suites/sessions/bridge-resilience.test.sh @@ -12,7 +12,7 @@ SESSION=$(session_name "resilience") # Test: create session test_case "create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/sessions/close.test.sh b/test/e2e/suites/sessions/close.test.sh index 1efb1bf..2cb099c 100755 --- a/test/e2e/suites/sessions/close.test.sh +++ b/test/e2e/suites/sessions/close.test.sh @@ -15,7 +15,7 @@ curl -s -X POST "$TEST_SERVER_URL/control/reset" >/dev/null # Create session SESSION=$(session_name "close-delete") -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") @@ -72,7 +72,7 @@ curl -s -X POST "$TEST_SERVER_URL/control/reset" >/dev/null SESSION2=$(session_name "rapid") for i in 1 2 3; do - run_mcpc "$TEST_SERVER_URL" connect "$SESSION2" + run_mcpc connect "$TEST_SERVER_URL" "$SESSION2" --header "X-Test: true" assert_success "iteration $i: create should succeed" run_mcpc "$SESSION2" close assert_success "iteration $i: close should succeed" diff --git a/test/e2e/suites/sessions/failover.test.sh b/test/e2e/suites/sessions/failover.test.sh index 1e3352a..bd022f5 100755 --- a/test/e2e/suites/sessions/failover.test.sh +++ b/test/e2e/suites/sessions/failover.test.sh @@ -12,7 +12,7 @@ SESSION=$(session_name "failover") # Test: create session for failover test test_case "create session for failover test" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/sessions/lifecycle.test.sh b/test/e2e/suites/sessions/lifecycle.test.sh index 5867040..5d401f8 100755 --- a/test/e2e/suites/sessions/lifecycle.test.sh +++ b/test/e2e/suites/sessions/lifecycle.test.sh @@ -12,7 +12,7 @@ SESSION=$(session_name "lifecycle") # Test: connect creates session test_case "connect creates session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success "connect should succeed" assert_contains "$STDOUT" "created" _SESSIONS_CREATED+=("$SESSION") diff --git a/test/e2e/suites/sessions/mcp-session.test.sh b/test/e2e/suites/sessions/mcp-session.test.sh index a692e72..90a055c 100755 --- a/test/e2e/suites/sessions/mcp-session.test.sh +++ b/test/e2e/suites/sessions/mcp-session.test.sh @@ -19,7 +19,7 @@ curl -s -X POST "$TEST_SERVER_URL/control/reset" >/dev/null initial_sessions=$(curl -s "$TEST_SERVER_URL/control/get-active-sessions" | jq '.activeSessions | length') # Create mcpc session -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") diff --git a/test/e2e/suites/sessions/notifications.test.sh b/test/e2e/suites/sessions/notifications.test.sh index 72b4a54..b227ddd 100755 --- a/test/e2e/suites/sessions/notifications.test.sh +++ b/test/e2e/suites/sessions/notifications.test.sh @@ -12,7 +12,7 @@ SESSION=$(session_name "notif") # Test: create session test_case "connect creates session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success "connect should succeed" assert_contains "$STDOUT" "created" _SESSIONS_CREATED+=("$SESSION") diff --git a/test/e2e/suites/sessions/pagination.test.sh b/test/e2e/suites/sessions/pagination.test.sh index 0b0e937..6c2d59d 100755 --- a/test/e2e/suites/sessions/pagination.test.sh +++ b/test/e2e/suites/sessions/pagination.test.sh @@ -12,7 +12,7 @@ SESSION=$(session_name "pagination") # Create session test_case "create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/sessions/proxy.test.sh b/test/e2e/suites/sessions/proxy.test.sh index 32c75a0..0a7089c 100755 --- a/test/e2e/suites/sessions/proxy.test.sh +++ b/test/e2e/suites/sessions/proxy.test.sh @@ -18,7 +18,7 @@ PROXY_PORT=$((8100 + RANDOM % 100)) # Test: connect with --proxy option creates session with proxy server test_case "connect with --proxy creates session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION_UPSTREAM" --proxy "$PROXY_PORT" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION_UPSTREAM" --header "X-Test: true" --proxy "$PROXY_PORT" assert_success "connect with --proxy should succeed" assert_contains "$STDOUT" "created" _SESSIONS_CREATED+=("$SESSION_UPSTREAM") @@ -53,7 +53,7 @@ test_pass test_case "connect to proxy server" # Ensure proxy is still healthy before connecting downstream wait_for "curl -s http://127.0.0.1:$PROXY_PORT/health 2>/dev/null | grep -q ok" -run_mcpc "127.0.0.1:$PROXY_PORT" connect "$SESSION_DOWNSTREAM" +run_mcpc connect "127.0.0.1:$PROXY_PORT" "$SESSION_DOWNSTREAM" assert_success "connect to proxy should succeed" _SESSIONS_CREATED+=("$SESSION_DOWNSTREAM") @@ -114,7 +114,7 @@ PROXY_PORT_CONFLICT=$((8300 + RANDOM % 100)) # Test: create first session with proxy test_case "create first session with proxy for conflict test" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION_CONFLICT1" --proxy "$PROXY_PORT_CONFLICT" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION_CONFLICT1" --header "X-Test: true" --proxy "$PROXY_PORT_CONFLICT" assert_success _SESSIONS_CREATED+=("$SESSION_CONFLICT1") test_pass @@ -124,7 +124,7 @@ wait_for "curl -s http://127.0.0.1:$PROXY_PORT_CONFLICT/health 2>/dev/null | gre # Test: second session on same port should fail test_case "second session on same port fails" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION_CONFLICT2" --proxy "$PROXY_PORT_CONFLICT" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION_CONFLICT2" --header "X-Test: true" --proxy "$PROXY_PORT_CONFLICT" assert_failure "should fail when port is already in use" assert_contains "$STDERR" "already in use" "error should mention port in use" test_pass @@ -146,7 +146,7 @@ BEARER_TOKEN="test-secret-token-12345" # Test: create session with proxy and bearer token test_case "connect with --proxy-bearer-token" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION_AUTH" --proxy "$PROXY_PORT_AUTH" --proxy-bearer-token "$BEARER_TOKEN" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION_AUTH" --header "X-Test: true" --proxy "$PROXY_PORT_AUTH" --proxy-bearer-token "$BEARER_TOKEN" assert_success "connect with --proxy-bearer-token should succeed" _SESSIONS_CREATED+=("$SESSION_AUTH") test_pass diff --git a/test/e2e/suites/sessions/restart.test.sh b/test/e2e/suites/sessions/restart.test.sh index 9c620a2..29063e9 100755 --- a/test/e2e/suites/sessions/restart.test.sh +++ b/test/e2e/suites/sessions/restart.test.sh @@ -16,7 +16,7 @@ SESSION=$(session_name "restart") # ============================================================================= test_case "setup: create session" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/sessions/server-abort.test.sh b/test/e2e/suites/sessions/server-abort.test.sh index 6c8199d..0725345 100755 --- a/test/e2e/suites/sessions/server-abort.test.sh +++ b/test/e2e/suites/sessions/server-abort.test.sh @@ -12,7 +12,7 @@ SESSION=$(session_name "server-abort") # Test: create and verify session works test_case "create session and verify it works" -run_mcpc "$TEST_SERVER_URL" connect "$SESSION" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION") @@ -55,7 +55,7 @@ _SESSIONS_CREATED=("${_SESSIONS_CREATED[@]/$SESSION}") # Create new session with same name SESSION2=$(session_name "server-abort-2") -run_mcpc "$TEST_SERVER_URL" connect "$SESSION2" +run_mcpc connect "$TEST_SERVER_URL" "$SESSION2" --header "X-Test: true" assert_success _SESSIONS_CREATED+=("$SESSION2") diff --git a/test/e2e/suites/stdio/bridge-restart.test.sh b/test/e2e/suites/stdio/bridge-restart.test.sh index 47fcdcc..6a16ad6 100755 --- a/test/e2e/suites/stdio/bridge-restart.test.sh +++ b/test/e2e/suites/stdio/bridge-restart.test.sh @@ -14,7 +14,7 @@ SESSION=$(session_name "restart") # Test: create session with stdio config test_case "create session with stdio config" -run_mcpc --config "$CONFIG" fs connect "$SESSION" +run_mcpc connect "$CONFIG:fs" "$SESSION" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/e2e/suites/stdio/filesystem.test.sh b/test/e2e/suites/stdio/filesystem.test.sh index b8330ea..9ed2ab2 100644 --- a/test/e2e/suites/stdio/filesystem.test.sh +++ b/test/e2e/suites/stdio/filesystem.test.sh @@ -7,70 +7,6 @@ test_init "stdio/filesystem" # Create a config file for the filesystem server CONFIG=$(create_fs_config "$TEST_TMP") -# ============================================================================= -# Test: One-shot commands (direct connection, no session) -# ============================================================================= - -test_case "one-shot: server info via stdio" -run_mcpc --config "$CONFIG" fs -assert_success -assert_contains "$STDOUT" "Capabilities:" -test_pass - -test_case "one-shot: ping via stdio" -run_mcpc --config "$CONFIG" fs ping -assert_success -assert_contains "$STDOUT" "Ping successful" -test_pass - -test_case "one-shot: ping --json via stdio" -run_mcpc --json --config "$CONFIG" fs ping -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '.durationMs' -test_pass - -test_case "one-shot: tools-list via stdio" -run_xmcpc --config "$CONFIG" fs tools-list -assert_success -assert_contains "$STDOUT" "read_file" -assert_contains "$STDOUT" "write_file" -test_pass - -test_case "one-shot: tools-list --json via stdio" -run_mcpc --json --config "$CONFIG" fs tools-list -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '. | type == "array"' -assert_json "$STDOUT" '. | length > 0' -test_pass - -# Note: Filesystem server doesn't support resources or prompts, -# so we skip those one-shot tests here. They are tested in basic/resources.test.sh -# and basic/prompts.test.sh using the test server which supports all MCP features. - -test_case "one-shot: help via stdio" -run_mcpc --config "$CONFIG" fs help -assert_success -assert_contains "$STDOUT" "Available commands:" -test_pass - -# Create test file for one-shot tool call -echo "One-shot test content" > "$TEST_TMP/oneshot.txt" - -test_case "one-shot: tools-call read_file via stdio" -run_xmcpc --config "$CONFIG" fs tools-call read_file "path:=$TEST_TMP/oneshot.txt" -assert_success -assert_contains "$STDOUT" "One-shot test content" -test_pass - -test_case "one-shot: tools-call --json via stdio" -run_mcpc --json --config "$CONFIG" fs tools-call read_file "path:=$TEST_TMP/oneshot.txt" -assert_success -assert_json_valid "$STDOUT" -assert_json "$STDOUT" '.content' -test_pass - # ============================================================================= # Test: Session-based commands # ============================================================================= @@ -80,7 +16,7 @@ SESSION=$(session_name "fs") # Test: create session with stdio config test_case "create session with stdio config" -run_mcpc --config "$CONFIG" fs connect "$SESSION" +run_mcpc connect "$CONFIG:fs" "$SESSION" assert_success _SESSIONS_CREATED+=("$SESSION") test_pass diff --git a/test/unit/cli/index.test.ts b/test/unit/cli/index.test.ts index 7ccddc3..21a0ee7 100644 --- a/test/unit/cli/index.test.ts +++ b/test/unit/cli/index.test.ts @@ -2,73 +2,111 @@ * Unit tests for CLI argument parsing functions */ -import { findTarget, extractOptions } from '../../../src/cli/parser.js'; +import { extractOptions, parseServerArg, hasSubcommand } from '../../../src/cli/parser.js'; -describe('findTarget', () => { - it('should find simple target without options', () => { - const result = findTarget(['apify']); - expect(result).toEqual({ target: 'apify', targetIndex: 0 }); +// args format mirrors process.argv: [node, script, ...actual_args] +const A = (...args: string[]) => ['node', 'script', ...args]; + +describe('hasSubcommand', () => { + it('returns true when a subcommand is present', () => { + expect(hasSubcommand(A('tools-list'))).toBe(true); }); - it('should find target after boolean flags', () => { - const result = findTarget(['--json', '--verbose', 'apify']); - expect(result).toEqual({ target: 'apify', targetIndex: 2 }); + it('returns true when subcommand follows options', () => { + expect(hasSubcommand(A('--json', 'tools-list'))).toBe(true); }); - it('should skip options with values', () => { - const result = findTarget(['--config', 'file.json', 'apify']); - expect(result).toEqual({ target: 'apify', targetIndex: 2 }); + it('returns true when subcommand follows an option with value', () => { + expect(hasSubcommand(A('--timeout', '30', 'ping'))).toBe(true); }); - it('should skip multiple options with values', () => { - const result = findTarget([ - '--config', - 'file.json', - '--header', - 'Auth: Bearer token', - '--timeout', - '60', - 'apify', - ]); - expect(result).toEqual({ target: 'apify', targetIndex: 6 }); + it('returns false when only options are present', () => { + expect(hasSubcommand(A('--json', '--verbose'))).toBe(false); }); - it('should handle options with inline values (=)', () => { - const result = findTarget(['--config=file.json', '--timeout=60', 'apify']); - expect(result).toEqual({ target: 'apify', targetIndex: 2 }); + it('returns false for empty args', () => { + expect(hasSubcommand(A())).toBe(false); }); - it('should return undefined when no target found', () => { - const result = findTarget(['--json', '--verbose']); - expect(result).toBeUndefined(); + it('does not treat option values as subcommands', () => { + expect(hasSubcommand(A('--timeout', '30'))).toBe(false); }); +}); - it('should return undefined for empty args', () => { - const result = findTarget([]); - expect(result).toBeUndefined(); +describe('parseServerArg', () => { + it('should parse a bare domain as URL', () => { + const result = parseServerArg('mcp.apify.com'); + expect(result).toEqual({ type: 'url', url: 'mcp.apify.com' }); + + const result2 = parseServerArg('example.com'); + expect(result2).toEqual({ type: 'url', url: 'example.com' }); + + const result3 = parseServerArg('example'); + expect(result3).toEqual({ type: 'url', url: 'example' }); }); - it('should handle mixed boolean and value options', () => { - const result = findTarget([ - '--json', - '--config', - 'file.json', - '--verbose', - '--header', - 'X-Key: value', - 'apify', - ]); - expect(result).toEqual({ target: 'apify', targetIndex: 6 }); + it('should parse a full URL as URL', () => { + const result = parseServerArg('https://mcp.apify.com'); + expect(result).toEqual({ type: 'url', url: 'https://mcp.apify.com' }); + + const result2 = parseServerArg('http://mcp.apify.com'); + expect(result2).toEqual({ type: 'url', url: 'http://mcp.apify.com' }); + }); + + it('should parse a URL with path (no colon-entry) as URL', () => { + const result = parseServerArg('https://mcp.apify.com/v1'); + expect(result).toEqual({ type: 'url', url: 'https://mcp.apify.com/v1' }); + + const result2 = parseServerArg('mcp.apify.com/v1'); + expect(result2).toEqual({ type: 'url', url: 'mcp.apify.com/v1' }); + }); + + it('should parse ~/.vscode/mcp.json:filesystem as config', () => { + const result = parseServerArg('~/.vscode/mcp.json:filesystem'); + expect(result).toEqual({ type: 'config', file: '~/.vscode/mcp.json', entry: 'filesystem' }); + }); + + it('should parse ./mcp.json:server as config', () => { + const result = parseServerArg('./mcp.json:server'); + expect(result).toEqual({ type: 'config', file: './mcp.json', entry: 'server' }); + }); + + it('should parse /absolute/path.json:entry as config', () => { + const result = parseServerArg('/absolute/path.json:entry'); + expect(result).toEqual({ type: 'config', file: '/absolute/path.json', entry: 'entry' }); + }); + + it('should parse .yaml extension as config', () => { + const result = parseServerArg('./config.yaml:myserver'); + expect(result).toEqual({ type: 'config', file: './config.yaml', entry: 'myserver' }); }); - it('should find target that looks like a file path', () => { - const result = findTarget(['--config', './config.json', './my-file.txt']); - expect(result).toEqual({ target: './my-file.txt', targetIndex: 2 }); + it('should parse .yml extension as config', () => { + const result = parseServerArg('config.yml:myserver'); + expect(result).toEqual({ type: 'config', file: 'config.yml', entry: 'myserver' }); + }); + + it('should NOT parse hostname:port as config', () => { + // 127.0.0.1:8080 β€” does not look like a file path + const result = parseServerArg('127.0.0.1:8080'); + expect(result).toEqual({ type: 'url', url: '127.0.0.1:8080' }); + + const result2 = parseServerArg('mcp.example.com:8080'); + expect(result2).toEqual({ type: 'url', url: 'mcp.example.com:8080' }); }); - it('should handle session names (@name)', () => { - const result = findTarget(['--json', '@apify']); - expect(result).toEqual({ target: '@apify', targetIndex: 1 }); + it('should NOT parse URL with :// as config', () => { + const result = parseServerArg('https://example.com'); + expect(result).toEqual({ type: 'url', url: 'https://example.com' }); + }); + + it('should return null for colon-only or leading-colon input', () => { + expect(parseServerArg(':')).toBeNull(); + expect(parseServerArg(':entry')).toBeNull(); + }); + + it('should return null for trailing-colon input', () => { + expect(parseServerArg('file:')).toBeNull(); }); }); @@ -83,16 +121,6 @@ describe('extractOptions', () => { expect(result).toEqual({ json: true, verbose: false }); }); - it('should extract --config', () => { - const result = extractOptions(['--config', 'file.json']); - expect(result).toEqual({ json: false, verbose: false, config: 'file.json' }); - }); - - it('should extract --config short form (-c)', () => { - const result = extractOptions(['-c', 'file.json']); - expect(result).toEqual({ json: false, verbose: false, config: 'file.json' }); - }); - it('should extract multiple --header options', () => { const result = extractOptions(['--header', 'Auth: Bearer token', '--header', 'X-Key: value']); expect(result).toEqual({ @@ -120,8 +148,6 @@ describe('extractOptions', () => { const result = extractOptions([ '--json', '--verbose', - '--config', - 'config.json', '--header', 'Auth: token', '--timeout', @@ -130,7 +156,6 @@ describe('extractOptions', () => { expect(result).toEqual({ json: true, verbose: true, - config: 'config.json', headers: ['Auth: token'], timeout: 60, }); @@ -141,11 +166,6 @@ describe('extractOptions', () => { expect(result).toEqual({ json: false, verbose: false }); }); - it('should ignore options without values', () => { - const result = extractOptions(['--config']); - expect(result).toEqual({ json: false, verbose: false }); - }); - it('should handle timeout at end of args', () => { const result = extractOptions(['--json', '--timeout']); expect(result).toEqual({ json: true, verbose: false }); @@ -160,16 +180,4 @@ describe('extractOptions', () => { const result = extractOptions(['--timeout', 'invalid']); expect(result.timeout).toBeNaN(); }); - - it('should handle args with target mixed in', () => { - // Target should be ignored - extractOptions only cares about options - const result = extractOptions(['--json', 'apify', '--config', 'file.json', 'tools-list']); - expect(result).toEqual({ json: true, verbose: false, config: 'file.json' }); - }); - - it('should handle repeated config (last one wins)', () => { - const result = extractOptions(['--config', 'first.json', '--config', 'second.json']); - // Only checks for first occurrence, so first.json wins - expect(result).toEqual({ json: false, verbose: false, config: 'first.json' }); - }); }); diff --git a/test/unit/cli/parser.test.ts b/test/unit/cli/parser.test.ts index 32626c5..0237954 100644 --- a/test/unit/cli/parser.test.ts +++ b/test/unit/cli/parser.test.ts @@ -2,7 +2,13 @@ * Tests for argument parsing utilities */ -import { parseCommandArgs, getVerboseFromEnv, getJsonFromEnv } from '../../../src/cli/parser.js'; +import { + parseCommandArgs, + getVerboseFromEnv, + getJsonFromEnv, + validateOptions, + validateArgValues, +} from '../../../src/cli/parser.js'; import { ClientError } from '../../../src/lib/errors.js'; describe('parseCommandArgs', () => { @@ -313,3 +319,82 @@ describe('getJsonFromEnv', () => { expect(getJsonFromEnv()).toBe(false); }); }); + +describe('validateOptions', () => { + it('should not throw for known global options', () => { + expect(() => validateOptions(['--verbose', '--json'])).not.toThrow(); + expect(() => validateOptions(['--json', '--verbose'])).not.toThrow(); + expect(() => validateOptions(['-j'])).not.toThrow(); + }); + + it('should not throw for known value options with separate values', () => { + expect(() => validateOptions(['--header', 'Authorization: Bearer token'])).not.toThrow(); + expect(() => validateOptions(['--timeout', '30'])).not.toThrow(); + expect(() => validateOptions(['--profile', 'personal'])).not.toThrow(); + }); + + it('should not throw for subcommand-specific options after a command token', () => { + // --scope appears after 'login' command token β€” must not be rejected + expect(() => validateOptions(['login', 'mcp.apify.com', '--scope', 'read'])).not.toThrow(); + // --payment-required, --amount, --expiry for x402 sign + expect(() => + validateOptions(['x402', 'sign', '--payment-required', 'data', '--amount', '1.0']) + ).not.toThrow(); + // -o/--output, --max-size for resources-read + expect(() => + validateOptions(['@session', 'resources-read', 'uri', '-o', 'out.txt', '--max-size', '1024']) + ).not.toThrow(); + }); + + it('should not throw for unknown options that appear after @session (non-option token)', () => { + expect(() => + validateOptions(['--json', '@mysession', '--unknown-subcommand-flag']) + ).not.toThrow(); + }); + + it('should throw for unknown options that appear before any command token', () => { + // No command token at all + expect(() => validateOptions(['--unknown'])).toThrow(ClientError); + expect(() => validateOptions(['--unknown'])).toThrow('Unknown option: --unknown'); + // Unknown option before a command token + expect(() => validateOptions(['--bad-flag', 'login'])).toThrow(ClientError); + expect(() => validateOptions(['--bad-flag', 'login'])).toThrow('Unknown option: --bad-flag'); + }); + + it('should accept empty args array', () => { + expect(() => validateOptions([])).not.toThrow(); + }); +}); + +describe('validateArgValues', () => { + it('should not throw for valid --schema-mode values', () => { + expect(() => validateArgValues(['--schema-mode', 'strict'])).not.toThrow(); + expect(() => validateArgValues(['--schema-mode', 'compatible'])).not.toThrow(); + expect(() => validateArgValues(['--schema-mode', 'ignore'])).not.toThrow(); + }); + + it('should throw for invalid --schema-mode value before command token', () => { + expect(() => validateArgValues(['--schema-mode', 'bad'])).toThrow(ClientError); + expect(() => validateArgValues(['--schema-mode', 'bad'])).toThrow( + 'Invalid --schema-mode value' + ); + }); + + it('should not validate --schema-mode value after command token', () => { + // Even an invalid value is not checked once we are past a command token + expect(() => + validateArgValues(['connect', 'example.com', '--schema-mode', 'bad']) + ).not.toThrow(); + }); + + it('should throw for invalid --timeout value before command token', () => { + expect(() => validateArgValues(['--timeout', 'notanumber'])).toThrow(ClientError); + expect(() => validateArgValues(['--timeout', 'notanumber'])).toThrow('Invalid --timeout value'); + }); + + it('should not validate --timeout after command token', () => { + expect(() => + validateArgValues(['connect', 'example.com', '--timeout', 'notanumber']) + ).not.toThrow(); + }); +});