From b19975ab3121749b40444fbeae4da7ca5d18df50 Mon Sep 17 00:00:00 2001 From: hmishra2250 Date: Mon, 27 Jul 2026 12:21:22 +0530 Subject: [PATCH 1/2] fix(cli): harden init MCP installer --- src/__tests__/commands/init.test.ts | 74 +++++++++++++++++++++++++++++ src/commands/init.ts | 21 +++----- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index d97dc11843..9063bd28dd 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -2,13 +2,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execSync } from 'child_process'; import { handleInitCommand } from '../../commands/init'; +const { installMcpMock, getApiKeyMock, confirmMock, checkboxMock } = vi.hoisted( + () => ({ + installMcpMock: vi.fn(), + getApiKeyMock: vi.fn(), + confirmMock: vi.fn(), + checkboxMock: vi.fn(), + }) +); + vi.mock('child_process', () => ({ execSync: vi.fn(), })); +vi.mock('../../commands/setup', () => ({ + installMcp: installMcpMock, +})); + +vi.mock('../../utils/config', async (importOriginal) => ({ + ...(await importOriginal()), + getApiKey: getApiKeyMock, +})); + +vi.mock('@inquirer/prompts', () => ({ + confirm: confirmMock, + checkbox: checkboxMock, + select: vi.fn(), +})); + describe('handleInitCommand', () => { beforeEach(() => { vi.clearAllMocks(); + getApiKeyMock.mockReturnValue(undefined); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -59,4 +84,53 @@ describe('handleInitCommand', () => { expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) ); }); + + it('routes interactive MCP setup through the hardened hosted installer', async () => { + getApiKeyMock.mockReturnValue('fc-stored-key'); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + checkboxMock.mockResolvedValueOnce(['mcp']); + installMcpMock.mockResolvedValueOnce(undefined); + + await handleInitCommand({ + skipInstall: true, + skipAuth: true, + global: true, + agent: 'codex', + }); + + expect(installMcpMock).toHaveBeenCalledWith({ + global: true, + agent: 'codex', + yes: true, + quiet: true, + }); + expect(execSync).not.toHaveBeenCalledWith( + expect.stringContaining('add-mcp'), + expect.anything() + ); + }); + + it('does not print installer errors that could contain a stored credential', async () => { + getApiKeyMock.mockReturnValue('fc-stored-key'); + confirmMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + checkboxMock.mockResolvedValueOnce(['mcp']); + installMcpMock.mockRejectedValueOnce( + new Error('installer failed for fc-stored-key') + ); + + await handleInitCommand({ skipInstall: true, skipAuth: true }); + + expect(console.error).toHaveBeenCalledWith( + ' Failed to install MCP securely. Run "firecrawl setup mcp" later.' + ); + expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain( + 'fc-stored-key' + ); + }); }); diff --git a/src/commands/init.ts b/src/commands/init.ts index bbc74885ca..45007b3fdc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -25,6 +25,7 @@ import { WEB_AGENTS, type WebAgent, } from '../utils/web-defaults'; +import { installMcp } from './setup'; export interface InitOptions { global?: boolean; @@ -606,25 +607,17 @@ async function stepIntegrations(options: InitOptions): Promise { ); break; } - const args = [ - 'npx', - '-y', - 'add-mcp', - '"npx -y firecrawl-mcp"', - '--name', - 'firecrawl', - ]; - if (options.global) args.push('--global'); - if (options.agent) args.push('--agent', options.agent); try { - execSync(args.join(' '), { - stdio: 'inherit', - env: { ...cleanNpmEnv(), FIRECRAWL_API_KEY: apiKey }, + await installMcp({ + global: options.global, + agent: options.agent, + yes: true, + quiet: true, }); console.log(` ${green}✓${reset} MCP server installed`); } catch { console.error( - ' Failed to install MCP. Run "firecrawl setup mcp" later.' + ' Failed to install MCP securely. Run "firecrawl setup mcp" later.' ); } break; From c28d8ed0a495bace96655b6dc3d8176622025f2b Mon Sep 17 00:00:00 2001 From: hmishra2250 Date: Mon, 27 Jul 2026 13:40:30 +0530 Subject: [PATCH 2/2] fix(cli): keep init MCP setup usable without exposing keys --- src/__tests__/commands/init.test.ts | 3 ++- src/__tests__/commands/setup.test.ts | 30 ++++++++++++++++++++++++++++ src/commands/init.ts | 27 +++++++++++++++---------- src/commands/setup.ts | 16 ++++++++------- 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/__tests__/commands/init.test.ts b/src/__tests__/commands/init.test.ts index 9063bd28dd..a777d17395 100644 --- a/src/__tests__/commands/init.test.ts +++ b/src/__tests__/commands/init.test.ts @@ -106,6 +106,7 @@ describe('handleInitCommand', () => { agent: 'codex', yes: true, quiet: true, + keyless: true, }); expect(execSync).not.toHaveBeenCalledWith( expect.stringContaining('add-mcp'), @@ -127,7 +128,7 @@ describe('handleInitCommand', () => { await handleInitCommand({ skipInstall: true, skipAuth: true }); expect(console.error).toHaveBeenCalledWith( - ' Failed to install MCP securely. Run "firecrawl setup mcp" later.' + ' Failed to install MCP securely: installer failed for [REDACTED]' ); expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain( 'fc-stored-key' diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 128c8d9a82..f49369cecd 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -14,6 +14,7 @@ import path from 'path'; import { handleMakeDefaultCommand, handleSetupCommand, + installMcp, installHermesMcp, installOpenClawMcp, installSkillsForAgent, @@ -205,6 +206,35 @@ describe('handleSetupCommand', () => { ).rejects.toThrow('Export FIRECRAWL_API_KEY'); expect(execFileSync).not.toHaveBeenCalled(); }); + it('can explicitly install keyless MCP without exposing a stored API key', async () => { + await installMcp({ + agent: 'claude-code', + global: true, + yes: true, + keyless: true, + }); + + expect(execFileSync).toHaveBeenCalledWith( + 'npx', + [ + '-y', + 'add-mcp@1.14.0', + 'https://mcp.firecrawl.dev/v2/mcp', + '--name', + 'firecrawl', + '--transport', + 'http', + '--global', + '--agent', + 'claude-code', + '--yes', + ], + expect.objectContaining({ stdio: 'inherit' }) + ); + expect(vi.mocked(execFileSync).mock.calls.flat().join(' ')).not.toContain( + 'fc-test-key' + ); + }); it('normalizes launch aliases for environment-backed MCP setup', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; diff --git a/src/commands/init.ts b/src/commands/init.ts index 45007b3fdc..144bcfd848 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -601,24 +601,29 @@ async function stepIntegrations(options: InitOptions): Promise { case 'mcp': { console.log(`\n Setting up MCP server...`); const apiKey = getApiKey(); - if (!apiKey) { - console.log( - ` ${dim}Skipped — no API key found. Run "firecrawl login" first, then "firecrawl setup mcp".${reset}` - ); - break; - } + const environmentBacked = Boolean( + apiKey && process.env.FIRECRAWL_API_KEY === apiKey + ); try { await installMcp({ global: options.global, - agent: options.agent, + agent: options.agent ?? (environmentBacked ? 'all' : undefined), yes: true, quiet: true, + // Stored credentials must never be persisted into MCP client config. + // Install the anonymous endpoint instead; an environment-backed + // credential continues through the authenticated setup path. + keyless: !environmentBacked, }); console.log(` ${green}✓${reset} MCP server installed`); - } catch { - console.error( - ' Failed to install MCP securely. Run "firecrawl setup mcp" later.' - ); + } catch (error) { + const message = + error instanceof Error + ? apiKey + ? error.message.replaceAll(apiKey, '[REDACTED]') + : error.message + : 'unknown error'; + console.error(` Failed to install MCP securely: ${message}`); } break; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 239121fb13..cd90403aa3 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -51,6 +51,8 @@ export interface SetupOptions { nativeSkills?: boolean; /** Render compact skill install output. */ quiet?: boolean; + /** Configure the anonymous hosted MCP path even when a stored key exists. */ + keyless?: boolean; } const green = '\x1b[32m'; @@ -185,7 +187,7 @@ function isEnvironmentBackedApiKey(apiKey: string | undefined): boolean { return Boolean(apiKey && process.env[ENV_API_KEY] === apiKey); } -function assertSubprocessSafeCredential(apiKey = getApiKey()): void { +function assertSubprocessSafeCredential(apiKey?: string): void { if (apiKey && !isEnvironmentBackedApiKey(apiKey)) { 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.' @@ -210,9 +212,9 @@ function environmentHeaderForAgent(agent?: string): string | undefined { } function firecrawlMcpHeaders( - agent?: string + agent?: string, + apiKey?: string ): Record | undefined { - const apiKey = getApiKey(); if (!apiKey) return undefined; // Keep this helper safe in isolation. Callers currently reject stored keys @@ -527,7 +529,7 @@ export async function installMcp(options: SetupOptions): Promise { throw new Error('Choose either --global or --project, not both.'); } - const apiKey = getApiKey(); + const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); if (resolvedAgent.kind === 'all-launchers' && options.project && apiKey) { throw new Error( @@ -569,7 +571,7 @@ async function installAddMcp( resolvedAgent: Extract ): Promise { const mcpUrl = firecrawlHostedMcpUrl(); - const apiKey = getApiKey(); + const apiKey = options.keyless ? undefined : getApiKey(); if ( resolvedAgent.agent === 'codex' && !options.project && @@ -580,7 +582,7 @@ async function installAddMcp( return; } - const headers = firecrawlMcpHeaders(resolvedAgent.agent); + const headers = firecrawlMcpHeaders(resolvedAgent.agent, apiKey); const useGlobal = !options.project && Boolean(options.global); const args = [ @@ -670,7 +672,7 @@ function firecrawlMcpConfig(agent?: string): { } { return { url: firecrawlHostedMcpUrl(), - headers: firecrawlMcpHeaders(agent), + headers: firecrawlMcpHeaders(agent, getApiKey()), }; }