diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab86ff..9478cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - OAuth callback ports for the hosted CIMD changed from the contiguous range 13316–13325 to 6 non-contiguous ports (13316, 13163, 31316, 31613, 16133, 16313) so one unrelated process is less likely to claim all of them. `localhost` variants dropped from the CIMD's `redirect_uris` in favor of `127.0.0.1` only (per RFC 8252 §8.3, which recommends the IP literal to avoid DNS resolution ambiguity). +- Stdio (command-based) config entries are now skipped by default when connecting from a config file (`mcpc connect `). Pass `--stdio` to include them. Single-entry connects (`mcpc connect file:entry @session`) are not affected. - `restart` success message now notes that previous session state (resource subscriptions, pending notifications, async tasks) was lost, since explicit restart always creates a fresh MCP session - `tasks-list` now shows a hint on how to start a new task (`mcpc @session tools-call [args] --task`) when there are no active tasks - `mcpc help ` now shows a "Did you mean?" suggestion when the command is unknown (e.g., `mcpc help tasks-gfet` → suggests `tasks-get`) diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 142dbec..df31bbf 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -55,7 +55,7 @@ import { getWallet } from '../../lib/wallets.js'; import chalk from 'chalk'; import { createLogger } from '../../lib/logger.js'; import { parseProxyArg } from '../parser.js'; -import { loadConfig, listServers } from '../../lib/config.js'; +import { loadConfig, listServers, isStdioEntry } from '../../lib/config.js'; const logger = createLogger('sessions'); @@ -911,23 +911,69 @@ export async function connectAllFromConfig( noProfile?: boolean; proxy?: string; proxyBearerToken?: string; + stdio?: boolean; x402?: boolean; insecure?: boolean; } ): Promise { const config = loadConfig(configFile); - const serverNames = listServers(config); + const allNames = listServers(config); - if (serverNames.length === 0) { + if (allNames.length === 0) { throw new ClientError(`No servers found in config file: ${configFile}`); } + // Filter out stdio entries unless --stdio is passed. Stdio entries execute + // arbitrary local commands via child_process.spawn(), so bulk-connect + // operations default to skipping them to mitigate supply-chain risk from + // malicious config files. + const stdioSkipped: string[] = []; + const serverNames = allNames.filter((name) => { + if (!options.stdio && isStdioEntry(config, name)) { + stdioSkipped.push(name); + return false; + } + return true; + }); + + if (serverNames.length === 0) { + if (options.outputMode === 'json') { + console.log( + formatOutput( + { + configFile, + results: [], + skipped: stdioSkipped.map((entry) => ({ + entry, + sessionName: generateSessionName({ type: 'config', file: configFile, entry }), + reason: 'stdio', + })), + }, + 'json' + ) + ); + return; + } + throw new ClientError( + `All ${allNames.length} server${allNames.length === 1 ? '' : 's'} in ${configFile} use stdio transport.\n` + + `Pass --stdio to include them: mcpc connect ${configFile} --stdio` + ); + } + if (options.outputMode === 'human') { console.log( chalk.cyan( `Connecting ${serverNames.length} server${serverNames.length === 1 ? '' : 's'} from ${configFile}...` ) ); + if (stdioSkipped.length > 0) { + console.log( + chalk.dim( + ` skipping ${stdioSkipped.length} stdio server${stdioSkipped.length === 1 ? '' : 's'} ` + + `(${stdioSkipped.join(', ')}), pass --stdio to include` + ) + ); + } } // Prepare entries with deterministic session names derived from entry names. @@ -1019,6 +1065,13 @@ export async function connectAllFromConfig( status: r.status, ...(r.error && { error: r.error }), })), + ...(stdioSkipped.length > 0 && { + skipped: stdioSkipped.map((entry) => ({ + entry, + sessionName: generateSessionName({ type: 'config', file: configFile, entry }), + reason: 'stdio', + })), + }), }, 'json' ) diff --git a/src/cli/index.ts b/src/cli/index.ts index b20ac4a..5ca9865 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -441,6 +441,7 @@ Full docs: ${docsUrl}` .option('--no-profile', 'Skip OAuth profile (connect anonymously)') .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') + .option('--stdio', 'Include stdio (command-based) servers when connecting from config files') .option('--x402', 'Enable x402 auto-payment using the configured wallet') .addHelpText( 'after', @@ -462,6 +463,10 @@ ${chalk.bold('Stdio servers:')} servers inherit a minimal env whitelist; forward extras (e.g. NODE_EXTRA_CA_CERTS, HTTPS_PROXY) via the "env" block. Server stderr is logged to ~/.mcpc/logs/bridge-.log. + + Bulk connects (\`mcpc connect \`) skip stdio entries by + default; pass --stdio to include them. Single-entry connects are + unaffected. ${jsonHelp('`InitializeResult` object extended with `toolNames` and `_mcpc` metadata', '`{ protocolVersion, capabilities, serverInfo, instructions?, toolNames?, _mcpc }`', `${SCHEMA_BASE}#initializeresult`)}` ) .action(async (server, sessionName, opts, command) => { @@ -500,6 +505,7 @@ ${jsonHelp('`InitializeResult` object extended with `toolNames` and `_mcpc` meta ...(headers && { headers }), ...(opts.proxy && { proxy: opts.proxy as string }), ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), + ...(opts.stdio && { stdio: true }), ...(opts.x402 && { x402: opts.x402 as boolean }), ...(globalOpts.insecure && { insecure: true }), }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 706e077..939b8a9 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -228,3 +228,11 @@ export function validateServerConfig(config: ServerConfig): boolean { return true; } + +/** + * Check whether a named entry in an MCP config uses the stdio transport + * (i.e. has a `command` field rather than a `url`). + */ +export function isStdioEntry(config: McpConfig, entryName: string): boolean { + return config.mcpServers[entryName]?.command !== undefined; +} diff --git a/test/unit/lib/config.test.ts b/test/unit/lib/config.test.ts index 29a93c5..67b1dd3 100644 --- a/test/unit/lib/config.test.ts +++ b/test/unit/lib/config.test.ts @@ -9,6 +9,7 @@ import { getServerConfig, validateServerConfig, listServers, + isStdioEntry, } from '../../../src/lib/config.js'; import { ClientError } from '../../../src/lib/errors.js'; @@ -170,6 +171,46 @@ describe('validateServerConfig', () => { }); }); +describe('isStdioEntry', () => { + it('returns true for entries with a command field', () => { + const config = { + mcpServers: { + local: { command: 'node', args: ['server.js'] }, + }, + }; + expect(isStdioEntry(config, 'local')).toBe(true); + }); + + it('returns false for entries with a url field', () => { + const config = { + mcpServers: { + remote: { url: 'https://example.com' }, + }, + }; + expect(isStdioEntry(config, 'remote')).toBe(false); + }); + + it('returns false for non-existent entries', () => { + const config = { + mcpServers: { + remote: { url: 'https://example.com' }, + }, + }; + expect(isStdioEntry(config, 'missing')).toBe(false); + }); + + it('correctly distinguishes entries in a mixed config', () => { + const config = { + mcpServers: { + http: { url: 'https://example.com' }, + stdio: { command: 'npx', args: ['-y', 'mcp-server'] }, + }, + }; + expect(isStdioEntry(config, 'http')).toBe(false); + expect(isStdioEntry(config, 'stdio')).toBe(true); + }); +}); + describe('listServers', () => { it('should list all server names', () => { const config = {