Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../../utils/config')>()),
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(() => {});
});
Expand Down Expand Up @@ -59,4 +84,54 @@ 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,
keyless: 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: installer failed for [REDACTED]'
);
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
'fc-stored-key'
);
});
});
30 changes: 30 additions & 0 deletions src/__tests__/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import path from 'path';
import {
handleMakeDefaultCommand,
handleSetupCommand,
installMcp,
installHermesMcp,
installOpenClawMcp,
installSkillsForAgent,
Expand Down Expand Up @@ -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';

Expand Down
44 changes: 21 additions & 23 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
WEB_AGENTS,
type WebAgent,
} from '../utils/web-defaults';
import { installMcp } from './setup';

export interface InitOptions {
global?: boolean;
Expand Down Expand Up @@ -600,32 +601,29 @@ async function stepIntegrations(options: InitOptions): Promise<number | null> {
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 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);
const environmentBacked = Boolean(
apiKey && process.env.FIRECRAWL_API_KEY === apiKey
);
try {
execSync(args.join(' '), {
stdio: 'inherit',
env: { ...cleanNpmEnv(), FIRECRAWL_API_KEY: apiKey },
await installMcp({
global: options.global,
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. 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;
}
Expand Down
16 changes: 9 additions & 7 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.'
Expand All @@ -210,9 +212,9 @@ function environmentHeaderForAgent(agent?: string): string | undefined {
}

function firecrawlMcpHeaders(
agent?: string
agent?: string,
apiKey?: string
): Record<string, string> | undefined {
const apiKey = getApiKey();
if (!apiKey) return undefined;

// Keep this helper safe in isolation. Callers currently reject stored keys
Expand Down Expand Up @@ -527,7 +529,7 @@ export async function installMcp(options: SetupOptions): Promise<void> {
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(
Expand Down Expand Up @@ -569,7 +571,7 @@ async function installAddMcp(
resolvedAgent: Extract<ResolvedMcpAgent, { kind: 'add-mcp' }>
): Promise<void> {
const mcpUrl = firecrawlHostedMcpUrl();
const apiKey = getApiKey();
const apiKey = options.keyless ? undefined : getApiKey();
if (
resolvedAgent.agent === 'codex' &&
!options.project &&
Expand All @@ -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 = [
Expand Down Expand Up @@ -670,7 +672,7 @@ function firecrawlMcpConfig(agent?: string): {
} {
return {
url: firecrawlHostedMcpUrl(),
headers: firecrawlMcpHeaders(agent),
headers: firecrawlMcpHeaders(agent, getApiKey()),
};
}

Expand Down
Loading