From 1b044badd844a3dfb118c2495d9d256b191dd342 Mon Sep 17 00:00:00 2001 From: hmishra2250 Date: Fri, 7 Aug 2026 13:35:59 +0530 Subject: [PATCH 1/2] fix(cli): restore authenticated MCP launch flow --- package.json | 2 +- src/__tests__/cli-argv.test.ts | 23 ++++ src/__tests__/commands/launch.test.ts | 159 ++++++++++++++++++++++++-- src/__tests__/commands/setup.test.ts | 46 ++++++++ src/commands/launch.ts | 52 ++++++--- src/commands/setup.ts | 92 ++++++++++----- src/index.ts | 8 ++ 7 files changed, 326 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 97f956892d..e9cbaf0caa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.19.29", + "version": "1.19.30", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": { diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index 24f168d066..f5080af546 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -34,6 +34,29 @@ describe('CLI argv parsing', () => { expect(result.stderr).not.toContain('unknown command'); }); + testWithBuiltCli( + 'exposes explicit keyless MCP setup and launch flags', + () => { + const setup = spawnSync(process.execPath, [cliPath, 'setup', '--help'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + const launch = spawnSync( + process.execPath, + [cliPath, 'launch', '--help'], + { + cwd: process.cwd(), + encoding: 'utf8', + } + ); + + expect(setup.status).toBe(0); + expect(setup.stdout).toContain('--keyless'); + expect(launch.status).toBe(0); + expect(launch.stdout).toContain('--keyless'); + } + ); + testWithBuiltCli( 'parses subcommands when a wrapper leaves the entry script path in argv', () => { diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index ecfaa448bb..23c03eae2c 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -2,13 +2,9 @@ import { spawnSync } from 'child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { select } from '@inquirer/prompts'; import { handleLaunchCommand } from '../../commands/launch'; -import { - installHermesMcp, - installMcp, - installOpenClawMcp, - installSkillsForAgent, -} from '../../commands/setup'; +import { installMcp, installSkillsForAgent } from '../../commands/setup'; import { ALL_SKILL_REPOS } from '../../commands/skills-install'; +import { getApiKey } from '../../utils/config'; vi.mock('child_process', () => ({ spawnSync: vi.fn(), @@ -19,17 +15,23 @@ vi.mock('@inquirer/prompts', () => ({ })); vi.mock('../../commands/setup', () => ({ - installHermesMcp: vi.fn(async () => undefined), installMcp: vi.fn(async () => undefined), - installOpenClawMcp: vi.fn(async () => undefined), installSkillsForAgent: vi.fn(async () => undefined), })); +vi.mock('../../utils/config', () => ({ + getApiKey: vi.fn(() => undefined), +})); + describe('handleLaunchCommand', () => { const originalIsTty = process.stdin.isTTY; + let originalApiKey: string | undefined; beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getApiKey).mockReturnValue(undefined); + originalApiKey = process.env.FIRECRAWL_API_KEY; + delete process.env.FIRECRAWL_API_KEY; vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, @@ -38,6 +40,8 @@ describe('handleLaunchCommand', () => { }); afterEach(() => { + if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; + else process.env.FIRECRAWL_API_KEY = originalApiKey; Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalIsTty, @@ -110,6 +114,23 @@ describe('handleLaunchCommand', () => { ); }); + it('warns when a GUI client may not inherit a launch-scoped stored key', async () => { + vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + await handleLaunchCommand('code', { skipSkills: true }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'may reuse an existing GUI process that cannot inherit the stored API key' + ) + ); + } finally { + warn.mockRestore(); + } + }); + it('passes extra arguments through to Codex', async () => { await handleLaunchCommand('codex', {}, ['--sandbox', 'workspace-write']); @@ -137,6 +158,47 @@ describe('handleLaunchCommand', () => { ); }); + it('keeps a stored API key indirect while launching an agent with authenticated MCP', async () => { + vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + + await handleLaunchCommand('claude'); + + expect(installMcp).toHaveBeenCalledWith( + { + agent: 'claude-code', + global: true, + yes: true, + quiet: true, + }, + expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' }) + ); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + 'claude', + [], + expect.objectContaining({ + stdio: 'inherit', + env: expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' }), + }) + ); + expect(process.env.FIRECRAWL_API_KEY).toBeUndefined(); + }); + + it('does not pretend a stored API key can persist beyond install-only mode', async () => { + vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + + await handleLaunchCommand('claude', { install: true }); + + expect(installMcp).toHaveBeenCalledWith({ + agent: 'claude-code', + global: true, + yes: true, + quiet: true, + }); + expect(spawnSync).not.toHaveBeenCalled(); + expect(process.env.FIRECRAWL_API_KEY).toBeUndefined(); + }); + it('asks which Codex setup to run and can install MCP only', async () => { const restoreStdin = setStdinTty(true); vi.mocked(select).mockResolvedValue('mcp'); @@ -251,7 +313,12 @@ describe('handleLaunchCommand', () => { it('configures Hermes MCP and skills, then launches Hermes Agent', async () => { await handleLaunchCommand('hermes'); - expect(installHermesMcp).toHaveBeenCalled(); + expect(installMcp).toHaveBeenCalledWith({ + agent: 'hermes', + global: true, + yes: true, + quiet: true, + }); expect(installSkillsForAgent).toHaveBeenCalledWith( 'hermes-agent', { @@ -273,10 +340,39 @@ describe('handleLaunchCommand', () => { ); }); + it('passes a stored API key only through the launched Hermes process environment', async () => { + vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + + await handleLaunchCommand('hermes'); + + expect(installMcp).toHaveBeenCalledWith( + { + agent: 'hermes', + global: true, + yes: true, + quiet: true, + }, + expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' }) + ); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + 'hermes', + [], + expect.objectContaining({ + env: expect.objectContaining({ FIRECRAWL_API_KEY: 'fc-stored-key' }), + }) + ); + }); + it('configures OpenClaw MCP and skills, then launches the TUI', async () => { await handleLaunchCommand('openclaw'); - expect(installOpenClawMcp).toHaveBeenCalled(); + expect(installMcp).toHaveBeenCalledWith({ + agent: 'openclaw', + global: true, + yes: true, + quiet: true, + }); expect(installSkillsForAgent).toHaveBeenCalledWith( 'openclaw', { @@ -301,10 +397,51 @@ describe('handleLaunchCommand', () => { it('can skip skills for Hermes and OpenClaw launch targets', async () => { await handleLaunchCommand('hermes', { skipSkills: true }); - expect(installHermesMcp).toHaveBeenCalled(); + expect(installMcp).toHaveBeenCalledWith({ + agent: 'hermes', + global: true, + yes: true, + quiet: true, + }); expect(installSkillsForAgent).not.toHaveBeenCalled(); }); + it('can explicitly launch keyless without passing a stored API key to the client', async () => { + vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + + await handleLaunchCommand('claude', { + keyless: true, + skipSkills: true, + }); + + expect(installMcp).toHaveBeenCalledWith({ + agent: 'claude-code', + global: true, + yes: true, + quiet: true, + keyless: true, + }); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + 'claude', + [], + expect.objectContaining({ + env: expect.not.objectContaining({ + FIRECRAWL_API_KEY: 'fc-stored-key', + }), + }) + ); + }); + + it('rejects contradictory keyless and skip-MCP options', async () => { + await expect( + handleLaunchCommand('claude', { keyless: true, skipMcp: true }) + ).rejects.toThrow('--keyless cannot be combined with --skip-mcp'); + + expect(installMcp).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + }); + it('requires an explicit target in non-interactive mode', async () => { const restoreStdin = setStdinTty(false); diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index f49369cecd..5dad063bc3 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -235,6 +235,22 @@ describe('handleSetupCommand', () => { 'fc-test-key' ); }); + it('accepts a launch-scoped environment while keeping the stored key out of MCP config and argv', async () => { + await installMcp( + { + agent: 'claude-code', + global: true, + yes: true, + }, + { ...process.env, FIRECRAWL_API_KEY: 'fc-test-key' } + ); + + const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; + expect(args).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); + expect(args?.join(' ')).not.toContain('fc-test-key'); + const subprocessEnv = vi.mocked(execFileSync).mock.calls[0]?.[2]?.env; + expect(subprocessEnv?.FIRECRAWL_API_KEY).toBeUndefined(); + }); it('normalizes launch aliases for environment-backed MCP setup', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; @@ -390,6 +406,27 @@ describe('handleSetupCommand', () => { } }); + it('honors explicit keyless setup for Hermes even when a key is stored', async () => { + const home = mkdtempSync( + path.join(os.tmpdir(), 'firecrawl-hermes-keyless-test-') + ); + process.env.HOME = home; + + try { + await installMcp({ agent: 'hermes', keyless: true }); + + const config = readFileSync( + path.join(home, '.hermes', 'config.yaml'), + 'utf-8' + ); + expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp'); + expect(config).not.toContain('Authorization'); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + it('rejects a stored key before invoking the OpenClaw CLI', async () => { await expect(installOpenClawMcp()).rejects.toThrow( 'Export FIRECRAWL_API_KEY' @@ -406,6 +443,15 @@ describe('handleSetupCommand', () => { expect(config).not.toContain('Bearer fc-test-key'); }); + it('honors explicit keyless setup for OpenClaw even when a key is stored', async () => { + await installMcp({ agent: 'openclaw', keyless: true }); + + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain('https://mcp.firecrawl.dev/v2/mcp'); + expect(config).not.toContain('Authorization'); + expect(config).not.toContain('fc-test-key'); + }); + it('surfaces a sanitized OpenClaw setup failure', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; vi.mocked(execFileSync).mockImplementationOnce(() => { diff --git a/src/commands/launch.ts b/src/commands/launch.ts index ee1f11e511..e0b1bcc00f 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -3,13 +3,9 @@ import os from 'os'; import path from 'path'; import readline from 'readline'; import { spawnSync } from 'child_process'; -import { - installHermesMcp, - installMcp, - installOpenClawMcp, - installSkillsForAgent, -} from './setup'; +import { installMcp, installSkillsForAgent } from './setup'; import { ALL_SKILL_REPOS } from './skills-install'; +import { getApiKey } from '../utils/config'; export interface LaunchOptions { config?: boolean; @@ -19,17 +15,18 @@ export interface LaunchOptions { yes?: boolean; skipMcp?: boolean; skipSkills?: boolean; + keyless?: boolean; } interface LaunchTarget { aliases: string[]; displayName: string; mcpAgent?: string; - mcpInstaller?: () => Promise; skillsAgent?: string; command: string; args?: string[]; supportsExtraArgs?: boolean; + mayReuseGuiProcess?: boolean; fallbackCommand?: () => { command: string; args: string[] } | null; } @@ -55,6 +52,7 @@ const TARGETS: LaunchTarget[] = [ mcpAgent: 'vscode', command: 'code', args: ['.'], + mayReuseGuiProcess: true, fallbackCommand: () => { if (process.platform !== 'darwin') return null; return { @@ -78,6 +76,7 @@ const TARGETS: LaunchTarget[] = [ command: 'open', args: ['-b', 'com.openai.codex'], supportsExtraArgs: false, + mayReuseGuiProcess: true, fallbackCommand: () => { if (process.platform !== 'darwin') return null; return { @@ -96,14 +95,14 @@ const TARGETS: LaunchTarget[] = [ { aliases: ['hermes', 'hermes-agent'], displayName: 'Hermes Agent', - mcpInstaller: installHermesMcp, + mcpAgent: 'hermes', skillsAgent: 'hermes-agent', command: 'hermes', }, { aliases: ['openclaw'], displayName: 'OpenClaw', - mcpInstaller: installOpenClawMcp, + mcpAgent: 'openclaw', skillsAgent: 'openclaw', command: 'openclaw', args: ['tui'], @@ -232,6 +231,10 @@ export async function handleLaunchCommand( options: LaunchOptions = {}, extraArgs: string[] = [] ): Promise { + if (options.keyless && options.skipMcp) { + throw new Error('--keyless cannot be combined with --skip-mcp.'); + } + if (!targetName && extraArgs.length > 0) { throw new Error( 'Extra launch arguments require an explicit launch target.' @@ -245,10 +248,18 @@ export async function handleLaunchCommand( ); } - const targetSupportsMcp = Boolean(target.mcpInstaller || target.mcpAgent); + const targetSupportsMcp = Boolean(target.mcpAgent); const targetSupportsSkills = Boolean(target.skillsAgent); let installMcpForTarget = targetSupportsMcp && !options.skipMcp; let installSkillsForTarget = targetSupportsSkills && !options.skipSkills; + const installOnly = Boolean( + options.config || options.install || options.setup + ); + const apiKey = options.keyless ? undefined : getApiKey(); + const runtimeEnv = + !installOnly && apiKey && process.env.FIRECRAWL_API_KEY !== apiKey + ? { ...process.env, FIRECRAWL_API_KEY: apiKey } + : process.env; if ( installMcpForTarget && @@ -262,15 +273,16 @@ export async function handleLaunchCommand( } if (installMcpForTarget) { - if (target.mcpInstaller) { - await target.mcpInstaller(); - } else if (target.mcpAgent) { - await installMcp({ + if (target.mcpAgent) { + const installOptions = { agent: target.mcpAgent, global: options.global !== false, yes: true, quiet: true, - }); + ...(options.keyless ? { keyless: true } : {}), + }; + if (runtimeEnv === process.env) await installMcp(installOptions); + else await installMcp(installOptions, runtimeEnv); } } @@ -288,15 +300,21 @@ export async function handleLaunchCommand( ); } - if (options.config || options.install || options.setup) { + if (installOnly) { console.log(`${target.displayName} is configured with Firecrawl MCP.`); return; } + if (runtimeEnv !== process.env && target.mayReuseGuiProcess) { + console.warn( + `${target.displayName} may reuse an existing GUI process that cannot inherit the stored API key. If MCP authentication fails, export FIRECRAWL_API_KEY before opening the app.` + ); + } + const launch = resolveLaunchCommand(target, extraArgs); const result = spawnSync(launch.command, launch.args, { stdio: 'inherit', - env: process.env, + env: runtimeEnv, }); if (result.error) { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index cd90403aa3..1ed3a4a0fd 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -183,14 +183,20 @@ function firecrawlHostedMcpUrl(): string { return 'https://mcp.firecrawl.dev/v2/mcp'; } -function isEnvironmentBackedApiKey(apiKey: string | undefined): boolean { - return Boolean(apiKey && process.env[ENV_API_KEY] === apiKey); +function isEnvironmentBackedApiKey( + apiKey: string | undefined, + runtimeEnv: NodeJS.ProcessEnv = process.env +): boolean { + return Boolean(apiKey && runtimeEnv[ENV_API_KEY] === apiKey); } -function assertSubprocessSafeCredential(apiKey?: string): void { - if (apiKey && !isEnvironmentBackedApiKey(apiKey)) { +function assertSubprocessSafeCredential( + apiKey?: string, + runtimeEnv: NodeJS.ProcessEnv = process.env +): void { + if (apiKey && !isEnvironmentBackedApiKey(apiKey, runtimeEnv)) { throw new Error( - 'Secure MCP setup cannot pass a stored API key to this client. Export FIRECRAWL_API_KEY and rerun with a supported --agent, or run keyless setup without a credential.' + 'Secure MCP setup cannot persist a stored API key for future client sessions. Export FIRECRAWL_API_KEY, launch the client through "firecrawl launch ", or configure keyless MCP.' ); } } @@ -213,14 +219,15 @@ function environmentHeaderForAgent(agent?: string): string | undefined { function firecrawlMcpHeaders( agent?: string, - apiKey?: string + apiKey?: string, + runtimeEnv: NodeJS.ProcessEnv = process.env ): Record | undefined { if (!apiKey) return undefined; // Keep this helper safe in isolation. Callers currently reject stored keys // before reaching it, but a future call site must not turn one into a raw // Authorization header in argv or a client configuration file. - assertSubprocessSafeCredential(apiKey); + assertSubprocessSafeCredential(apiKey, runtimeEnv); const environmentHeader = environmentHeaderForAgent(agent); if (environmentHeader) return { Authorization: environmentHeader }; throw new Error( @@ -524,7 +531,13 @@ export async function installSkillsForAgent( ); } -export async function installMcp(options: SetupOptions): Promise { +export async function installMcp( + options: SetupOptions, + // `firecrawl launch` may provide the exact environment inherited by the + // client it starts. This lets MCP config keep an indirect env reference + // without mutating the parent shell or exposing the key to setup commands. + runtimeEnv: NodeJS.ProcessEnv = process.env +): Promise { if (options.global && options.project) { throw new Error('Choose either --global or --project, not both.'); } @@ -536,53 +549,64 @@ export async function installMcp(options: SetupOptions): Promise { 'Authenticated --agent all setup does not support --project because Codex requires a global environment-backed MCP configuration. Choose one --agent for project setup, use --agent all --global, or run keyless setup.' ); } - if (!options.agent && isEnvironmentBackedApiKey(apiKey)) { + if (!options.agent && isEnvironmentBackedApiKey(apiKey, runtimeEnv)) { throw new Error( "Environment-backed MCP setup requires --agent so Firecrawl can use that client's native variable syntax. Choose a supported client or use --agent all; the API key will not be written literally." ); } if (resolvedAgent.kind === 'hermes') { - await installHermesMcp(); + await installHermesMcp(runtimeEnv, options.keyless); return; } - assertSubprocessSafeCredential(apiKey); + assertSubprocessSafeCredential(apiKey, runtimeEnv); if (resolvedAgent.kind === 'openclaw') { - await installOpenClawMcp(); + await installOpenClawMcp(runtimeEnv, options.keyless); return; } if (resolvedAgent.kind === 'all-launchers') { - await installAllMcpLaunchers(options); + await installAllMcpLaunchers(options, runtimeEnv); return; } - await installAddMcp(options, resolvedAgent); + await installAddMcp(options, resolvedAgent, runtimeEnv); } -async function installAllMcpLaunchers(options: SetupOptions): Promise { +async function installAllMcpLaunchers( + options: SetupOptions, + runtimeEnv: NodeJS.ProcessEnv +): Promise { for (const agent of ADD_MCP_LAUNCH_AGENTS) { - await installAddMcp({ ...options, yes: true }, { kind: 'add-mcp', agent }); + await installAddMcp( + { ...options, yes: true }, + { kind: 'add-mcp', agent }, + runtimeEnv + ); } - await installHermesMcp(); - await installOpenClawMcp(); + await installHermesMcp(runtimeEnv, options.keyless); + await installOpenClawMcp(runtimeEnv, options.keyless); } async function installAddMcp( options: SetupOptions, - resolvedAgent: Extract + resolvedAgent: Extract, + runtimeEnv: NodeJS.ProcessEnv ): Promise { const mcpUrl = firecrawlHostedMcpUrl(); const apiKey = options.keyless ? undefined : getApiKey(); + // Codex has no Authorization template in environmentHeaderForAgent. Its + // native bearer-token option is the verified env indirection, so this must + // remain before the generic firecrawlMcpHeaders path. if ( resolvedAgent.agent === 'codex' && !options.project && apiKey && - isEnvironmentBackedApiKey(apiKey) + isEnvironmentBackedApiKey(apiKey, runtimeEnv) ) { installCodexMcpFromEnvironment(options, mcpUrl); return; } - const headers = firecrawlMcpHeaders(resolvedAgent.agent, apiKey); + const headers = firecrawlMcpHeaders(resolvedAgent.agent, apiKey, runtimeEnv); const useGlobal = !options.project && Boolean(options.global); const args = [ @@ -665,19 +689,30 @@ function installCodexMcpFromEnvironment( } } -function firecrawlMcpConfig(agent?: string): { +function firecrawlMcpConfig( + agent?: string, + runtimeEnv: NodeJS.ProcessEnv = process.env, + keyless = false +): { url: string; headers?: Record; transport?: string; } { return { url: firecrawlHostedMcpUrl(), - headers: firecrawlMcpHeaders(agent, getApiKey()), + headers: firecrawlMcpHeaders( + agent, + keyless ? undefined : getApiKey(), + runtimeEnv + ), }; } -export async function installHermesMcp(): Promise { - const config = firecrawlMcpConfig('hermes'); +export async function installHermesMcp( + runtimeEnv: NodeJS.ProcessEnv = process.env, + keyless = false +): Promise { + const config = firecrawlMcpConfig('hermes', runtimeEnv, keyless); const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); mkdirSync(path.dirname(configPath), { recursive: true }); @@ -704,9 +739,12 @@ export async function installHermesMcp(): Promise { console.log(`Hermes Agent MCP configured at ${configPath}.`); } -export async function installOpenClawMcp(): Promise { +export async function installOpenClawMcp( + runtimeEnv: NodeJS.ProcessEnv = process.env, + keyless = false +): Promise { const config = { - ...firecrawlMcpConfig('openclaw'), + ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless), transport: 'streamable-http', }; console.log('Configuring Firecrawl MCP for OpenClaw...\n'); diff --git a/src/index.ts b/src/index.ts index d05eaeaa7e..3340ed0dfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2250,6 +2250,10 @@ program '-y, --yes', 'Skip prompts; for bare setup, install the default skills + MCP bundle' ) + .option( + '--keyless', + 'Configure anonymous hosted MCP even when an API key is stored' + ) .option( '--undo', 'Undo setup defaults by re-enabling native web tools where supported' @@ -2293,6 +2297,10 @@ program .option('--config', 'Alias for --install') .option('--skip-mcp', 'Launch without installing or updating Firecrawl MCP') .option('--skip-skills', 'Launch without installing Firecrawl skills') + .option( + '--keyless', + 'Configure anonymous hosted MCP without an Authorization header' + ) .option( '-g, --global', 'Install Firecrawl MCP globally for the selected agent', From 4a791283bf514ebb7118e97487cb10e84b843343 Mon Sep 17 00:00:00 2001 From: hmishra2250 Date: Fri, 7 Aug 2026 14:21:07 +0530 Subject: [PATCH 2/2] test(cli): cover MCP launch credential modes --- src/__tests__/commands/launch.test.ts | 66 ++++++++++++++++----------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/src/__tests__/commands/launch.test.ts b/src/__tests__/commands/launch.test.ts index 23c03eae2c..600ddcfc71 100644 --- a/src/__tests__/commands/launch.test.ts +++ b/src/__tests__/commands/launch.test.ts @@ -186,8 +186,15 @@ describe('handleLaunchCommand', () => { it('does not pretend a stored API key can persist beyond install-only mode', async () => { vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + vi.mocked(installMcp).mockRejectedValueOnce( + new Error( + 'Secure MCP setup cannot persist a stored API key for future client sessions. Export FIRECRAWL_API_KEY, launch the client through "firecrawl launch ", or configure keyless MCP.' + ) + ); - await handleLaunchCommand('claude', { install: true }); + await expect( + handleLaunchCommand('claude', { install: true }) + ).rejects.toThrow('Export FIRECRAWL_API_KEY'); expect(installMcp).toHaveBeenCalledWith({ agent: 'claude-code', @@ -406,32 +413,39 @@ describe('handleLaunchCommand', () => { expect(installSkillsForAgent).not.toHaveBeenCalled(); }); - it('can explicitly launch keyless without passing a stored API key to the client', async () => { - vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); - - await handleLaunchCommand('claude', { - keyless: true, - skipSkills: true, - }); + it.each([ + ['claude', 'claude-code', 'claude', []], + ['hermes', 'hermes', 'hermes', []], + ['openclaw', 'openclaw', 'openclaw', ['tui']], + ])( + 'can explicitly launch %s keyless without passing a stored API key to the client', + async (target, mcpAgent, command, args) => { + vi.mocked(getApiKey).mockReturnValue('fc-stored-key'); + + await handleLaunchCommand(target, { + keyless: true, + skipSkills: true, + }); - expect(installMcp).toHaveBeenCalledWith({ - agent: 'claude-code', - global: true, - yes: true, - quiet: true, - keyless: true, - }); - expect(spawnSync).toHaveBeenNthCalledWith( - 2, - 'claude', - [], - expect.objectContaining({ - env: expect.not.objectContaining({ - FIRECRAWL_API_KEY: 'fc-stored-key', - }), - }) - ); - }); + expect(installMcp).toHaveBeenCalledWith({ + agent: mcpAgent, + global: true, + yes: true, + quiet: true, + keyless: true, + }); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + command, + args, + expect.objectContaining({ + env: expect.not.objectContaining({ + FIRECRAWL_API_KEY: 'fc-stored-key', + }), + }) + ); + } + ); it('rejects contradictory keyless and skip-MCP options', async () => { await expect(