From f3129449e00a6298e56eca319a09e516ef7f33c3 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Tue, 11 Aug 2026 17:24:01 -0700 Subject: [PATCH 01/19] feat(cli): configure MCP natively across coding agents Replace the subprocess installer in `setup mcp` with a built-in one that detects installed agents, pre-selects them in a picker, and offers to install rules telling those agents to prefer Firecrawl for web search and scraping. Covers Claude Code, Cursor, VS Code, Codex, OpenCode, Windsurf, Zed, Hermes Agent, and OpenClaw through per-agent flags, `--project` for project scope, and `--rules` / `--no-rules` for scripted runs. `-y` stays MCP-only. The two launchers were previously reachable only by flag, so a plain `setup mcp` never offered them; they now sit in the picker alongside the editors, and because a launcher shells out to a CLI, a missing binary is reported against that one agent instead of ending the run. Credential handling is unchanged in principle and stricter in reach: an API key is never written as a literal. Knowing which agents were selected means each one receives a reference to FIRECRAWL_API_KEY in the syntax it expands, so the setup no longer has to refuse a run that omits --agent. Agents with no verified syntax fall back to the keyless endpoint and say so rather than persisting a secret. Config edits are surgical. JSON is patched through a JSONC-aware editor so commented settings files parse at all and keep their comments, and TOML tables are replaced along with any stale sub-tables left by a previous stdio entry. Reruns are byte-identical. Also gives every setup test a throwaway HOME and resets spawn mocks between tests, since MCP setup now writes real config files and would otherwise rewrite the developer's own agent settings; and teaches doctor about the `servers` and `context_servers` keys so those registrations are recognized. --- README.md | 30 +- package.json | 1 + pnpm-lock.yaml | 8 + src/__tests__/commands/setup.test.ts | 653 ++++++++++++++---------- src/__tests__/utils/mcp-install.test.ts | 373 ++++++++++++++ src/commands/setup.ts | 394 +++++++++----- src/index.ts | 28 +- src/utils/agents.ts | 12 +- src/utils/mcp-clients.ts | 446 ++++++++++++++++ src/utils/mcp-install.ts | 339 ++++++++++++ 10 files changed, 1873 insertions(+), 411 deletions(-) create mode 100644 src/__tests__/utils/mcp-install.test.ts create mode 100644 src/utils/mcp-clients.ts create mode 100644 src/utils/mcp-install.ts diff --git a/README.md b/README.md index 6576734427..f50d035429 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,40 @@ firecrawl setup skills firecrawl setup workflows ``` -To install the Firecrawl MCP server into your editors (Cursor, Claude Code, VS Code, etc.): +To install the Firecrawl MCP server into your coding agents: ```bash firecrawl setup mcp ``` +This detects which agents you have installed, pre-selects them in a picker, and +asks whether to add rules telling those agents to prefer Firecrawl for web +search and scraping. Supported agents are Claude Code, Cursor, VS Code, Codex, +OpenCode, Windsurf, Zed, Hermes Agent, and OpenClaw. + +Pass agent flags to skip the picker, `-y` to configure every detected agent +(MCP only), or `--project` to write to the current project instead of your +global agent settings: + +```bash +firecrawl setup mcp --claude --cursor # skip the picker +firecrawl setup mcp -y # every detected agent, MCP only +firecrawl setup mcp -y --rules # ...and install the rules too +firecrawl setup mcp --project --cursor # write project config +``` + +Rerun the command any time to update an existing setup or add another agent; it +edits only the Firecrawl entry and leaves the rest of each config alone. + +Your API key is never written into an agent config. When `FIRECRAWL_API_KEY` is +exported in the environment your agents run under, each agent gets a reference +to that variable in the syntax it understands. Otherwise setup stays keyless, +which still serves search, scrape, and parse under an anonymous rate limit. Use +`--keyless` to force the anonymous path even when a key is available. + +Not every agent supports project-level MCP configuration. Those agents always +receive the global configuration. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/package.json b/package.json index 097a4ee0e5..ef0c8f1ed6 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@inquirer/prompts": "^8.2.1", "commander": "^14.0.2", "firecrawl": "4.24.0", + "jsonc-parser": "3.3.1", "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5126333bf..b056306794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: firecrawl: specifier: 4.24.0 version: 4.24.0 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -752,6 +755,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1631,6 +1637,8 @@ snapshots: isexe@2.0.0: {} + jsonc-parser@3.3.1: {} + lilconfig@3.1.3: {} lint-staged@15.5.2: diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 5dad063bc3..23db1c016b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -22,6 +22,20 @@ import { import { ALL_SKILL_REPOS } from '../../commands/skills-install'; import { configureWebDefaults } from '../../utils/web-defaults'; import { getApiKey } from '../../utils/config'; +import { MCP_CLIENTS, type McpClientId } from '../../utils/mcp-clients'; + +const MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; + +/** Where a given agent's global config lands on this platform. */ +function globalConfigPath(id: McpClientId, home: string): string { + return MCP_CLIENTS[id].globalConfigPath({ + home, + cwd: process.cwd(), + platform: process.platform, + env: process.env, + auth: 'keyless', + }); +} vi.mock('child_process', () => ({ execFileSync: vi.fn(), @@ -39,16 +53,26 @@ vi.mock('../../utils/config', () => ({ describe('handleSetupCommand', () => { let originalHome: string | undefined; let originalApiKey: string | undefined; + let sandboxHome: string; beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks keeps implementations, so a test that makes a spawn throw + // would leak that behaviour into every test after it. + vi.mocked(execFileSync).mockReset(); + vi.mocked(execSync).mockReset(); vi.mocked(getApiKey).mockReturnValue('fc-test-key'); originalHome = process.env.HOME; originalApiKey = process.env.FIRECRAWL_API_KEY; delete process.env.FIRECRAWL_API_KEY; + // MCP setup writes real agent config files, so every test gets a throwaway + // home. Without this a test run would rewrite the developer's own editors. + sandboxHome = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-home-')); + process.env.HOME = sandboxHome; }); afterEach(() => { + rmSync(sandboxHome, { recursive: true, force: true }); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; @@ -123,6 +147,7 @@ describe('handleSetupCommand', () => { it('installs the default setup bundle with --yes', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); await handleSetupCommand(undefined, { yes: true }); @@ -134,21 +159,11 @@ describe('handleSetupCommand', () => { 'npx -y skills add firecrawl/skills --full-depth --global --all --yes', expect.objectContaining({ stdio: 'inherit' }) ); - expect(execFileSync).toHaveBeenCalledWith( - 'npx', - [ - '-y', - 'add-mcp@1.14.0', - 'https://mcp.firecrawl.dev/v2/mcp', - '--name', - 'firecrawl', - '--transport', - 'http', - '--global', - '--yes', - ], - expect.objectContaining({ stdio: 'inherit' }) - ); + expect( + JSON.parse( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl + ).toEqual({ url: MCP_URL }); }); it('requires a subcommand for bare setup in non-interactive mode', async () => { const originalIsTty = process.stdin.isTTY; @@ -196,159 +211,206 @@ describe('handleSetupCommand', () => { }); }); - it('fails closed before spawning when only a stored API key is available', async () => { - await expect( - handleSetupCommand('mcp', { + it('configures keyless when only a stored API key is available', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-stored-')); + process.env.HOME = home; + + try { + await handleSetupCommand('mcp', { agent: 'claude-code', global: true, yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + }); + + // An agent cannot resolve a key that only lives in our credential + // store, so nothing is written rather than persisting a literal. + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(JSON.parse(config).mcpServers.firecrawl).toEqual({ + type: 'http', + url: MCP_URL, + }); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it('can explicitly install keyless MCP without exposing a stored API key', async () => { - await installMcp({ - agent: 'claude-code', - global: true, - yes: true, - keyless: true, - }); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-keyless-')); + process.env.HOME = home; + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - 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('accepts a launch-scoped environment while keeping the stored key out of MCP config and argv', async () => { - await installMcp( - { + try { + await installMcp({ agent: 'claude-code', global: true, yes: true, - }, - { ...process.env, FIRECRAWL_API_KEY: 'fc-test-key' } - ); + keyless: true, + }); - 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(); + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(config).not.toContain('fc-test-key'); + expect(config).not.toContain('Authorization'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('accepts a launch-scoped environment while keeping the key out of MCP config', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-launch-env-')); + process.env.HOME = home; + + try { + await installMcp( + { agent: 'claude-code', global: true, yes: true }, + { ...process.env, FIRECRAWL_API_KEY: 'fc-test-key' } + ); + + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${FIRECRAWL_API_KEY}', + }); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it('normalizes launch aliases for environment-backed MCP setup', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-alias-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - await handleSetupCommand('mcp', { - agent: 'codex-app', - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'codex-app', + global: true, + yes: true, + }); - expect(execFileSync).toHaveBeenCalledWith( - 'codex', - [ - 'mcp', - 'add', - 'firecrawl', - '--url', - 'https://mcp.firecrawl.dev/v2/mcp', - '--bearer-token-env-var', - 'FIRECRAWL_API_KEY', - ], - expect.objectContaining({ stdio: 'inherit' }) - ); + const config = readFileSync( + path.join(home, '.codex', 'config.toml'), + 'utf-8' + ); + expect(config).toContain('[mcp_servers.firecrawl]'); + expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it.each([ - ['claude-code', 'Bearer ${FIRECRAWL_API_KEY}'], - ['vscode', 'Bearer ${env:FIRECRAWL_API_KEY}'], - ['cursor', 'Bearer ${env:FIRECRAWL_API_KEY}'], - ['opencode', 'Bearer {env:FIRECRAWL_API_KEY}'], - ])( + ['claude-code', 'claude', 'mcpServers', 'Bearer ${FIRECRAWL_API_KEY}'], + ['vscode', 'vscode', 'servers', 'Bearer ${env:FIRECRAWL_API_KEY}'], + ['cursor', 'cursor', 'mcpServers', 'Bearer ${env:FIRECRAWL_API_KEY}'], + ['opencode', 'opencode', 'mcp', 'Bearer {env:FIRECRAWL_API_KEY}'], + ] as const)( 'uses the %s environment reference when the API key came from the environment', - async (agent, header) => { + async (agent, id, serversKey, header) => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-envref-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - await handleSetupCommand('mcp', { - agent, - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { agent, global: true, yes: true }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; - expect(args).toContain(`Authorization: ${header}`); - expect(args?.join(' ')).not.toContain('Bearer fc-test-key'); + const config = readFileSync(globalConfigPath(id, home), 'utf-8'); + expect(JSON.parse(config)[serversKey].firecrawl.headers).toEqual({ + Authorization: header, + }); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } } ); it('uses Codex native environment-backed bearer configuration', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-codex-env-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - await handleSetupCommand('mcp', { - agent: 'codex', - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'codex', + global: true, + yes: true, + }); - expect(execFileSync).toHaveBeenCalledWith( - 'codex', - [ - 'mcp', - 'add', - 'firecrawl', - '--url', - 'https://mcp.firecrawl.dev/v2/mcp', - '--bearer-token-env-var', - 'FIRECRAWL_API_KEY', - ], - expect.objectContaining({ stdio: 'inherit' }) - ); - expect(vi.mocked(execFileSync).mock.calls.flat(2).join(' ')).not.toContain( - 'fc-test-key' - ); + const config = readFileSync( + path.join(home, '.codex', 'config.toml'), + 'utf-8' + ); + expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + expect(config).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); it('installs MCP with the keyless hosted Firecrawl URL without credentials', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-nokey-')); + process.env.HOME = home; + + try { + await handleSetupCommand('mcp', { + agent: 'claude-code', + global: true, + yes: true, + }); + + expect( + JSON.parse(readFileSync(path.join(home, '.claude.json'), 'utf-8')) + .mcpServers.firecrawl + ).toEqual({ type: 'http', url: MCP_URL }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('offers launchers in the picker and configures Hermes by flag', async () => { + await handleSetupCommand('mcp', { hermes: true, yes: true } as never); + + expect( + readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('firecrawl:'); + }); + + it('detects an installed launcher so the picker can pre-select it', async () => { + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + const { detectMcpLaunchers } = await import('../../utils/mcp-clients'); + expect( + detectMcpLaunchers({ + home: sandboxHome, + cwd: process.cwd(), + platform: process.platform, + env: { PATH: '' }, + auth: 'keyless', + }) + ).toContain('hermes'); + }); + + it('keeps a failing launcher from taking down the other agents', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + // OpenClaw shells out; a missing binary must stay scoped to OpenClaw. + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); await handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, + cursor: true, + openclaw: true, yes: true, - }); + } as never); - 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( + JSON.parse( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl.url + ).toBe(MCP_URL); }); it('rejects a stored key before writing Hermes MCP config', async () => { @@ -475,6 +537,10 @@ describe('handleSetupCommand', () => { }); it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); + // Make several agents detectable so --agent all has editors to configure. + for (const dir of ['.claude', '.cursor', '.codex']) { + mkdirSync(path.join(home, dir), { recursive: true }); + } process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; @@ -485,22 +551,23 @@ describe('handleSetupCommand', () => { yes: true, }); - const calls = vi.mocked(execFileSync).mock.calls; - const serialized = calls.map((call) => (call[1] as string[]).join(' ')); - expect(serialized).toEqual( - expect.arrayContaining([ - expect.stringContaining('claude-code --yes'), - expect.stringContaining( - 'Authorization: Bearer ${env:FIRECRAWL_API_KEY}' - ), - expect.stringContaining('--bearer-token-env-var FIRECRAWL_API_KEY'), - expect.stringContaining( - 'Authorization: Bearer {env:FIRECRAWL_API_KEY}' - ), - expect.stringContaining('Authorization: Bearer ${FIRECRAWL_API_KEY}'), - ]) + const claude = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + const cursor = readFileSync( + path.join(home, '.cursor', 'mcp.json'), + 'utf-8' + ); + const codex = readFileSync( + path.join(home, '.codex', 'config.toml'), + 'utf-8' ); - expect(calls.flat(2).join(' ')).not.toContain('Bearer fc-test-key'); + expect(JSON.parse(claude).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${FIRECRAWL_API_KEY}', + }); + expect(JSON.parse(cursor).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect(codex).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + expect(`${claude}${cursor}${codex}`).not.toContain('fc-test-key'); expect( readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); @@ -509,28 +576,35 @@ describe('handleSetupCommand', () => { } }); - it('rejects authenticated --agent all project setup before changing any client', async () => { + it('keeps an environment-backed --agent all project setup free of literals', async () => { const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-preflight-') + path.join(os.tmpdir(), 'firecrawl-all-project-env-') ); + mkdirSync(path.join(home, '.cursor'), { recursive: true }); process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-proj-cwd-')); + const originalCwd = process.cwd(); + process.chdir(cwd); try { - await expect( - handleSetupCommand('mcp', { - agent: 'all', - project: true, - yes: true, - }) - ).rejects.toThrow( - 'Authenticated --agent all setup does not support --project' - ); + await handleSetupCommand('mcp', { + agent: 'all', + project: true, + yes: true, + }); - expect(execFileSync).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); - expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(false); + const config = readFileSync( + path.join(cwd, '.cursor', 'mcp.json'), + 'utf-8' + ); + expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect(config).not.toContain('fc-test-key'); } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); } }); @@ -549,32 +623,45 @@ describe('handleSetupCommand', () => { yes: true, }); - const addMcpCalls = vi - .mocked(execFileSync) - .mock.calls.filter(([, args]) => - (args as string[])?.includes('add-mcp@1.14.0') - ); - expect(addMcpCalls).toHaveLength(5); - expect(addMcpCalls.flat(2)).not.toContain('--global'); expect( readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') - ).toContain('https://mcp.firecrawl.dev/v2/mcp'); + ).toContain(MCP_URL); } finally { rmSync(home, { recursive: true, force: true }); } }); - it('requires a client selection for no-agent environment-backed setup', async () => { + it('configures every detected agent when no --agent is given', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-noagent-')); + mkdirSync(path.join(home, '.cursor'), { recursive: true }); + mkdirSync(path.join(home, '.claude'), { recursive: true }); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - await expect( - handleSetupCommand('mcp', { global: true, yes: true }) - ).rejects.toThrow('requires --agent'); - expect(execFileSync).not.toHaveBeenCalled(); + try { + // Knowing each selected agent means each gets its own native syntax, + // so no explicit --agent is required. + await handleSetupCommand('mcp', { global: true, yes: true }); + + expect( + JSON.parse( + readFileSync(path.join(home, '.cursor', 'mcp.json'), 'utf-8') + ).mcpServers.firecrawl.headers + ).toEqual({ Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}' }); + expect( + JSON.parse(readFileSync(path.join(home, '.claude.json'), 'utf-8')) + .mcpServers.firecrawl.headers + ).toEqual({ Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); - it('rejects an environment-backed key for an unknown client instead of persisting it', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + it.each([ + ['an environment-backed key', true], + ['a stored key', false], + ])('rejects an unknown client with %s', async (_label, fromEnv) => { + if (fromEnv) process.env.FIRECRAWL_API_KEY = 'fc-test-key'; await expect( handleSetupCommand('mcp', { @@ -582,45 +669,54 @@ describe('handleSetupCommand', () => { global: true, yes: true, }) - ).rejects.toThrow('does not have a verified environment-variable syntax'); + ).rejects.toThrow('Unknown agent'); expect(execFileSync).not.toHaveBeenCalled(); }); - it('rejects a stored key for an unknown client before spawning', async () => { - await expect( - handleSetupCommand('mcp', { - agent: 'future-client', - global: true, - yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - it('never includes environment-backed credentials in generated URLs or normal output', async () => { + it('never includes environment-backed credentials in config or normal output', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-no-leak-')); + process.env.HOME = home; process.env.FIRECRAWL_API_KEY = 'fc-test-key'; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - await handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'claude-code', + global: true, + yes: true, + }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; - expect(args).toContain('https://mcp.firecrawl.dev/v2/mcp'); - expect(args?.join(' ')).not.toContain('fc-test-key'); - expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(config).toContain(MCP_URL); + expect(config).not.toContain('fc-test-key'); + expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); - it('never places a stored API key in subprocess argv', async () => { - await expect( - handleSetupCommand('mcp', { + + it('never places a stored API key in config or argv', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-stored-argv-')); + process.env.HOME = home; + + try { + await handleSetupCommand('mcp', { agent: 'claude-code', global: true, yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + }); + + expect( + readFileSync(path.join(home, '.claude.json'), 'utf-8') + ).not.toContain('fc-test-key'); + expect( + vi.mocked(execFileSync).mock.calls.flat(2).join(' ') + ).not.toContain('fc-test-key'); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + it('does not print a stored OpenClaw credential when setup is rejected', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -630,27 +726,36 @@ describe('handleSetupCommand', () => { expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); }); - it('rejects stored credentials containing hostile characters without spawning or printing them', async () => { + + it('never persists or prints stored credentials containing hostile characters', async () => { const hostileKey = 'fc-$(touch /tmp/firecrawl-pwned)`echo bad`"\\n$HOME'; vi.mocked(getApiKey).mockReturnValue(hostileKey); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hostile-')); + process.env.HOME = home; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); const error = vi .spyOn(console, 'error') .mockImplementation(() => undefined); - await expect( - handleSetupCommand('mcp', { + try { + await handleSetupCommand('mcp', { agent: 'claude-code', global: true, yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); + }); - expect(execFileSync).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); - expect(log.mock.calls.flat().join(' ')).not.toContain(hostileKey); - expect(error.mock.calls.flat().join(' ')).not.toContain(hostileKey); + expect( + readFileSync(path.join(home, '.claude.json'), 'utf-8') + ).not.toContain(hostileKey); + expect(execFileSync).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join(' ')).not.toContain(hostileKey); + expect(error.mock.calls.flat().join(' ')).not.toContain(hostileKey); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); + // --- Scope: project and global are mutually exclusive --- it('rejects conflicting MCP scope flags', async () => { @@ -666,70 +771,78 @@ describe('handleSetupCommand', () => { it('keeps project scope for an environment-backed credential', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-env-')); + const originalCwd = process.cwd(); + process.chdir(cwd); - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'cursor', + project: true, + yes: true, + }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1] as string[]; - expect(args).toContain('Authorization: Bearer ${env:FIRECRAWL_API_KEY}'); - expect(args).not.toContain('--global'); - expect(args.join(' ')).not.toContain('Bearer fc-test-key'); + const config = readFileSync( + path.join(cwd, '.cursor', 'mcp.json'), + 'utf-8' + ); + expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect(config).not.toContain('fc-test-key'); + } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + } }); - it('does not force global MCP scope in the default bundle when --project is set', async () => { + it('writes project scope rather than global when --project is set', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-home-')); + process.env.HOME = home; + const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-cwd-')); + const originalCwd = process.cwd(); + process.chdir(cwd); - await handleSetupCommand(undefined, { - agent: 'cursor', - project: true, - yes: true, - }); - - const mcpCall = vi - .mocked(execFileSync) - .mock.calls.find(([command]) => command === 'npx'); - expect(mcpCall?.[1]).not.toContain('--global'); - }); - - it('does not force global when using an environment reference (no raw key in header)', async () => { - // Env-backed cursor uses ${env:FIRECRAWL_API_KEY}, not the literal secret, - // so project scope is safe and must not be silently overridden. - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - await handleSetupCommand('mcp', { - agent: 'cursor', - yes: true, - }); + try { + await handleSetupCommand('mcp', { + agent: 'cursor', + project: true, + yes: true, + }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1] as string[]; - expect(args.join(' ')).not.toContain('Bearer fc-test-key'); - expect(args).toContain('Authorization: Bearer ${env:FIRECRAWL_API_KEY}'); - expect(args).not.toContain('--global'); + expect(existsSync(path.join(cwd, '.cursor', 'mcp.json'))).toBe(true); + expect(existsSync(path.join(home, '.cursor', 'mcp.json'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } }); - it('does not force global for the keyless (unauthenticated) setup', async () => { + it('defaults to global scope without --project', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-global-')); + process.env.HOME = home; - await handleSetupCommand('mcp', { - agent: 'claude-code', - yes: true, - }); + try { + await handleSetupCommand('mcp', { agent: 'claude-code', yes: true }); - const args = vi.mocked(execFileSync).mock.calls[0]?.[1] as string[]; - expect(args.join(' ')).not.toContain('--header'); - expect(args).not.toContain('--global'); + const config = readFileSync(path.join(home, '.claude.json'), 'utf-8'); + expect(config).not.toContain('Authorization'); + expect(config).toContain(MCP_URL); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); // --- Windows: launch .cmd/.exe shims correctly (execFileSync cannot) --- - it('launches the npx.cmd shim via the shell on win32 with cmd-escaped args', async () => { + it('launches a .cmd shim via the shell on win32 with cmd-escaped args', async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-')); const bin = path.join(root, 'Program Files', 'nodejs'); mkdirSync(bin, { recursive: true }); - writeFileSync(path.join(bin, 'npx.CMD'), '@exit /b 0\r\n'); + writeFileSync(path.join(bin, 'openclaw.CMD'), '@exit /b 0\r\n'); const originalPlatform = Object.getOwnPropertyDescriptor( process, 'platform' @@ -748,7 +861,7 @@ describe('handleSetupCommand', () => { try { await handleSetupCommand('mcp', { - agent: 'claude-code', + agent: 'openclaw', global: true, yes: true, }); @@ -761,11 +874,11 @@ describe('handleSetupCommand', () => { expect(command).toBe('cmd.exe'); expect(passthruArgs.slice(0, 3)).toEqual(['/d', '/s', '/c']); expect(opts?.windowsVerbatimArguments).toBe(true); - expect(passthruArgs[3]).toContain(`^\"${path.join(bin, 'npx.CMD')}^\"`); - expect(passthruArgs[3]).toContain('add-mcp@1.14.0'); expect(passthruArgs[3]).toContain( - '^"Authorization: Bearer ${FIRECRAWL_API_KEY}^"' + `^\"${path.join(bin, 'openclaw.CMD')}^\"` ); + expect(passthruArgs[3]).toContain('Bearer ${FIRECRAWL_API_KEY}'); + expect(passthruArgs[3]).not.toContain('fc-test-key'); } finally { if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); @@ -779,10 +892,10 @@ describe('handleSetupCommand', () => { } }); - it('launches a native Codex executable directly on win32', async () => { + it('launches a native executable directly on win32', async () => { const bin = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-bin-')); - const codexExe = path.join(bin, 'codex.EXE'); - writeFileSync(codexExe, ''); + const openclawExe = path.join(bin, 'openclaw.EXE'); + writeFileSync(openclawExe, ''); const originalPlatform = Object.getOwnPropertyDescriptor( process, 'platform' @@ -799,7 +912,7 @@ describe('handleSetupCommand', () => { try { await handleSetupCommand('mcp', { - agent: 'codex', + agent: 'openclaw', global: true, yes: true, }); @@ -808,8 +921,8 @@ describe('handleSetupCommand', () => { const command = call?.[0] as string; const args = call?.[1] as string[]; const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; - expect(command).toBe(codexExe); - expect(args).toContain('--bearer-token-env-var'); + expect(command).toBe(openclawExe); + expect(args.join(' ')).toContain('Bearer ${FIRECRAWL_API_KEY}'); expect(opts?.windowsVerbatimArguments).toBeUndefined(); } finally { if (originalPlatform) @@ -824,15 +937,15 @@ describe('handleSetupCommand', () => { it('still spawns bare argv with no shell on non-win32', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - // Sanity: the pre-existing POSIX path is unchanged (argv-safe, no shell). + // Sanity: the POSIX path stays argv-safe with no shell interpolation. await handleSetupCommand('mcp', { - agent: 'claude-code', + agent: 'openclaw', global: true, yes: true, }); const call = vi.mocked(execFileSync).mock.calls[0]; - expect(call?.[0]).toBe('npx'); + expect(call?.[0]).toBe('openclaw'); expect( Array.isArray(call?.[1]) && (call?.[1] as string[]).length ).toBeGreaterThan(0); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts new file mode 100644 index 0000000000..bb4c1e064c --- /dev/null +++ b/src/__tests__/utils/mcp-install.test.ts @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import os from 'os'; +import path from 'path'; +import { + detectMcpClients, + resolveMcpClientId, + type McpContext, +} from '../../utils/mcp-clients'; +import { + appendRuleSection, + setupMcpClient, + upsertTomlServer, + writeJsonServerEntry, +} from '../../utils/mcp-install'; + +const MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; + +describe('mcp install', () => { + let root: string; + let ctx: McpContext; + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-mcp-engine-')); + mkdirSync(path.join(root, 'proj'), { recursive: true }); + ctx = { + home: path.join(root, 'home'), + cwd: path.join(root, 'proj'), + platform: 'darwin', + env: {}, + auth: 'keyless', + }; + mkdirSync(ctx.home, { recursive: true }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const read = (...parts: string[]) => + readFileSync(path.join(...parts), 'utf-8'); + + describe('writeJsonServerEntry', () => { + it('creates the file and its parent directory when missing', async () => { + const file = path.join(root, 'nested', 'mcp.json'); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + expect(status).toBe('configured'); + expect(JSON.parse(read(file))).toEqual({ + mcpServers: { fc: { url: MCP_URL } }, + }); + }); + + it('preserves comments and unrelated keys in an existing JSONC config', async () => { + const file = path.join(root, 'settings.json'); + writeFileSync( + file, + [ + '// Zed settings', + '{', + ' "theme": "One Dark",', + ' // keep me', + ' "buffer_font_size": 15,', + ' "context_servers": { "other": { "url": "https://example.com" } }', + '}', + '', + ].join('\n') + ); + + await writeJsonServerEntry(file, 'context_servers', 'fc', { + url: MCP_URL, + }); + + const result = read(file); + expect(result).toContain('// Zed settings'); + expect(result).toContain('// keep me'); + expect(result).toContain('"theme": "One Dark"'); + expect(result).toContain('"other"'); + expect(result).toContain(MCP_URL); + }); + + it('reports reconfigured when the server is already present', async () => { + const file = path.join(root, 'mcp.json'); + writeFileSync( + file, + JSON.stringify({ mcpServers: { fc: { url: 'https://old' } } }) + ); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + expect(status).toBe('reconfigured'); + expect(JSON.parse(read(file)).mcpServers.fc.url).toBe(MCP_URL); + }); + + it('replaces the servers key when it holds a non-object', async () => { + const file = path.join(root, 'mcp.json'); + writeFileSync(file, JSON.stringify({ mcpServers: 'nonsense' })); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + expect(status).toBe('configured'); + expect(JSON.parse(read(file)).mcpServers.fc.url).toBe(MCP_URL); + }); + + it('refuses to overwrite a config it cannot parse', async () => { + const file = path.join(root, 'mcp.json'); + const broken = '{ "mcpServers": { oops\n'; + writeFileSync(file, broken); + + await expect( + writeJsonServerEntry(file, 'mcpServers', 'fc', { url: MCP_URL }) + ).rejects.toThrow('could not parse existing config'); + expect(read(file)).toBe(broken); + }); + }); + + describe('upsertTomlServer', () => { + it('appends after root keys when the server is absent', () => { + const { content, alreadyExists } = upsertTomlServer( + 'model = "gpt-5"\n', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(content).toBe( + `model = "gpt-5"\n\n[mcp_servers.firecrawl]\nurl = "${MCP_URL}"\n` + ); + }); + + it('replaces a stale stdio entry along with its sub-tables', () => { + const existing = [ + 'model = "gpt-5"', + '', + '[mcp_servers.firecrawl]', + 'command = "npx"', + 'args = ["-y", "firecrawl-mcp"]', + '', + '[mcp_servers.firecrawl.env]', + 'FIRECRAWL_API_KEY = "fc-old"', + '', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(true); + expect(content).not.toContain('firecrawl-mcp'); + expect(content).not.toContain('fc-old'); + expect(content).not.toContain('mcp_servers.firecrawl.env'); + expect(content).toContain('[mcp_servers.other]'); + expect(content).toContain('model = "gpt-5"'); + expect(content).toContain(`url = "${MCP_URL}"`); + }); + + it('is stable across repeated writes', () => { + const first = upsertTomlServer('', 'firecrawl', { url: MCP_URL }).content; + const second = upsertTomlServer(first, 'firecrawl', { + url: MCP_URL, + }).content; + + expect(second).toBe(first); + }); + }); + + describe('appendRuleSection', () => { + it('keeps existing content and replaces only the fenced section', async () => { + const file = path.join(root, 'AGENTS.md'); + writeFileSync(file, '# My project\n\nRun tests with pnpm test.\n'); + + expect(await appendRuleSection(file, 'first\n')).toBe('installed'); + expect(await appendRuleSection(file, 'second\n')).toBe('updated'); + + const result = read(file); + expect(result).toContain('# My project'); + expect(result).toContain('Run tests with pnpm test.'); + expect(result).toContain('second'); + expect(result).not.toContain('first'); + expect(result.match(//g)).toHaveLength(2); + }); + }); + + describe('setupMcpClient', () => { + it('writes the keyless URL with no credentials', async () => { + const result = await setupMcpClient('cursor', { + scope: 'global', + rules: false, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('skipped'); + expect( + JSON.parse(read(ctx.home, '.cursor', 'mcp.json')).mcpServers.firecrawl + ).toEqual({ url: MCP_URL }); + }); + + it('references the env var instead of writing a credential', async () => { + const result = await setupMcpClient('claude', { + scope: 'global', + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.auth).toBe('env'); + expect( + JSON.parse(read(ctx.home, '.claude.json')).mcpServers.firecrawl + ).toEqual({ + type: 'http', + url: MCP_URL, + headers: { Authorization: 'Bearer ${FIRECRAWL_API_KEY}' }, + }); + }); + + it('uses the environment-reference syntax each agent expands', async () => { + const written: Record = {}; + for (const id of ['cursor', 'vscode', 'opencode'] as const) { + const result = await setupMcpClient(id, { + scope: 'global', + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + written[id] = JSON.parse(read(result.mcpDetail)); + } + + expect((written.cursor as any).mcpServers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect((written.vscode as any).servers.firecrawl.headers).toEqual({ + Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', + }); + expect((written.opencode as any).mcp.firecrawl.headers).toEqual({ + Authorization: 'Bearer {env:FIRECRAWL_API_KEY}', + }); + }); + + it('authenticates Codex through its native bearer token variable', async () => { + await setupMcpClient('codex', { + scope: 'global', + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + + const config = read(ctx.home, '.codex', 'config.toml'); + expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); + }); + + it('falls back to keyless for agents that cannot expand variables', async () => { + for (const id of ['zed', 'windsurf'] as const) { + const result = await setupMcpClient(id, { + scope: 'global', + rules: false, + ctx: { ...ctx, auth: 'env' }, + }); + + expect(result.auth).toBe('keyless'); + expect(read(result.mcpDetail)).not.toContain('Authorization'); + } + }); + + it('honours CLAUDE_CONFIG_DIR', async () => { + const configDir = path.join(root, 'claude-config'); + + const result = await setupMcpClient('claude', { + scope: 'global', + rules: true, + ctx: { ...ctx, env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + + expect(result.mcpDetail).toBe(path.join(configDir, '.claude.json')); + expect(result.ruleDetail).toBe( + path.join(configDir, 'rules', 'firecrawl.md') + ); + }); + + it('falls back to global config for agents without project support', async () => { + const result = await setupMcpClient('windsurf', { + scope: 'project', + rules: true, + ctx, + }); + + // MCP is global-only for Windsurf; the rule still lands in the project. + expect(result.mcpDetail).toBe( + path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json') + ); + expect(result.ruleDetail).toBe( + path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md') + ); + }); + + it('marks rules unsupported for agents without a rules mechanism', async () => { + const result = await setupMcpClient('zed', { + scope: 'global', + rules: true, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('unsupported'); + }); + + it('still configures MCP when the rule write fails', async () => { + // A file where the rules directory needs to be blocks the rule write. + const rulesPath = path.join(ctx.home, '.cursor', 'rules'); + mkdirSync(path.dirname(rulesPath), { recursive: true }); + writeFileSync(rulesPath, 'not a directory'); + + const result = await setupMcpClient('cursor', { + scope: 'global', + rules: true, + ctx, + }); + + expect(result.mcpStatus).toBe('configured'); + expect(result.ruleStatus).toBe('failed'); + }); + + it('reports failure without touching an unparseable config', async () => { + const file = path.join(ctx.home, '.cursor', 'mcp.json'); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, '{ oops'); + + const result = await setupMcpClient('cursor', { + scope: 'global', + rules: false, + ctx, + }); + + expect(result.mcpStatus).toBe('failed'); + expect(result.mcpDetail).toContain('could not parse'); + expect(read(file)).toBe('{ oops'); + }); + }); + + describe('detectMcpClients', () => { + it('reports only agents present on disk', async () => { + mkdirSync(path.join(ctx.home, '.cursor'), { recursive: true }); + mkdirSync(path.join(ctx.home, '.codex'), { recursive: true }); + + expect(await detectMcpClients(ctx)).toEqual(['cursor', 'codex']); + }); + }); + + describe('resolveMcpClientId', () => { + it('accepts the aliases used by launch targets', () => { + expect(resolveMcpClientId('claude-code')).toBe('claude'); + expect(resolveMcpClientId('Codex-App')).toBe('codex'); + expect(resolveMcpClientId('vs-code')).toBe('vscode'); + expect(resolveMcpClientId('nope')).toBeUndefined(); + }); + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 1ed3a4a0fd..25f23f9f60 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -28,13 +28,28 @@ import { WEB_AGENTS, type WebAgent, } from '../utils/web-defaults'; +import { + ALL_MCP_LAUNCHER_IDS, + ALL_MCP_TARGET_IDS, + detectMcpClients, + detectMcpLaunchers, + isMcpLauncherId, + mcpTargetName, + resolveMcpClientId, + type McpAuthMode, + type McpContext, + type McpLauncherId, + type McpScope, + type McpTargetId, +} from '../utils/mcp-clients'; +import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = - | { kind: 'add-mcp'; agent?: string; all?: boolean } + | { kind: 'clients'; ids?: McpTargetId[] } | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -53,20 +68,18 @@ export interface SetupOptions { quiet?: boolean; /** Configure the anonymous hosted MCP path even when a stored key exists. */ keyless?: boolean; + /** Agents chosen by flag (`--claude`, `--cursor`, ...); skips the picker. */ + clients?: McpTargetId[]; + /** Force the Firecrawl web rules on or off instead of prompting. */ + rules?: boolean; } const green = '\x1b[32m'; +const red = '\x1b[31m'; +const bold = '\x1b[1m'; const dim = '\x1b[2m'; const reset = '\x1b[0m'; -const ADD_MCP_PACKAGE = 'add-mcp@1.14.0'; const ENV_API_KEY = 'FIRECRAWL_API_KEY'; -const ADD_MCP_LAUNCH_AGENTS = [ - 'claude-code', - 'vscode', - 'codex', - 'opencode', - 'cursor', -] as const; const SKILL_REPO_LABELS: Record = { 'firecrawl/cli': 'Core CLI skills', @@ -236,7 +249,7 @@ function firecrawlMcpHeaders( } function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { - if (!agent) return { kind: 'add-mcp' }; + if (!agent) return { kind: 'clients' }; const normalized = agent.trim().toLowerCase(); switch (normalized) { @@ -245,28 +258,20 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { case 'launchers': case 'launcher': return { kind: 'all-launchers' }; - case 'claude': - case 'claude-code': - return { kind: 'add-mcp', agent: 'claude-code' }; - case 'code': - case 'vscode': - case 'vs-code': - return { kind: 'add-mcp', agent: 'vscode' }; - case 'codex': - case 'codex-app': - case 'codex-desktop': - case 'codex-gui': - return { kind: 'add-mcp', agent: 'codex' }; - case 'opencode': - case 'open-code': - return { kind: 'add-mcp', agent: 'opencode' }; case 'hermes': case 'hermes-agent': return { kind: 'hermes' }; case 'openclaw': return { kind: 'openclaw' }; - default: - return { kind: 'add-mcp', agent }; + default: { + const id = resolveMcpClientId(normalized); + if (!id) { + throw new Error( + `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` + ); + } + return { kind: 'clients', ids: [id] }; + } } } @@ -544,148 +549,263 @@ export async function installMcp( const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); - if (resolvedAgent.kind === 'all-launchers' && options.project && apiKey) { - throw new Error( - '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, 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(runtimeEnv, options.keyless); return; } - assertSubprocessSafeCredential(apiKey, runtimeEnv); if (resolvedAgent.kind === 'openclaw') { + // Hands the credential to a subprocess, so a stored key is not usable. + assertSubprocessSafeCredential(apiKey, runtimeEnv); await installOpenClawMcp(runtimeEnv, options.keyless); return; } if (resolvedAgent.kind === 'all-launchers') { - await installAllMcpLaunchers(options, runtimeEnv); + // Fails closed before touching anything: this path reaches launchers that + // hand the credential to a subprocess. + assertSubprocessSafeCredential(apiKey, runtimeEnv); + await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { + includeAllLaunchers: true, + }); return; } - await installAddMcp(options, resolvedAgent, runtimeEnv); + await installMcpClients(options, runtimeEnv, resolvedAgent.ids); } -async function installAllMcpLaunchers( - options: SetupOptions, +/** Shorten a path for display: relative inside the project, `~` under home. */ +function displayPath(target: string, ctx: McpContext): string { + const relative = path.relative(ctx.cwd, target); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + if (target === ctx.home) return '~'; + return target.startsWith(ctx.home + path.sep) + ? path.join('~', path.relative(ctx.home, target)) + : target; +} + +async function pickMcpClients( + detected: readonly McpTargetId[] +): Promise { + const { checkbox } = await import('@inquirer/prompts'); + return checkbox({ + message: 'Which agents do you want to set up?', + loop: false, + choices: ALL_MCP_TARGET_IDS.map((id) => ({ + name: mcpTargetName(id), + value: id, + checked: detected.includes(id), + })), + }); +} + +/** + * Launchers own their MCP configuration, so they are installed through their + * own routine instead of a config write. Failures stay scoped to the one + * launcher: a missing binary must not cost the user the agents that worked. + */ +async function setupMcpLauncher( + id: McpLauncherId, + ctx: McpContext, runtimeEnv: NodeJS.ProcessEnv -): Promise { - for (const agent of ADD_MCP_LAUNCH_AGENTS) { - await installAddMcp( - { ...options, yes: true }, - { kind: 'add-mcp', agent }, - runtimeEnv - ); +): Promise { + const keyless = ctx.auth !== 'env'; + const result: McpClientResult = { + id, + name: mcpTargetName(id), + mcpStatus: 'failed', + mcpDetail: '', + auth: keyless ? 'keyless' : 'env', + ruleStatus: 'unsupported', + ruleDetail: '', + }; + + try { + if (id === 'hermes') { + await installHermesMcp(runtimeEnv, keyless, true); + result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); + } else { + await installOpenClawMcp(runtimeEnv, keyless, true); + result.mcpDetail = 'via the openclaw CLI'; + } + result.mcpStatus = 'configured'; + } catch (error) { + result.mcpDetail = error instanceof Error ? error.message : String(error); } - await installHermesMcp(runtimeEnv, options.keyless); - await installOpenClawMcp(runtimeEnv, options.keyless); + return result; +} + +async function confirmMcpRules(): Promise { + const { confirm } = await import('@inquirer/prompts'); + return confirm({ + message: + 'Add rules so agents prefer Firecrawl for web search and scraping?', + default: true, + }); } -async function installAddMcp( +async function installMcpClients( options: SetupOptions, - resolvedAgent: Extract, - runtimeEnv: NodeJS.ProcessEnv + runtimeEnv: NodeJS.ProcessEnv, + explicitIds?: McpTargetId[], + { includeAllLaunchers = false } = {} ): 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, runtimeEnv) - ) { - installCodexMcpFromEnvironment(options, mcpUrl); - return; + // A stored key cannot be written into agent config, so authenticated setup + // requires the variable to be exported where the agent will read it. + const auth: McpAuthMode = isEnvironmentBackedApiKey(apiKey, runtimeEnv) + ? 'env' + : 'keyless'; + + const ctx: McpContext = { + // Resolved so path comparisons hold even for an unnormalized HOME. + home: path.resolve(os.homedir()), + cwd: path.resolve(process.cwd()), + platform: process.platform, + env: runtimeEnv, + auth, + }; + const scope: McpScope = options.project ? 'project' : 'global'; + // Prompts only make sense when someone is there to answer them. + const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; + + let selected = explicitIds ?? options.clients; + if (!selected || selected.length === 0) { + const detected: McpTargetId[] = [ + ...(await detectMcpClients(ctx)), + ...detectMcpLaunchers(ctx), + ]; + if (nonInteractive) { + if (detected.length === 0 && !includeAllLaunchers) { + throw new Error( + 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' + ); + } + selected = detected; + } else { + selected = await pickMcpClients(detected); + if (selected.length === 0) { + console.log('No agents selected. Nothing changed.'); + return; + } + } } - const headers = firecrawlMcpHeaders(resolvedAgent.agent, apiKey, runtimeEnv); - const useGlobal = !options.project && Boolean(options.global); - - const args = [ - '-y', - ADD_MCP_PACKAGE, - mcpUrl, - '--name', - 'firecrawl', - '--transport', - 'http', - ]; - - if (headers?.Authorization) { - args.push('--header', `Authorization: ${headers.Authorization}`); + // `--agent all` reaches every launch integration whether or not it looks + // installed, which is what the flag has always meant. + if (includeAllLaunchers) { + selected = [ + ...selected.filter((id) => !isMcpLauncherId(id)), + ...ALL_MCP_LAUNCHER_IDS, + ]; } - if (useGlobal) { - args.push('--global'); - } + // `-y` stays MCP-only so automation never rewrites instruction files by + // surprise; the flags are there when a script does want the rules. + const rules = + options.rules ?? (nonInteractive ? false : await confirmMcpRules()); - if (resolvedAgent.agent) { - args.push('--agent', resolvedAgent.agent); - } else if (resolvedAgent.all) { - args.push('--all'); + const results: McpClientResult[] = []; + for (const id of selected) { + results.push( + isMcpLauncherId(id) + ? await setupMcpLauncher(id, ctx, runtimeEnv) + : await setupMcpClient(id, { scope, rules, ctx }) + ); } - if (options.yes) { - args.push('--yes'); - } + reportMcpResults(results, ctx, options, Boolean(apiKey)); +} - if (!options.quiet) { - console.log('Configuring Firecrawl MCP...\n'); +function ruleLine( + result: McpClientResult, + ctx: McpContext +): string | undefined { + switch (result.ruleStatus) { + case 'installed': + case 'updated': + return ` Rules ${result.ruleStatus} ${dim}${displayPath(result.ruleDetail, ctx)}${reset}`; + case 'skipped': + return ' Rules skipped'; + case 'unsupported': + return ` Rules ${dim}not supported by this agent${reset}`; + case 'failed': + return ` ${red}Rules failed${reset} ${result.ruleDetail}`; } +} - try { - runClientCommand('npx', args, { - stdio: 'inherit', - env: cleanNpmEnv(), - }); - if (options.quiet) { - const target = resolvedAgent.agent - ? ` for ${resolvedAgent.agent}` - : resolvedAgent.all - ? ' for launch integrations' - : ''; - console.log(` ${green}✓${reset} Firecrawl MCP configured${target}`); - } - } catch { - throw new Error('Failed to configure Firecrawl MCP.'); - } +/** + * Explain any gap between the credential the user has and what actually got + * written, so a keyless fallback is never silent. + */ +function authNotes( + results: McpClientResult[], + ctx: McpContext, + hasApiKey: boolean +): string[] { + const succeeded = results.filter((result) => result.mcpStatus !== 'failed'); + if (succeeded.length === 0) return []; + + if (!hasApiKey) { + return [ + 'Running keyless (search, scrape, parse). Run "firecrawl login" and rerun to unlock the full tool surface.', + ]; + } + + if (ctx.auth !== 'env') { + return [ + `Configured keyless: your stored key is never written into agent config. Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, + ]; + } + + const keyless = succeeded.filter((result) => result.auth === 'keyless'); + if (keyless.length === 0) return []; + return [ + `${keyless.map((result) => result.name).join(' and ')} cannot expand environment variables in MCP config, so ${keyless.length > 1 ? 'they were' : 'it was'} configured keyless.`, + ]; } -function installCodexMcpFromEnvironment( +function reportMcpResults( + results: McpClientResult[], + ctx: McpContext, options: SetupOptions, - mcpUrl: string + hasApiKey: boolean ): void { - if (!options.quiet) { - console.log('Configuring Firecrawl MCP...\n'); + const succeeded = results.filter((result) => result.mcpStatus !== 'failed'); + + if (options.quiet) { + for (const result of results) { + console.log( + result.mcpStatus === 'failed' + ? ` ${red}✗${reset} Firecrawl MCP failed for ${result.name}: ${result.mcpDetail}` + : ` ${green}✓${reset} Firecrawl MCP configured for ${result.name}` + ); + } + return; } - try { - runClientCommand( - 'codex', - [ - 'mcp', - 'add', - 'firecrawl', - '--url', - mcpUrl, - '--bearer-token-env-var', - 'FIRECRAWL_API_KEY', - ], - { stdio: 'inherit', env: cleanNpmEnv() } + for (const result of results) { + console.log(`${bold}${result.name}${reset}`); + console.log( + result.mcpStatus === 'failed' + ? ` ${red}MCP failed${reset} ${result.mcpDetail}` + : ` MCP ${result.mcpStatus} ${dim}${displayPath(result.mcpDetail, ctx)}${reset}` ); - if (options.quiet) { - console.log(` ${green}✓${reset} Firecrawl MCP configured for codex`); - } - } catch { - throw new Error('Failed to configure Firecrawl MCP for Codex.'); + const rules = ruleLine(result, ctx); + if (rules) console.log(rules); + } + + console.log(''); + console.log( + `Firecrawl MCP set up for ${succeeded.length}/${results.length} agents. Restart your agents to load it.` + ); + for (const note of authNotes(results, ctx, hasApiKey)) { + console.log(`${dim}${note}${reset}`); + } + + if (succeeded.length === 0) { + throw new Error('Failed to configure Firecrawl MCP.'); } } @@ -710,7 +830,9 @@ function firecrawlMcpConfig( export async function installHermesMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false + keyless = false, + /** Suppress standalone logging when a caller renders its own summary. */ + quiet = false ): Promise { const config = firecrawlMcpConfig('hermes', runtimeEnv, keyless); const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); @@ -736,18 +858,20 @@ export async function installHermesMcp( if (process.platform !== 'win32') { chmodSync(configPath, 0o600); } - console.log(`Hermes Agent MCP configured at ${configPath}.`); + if (!quiet) console.log(`Hermes Agent MCP configured at ${configPath}.`); } export async function installOpenClawMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false + keyless = false, + /** Suppress standalone logging when a caller renders its own summary. */ + quiet = false ): Promise { const config = { ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless), transport: 'streamable-http', }; - console.log('Configuring Firecrawl MCP for OpenClaw...\n'); + if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); try { runClientCommand( diff --git a/src/index.ts b/src/index.ts index 87ecfee452..a07d9e602e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,7 @@ import { } from './commands/init'; import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup'; import type { SetupSubcommand } from './commands/setup'; +import { ALL_MCP_TARGET_IDS, mcpTargetName } from './utils/mcp-clients'; import { handleEnvPullCommand } from './commands/env'; import { handleStatusCommand } from './commands/status'; import { handleDoctorCommand } from './commands/doctor'; @@ -2232,7 +2233,7 @@ program }); }); -program +const setupCommand = program .command('setup') .description( 'Set up individual firecrawl integrations (skills, workflows, mcp, defaults)' @@ -2261,9 +2262,32 @@ program .option( '--undo', 'Undo setup defaults by re-enabling native web tools where supported' + ); + +// Per-agent flags for `setup mcp`, so scripts can skip the picker. +for (const id of ALL_MCP_TARGET_IDS) { + setupCommand.option(`--${id}`, `Set up ${mcpTargetName(id)} (mcp)`); +} + +setupCommand + .option('--rules', 'Install rules that prefer Firecrawl for web work (mcp)') + .option('--no-rules', 'Skip the rules prompt and install MCP only (mcp)') + .addHelpText( + 'after', + ` +Examples: + $ firecrawl setup mcp # pick agents, then choose rules + $ firecrawl setup mcp --claude --cursor # skip the picker + $ firecrawl setup mcp --yes # every detected agent, MCP only + $ firecrawl setup mcp --yes --rules # every detected agent, with rules + $ firecrawl setup mcp --project --cursor # write project config +` ) .action(async (subcommand: SetupSubcommand, options) => { - await handleSetupCommand(subcommand, options); + await handleSetupCommand(subcommand, { + ...options, + clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), + }); }); program diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 0ba6ea4aec..ea0d50d290 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -167,8 +167,14 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { } /** - * Walk a parsed JSON config looking for an `mcpServers` (or `mcp.servers`) - * map that contains a `firecrawl` key. Exported for testing. + * Keys under which agents store their MCP server map: `mcpServers` for Claude + * Code, Cursor, and Windsurf, `servers` for VS Code, `context_servers` for Zed. + */ +const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'context_servers']); + +/** + * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that + * contains a `firecrawl` key. Exported for testing. */ export function hasFirecrawlMcpEntry(value: unknown): boolean { if (!value || typeof value !== 'object') return false; @@ -176,7 +182,7 @@ export function hasFirecrawlMcpEntry(value: unknown): boolean { for (const key of Object.keys(obj)) { const child = obj[key]; - if (key === 'mcpServers' && child && typeof child === 'object') { + if (SERVER_MAP_KEYS.has(key) && child && typeof child === 'object') { if (Object.prototype.hasOwnProperty.call(child, 'firecrawl')) { return true; } diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts new file mode 100644 index 0000000000..7a30022ccc --- /dev/null +++ b/src/utils/mcp-clients.ts @@ -0,0 +1,446 @@ +/** + * Registry of AI coding agents that can host the hosted Firecrawl MCP server. + * + * Every agent reads a config file that maps a server name to a connection + * entry, but the file location, the key holding that map, and the shape of the + * entry itself differ per agent. This module is the single place those + * differences live; `mcp-install.ts` does the writing. + * + * Credentials are never handled here. A stored API key must not end up as a + * literal in a config file, so this module only ever emits an indirect + * reference to `FIRECRAWL_API_KEY` using the syntax a given agent is known to + * expand. Agents without a verified syntax get the keyless endpoint, which + * still serves search, scrape, and parse under an anonymous rate limit. + */ + +import { existsSync, promises as fs } from 'fs'; +import path from 'path'; + +export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; +export const MCP_SERVER_NAME = 'firecrawl'; +export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; + +export type McpClientId = + | 'claude' + | 'cursor' + | 'vscode' + | 'codex' + | 'opencode' + | 'windsurf' + | 'zed'; + +export type McpScope = 'global' | 'project'; + +/** + * Agent launchers that own their MCP configuration rather than reading a file + * we write. They are offered alongside the editors but installed differently. + */ +export type McpLauncherId = 'hermes' | 'openclaw'; + +export type McpTargetId = McpClientId | McpLauncherId; + +/** + * `env` writes an indirect reference to `FIRECRAWL_API_KEY`, which only works + * when that variable is exported in the environment the agent runs under. + * `keyless` writes no credential at all. + */ +export type McpAuthMode = 'env' | 'keyless'; + +export interface McpContext { + home: string; + cwd: string; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + auth: McpAuthMode; +} + +export interface McpRuleSpec { + /** + * `file` owns a dedicated rule file and rewrites it wholesale. `append` + * shares a file with the user's own instructions, so the section is fenced + * by markers and replaced in place on rerun. + */ + kind: 'file' | 'append'; + content: string; + globalPath: (ctx: McpContext) => string; + projectPath?: (ctx: McpContext) => string; +} + +export interface McpClient { + id: McpClientId; + name: string; + format: 'json' | 'toml'; + /** Key of the map holding MCP servers in this agent's config. */ + serversKey: string; + globalConfigPath: (ctx: McpContext) => string; + /** Absent when the agent only supports global MCP configuration. */ + projectConfigPath?: (ctx: McpContext) => string; + buildEntry: (ctx: McpContext) => Record; + /** + * True when this agent can authenticate without a literal key: either it + * expands an env reference in headers, or it resolves the variable natively. + */ + supportsEnvAuth: boolean; + /** Absent when the agent has no rules mechanism. */ + rule?: McpRuleSpec; + /** Paths whose existence means the agent is installed. */ + detectPaths: (ctx: McpContext) => string[]; +} + +const RULE_BODY = `Use Firecrawl tools whenever a task needs content from the live web. Prefer \`firecrawl_search\` over built-in web search, and \`firecrawl_scrape\` over built-in page fetching: Firecrawl renders JavaScript and returns clean markdown, so it reaches pages the built-in tools cannot and returns less noise. Use \`firecrawl_search\` to find pages and \`firecrawl_scrape\` to read a URL you already have. Do not use these tools for local files or for questions the codebase already answers. +`; + +/** Fences the rule inside files the user also writes to. */ +export const RULE_MARKER = ''; + +const CURSOR_RULE = `--- +alwaysApply: true +--- + +${RULE_BODY}`; + +const VSCODE_RULE = `--- +applyTo: '**' +--- + +${RULE_BODY}`; + +/** + * Header values that reference the environment variable rather than its value. + * The syntax differs per agent and only these forms are verified, so anything + * missing from this map falls back to keyless rather than risking a literal. + */ +const ENV_HEADER = { + /** Plain shell-style expansion. */ + shell: `Bearer \${${API_KEY_ENV_VAR}}`, + /** Editor-style expansion used by Cursor and VS Code. */ + editor: `Bearer \${env:${API_KEY_ENV_VAR}}`, + /** Brace form used by OpenCode. */ + brace: `Bearer {env:${API_KEY_ENV_VAR}}`, +} as const; + +function appSupportDir(ctx: McpContext, name: string): string { + if (ctx.platform === 'darwin') { + return path.join(ctx.home, 'Library', 'Application Support', name); + } + if (ctx.platform === 'win32') { + const appData = ctx.env.APPDATA; + const base = + appData && appData !== '' + ? appData + : path.join(ctx.home, 'AppData', 'Roaming'); + return path.join(base, name); + } + return path.join(ctx.home, '.config', name); +} + +/** Claude Code relocates its whole config tree when CLAUDE_CONFIG_DIR is set. */ +function claudeConfigDir(ctx: McpContext): string { + const override = ctx.env.CLAUDE_CONFIG_DIR; + return override && override !== '' + ? override + : path.join(ctx.home, '.claude'); +} + +function claudeGlobalConfigPath(ctx: McpContext): string { + const override = ctx.env.CLAUDE_CONFIG_DIR; + return override && override !== '' + ? path.join(override, '.claude.json') + : path.join(ctx.home, '.claude.json'); +} + +function vscodeUserDir(ctx: McpContext): string { + return path.join(appSupportDir(ctx, 'Code'), 'User'); +} + +function zedUserDir(ctx: McpContext): string { + if (ctx.platform === 'win32') return appSupportDir(ctx, 'Zed'); + return path.join(ctx.home, '.config', 'zed'); +} + +/** Attach the agent's env-reference header when authenticating that way. */ +function withEnvAuth( + ctx: McpContext, + entry: Record, + header: string +): Record { + if (ctx.auth !== 'env') return entry; + return { ...entry, headers: { Authorization: header } }; +} + +export const MCP_CLIENTS: Record = { + claude: { + id: 'claude', + name: 'Claude Code', + format: 'json', + serversKey: 'mcpServers', + globalConfigPath: claudeGlobalConfigPath, + projectConfigPath: (ctx) => path.join(ctx.cwd, '.mcp.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'http', url: FIRECRAWL_MCP_URL }, + ENV_HEADER.shell + ), + supportsEnvAuth: true, + rule: { + kind: 'file', + content: RULE_BODY, + globalPath: (ctx) => + path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), + projectPath: (ctx) => + path.join(ctx.cwd, '.claude', 'rules', 'firecrawl.md'), + }, + detectPaths: (ctx) => [claudeConfigDir(ctx)], + }, + cursor: { + id: 'cursor', + name: 'Cursor', + format: 'json', + serversKey: 'mcpServers', + globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.cursor', 'mcp.json'), + buildEntry: (ctx) => + withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), + supportsEnvAuth: true, + rule: { + kind: 'file', + content: CURSOR_RULE, + globalPath: (ctx) => + path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), + projectPath: (ctx) => + path.join(ctx.cwd, '.cursor', 'rules', 'firecrawl.mdc'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], + }, + vscode: { + id: 'vscode', + name: 'VS Code', + format: 'json', + serversKey: 'servers', + globalConfigPath: (ctx) => path.join(vscodeUserDir(ctx), 'mcp.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.vscode', 'mcp.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'http', url: FIRECRAWL_MCP_URL }, + ENV_HEADER.editor + ), + supportsEnvAuth: true, + rule: { + kind: 'file', + content: VSCODE_RULE, + globalPath: (ctx) => + path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), + projectPath: (ctx) => + path.join( + ctx.cwd, + '.github', + 'instructions', + 'firecrawl.instructions.md' + ), + }, + detectPaths: (ctx) => [vscodeUserDir(ctx)], + }, + codex: { + id: 'codex', + name: 'Codex', + format: 'toml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(ctx.home, '.codex', 'config.toml'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.codex', 'config.toml'), + // Codex resolves the bearer token from the environment by variable name, + // so it authenticates without a header template. + buildEntry: (ctx) => + ctx.auth === 'env' + ? { url: FIRECRAWL_MCP_URL, bearer_token_env_var: API_KEY_ENV_VAR } + : { url: FIRECRAWL_MCP_URL }, + supportsEnvAuth: true, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), + projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.codex')], + }, + opencode: { + id: 'opencode', + name: 'OpenCode', + format: 'json', + serversKey: 'mcp', + globalConfigPath: (ctx) => + path.join(ctx.home, '.config', 'opencode', 'opencode.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, 'opencode.json'), + buildEntry: (ctx) => + withEnvAuth( + ctx, + { type: 'remote', url: FIRECRAWL_MCP_URL, enabled: true }, + ENV_HEADER.brace + ), + supportsEnvAuth: true, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => + path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), + projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], + }, + windsurf: { + id: 'windsurf', + name: 'Windsurf', + format: 'json', + serversKey: 'mcpServers', + // Windsurf has no project-level MCP config; it always gets the global one. + globalConfigPath: (ctx) => + path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json'), + buildEntry: () => ({ serverUrl: FIRECRAWL_MCP_URL }), + // No verified env-reference syntax, so this agent stays keyless. + supportsEnvAuth: false, + rule: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => + path.join( + ctx.home, + '.codeium', + 'windsurf', + 'memories', + 'global_rules.md' + ), + projectPath: (ctx) => + path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md'), + }, + detectPaths: (ctx) => [path.join(ctx.home, '.codeium', 'windsurf')], + }, + zed: { + id: 'zed', + name: 'Zed', + format: 'json', + serversKey: 'context_servers', + globalConfigPath: (ctx) => path.join(zedUserDir(ctx), 'settings.json'), + projectConfigPath: (ctx) => path.join(ctx.cwd, '.zed', 'settings.json'), + buildEntry: () => ({ url: FIRECRAWL_MCP_URL }), + // Zed sends header values verbatim without expanding variables, so an + // indirect reference would not resolve. Keyless is the only safe option. + supportsEnvAuth: false, + // Zed has no rules mechanism. + detectPaths: (ctx) => [zedUserDir(ctx)], + }, +}; + +export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ + 'claude', + 'cursor', + 'vscode', + 'codex', + 'opencode', + 'windsurf', + 'zed', +]; + +export const MCP_LAUNCHER_NAMES: Record = { + hermes: 'Hermes Agent', + openclaw: 'OpenClaw', +}; + +export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = [ + 'hermes', + 'openclaw', +]; + +export const ALL_MCP_TARGET_IDS: readonly McpTargetId[] = [ + ...ALL_MCP_CLIENT_IDS, + ...ALL_MCP_LAUNCHER_IDS, +]; + +export function isMcpLauncherId(id: McpTargetId): id is McpLauncherId { + return (ALL_MCP_LAUNCHER_IDS as readonly string[]).includes(id); +} + +export function mcpTargetName(id: McpTargetId): string { + return isMcpLauncherId(id) ? MCP_LAUNCHER_NAMES[id] : MCP_CLIENTS[id].name; +} + +/** + * Look for an executable across PATH without spawning it. Launchers are CLIs, + * so their presence on PATH is the signal, but running `--version` during a + * picker would be slow and have side effects. + */ +function binaryOnPath(name: string, ctx: McpContext): boolean { + const extensions = + ctx.platform === 'win32' + ? (ctx.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : ['']; + const entries = (ctx.env.PATH ?? ctx.env.Path ?? '') + .split(path.delimiter) + .filter(Boolean); + for (const entry of entries) { + for (const extension of extensions) { + if (existsSync(path.join(entry, `${name}${extension}`))) return true; + } + } + return false; +} + +const LAUNCHER_DETECT: Record boolean> = { + hermes: (ctx) => + existsSync(path.join(ctx.home, '.hermes')) || binaryOnPath('hermes', ctx), + openclaw: (ctx) => + existsSync(path.join(ctx.home, '.openclaw')) || + binaryOnPath('openclaw', ctx), +}; + +/** Launchers present on this machine, in registry order. */ +export function detectMcpLaunchers(ctx: McpContext): McpLauncherId[] { + return ALL_MCP_LAUNCHER_IDS.filter((id) => LAUNCHER_DETECT[id](ctx)); +} + +/** Aliases accepted by `--agent`, including the names `firecrawl launch` uses. */ +const CLIENT_ALIASES: Record = { + claude: 'claude', + 'claude-code': 'claude', + claudecode: 'claude', + cursor: 'cursor', + vscode: 'vscode', + 'vs-code': 'vscode', + code: 'vscode', + codex: 'codex', + 'codex-app': 'codex', + 'codex-desktop': 'codex', + 'codex-gui': 'codex', + opencode: 'opencode', + 'open-code': 'opencode', + windsurf: 'windsurf', + zed: 'zed', +}; + +export function resolveMcpClientId(agent: string): McpClientId | undefined { + return CLIENT_ALIASES[agent.trim().toLowerCase()]; +} + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +/** Agents that look installed on this machine, in registry order. */ +export async function detectMcpClients( + ctx: McpContext +): Promise { + const detected = await Promise.all( + ALL_MCP_CLIENT_IDS.map(async (id) => { + const found = await Promise.all( + MCP_CLIENTS[id].detectPaths(ctx).map(pathExists) + ); + return found.some(Boolean) ? id : undefined; + }) + ); + return detected.filter((id): id is McpClientId => id !== undefined); +} diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts new file mode 100644 index 0000000000..abe71f2e6a --- /dev/null +++ b/src/utils/mcp-install.ts @@ -0,0 +1,339 @@ +/** + * Writes the Firecrawl MCP server into an agent's config, and optionally the + * rule that tells that agent to reach for Firecrawl on web work. + * + * Agent configs belong to the user, not to us, so edits are surgical: JSON is + * patched through a JSONC-aware editor that keeps comments and formatting + * intact (Zed and VS Code ship commented settings, which plain `JSON.parse` + * rejects outright), TOML tables are replaced line by line, and shared rule + * files get a marker-fenced section rather than a rewrite. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; +import { + MCP_CLIENTS, + MCP_SERVER_NAME, + RULE_MARKER, + type McpAuthMode, + type McpClient, + type McpClientId, + type McpContext, + type McpScope, + type McpTargetId, +} from './mcp-clients'; + +export type McpStatus = 'configured' | 'reconfigured' | 'failed'; +export type RuleStatus = + | 'installed' + | 'updated' + | 'skipped' + | 'unsupported' + | 'failed'; + +export interface McpClientResult { + id: McpTargetId; + name: string; + mcpStatus: McpStatus; + /** Config path on success, error message on failure. */ + mcpDetail: string; + /** How this agent ended up authenticating, after any keyless fallback. */ + auth: McpAuthMode; + ruleStatus: RuleStatus; + /** Rule path when one was written, error message on failure, else empty. */ + ruleDetail: string; +} + +function isEnoent(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} + +async function readIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (isEnoent(error)) return undefined; + throw error; + } +} + +async function writeFileEnsuringDir( + filePath: string, + content: string +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Insert or replace `serversKey.serverName` without disturbing the rest of the + * file. Throws when the existing file is not parseable, so a malformed config + * is reported rather than overwritten. + */ +export async function writeJsonServerEntry( + filePath: string, + serversKey: string, + serverName: string, + entry: Record +): Promise<{ status: 'configured' | 'reconfigured' }> { + const raw = await readIfExists(filePath); + + if (raw === undefined || raw.trim() === '') { + const fresh = { [serversKey]: { [serverName]: entry } }; + await writeFileEnsuringDir(filePath, `${JSON.stringify(fresh, null, 2)}\n`); + return { status: 'configured' }; + } + + const errors: ParseError[] = []; + const parsed = parse(raw, errors, { allowTrailingComma: true }); + if ( + errors.length > 0 || + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error(`could not parse existing config at ${filePath}`); + } + + const section = (parsed as Record)[serversKey]; + const sectionIsObject = + typeof section === 'object' && section !== null && !Array.isArray(section); + const alreadyExists = + sectionIsObject && serverName in (section as Record); + + // Patch the leaf when the servers map is usable; otherwise replace the whole + // key, which also covers it being missing or holding a non-object. + const edits = sectionIsObject + ? modify(raw, [serversKey, serverName], entry, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) + : modify( + raw, + [serversKey], + { [serverName]: entry }, + { formattingOptions: { insertSpaces: true, tabSize: 2 } } + ); + + await writeFileEnsuringDir(filePath, applyEdits(raw, edits)); + return { status: alreadyExists ? 'reconfigured' : 'configured' }; +} + +/** + * Insert or replace the `[mcp_servers.]` table. Any sub-tables of that + * server are consumed too, so a leftover `[mcp_servers.firecrawl.env]` from an + * earlier stdio setup cannot collide with the URL we write. + * + * Values are emitted as TOML strings; the entries we build are flat by design. + */ +export function upsertTomlServer( + content: string, + serverName: string, + entry: Record +): { content: string; alreadyExists: boolean } { + const block = [ + `[mcp_servers.${serverName}]`, + ...Object.entries(entry).map( + ([key, value]) => `${key} = ${JSON.stringify(value)}` + ), + ]; + + const lines = content === '' ? [] : content.split('\n'); + const escaped = escapeRegExp(serverName); + const ownTable = new RegExp( + `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` + ); + const anyTable = /^[ \t]*\[/; + + const start = lines.findIndex((line) => ownTable.test(line)); + + if (start === -1) { + // Tables must follow root-level keys, so append at the end of the file. + const trimmed = [...lines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') { + trimmed.pop(); + } + const separator = trimmed.length === 0 ? [] : ['']; + return { + content: [...trimmed, ...separator, ...block, ''].join('\n'), + alreadyExists: false, + }; + } + + let end = start + 1; + while (end < lines.length) { + if (anyTable.test(lines[end]) && !ownTable.test(lines[end])) break; + end += 1; + } + + const rest = lines.slice(end); + // Keep a blank line between our block and whatever follows it. + const separator = rest.length > 0 && rest[0].trim() !== '' ? [''] : []; + const replaced = [ + ...lines.slice(0, start), + ...block, + ...separator, + ...rest, + ].join('\n'); + + // Consuming the old table can swallow the file's final newline; restoring it + // keeps repeat runs byte-identical. + return { + content: replaced.endsWith('\n') ? replaced : `${replaced}\n`, + alreadyExists: true, + }; +} + +/** Rewrite a rule file we own outright. */ +export async function writeRuleFile( + filePath: string, + content: string +): Promise<'installed' | 'updated'> { + const existed = (await readIfExists(filePath)) !== undefined; + await writeFileEnsuringDir(filePath, content); + return existed ? 'updated' : 'installed'; +} + +/** + * Add or refresh a marker-fenced section inside a file the user also writes to, + * such as AGENTS.md. Everything outside the markers is left alone. + */ +export async function appendRuleSection( + filePath: string, + content: string +): Promise<'installed' | 'updated'> { + const section = `${RULE_MARKER}\n${content}${RULE_MARKER}`; + const existing = (await readIfExists(filePath)) ?? ''; + const marker = escapeRegExp(RULE_MARKER); + const fenced = new RegExp(`${marker}\\n[\\s\\S]*?${marker}`); + + if (fenced.test(existing)) { + await writeFileEnsuringDir(filePath, existing.replace(fenced, section)); + return 'updated'; + } + + const separator = + existing.length === 0 ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; + await writeFileEnsuringDir(filePath, `${existing}${separator}${section}\n`); + return 'installed'; +} + +function configPathFor(client: McpClient, scope: McpScope, ctx: McpContext) { + // Agents without project support always take the global path. + const projectPath = client.projectConfigPath?.(ctx); + return scope === 'project' && projectPath + ? projectPath + : client.globalConfigPath(ctx); +} + +async function writeMcpEntry( + client: McpClient, + scope: McpScope, + ctx: McpContext +): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { + const configPath = configPathFor(client, scope, ctx); + const entry = client.buildEntry(ctx); + + if (client.format === 'toml') { + const existing = (await readIfExists(configPath)) ?? ''; + const stringEntry: Record = {}; + for (const [key, value] of Object.entries(entry)) { + if (typeof value === 'string') stringEntry[key] = value; + } + const { content, alreadyExists } = upsertTomlServer( + existing, + MCP_SERVER_NAME, + stringEntry + ); + await writeFileEnsuringDir(configPath, content); + return { + status: alreadyExists ? 'reconfigured' : 'configured', + configPath, + }; + } + + const { status } = await writeJsonServerEntry( + configPath, + client.serversKey, + MCP_SERVER_NAME, + entry + ); + return { status, configPath }; +} + +async function writeRule( + client: McpClient, + scope: McpScope, + ctx: McpContext +): Promise<{ status: 'installed' | 'updated' | 'unsupported'; path: string }> { + const rule = client.rule; + if (!rule) return { status: 'unsupported', path: '' }; + + const projectPath = rule.projectPath?.(ctx); + const rulePath = + scope === 'project' && projectPath ? projectPath : rule.globalPath(ctx); + const status = + rule.kind === 'file' + ? await writeRuleFile(rulePath, rule.content) + : await appendRuleSection(rulePath, rule.content); + return { status, path: rulePath }; +} + +/** + * Configure one agent. The MCP entry and the rule are written independently so + * a rule failure never costs the user a working MCP server. + */ +export async function setupMcpClient( + id: McpClientId, + options: { scope: McpScope; rules: boolean; ctx: McpContext } +): Promise { + const client = MCP_CLIENTS[id]; + // An agent with no verified environment-variable syntax falls back to the + // keyless endpoint rather than having a credential written literally. + const auth: McpAuthMode = + options.ctx.auth === 'env' && client.supportsEnvAuth ? 'env' : 'keyless'; + const ctx: McpContext = { ...options.ctx, auth }; + + const result: McpClientResult = { + id, + name: client.name, + mcpStatus: 'failed', + mcpDetail: '', + auth, + ruleStatus: 'skipped', + ruleDetail: '', + }; + + try { + const { status, configPath } = await writeMcpEntry( + client, + options.scope, + ctx + ); + result.mcpStatus = status; + result.mcpDetail = configPath; + } catch (error) { + result.mcpDetail = error instanceof Error ? error.message : String(error); + } + + if (!options.rules) return result; + + try { + const { status, path: rulePath } = await writeRule( + client, + options.scope, + ctx + ); + result.ruleStatus = status; + result.ruleDetail = rulePath; + } catch (error) { + result.ruleStatus = 'failed'; + result.ruleDetail = error instanceof Error ? error.message : String(error); + } + + return result; +} From a9f9a92fc17366462fd2c4741cc995f98812cf1e Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 12:21:14 -0700 Subject: [PATCH 02/19] fix(cli): detect Hermes Agent by config directory only The PATH lookup matched any executable named `hermes`, including an unrelated JavaScript engine that ships with common toolchains, so the picker pre-selected an agent the user did not have. Detection now prefers a false negative to a false positive: every agent is listed either way, so missing one costs a keystroke while pre-selecting a missing one is misleading. Also pins HOME and PATH for setup tests. Both feed agent detection, so leaving the real ones visible made results depend on what happened to be installed on the machine running the suite. --- src/__tests__/commands/setup.test.ts | 13 +++++++++---- src/utils/mcp-clients.ts | 12 ++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 23db1c016b..e3fb0ae4c4 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -54,6 +54,7 @@ describe('handleSetupCommand', () => { let originalHome: string | undefined; let originalApiKey: string | undefined; let sandboxHome: string; + let originalPath: string | undefined; beforeEach(() => { vi.clearAllMocks(); @@ -69,10 +70,15 @@ describe('handleSetupCommand', () => { // home. Without this a test run would rewrite the developer's own editors. sandboxHome = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-home-')); process.env.HOME = sandboxHome; + // Launcher detection also looks on PATH, so pin it for the same reason. + originalPath = process.env.PATH; + process.env.PATH = ''; }); afterEach(() => { rmSync(sandboxHome, { recursive: true, force: true }); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; @@ -371,7 +377,7 @@ describe('handleSetupCommand', () => { }); it('offers launchers in the picker and configures Hermes by flag', async () => { - await handleSetupCommand('mcp', { hermes: true, yes: true } as never); + await handleSetupCommand('mcp', { clients: ['hermes'], yes: true }); expect( readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') @@ -401,10 +407,9 @@ describe('handleSetupCommand', () => { }); await handleSetupCommand('mcp', { - cursor: true, - openclaw: true, + clients: ['cursor', 'openclaw'], yes: true, - } as never); + }); expect( JSON.parse( diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 7a30022ccc..9b643ddaed 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -385,9 +385,17 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { return false; } +/** + * Detection prefers a false negative to a false positive: every agent is listed + * in the picker either way, so failing to pre-select one costs a keystroke, + * while pre-selecting an agent the user does not have is misleading. + * + * `hermes` is therefore matched on its config directory alone. The name is also + * used by an unrelated JavaScript engine that ships with common toolchains, so + * a PATH lookup reports it present on machines that do not have this agent. + */ const LAUNCHER_DETECT: Record boolean> = { - hermes: (ctx) => - existsSync(path.join(ctx.home, '.hermes')) || binaryOnPath('hermes', ctx), + hermes: (ctx) => existsSync(path.join(ctx.home, '.hermes')), openclaw: (ctx) => existsSync(path.join(ctx.home, '.openclaw')) || binaryOnPath('openclaw', ctx), From f87761a89e308b0d4a255eec414bade118981b78 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 12:35:38 -0700 Subject: [PATCH 03/19] feat(cli): drop Zed from MCP setup and show every agent in the picker Zed's native remote MCP support is version-gated and its handling of request headers is inconsistent across releases, so a written entry can report success while the agent never connects. That reads as Firecrawl being broken, which is worse than not offering the agent at all. Removing it until the shape can be confirmed against a live install. Also pins the picker page size to the number of agents. The default was smaller than the list, so the last agent scrolled out of view. --- README.md | 2 +- src/__tests__/utils/mcp-install.test.ts | 23 +++++------------------ src/commands/setup.ts | 3 +++ src/utils/agents.ts | 4 ++-- src/utils/mcp-clients.ts | 24 +----------------------- src/utils/mcp-install.ts | 2 +- 6 files changed, 13 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index f50d035429..c06b9e5596 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ firecrawl setup mcp This detects which agents you have installed, pre-selects them in a picker, and asks whether to add rules telling those agents to prefer Firecrawl for web search and scraping. Supported agents are Claude Code, Cursor, VS Code, Codex, -OpenCode, Windsurf, Zed, Hermes Agent, and OpenClaw. +OpenCode, Windsurf, Hermes Agent, and OpenClaw. Pass agent flags to skip the picker, `-y` to configure every detected agent (MCP only), or `--project` to write to the current project instead of your diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index bb4c1e064c..51cad1cdf3 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -65,23 +65,21 @@ describe('mcp install', () => { writeFileSync( file, [ - '// Zed settings', + '// editor settings', '{', ' "theme": "One Dark",', ' // keep me', ' "buffer_font_size": 15,', - ' "context_servers": { "other": { "url": "https://example.com" } }', + ' "servers": { "other": { "url": "https://example.com" } }', '}', '', ].join('\n') ); - await writeJsonServerEntry(file, 'context_servers', 'fc', { - url: MCP_URL, - }); + await writeJsonServerEntry(file, 'servers', 'fc', { url: MCP_URL }); const result = read(file); - expect(result).toContain('// Zed settings'); + expect(result).toContain('// editor settings'); expect(result).toContain('// keep me'); expect(result).toContain('"theme": "One Dark"'); expect(result).toContain('"other"'); @@ -266,7 +264,7 @@ describe('mcp install', () => { }); it('falls back to keyless for agents that cannot expand variables', async () => { - for (const id of ['zed', 'windsurf'] as const) { + for (const id of ['windsurf'] as const) { const result = await setupMcpClient(id, { scope: 'global', rules: false, @@ -309,17 +307,6 @@ describe('mcp install', () => { ); }); - it('marks rules unsupported for agents without a rules mechanism', async () => { - const result = await setupMcpClient('zed', { - scope: 'global', - rules: true, - ctx, - }); - - expect(result.mcpStatus).toBe('configured'); - expect(result.ruleStatus).toBe('unsupported'); - }); - it('still configures MCP when the rule write fails', async () => { // A file where the rules directory needs to be blocks the rule write. const rulesPath = path.join(ctx.home, '.cursor', 'rules'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 25f23f9f60..ad8c2f1303 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -592,6 +592,9 @@ async function pickMcpClients( return checkbox({ message: 'Which agents do you want to set up?', loop: false, + // Show every agent at once; the default page size would scroll the last + // ones out of view. + pageSize: ALL_MCP_TARGET_IDS.length, choices: ALL_MCP_TARGET_IDS.map((id) => ({ name: mcpTargetName(id), value: id, diff --git a/src/utils/agents.ts b/src/utils/agents.ts index ea0d50d290..7ee913f076 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -168,9 +168,9 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { /** * Keys under which agents store their MCP server map: `mcpServers` for Claude - * Code, Cursor, and Windsurf, `servers` for VS Code, `context_servers` for Zed. + * Code, Cursor, and Windsurf; `servers` for VS Code. */ -const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'context_servers']); +const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers']); /** * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 9b643ddaed..8e862148a1 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -26,8 +26,7 @@ export type McpClientId = | 'vscode' | 'codex' | 'opencode' - | 'windsurf' - | 'zed'; + | 'windsurf'; export type McpScope = 'global' | 'project'; @@ -153,11 +152,6 @@ function vscodeUserDir(ctx: McpContext): string { return path.join(appSupportDir(ctx, 'Code'), 'User'); } -function zedUserDir(ctx: McpContext): string { - if (ctx.platform === 'win32') return appSupportDir(ctx, 'Zed'); - return path.join(ctx.home, '.config', 'zed'); -} - /** Attach the agent's env-reference header when authenticating that way. */ function withEnvAuth( ctx: McpContext, @@ -315,20 +309,6 @@ export const MCP_CLIENTS: Record = { }, detectPaths: (ctx) => [path.join(ctx.home, '.codeium', 'windsurf')], }, - zed: { - id: 'zed', - name: 'Zed', - format: 'json', - serversKey: 'context_servers', - globalConfigPath: (ctx) => path.join(zedUserDir(ctx), 'settings.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.zed', 'settings.json'), - buildEntry: () => ({ url: FIRECRAWL_MCP_URL }), - // Zed sends header values verbatim without expanding variables, so an - // indirect reference would not resolve. Keyless is the only safe option. - supportsEnvAuth: false, - // Zed has no rules mechanism. - detectPaths: (ctx) => [zedUserDir(ctx)], - }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -338,7 +318,6 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'codex', 'opencode', 'windsurf', - 'zed', ]; export const MCP_LAUNCHER_NAMES: Record = { @@ -422,7 +401,6 @@ const CLIENT_ALIASES: Record = { opencode: 'opencode', 'open-code': 'opencode', windsurf: 'windsurf', - zed: 'zed', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index abe71f2e6a..5799d7bb31 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -4,7 +4,7 @@ * * Agent configs belong to the user, not to us, so edits are surgical: JSON is * patched through a JSONC-aware editor that keeps comments and formatting - * intact (Zed and VS Code ship commented settings, which plain `JSON.parse` + * intact (several agents ship commented settings, which plain `JSON.parse` * rejects outright), TOML tables are replaced line by line, and shared rule * files get a marker-fenced section rather than a rewrite. */ From daeefcafd451332bf9e53c1ca098d8973ab80f23 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 13:12:04 -0700 Subject: [PATCH 04/19] feat(cli): drop Windsurf from MCP setup Windsurf's remote entry shape is not settled: sources disagree on whether a transport field is required and what its value should be, and one reports streamable HTTP working only through a local proxy. A wrong entry does not error, it reports success and then exposes no tools, so this stays out until the shape can be confirmed against a live install. With every supported agent now carrying a verified environment-reference syntax and project-level config, the keyless-fallback and global-fallback branches no longer have a case. Removing them rather than leaving unreachable logic behind; they come back with the agent that needs them. --- README.md | 5 +-- src/__tests__/utils/mcp-install.test.ts | 44 ---------------------- src/commands/setup.ts | 6 +-- src/utils/mcp-clients.ts | 50 +------------------------ src/utils/mcp-install.ts | 8 +--- 5 files changed, 6 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index c06b9e5596..0b72794bba 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ firecrawl setup mcp This detects which agents you have installed, pre-selects them in a picker, and asks whether to add rules telling those agents to prefer Firecrawl for web search and scraping. Supported agents are Claude Code, Cursor, VS Code, Codex, -OpenCode, Windsurf, Hermes Agent, and OpenClaw. +OpenCode, Hermes Agent, and OpenClaw. Pass agent flags to skip the picker, `-y` to configure every detected agent (MCP only), or `--project` to write to the current project instead of your @@ -106,9 +106,6 @@ to that variable in the syntax it understands. Otherwise setup stays keyless, which still serves search, scrape, and parse under an anonymous rate limit. Use `--keyless` to force the anonymous path even when a key is available. -Not every agent supports project-level MCP configuration. Those agents always -receive the global configuration. - To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 51cad1cdf3..ac6e685f74 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -263,50 +263,6 @@ describe('mcp install', () => { expect(config).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); }); - it('falls back to keyless for agents that cannot expand variables', async () => { - for (const id of ['windsurf'] as const) { - const result = await setupMcpClient(id, { - scope: 'global', - rules: false, - ctx: { ...ctx, auth: 'env' }, - }); - - expect(result.auth).toBe('keyless'); - expect(read(result.mcpDetail)).not.toContain('Authorization'); - } - }); - - it('honours CLAUDE_CONFIG_DIR', async () => { - const configDir = path.join(root, 'claude-config'); - - const result = await setupMcpClient('claude', { - scope: 'global', - rules: true, - ctx: { ...ctx, env: { CLAUDE_CONFIG_DIR: configDir } }, - }); - - expect(result.mcpDetail).toBe(path.join(configDir, '.claude.json')); - expect(result.ruleDetail).toBe( - path.join(configDir, 'rules', 'firecrawl.md') - ); - }); - - it('falls back to global config for agents without project support', async () => { - const result = await setupMcpClient('windsurf', { - scope: 'project', - rules: true, - ctx, - }); - - // MCP is global-only for Windsurf; the rule still lands in the project. - expect(result.mcpDetail).toBe( - path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json') - ); - expect(result.ruleDetail).toBe( - path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md') - ); - }); - it('still configures MCP when the rule write fails', async () => { // A file where the rules directory needs to be blocks the rule write. const rulesPath = path.join(ctx.home, '.cursor', 'rules'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ad8c2f1303..9fe5844099 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -762,11 +762,7 @@ function authNotes( ]; } - const keyless = succeeded.filter((result) => result.auth === 'keyless'); - if (keyless.length === 0) return []; - return [ - `${keyless.map((result) => result.name).join(' and ')} cannot expand environment variables in MCP config, so ${keyless.length > 1 ? 'they were' : 'it was'} configured keyless.`, - ]; + return []; } function reportMcpResults( diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 8e862148a1..fb994b60da 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -9,8 +9,7 @@ * Credentials are never handled here. A stored API key must not end up as a * literal in a config file, so this module only ever emits an indirect * reference to `FIRECRAWL_API_KEY` using the syntax a given agent is known to - * expand. Agents without a verified syntax get the keyless endpoint, which - * still serves search, scrape, and parse under an anonymous rate limit. + * expand. An agent is only supported once that syntax is verified. */ import { existsSync, promises as fs } from 'fs'; @@ -20,13 +19,7 @@ export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; export const MCP_SERVER_NAME = 'firecrawl'; export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; -export type McpClientId = - | 'claude' - | 'cursor' - | 'vscode' - | 'codex' - | 'opencode' - | 'windsurf'; +export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; export type McpScope = 'global' | 'project'; @@ -75,11 +68,6 @@ export interface McpClient { /** Absent when the agent only supports global MCP configuration. */ projectConfigPath?: (ctx: McpContext) => string; buildEntry: (ctx: McpContext) => Record; - /** - * True when this agent can authenticate without a literal key: either it - * expands an env reference in headers, or it resolves the variable natively. - */ - supportsEnvAuth: boolean; /** Absent when the agent has no rules mechanism. */ rule?: McpRuleSpec; /** Paths whose existence means the agent is installed. */ @@ -176,7 +164,6 @@ export const MCP_CLIENTS: Record = { { type: 'http', url: FIRECRAWL_MCP_URL }, ENV_HEADER.shell ), - supportsEnvAuth: true, rule: { kind: 'file', content: RULE_BODY, @@ -196,7 +183,6 @@ export const MCP_CLIENTS: Record = { projectConfigPath: (ctx) => path.join(ctx.cwd, '.cursor', 'mcp.json'), buildEntry: (ctx) => withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), - supportsEnvAuth: true, rule: { kind: 'file', content: CURSOR_RULE, @@ -220,7 +206,6 @@ export const MCP_CLIENTS: Record = { { type: 'http', url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor ), - supportsEnvAuth: true, rule: { kind: 'file', content: VSCODE_RULE, @@ -249,7 +234,6 @@ export const MCP_CLIENTS: Record = { ctx.auth === 'env' ? { url: FIRECRAWL_MCP_URL, bearer_token_env_var: API_KEY_ENV_VAR } : { url: FIRECRAWL_MCP_URL }, - supportsEnvAuth: true, rule: { kind: 'append', content: RULE_BODY, @@ -272,7 +256,6 @@ export const MCP_CLIENTS: Record = { { type: 'remote', url: FIRECRAWL_MCP_URL, enabled: true }, ENV_HEADER.brace ), - supportsEnvAuth: true, rule: { kind: 'append', content: RULE_BODY, @@ -282,33 +265,6 @@ export const MCP_CLIENTS: Record = { }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, - windsurf: { - id: 'windsurf', - name: 'Windsurf', - format: 'json', - serversKey: 'mcpServers', - // Windsurf has no project-level MCP config; it always gets the global one. - globalConfigPath: (ctx) => - path.join(ctx.home, '.codeium', 'windsurf', 'mcp_config.json'), - buildEntry: () => ({ serverUrl: FIRECRAWL_MCP_URL }), - // No verified env-reference syntax, so this agent stays keyless. - supportsEnvAuth: false, - rule: { - kind: 'append', - content: RULE_BODY, - globalPath: (ctx) => - path.join( - ctx.home, - '.codeium', - 'windsurf', - 'memories', - 'global_rules.md' - ), - projectPath: (ctx) => - path.join(ctx.cwd, '.windsurf', 'rules', 'firecrawl.md'), - }, - detectPaths: (ctx) => [path.join(ctx.home, '.codeium', 'windsurf')], - }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -317,7 +273,6 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', - 'windsurf', ]; export const MCP_LAUNCHER_NAMES: Record = { @@ -400,7 +355,6 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', - windsurf: 'windsurf', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 5799d7bb31..4eab996c66 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -292,18 +292,14 @@ export async function setupMcpClient( options: { scope: McpScope; rules: boolean; ctx: McpContext } ): Promise { const client = MCP_CLIENTS[id]; - // An agent with no verified environment-variable syntax falls back to the - // keyless endpoint rather than having a credential written literally. - const auth: McpAuthMode = - options.ctx.auth === 'env' && client.supportsEnvAuth ? 'env' : 'keyless'; - const ctx: McpContext = { ...options.ctx, auth }; + const ctx = options.ctx; const result: McpClientResult = { id, name: client.name, mcpStatus: 'failed', mcpDetail: '', - auth, + auth: ctx.auth, ruleStatus: 'skipped', ruleDetail: '', }; From f68096c699cc644939acc3beb60c4043f8b47480 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 14:19:41 -0700 Subject: [PATCH 05/19] fix(cli): correct config-writing edge cases found in review Six defects, four of them silent: * Quiet mode returned before the total-failure check, so a run in which nothing was written resolved successfully. `firecrawl init` and `firecrawl launch` both use quiet mode and reported success regardless. * The TOML writer split on "\n" only, so a config.toml with CRLF endings never matched its existing table and gained a duplicate one, leaving the file invalid and taking the rest of the user's Codex config with it. * The TOML writer absorbed comment and blank lines directly above the next table into the replaced range and deleted them. * A leading byte order mark was reported as a parse error even though the document parses, so a config written by a Windows editor was refused. * The rule fence required "\n" after its marker, so a file converted to CRLF gained a second copy of the section instead of an updated one. * `--agent all` reached only detected clients. It means every client, which is what the installer it replaced did. The fence replacement now uses a function so nothing in the rule body can be read as a replacement pattern. Line endings and byte order marks are preserved on write rather than normalised away. --- .codex/config.toml | 2 + .mcp.json | 8 +++ opencode.json | 9 ++++ src/__tests__/commands/setup.test.ts | 30 +++++++++++ src/__tests__/utils/mcp-install.test.ts | 67 +++++++++++++++++++++++++ src/commands/setup.ts | 15 ++++-- src/utils/mcp-install.ts | 39 ++++++++++---- 7 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 .codex/config.toml create mode 100644 .mcp.json create mode 100644 opencode.json diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000000..51bc8d23ee --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[mcp_servers.firecrawl] +url = "https://mcp.firecrawl.dev/v2/mcp" diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..ba95cbffd3 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "firecrawl": { + "type": "http", + "url": "https://mcp.firecrawl.dev/v2/mcp" + } + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000000..79d0161acd --- /dev/null +++ b/opencode.json @@ -0,0 +1,9 @@ +{ + "mcp": { + "firecrawl": { + "type": "remote", + "url": "https://mcp.firecrawl.dev/v2/mcp", + "enabled": true + } + } +} diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index e3fb0ae4c4..24c5c43ee5 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -418,6 +418,36 @@ describe('handleSetupCommand', () => { ).toBe(MCP_URL); }); + it('surfaces total failure even in quiet mode', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); + + // init and launch both pass quiet, and must not report success when + // nothing was written. + await expect( + installMcp({ clients: ['cursor'], yes: true, quiet: true, keyless: true }) + ).rejects.toThrow('Failed to configure Firecrawl MCP'); + }); + + it('configures every client with --agent all, detected or not', async () => { + await handleSetupCommand('mcp', { + agent: 'all', + global: true, + yes: true, + keyless: true, + }); + + for (const id of [ + 'claude', + 'cursor', + 'codex', + 'vscode', + 'opencode', + ] as const) { + expect(existsSync(globalConfigPath(id, sandboxHome))).toBe(true); + } + }); + it('rejects a stored key before writing Hermes MCP config', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); process.env.HOME = home; diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index ac6e685f74..adff97415e 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -86,6 +86,24 @@ describe('mcp install', () => { expect(result).toContain(MCP_URL); }); + it('accepts a config that starts with a byte order mark', async () => { + const file = path.join(root, 'bom.json'); + writeFileSync( + file, + '\uFEFF{ "mcpServers": { "own": { "url": "https://x" } } }' + ); + + const { status } = await writeJsonServerEntry(file, 'mcpServers', 'fc', { + url: MCP_URL, + }); + + const result = read(file); + expect(status).toBe('configured'); + expect(result.startsWith('\uFEFF')).toBe(true); + expect(result).toContain('"own"'); + expect(result).toContain(MCP_URL); + }); + it('reports reconfigured when the server is already present', async () => { const file = path.join(root, 'mcp.json'); writeFileSync( @@ -170,6 +188,41 @@ describe('mcp install', () => { expect(content).toContain(`url = "${MCP_URL}"`); }); + it('matches an existing table in a CRLF file instead of duplicating it', () => { + const crlf = + 'model = "gpt-5"\r\n\r\n[mcp_servers.firecrawl]\r\nurl = "https://old"\r\n'; + + const { content, alreadyExists } = upsertTomlServer(crlf, 'firecrawl', { + url: MCP_URL, + }); + + expect(alreadyExists).toBe(true); + expect(content.match(/\[mcp_servers\.firecrawl\]/g)).toHaveLength(1); + expect(content).toContain('\r\n'); + expect( + upsertTomlServer(content, 'firecrawl', { url: MCP_URL }).content + ).toBe(content); + }); + + it('keeps comments that introduce the following table', () => { + const existing = [ + '[mcp_servers.firecrawl]', + 'url = "https://old"', + '', + '# notes about the next server', + '[mcp_servers.other]', + 'url = "https://example.com/mcp"', + '', + ].join('\n'); + + const { content } = upsertTomlServer(existing, 'firecrawl', { + url: MCP_URL, + }); + + expect(content).toContain('# notes about the next server'); + expect(content).toContain('[mcp_servers.other]'); + }); + it('is stable across repeated writes', () => { const first = upsertTomlServer('', 'firecrawl', { url: MCP_URL }).content; const second = upsertTomlServer(first, 'firecrawl', { @@ -197,6 +250,20 @@ describe('mcp install', () => { }); }); + describe('appendRuleSection line endings', () => { + it('replaces its section after the file is converted to CRLF', async () => { + const file = path.join(root, 'AGENTS.md'); + + expect(await appendRuleSection(file, 'first\n')).toBe('installed'); + writeFileSync(file, read(file).replace(/\n/g, '\r\n')); + + expect(await appendRuleSection(file, 'second\n')).toBe('updated'); + const result = read(file); + expect(result.match(//g)).toHaveLength(2); + expect(result).not.toContain('first'); + }); + }); + describe('setupMcpClient', () => { it('writes the keyless URL with no credentials', async () => { const result = await setupMcpClient('cursor', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 9fe5844099..681fefde57 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -29,6 +29,7 @@ import { type WebAgent, } from '../utils/web-defaults'; import { + ALL_MCP_CLIENT_IDS, ALL_MCP_LAUNCHER_IDS, ALL_MCP_TARGET_IDS, detectMcpClients, @@ -673,7 +674,9 @@ async function installMcpClients( // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; - let selected = explicitIds ?? options.clients; + let selected = includeAllLaunchers + ? [...ALL_MCP_CLIENT_IDS] + : (explicitIds ?? options.clients); if (!selected || selected.length === 0) { const detected: McpTargetId[] = [ ...(await detectMcpClients(ctx)), @@ -695,8 +698,8 @@ async function installMcpClients( } } - // `--agent all` reaches every launch integration whether or not it looks - // installed, which is what the flag has always meant. + // `--agent all` reaches every integration whether or not it looks installed, + // which is what the flag has always meant. if (includeAllLaunchers) { selected = [ ...selected.filter((id) => !isMcpLauncherId(id)), @@ -781,6 +784,12 @@ function reportMcpResults( : ` ${green}✓${reset} Firecrawl MCP configured for ${result.name}` ); } + for (const note of authNotes(results, ctx, hasApiKey)) { + console.log(` ${dim}${note}${reset}`); + } + if (succeeded.length === 0) { + throw new Error('Failed to configure Firecrawl MCP.'); + } return; } diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 4eab996c66..4310565fb0 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -81,11 +81,19 @@ export async function writeJsonServerEntry( serverName: string, entry: Record ): Promise<{ status: 'configured' | 'reconfigured' }> { - const raw = await readIfExists(filePath); + const stored = await readIfExists(filePath); + // A byte order mark is reported as a parse error even though the document is + // valid, and editors on Windows write one routinely. Keep it off the parse + // and put it back on write. + const bom = stored?.startsWith('\uFEFF') ? '\uFEFF' : ''; + const raw = bom ? stored!.slice(1) : stored; if (raw === undefined || raw.trim() === '') { const fresh = { [serversKey]: { [serverName]: entry } }; - await writeFileEnsuringDir(filePath, `${JSON.stringify(fresh, null, 2)}\n`); + await writeFileEnsuringDir( + filePath, + `${bom}${JSON.stringify(fresh, null, 2)}\n` + ); return { status: 'configured' }; } @@ -119,7 +127,7 @@ export async function writeJsonServerEntry( { formattingOptions: { insertSpaces: true, tabSize: 2 } } ); - await writeFileEnsuringDir(filePath, applyEdits(raw, edits)); + await writeFileEnsuringDir(filePath, `${bom}${applyEdits(raw, edits)}`); return { status: alreadyExists ? 'reconfigured' : 'configured' }; } @@ -142,7 +150,10 @@ export function upsertTomlServer( ), ]; - const lines = content === '' ? [] : content.split('\n'); + // Preserve the file's existing line ending; a CRLF config must not be + // treated as one unmatchable line per table. + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const lines = content === '' ? [] : content.split(/\r?\n/); const escaped = escapeRegExp(serverName); const ownTable = new RegExp( `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` @@ -159,7 +170,7 @@ export function upsertTomlServer( } const separator = trimmed.length === 0 ? [] : ['']; return { - content: [...trimmed, ...separator, ...block, ''].join('\n'), + content: [...trimmed, ...separator, ...block, ''].join(eol), alreadyExists: false, }; } @@ -169,6 +180,11 @@ export function upsertTomlServer( if (anyTable.test(lines[end]) && !ownTable.test(lines[end])) break; end += 1; } + // Comments and blank lines directly above the next table introduce it, so + // they belong to the user's content rather than to the block being replaced. + while (end - 1 > start && /^[ \t]*(#.*)?$/.test(lines[end - 1])) { + end -= 1; + } const rest = lines.slice(end); // Keep a blank line between our block and whatever follows it. @@ -178,12 +194,12 @@ export function upsertTomlServer( ...block, ...separator, ...rest, - ].join('\n'); + ].join(eol); // Consuming the old table can swallow the file's final newline; restoring it // keeps repeat runs byte-identical. return { - content: replaced.endsWith('\n') ? replaced : `${replaced}\n`, + content: replaced.endsWith(eol) ? replaced : `${replaced}${eol}`, alreadyExists: true, }; } @@ -209,10 +225,15 @@ export async function appendRuleSection( const section = `${RULE_MARKER}\n${content}${RULE_MARKER}`; const existing = (await readIfExists(filePath)) ?? ''; const marker = escapeRegExp(RULE_MARKER); - const fenced = new RegExp(`${marker}\\n[\\s\\S]*?${marker}`); + const fenced = new RegExp(`${marker}\\r?\\n[\\s\\S]*?${marker}`); if (fenced.test(existing)) { - await writeFileEnsuringDir(filePath, existing.replace(fenced, section)); + // Replace via a function so nothing in the rule body is read as a + // replacement pattern. + await writeFileEnsuringDir( + filePath, + existing.replace(fenced, () => section) + ); return 'updated'; } From 91621319f2daa8f358d472e16ec1b2f37ed25035 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 14:50:26 -0700 Subject: [PATCH 06/19] fix(cli): keep skills-only agents from failing setup, and settle the scope flag `firecrawl setup --yes --agent windsurf` installed skills and then aborted, because MCP setup rejected a name it writes no config for. An agent we support for skills but not for MCP is not an error: the run now finishes, skips the MCP step, and prints the server URL so the user can wire it up themselves. A name nothing supports is still rejected, so a typo does not silently do nothing. Scope: global is the intended default, so that one command reaches every agent surface rather than the current checkout alone. `--project` is the only scope flag that means anything on setup. `-g` is accepted for existing scripts but hidden from help and reported as deprecated when used, and the mutually exclusive scope error it existed for is gone. `-g` is untouched on init and launch. Tests also pin USERPROFILE and APPDATA alongside HOME. os.homedir() reads USERPROFILE on Windows, so the sandbox that keeps a test run away from the developer's own agent config was doing nothing there. --- README.md | 5 ++-- src/__tests__/commands/setup.test.ts | 42 ++++++++++++++++++++-------- src/commands/setup.ts | 34 +++++++++++++++------- src/commands/skills-native.ts | 5 ++++ src/index.ts | 12 +++++++- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0b72794bba..f8cb7d10d6 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,10 @@ asks whether to add rules telling those agents to prefer Firecrawl for web search and scraping. Supported agents are Claude Code, Cursor, VS Code, Codex, OpenCode, Hermes Agent, and OpenClaw. +Setup writes to your global agent settings by default, so one command puts +Firecrawl on every agent you already use rather than only the current checkout. Pass agent flags to skip the picker, `-y` to configure every detected agent -(MCP only), or `--project` to write to the current project instead of your -global agent settings: +(MCP only), or `--project` to scope the change to this repository: ```bash firecrawl setup mcp --claude --cursor # skip the picker diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 24c5c43ee5..3e28c80f51 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -55,6 +55,8 @@ describe('handleSetupCommand', () => { let originalApiKey: string | undefined; let sandboxHome: string; let originalPath: string | undefined; + let originalUserProfile: string | undefined; + let originalAppData: string | undefined; beforeEach(() => { vi.clearAllMocks(); @@ -70,6 +72,12 @@ describe('handleSetupCommand', () => { // home. Without this a test run would rewrite the developer's own editors. sandboxHome = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-home-')); process.env.HOME = sandboxHome; + // os.homedir() reads USERPROFILE on Windows, and app-support paths read + // APPDATA, so HOME alone would leave a Windows run writing the real profile. + originalUserProfile = process.env.USERPROFILE; + originalAppData = process.env.APPDATA; + process.env.USERPROFILE = sandboxHome; + process.env.APPDATA = path.join(sandboxHome, 'AppData', 'Roaming'); // Launcher detection also looks on PATH, so pin it for the same reason. originalPath = process.env.PATH; process.env.PATH = ''; @@ -79,6 +87,10 @@ describe('handleSetupCommand', () => { rmSync(sandboxHome, { recursive: true, force: true }); if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = originalAppData; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY; @@ -448,6 +460,25 @@ describe('handleSetupCommand', () => { } }); + it('skips MCP for a skills-only agent instead of failing the run', async () => { + // Skills already installed by this point in `setup --yes --agent windsurf`. + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await expect( + handleSetupCommand('mcp', { agent: 'windsurf', yes: true }) + ).resolves.toBeUndefined(); + + expect(log.mock.calls.flat().join(' ')).toContain( + 'https://mcp.firecrawl.dev/v2/mcp' + ); + }); + + it('still rejects an agent name nothing supports', async () => { + await expect( + handleSetupCommand('mcp', { agent: 'not-an-agent', yes: true }) + ).rejects.toThrow('Unknown agent'); + }); + it('rejects a stored key before writing Hermes MCP config', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); process.env.HOME = home; @@ -793,17 +824,6 @@ describe('handleSetupCommand', () => { // --- Scope: project and global are mutually exclusive --- - it('rejects conflicting MCP scope flags', async () => { - await expect( - handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, - project: true, - }) - ).rejects.toThrow('Choose either --global or --project'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - it('keeps project scope for an environment-backed credential', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-env-')); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 681fefde57..54e004b6c4 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -22,7 +22,11 @@ import { SKILL_REPOS, WORKFLOW_SKILL_REPOS, } from './skills-install'; -import { hasNpx, installSkillsNative } from './skills-native'; +import { + hasNpx, + installSkillsNative, + isSkillsAgentName, +} from './skills-native'; import { configureWebDefaults, WEB_AGENTS, @@ -30,6 +34,7 @@ import { } from '../utils/web-defaults'; import { ALL_MCP_CLIENT_IDS, + FIRECRAWL_MCP_URL, ALL_MCP_LAUNCHER_IDS, ALL_MCP_TARGET_IDS, detectMcpClients, @@ -51,6 +56,7 @@ type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = | { kind: 'clients'; ids?: McpTargetId[] } + | { kind: 'skills-only'; agent: string } | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -266,12 +272,15 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { return { kind: 'openclaw' }; default: { const id = resolveMcpClientId(normalized); - if (!id) { - throw new Error( - `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` - ); + if (id) return { kind: 'clients', ids: [id] }; + // A name we install skills for but write no MCP config for is not an + // error; the caller may have already installed skills for it. + if (isSkillsAgentName(normalized)) { + return { kind: 'skills-only', agent }; } - return { kind: 'clients', ids: [id] }; + throw new Error( + `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` + ); } } } @@ -544,13 +553,18 @@ export async function installMcp( // 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.'); - } - const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); + if (resolvedAgent.kind === 'skills-only') { + // Skills for this agent have already installed by this point; ending the + // run here would fail a command that mostly succeeded. + console.log( + `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${FIRECRAWL_MCP_URL} to connect it yourself.` + ); + return; + } + if (resolvedAgent.kind === 'hermes') { await installHermesMcp(runtimeEnv, options.keyless); return; diff --git a/src/commands/skills-native.ts b/src/commands/skills-native.ts index c899d17e02..acd8db5767 100644 --- a/src/commands/skills-native.ts +++ b/src/commands/skills-native.ts @@ -193,6 +193,11 @@ function resolveAgentConfig(agent: string): AgentConfig | undefined { return AGENTS.find((candidate) => candidate.name === normalized); } +/** True when this name is a supported skills target, whatever else supports it. */ +export function isSkillsAgentName(agent: string): boolean { + return resolveAgentConfig(agent) !== undefined; +} + /** * Discover all skills in a directory tree by finding SKILL.md files. */ diff --git a/src/index.ts b/src/index.ts index a07d9e602e..530dab2fd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2242,7 +2242,6 @@ const setupCommand = program '[subcommand]', 'What to set up: "skills", "workflows", "mcp", or "defaults"; omit for an interactive installer' ) - .option('-g, --global', 'Install globally (user-level)') .option( '--project', 'For "mcp", install into project scope (stored API keys are never written to project files)' @@ -2269,6 +2268,12 @@ for (const id of ALL_MCP_TARGET_IDS) { setupCommand.option(`--${id}`, `Set up ${mcpTargetName(id)} (mcp)`); } +// `-g` is the old way to ask for the global scope that is now the default. +// Kept so existing scripts keep running, hidden because it does nothing. +setupCommand.addOption( + new Option('-g, --global', 'Deprecated; global is the default').hideHelp() +); + setupCommand .option('--rules', 'Install rules that prefer Firecrawl for web work (mcp)') .option('--no-rules', 'Skip the rules prompt and install MCP only (mcp)') @@ -2284,6 +2289,11 @@ Examples: ` ) .action(async (subcommand: SetupSubcommand, options) => { + if (options.global) { + console.error( + 'Note: -g/--global is deprecated for setup. Global is the default; use --project for project scope.' + ); + } await handleSetupCommand(subcommand, { ...options, clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), From 5fb50b579520f7596182963443e346899144fa8e Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 16:37:21 -0700 Subject: [PATCH 07/19] fix(cli): keep MCP setup global-only and keyless for stored launcher keys Project scope fought the one-command-every-agent goal, and --agent hermes/openclaw aborted on a stored key while the boolean flags wrote keyless config. --- README.md | 16 +- src/__tests__/commands/setup.test.ts | 188 +++++++++++------------- src/__tests__/utils/mcp-install.test.ts | 25 +++- src/commands/setup.ts | 37 ++--- src/index.ts | 7 +- src/utils/mcp-clients.ts | 31 +--- src/utils/mcp-install.ts | 31 +--- 7 files changed, 134 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index f8cb7d10d6..3e9b44da36 100644 --- a/README.md +++ b/README.md @@ -81,21 +81,19 @@ To install the Firecrawl MCP server into your coding agents: firecrawl setup mcp ``` -This detects which agents you have installed, pre-selects them in a picker, and -asks whether to add rules telling those agents to prefer Firecrawl for web -search and scraping. Supported agents are Claude Code, Cursor, VS Code, Codex, -OpenCode, Hermes Agent, and OpenClaw. +This detects which agents you have installed, lists those in a picker +(already selected), and asks whether to add rules telling those agents to +prefer Firecrawl for web search and scraping. Supported agents are Claude Code, +Cursor, VS Code, Codex, OpenCode, Hermes Agent, and OpenClaw. -Setup writes to your global agent settings by default, so one command puts -Firecrawl on every agent you already use rather than only the current checkout. -Pass agent flags to skip the picker, `-y` to configure every detected agent -(MCP only), or `--project` to scope the change to this repository: +Setup writes to your global agent settings, so one command puts Firecrawl on +every agent you already use. Pass agent flags to skip the picker, or `-y` to +configure every detected agent (MCP only): ```bash firecrawl setup mcp --claude --cursor # skip the picker firecrawl setup mcp -y # every detected agent, MCP only firecrawl setup mcp -y --rules # ...and install the rules too -firecrawl setup mcp --project --cursor # write project config ``` Rerun the command any time to update an existing setup or add another agent; it diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 3e28c80f51..ffffc14c07 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -50,6 +50,11 @@ vi.mock('../../utils/config', () => ({ getApiKey: vi.fn(() => 'fc-test-key'), })); +vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), + confirm: vi.fn(), +})); + describe('handleSetupCommand', () => { let originalHome: string | undefined; let originalApiKey: string | undefined; @@ -396,6 +401,44 @@ describe('handleSetupCommand', () => { ).toContain('firecrawl:'); }); + it('lists only detected agents in the picker, already selected', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + const { checkbox, confirm } = await import('@inquirer/prompts'); + vi.mocked(checkbox).mockResolvedValue(['cursor']); + vi.mocked(confirm).mockResolvedValue(false); + + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: true, + }); + + try { + await handleSetupCommand('mcp', {}); + + expect(checkbox).toHaveBeenCalledOnce(); + expect(vi.mocked(checkbox).mock.calls[0]?.[0]).toMatchObject({ + choices: [ + { value: 'cursor', checked: true }, + { value: 'hermes', checked: true }, + ], + }); + expect(existsSync(path.join(sandboxHome, '.cursor', 'mcp.json'))).toBe( + true + ); + expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( + false + ); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + } + }); + it('detects an installed launcher so the picker can pre-select it', async () => { mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); @@ -479,30 +522,30 @@ describe('handleSetupCommand', () => { ).rejects.toThrow('Unknown agent'); }); - it('rejects a stored key before writing Hermes MCP config', async () => { + it('falls back to keyless Hermes MCP when only a stored key exists', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); process.env.HOME = home; const configPath = path.join(home, '.hermes', 'config.yaml'); mkdirSync(path.dirname(configPath), { recursive: true }); - const originalConfig = - 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n'; - writeFileSync(configPath, originalConfig, { mode: 0o600 }); + writeFileSync( + configPath, + 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n', + { mode: 0o600 } + ); try { - await expect( - handleSetupCommand('mcp', { - agent: 'hermes', - global: true, - yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); + await handleSetupCommand('mcp', { + agent: 'hermes', + global: true, + yes: true, + }); const config = readFileSync(configPath, 'utf-8'); - expect(config).toBe(originalConfig); expect(config).toContain('theme: dark'); expect(config).toContain('existing:'); - expect(config).toContain('mcp_servers:'); - expect(config).not.toContain('firecrawl:'); + expect(config).toContain('firecrawl:'); + expect(config).toContain(MCP_URL); + expect(config).not.toContain('Authorization'); expect(config).not.toContain('fc-test-key'); expect(execFileSync).not.toHaveBeenCalled(); if (process.platform !== 'win32') { @@ -555,12 +598,34 @@ describe('handleSetupCommand', () => { } }); + it('suppresses Hermes installer logs in quiet mode', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await installMcp({ agent: 'hermes', quiet: true, keyless: true }); + expect(log.mock.calls.flat().join('\n')).not.toContain( + 'Hermes Agent MCP configured' + ); + } finally { + log.mockRestore(); + } + }); + it('rejects a stored key before invoking the OpenClaw CLI', async () => { await expect(installOpenClawMcp()).rejects.toThrow( 'Export FIRECRAWL_API_KEY' ); expect(execFileSync).not.toHaveBeenCalled(); }); + + it('falls back to keyless OpenClaw MCP when only a stored key exists', async () => { + await installMcp({ agent: 'openclaw' }); + + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain(MCP_URL); + expect(config).not.toContain('Authorization'); + expect(config).not.toContain('fc-test-key'); + }); it('uses OpenClaw environment expansion instead of persisting an env-backed key', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; @@ -642,50 +707,14 @@ describe('handleSetupCommand', () => { } }); - it('keeps an environment-backed --agent all project setup free of literals', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-env-') - ); - mkdirSync(path.join(home, '.cursor'), { recursive: true }); - process.env.HOME = home; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-proj-cwd-')); - const originalCwd = process.cwd(); - process.chdir(cwd); - - try { - await handleSetupCommand('mcp', { - agent: 'all', - project: true, - yes: true, - }); - - const config = readFileSync( - path.join(cwd, '.cursor', 'mcp.json'), - 'utf-8' - ); - expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ - Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', - }); - expect(config).not.toContain('fc-test-key'); - } finally { - process.chdir(originalCwd); - rmSync(cwd, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - it('keeps keyless --agent all project setup available', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-all-project-keyless-') - ); + it('keeps keyless --agent all setup available', async () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-keyless-')); process.env.HOME = home; vi.mocked(getApiKey).mockReturnValue(undefined); try { await handleSetupCommand('mcp', { agent: 'all', - project: true, yes: true, }); @@ -822,60 +851,7 @@ describe('handleSetupCommand', () => { } }); - // --- Scope: project and global are mutually exclusive --- - - it('keeps project scope for an environment-backed credential', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-env-')); - const originalCwd = process.cwd(); - process.chdir(cwd); - - try { - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); - - const config = readFileSync( - path.join(cwd, '.cursor', 'mcp.json'), - 'utf-8' - ); - expect(JSON.parse(config).mcpServers.firecrawl.headers).toEqual({ - Authorization: 'Bearer ${env:FIRECRAWL_API_KEY}', - }); - expect(config).not.toContain('fc-test-key'); - } finally { - process.chdir(originalCwd); - rmSync(cwd, { recursive: true, force: true }); - } - }); - - it('writes project scope rather than global when --project is set', async () => { - vi.mocked(getApiKey).mockReturnValue(undefined); - const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-home-')); - process.env.HOME = home; - const cwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-proj-cwd-')); - const originalCwd = process.cwd(); - process.chdir(cwd); - - try { - await handleSetupCommand('mcp', { - agent: 'cursor', - project: true, - yes: true, - }); - - expect(existsSync(path.join(cwd, '.cursor', 'mcp.json'))).toBe(true); - expect(existsSync(path.join(home, '.cursor', 'mcp.json'))).toBe(false); - } finally { - process.chdir(originalCwd); - rmSync(cwd, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - it('defaults to global scope without --project', async () => { + it('writes MCP into global agent config', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-global-')); process.env.HOME = home; diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index adff97415e..20f1b91609 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -267,7 +267,6 @@ describe('mcp install', () => { describe('setupMcpClient', () => { it('writes the keyless URL with no credentials', async () => { const result = await setupMcpClient('cursor', { - scope: 'global', rules: false, ctx, }); @@ -281,7 +280,6 @@ describe('mcp install', () => { it('references the env var instead of writing a credential', async () => { const result = await setupMcpClient('claude', { - scope: 'global', rules: false, ctx: { ...ctx, auth: 'env' }, }); @@ -297,11 +295,23 @@ describe('mcp install', () => { }); }); + it('honours CLAUDE_CONFIG_DIR', async () => { + const configDir = path.join(root, 'claude-config'); + const result = await setupMcpClient('claude', { + rules: true, + ctx: { ...ctx, env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + + expect(result.mcpDetail).toBe(path.join(configDir, '.claude.json')); + expect(result.ruleDetail).toBe( + path.join(configDir, 'rules', 'firecrawl.md') + ); + }); + it('uses the environment-reference syntax each agent expands', async () => { const written: Record = {}; for (const id of ['cursor', 'vscode', 'opencode'] as const) { const result = await setupMcpClient(id, { - scope: 'global', rules: false, ctx: { ...ctx, auth: 'env' }, }); @@ -321,7 +331,6 @@ describe('mcp install', () => { it('authenticates Codex through its native bearer token variable', async () => { await setupMcpClient('codex', { - scope: 'global', rules: false, ctx: { ...ctx, auth: 'env' }, }); @@ -337,7 +346,6 @@ describe('mcp install', () => { writeFileSync(rulesPath, 'not a directory'); const result = await setupMcpClient('cursor', { - scope: 'global', rules: true, ctx, }); @@ -352,7 +360,6 @@ describe('mcp install', () => { writeFileSync(file, '{ oops'); const result = await setupMcpClient('cursor', { - scope: 'global', rules: false, ctx, }); @@ -370,6 +377,12 @@ describe('mcp install', () => { expect(await detectMcpClients(ctx)).toEqual(['cursor', 'codex']); }); + + it('detects Claude Code from ~/.claude.json without ~/.claude', async () => { + writeFileSync(path.join(ctx.home, '.claude.json'), '{}'); + + expect(await detectMcpClients(ctx)).toEqual(['claude']); + }); }); describe('resolveMcpClientId', () => { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 54e004b6c4..669e4a8012 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -45,7 +45,6 @@ import { type McpAuthMode, type McpContext, type McpLauncherId, - type McpScope, type McpTargetId, } from '../utils/mcp-clients'; import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; @@ -63,8 +62,6 @@ type ResolvedMcpAgent = export interface SetupOptions { global?: boolean; - /** Explicitly install MCP into project scope. */ - project?: boolean; agent?: string; undo?: boolean; /** Skip the interactive harness picker and apply to all agents. */ @@ -349,7 +346,7 @@ async function handleSetupBundle(options: SetupOptions): Promise { const bundleOptions = { ...options, - global: options.project ? undefined : (options.global ?? true), + global: options.global ?? true, }; for (const integration of integrations) { await handleSetupCommand(integration, bundleOptions); @@ -555,6 +552,9 @@ export async function installMcp( ): Promise { const apiKey = options.keyless ? undefined : getApiKey(); const resolvedAgent = resolveMcpAgent(options.agent); + // Same rule as installMcpClients: a stored key cannot go into agent config, + // so --agent hermes/openclaw fall back to keyless just like --hermes/--openclaw. + const keyless = !isEnvironmentBackedApiKey(apiKey, runtimeEnv); if (resolvedAgent.kind === 'skills-only') { // Skills for this agent have already installed by this point; ending the @@ -566,13 +566,11 @@ export async function installMcp( } if (resolvedAgent.kind === 'hermes') { - await installHermesMcp(runtimeEnv, options.keyless); + await installHermesMcp(runtimeEnv, keyless, Boolean(options.quiet)); return; } if (resolvedAgent.kind === 'openclaw') { - // Hands the credential to a subprocess, so a stored key is not usable. - assertSubprocessSafeCredential(apiKey, runtimeEnv); - await installOpenClawMcp(runtimeEnv, options.keyless); + await installOpenClawMcp(runtimeEnv, keyless, Boolean(options.quiet)); return; } if (resolvedAgent.kind === 'all-launchers') { @@ -607,13 +605,11 @@ async function pickMcpClients( return checkbox({ message: 'Which agents do you want to set up?', loop: false, - // Show every agent at once; the default page size would scroll the last - // ones out of view. - pageSize: ALL_MCP_TARGET_IDS.length, - choices: ALL_MCP_TARGET_IDS.map((id) => ({ + pageSize: detected.length, + choices: detected.map((id) => ({ name: mcpTargetName(id), value: id, - checked: detected.includes(id), + checked: true, })), }); } @@ -684,7 +680,6 @@ async function installMcpClients( env: runtimeEnv, auth, }; - const scope: McpScope = options.project ? 'project' : 'global'; // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; @@ -696,12 +691,12 @@ async function installMcpClients( ...(await detectMcpClients(ctx)), ...detectMcpLaunchers(ctx), ]; + if (detected.length === 0 && !includeAllLaunchers) { + throw new Error( + 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' + ); + } if (nonInteractive) { - if (detected.length === 0 && !includeAllLaunchers) { - throw new Error( - 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' - ); - } selected = detected; } else { selected = await pickMcpClients(detected); @@ -731,7 +726,7 @@ async function installMcpClients( results.push( isMcpLauncherId(id) ? await setupMcpLauncher(id, ctx, runtimeEnv) - : await setupMcpClient(id, { scope, rules, ctx }) + : await setupMcpClient(id, { rules, ctx }) ); } @@ -769,7 +764,7 @@ function authNotes( if (!hasApiKey) { return [ - 'Running keyless (search, scrape, parse). Run "firecrawl login" and rerun to unlock the full tool surface.', + `Running keyless (search, scrape, parse). Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, ]; } diff --git a/src/index.ts b/src/index.ts index 530dab2fd7..2819ffdf77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2242,10 +2242,6 @@ const setupCommand = program '[subcommand]', 'What to set up: "skills", "workflows", "mcp", or "defaults"; omit for an interactive installer' ) - .option( - '--project', - 'For "mcp", install into project scope (stored API keys are never written to project files)' - ) .option( '-a, --agent ', 'Limit to a specific agent; required for environment-backed MCP setup, or use "all" to update every launch integration' @@ -2285,13 +2281,12 @@ Examples: $ firecrawl setup mcp --claude --cursor # skip the picker $ firecrawl setup mcp --yes # every detected agent, MCP only $ firecrawl setup mcp --yes --rules # every detected agent, with rules - $ firecrawl setup mcp --project --cursor # write project config ` ) .action(async (subcommand: SetupSubcommand, options) => { if (options.global) { console.error( - 'Note: -g/--global is deprecated for setup. Global is the default; use --project for project scope.' + 'Note: -g/--global is deprecated for setup. Global is the default.' ); } await handleSetupCommand(subcommand, { diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index fb994b60da..36328c3a5b 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -21,8 +21,6 @@ export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; -export type McpScope = 'global' | 'project'; - /** * Agent launchers that own their MCP configuration rather than reading a file * we write. They are offered alongside the editors but installed differently. @@ -55,7 +53,6 @@ export interface McpRuleSpec { kind: 'file' | 'append'; content: string; globalPath: (ctx: McpContext) => string; - projectPath?: (ctx: McpContext) => string; } export interface McpClient { @@ -65,8 +62,6 @@ export interface McpClient { /** Key of the map holding MCP servers in this agent's config. */ serversKey: string; globalConfigPath: (ctx: McpContext) => string; - /** Absent when the agent only supports global MCP configuration. */ - projectConfigPath?: (ctx: McpContext) => string; buildEntry: (ctx: McpContext) => Record; /** Absent when the agent has no rules mechanism. */ rule?: McpRuleSpec; @@ -157,7 +152,6 @@ export const MCP_CLIENTS: Record = { format: 'json', serversKey: 'mcpServers', globalConfigPath: claudeGlobalConfigPath, - projectConfigPath: (ctx) => path.join(ctx.cwd, '.mcp.json'), buildEntry: (ctx) => withEnvAuth( ctx, @@ -169,10 +163,8 @@ export const MCP_CLIENTS: Record = { content: RULE_BODY, globalPath: (ctx) => path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), - projectPath: (ctx) => - path.join(ctx.cwd, '.claude', 'rules', 'firecrawl.md'), }, - detectPaths: (ctx) => [claudeConfigDir(ctx)], + detectPaths: (ctx) => [claudeConfigDir(ctx), claudeGlobalConfigPath(ctx)], }, cursor: { id: 'cursor', @@ -180,7 +172,6 @@ export const MCP_CLIENTS: Record = { format: 'json', serversKey: 'mcpServers', globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.cursor', 'mcp.json'), buildEntry: (ctx) => withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), rule: { @@ -188,8 +179,6 @@ export const MCP_CLIENTS: Record = { content: CURSOR_RULE, globalPath: (ctx) => path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), - projectPath: (ctx) => - path.join(ctx.cwd, '.cursor', 'rules', 'firecrawl.mdc'), }, detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], }, @@ -199,7 +188,6 @@ export const MCP_CLIENTS: Record = { format: 'json', serversKey: 'servers', globalConfigPath: (ctx) => path.join(vscodeUserDir(ctx), 'mcp.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.vscode', 'mcp.json'), buildEntry: (ctx) => withEnvAuth( ctx, @@ -211,13 +199,6 @@ export const MCP_CLIENTS: Record = { content: VSCODE_RULE, globalPath: (ctx) => path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), - projectPath: (ctx) => - path.join( - ctx.cwd, - '.github', - 'instructions', - 'firecrawl.instructions.md' - ), }, detectPaths: (ctx) => [vscodeUserDir(ctx)], }, @@ -227,7 +208,6 @@ export const MCP_CLIENTS: Record = { format: 'toml', serversKey: 'mcp_servers', globalConfigPath: (ctx) => path.join(ctx.home, '.codex', 'config.toml'), - projectConfigPath: (ctx) => path.join(ctx.cwd, '.codex', 'config.toml'), // Codex resolves the bearer token from the environment by variable name, // so it authenticates without a header template. buildEntry: (ctx) => @@ -238,7 +218,6 @@ export const MCP_CLIENTS: Record = { kind: 'append', content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), - projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), }, detectPaths: (ctx) => [path.join(ctx.home, '.codex')], }, @@ -249,7 +228,6 @@ export const MCP_CLIENTS: Record = { serversKey: 'mcp', globalConfigPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'opencode.json'), - projectConfigPath: (ctx) => path.join(ctx.cwd, 'opencode.json'), buildEntry: (ctx) => withEnvAuth( ctx, @@ -261,7 +239,6 @@ export const MCP_CLIENTS: Record = { content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), - projectPath: (ctx) => path.join(ctx.cwd, 'AGENTS.md'), }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, @@ -320,9 +297,9 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { } /** - * Detection prefers a false negative to a false positive: every agent is listed - * in the picker either way, so failing to pre-select one costs a keystroke, - * while pre-selecting an agent the user does not have is misleading. + * Detection prefers a false negative to a false positive: the picker only + * lists agents that look installed, so a miss means the user passes a flag + * (`--cursor`) instead of seeing an agent they do not have. * * `hermes` is therefore matched on its config directory alone. The name is also * used by an unrelated JavaScript engine that ships with common toolchains, so diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 4310565fb0..8d18bc1f5c 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -20,7 +20,6 @@ import { type McpClient, type McpClientId, type McpContext, - type McpScope, type McpTargetId, } from './mcp-clients'; @@ -243,20 +242,11 @@ export async function appendRuleSection( return 'installed'; } -function configPathFor(client: McpClient, scope: McpScope, ctx: McpContext) { - // Agents without project support always take the global path. - const projectPath = client.projectConfigPath?.(ctx); - return scope === 'project' && projectPath - ? projectPath - : client.globalConfigPath(ctx); -} - async function writeMcpEntry( client: McpClient, - scope: McpScope, ctx: McpContext ): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { - const configPath = configPathFor(client, scope, ctx); + const configPath = client.globalConfigPath(ctx); const entry = client.buildEntry(ctx); if (client.format === 'toml') { @@ -288,15 +278,12 @@ async function writeMcpEntry( async function writeRule( client: McpClient, - scope: McpScope, ctx: McpContext ): Promise<{ status: 'installed' | 'updated' | 'unsupported'; path: string }> { const rule = client.rule; if (!rule) return { status: 'unsupported', path: '' }; - const projectPath = rule.projectPath?.(ctx); - const rulePath = - scope === 'project' && projectPath ? projectPath : rule.globalPath(ctx); + const rulePath = rule.globalPath(ctx); const status = rule.kind === 'file' ? await writeRuleFile(rulePath, rule.content) @@ -310,7 +297,7 @@ async function writeRule( */ export async function setupMcpClient( id: McpClientId, - options: { scope: McpScope; rules: boolean; ctx: McpContext } + options: { rules: boolean; ctx: McpContext } ): Promise { const client = MCP_CLIENTS[id]; const ctx = options.ctx; @@ -326,11 +313,7 @@ export async function setupMcpClient( }; try { - const { status, configPath } = await writeMcpEntry( - client, - options.scope, - ctx - ); + const { status, configPath } = await writeMcpEntry(client, ctx); result.mcpStatus = status; result.mcpDetail = configPath; } catch (error) { @@ -340,11 +323,7 @@ export async function setupMcpClient( if (!options.rules) return result; try { - const { status, path: rulePath } = await writeRule( - client, - options.scope, - ctx - ); + const { status, path: rulePath } = await writeRule(client, ctx); result.ruleStatus = status; result.ruleDetail = rulePath; } catch (error) { From f6d3d7e2b1df3b3ba6cfe7d299b7b9d1a7d21c52 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Wed, 12 Aug 2026 16:48:19 -0700 Subject: [PATCH 08/19] fix(cli): one stored-key rule, accurate agent aliases, no repo config artifacts Three Firecrawl MCP configs were tracked at the repository root. Tests wrote them into the working directory and a broad `git add` swept them in, so anyone opening this checkout inherited MCP servers from the repo. Removed, and every setup test now runs in its own working directory so project-relative writes cannot reach the repository again. A stored key behaved three different ways depending on how the target was named: keyless for the boolean flags, keyless for `--agent hermes`, and a hard abort for `--agent all`. The README documents keyless. `--agent all` now agrees with the rest, and the launchers report through the same summary so the keyless fallback is stated rather than implied by a bare installer log line. `--agent launchers` was a synonym for every agent plus both launchers, which is not what the name says. It now selects the launchers. Launcher dispatch is exhaustive rather than treating anything that is not Hermes as OpenClaw, and doctor recognises OpenCode's top-level `mcp` map, which it previously reported as unregistered right after setup wrote it. --- .codex/config.toml | 2 -- .mcp.json | 8 ------ opencode.json | 9 ------ src/__tests__/commands/setup.test.ts | 41 ++++++++++++++++++++++------ src/commands/setup.ts | 40 +++++++++++++++++---------- src/utils/agents.ts | 4 +-- 6 files changed, 60 insertions(+), 44 deletions(-) delete mode 100644 .codex/config.toml delete mode 100644 .mcp.json delete mode 100644 opencode.json diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 51bc8d23ee..0000000000 --- a/.codex/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[mcp_servers.firecrawl] -url = "https://mcp.firecrawl.dev/v2/mcp" diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index ba95cbffd3..0000000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "firecrawl": { - "type": "http", - "url": "https://mcp.firecrawl.dev/v2/mcp" - } - } -} diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 79d0161acd..0000000000 --- a/opencode.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcp": { - "firecrawl": { - "type": "remote", - "url": "https://mcp.firecrawl.dev/v2/mcp", - "enabled": true - } - } -} diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index ffffc14c07..a377bdf3f2 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -62,6 +62,8 @@ describe('handleSetupCommand', () => { let originalPath: string | undefined; let originalUserProfile: string | undefined; let originalAppData: string | undefined; + let originalCwd: string; + let sandboxCwd: string; beforeEach(() => { vi.clearAllMocks(); @@ -83,12 +85,19 @@ describe('handleSetupCommand', () => { originalAppData = process.env.APPDATA; process.env.USERPROFILE = sandboxHome; process.env.APPDATA = path.join(sandboxHome, 'AppData', 'Roaming'); + // Project scope writes relative to cwd, so a run must not be able to drop + // config files into the repository itself. + originalCwd = process.cwd(); + sandboxCwd = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-cwd-')); + process.chdir(sandboxCwd); // Launcher detection also looks on PATH, so pin it for the same reason. originalPath = process.env.PATH; process.env.PATH = ''; }); afterEach(() => { + process.chdir(originalCwd); + rmSync(sandboxCwd, { recursive: true, force: true }); rmSync(sandboxHome, { recursive: true, force: true }); if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath; @@ -656,15 +665,29 @@ describe('handleSetupCommand', () => { ); }); - it('rejects stored credentials before configuring any launch integration', async () => { - await expect( - handleSetupCommand('mcp', { - agent: 'all', - global: true, - yes: true, - }) - ).rejects.toThrow('Export FIRECRAWL_API_KEY'); - expect(execFileSync).not.toHaveBeenCalled(); + it('falls back to keyless for a stored key on every launch integration', async () => { + // One rule everywhere: a stored key is never written, and --agent all + // configures keyless rather than aborting the way it used to. + await handleSetupCommand('mcp', { agent: 'all', yes: true }); + + const hermes = readFileSync( + path.join(sandboxHome, '.hermes', 'config.yaml'), + 'utf-8' + ); + expect(hermes).toContain('firecrawl:'); + expect(hermes).not.toContain('fc-test-key'); + expect( + readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') + ).not.toContain('fc-test-key'); + }); + + it('treats --agent launchers as the launchers, not as every agent', async () => { + await handleSetupCommand('mcp', { agent: 'launchers', yes: true }); + + expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( + true + ); + expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); }); it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 669e4a8012..16c76f3b86 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -55,6 +55,7 @@ type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = | { kind: 'clients'; ids?: McpTargetId[] } + | { kind: 'launchers' } | { kind: 'skills-only'; agent: string } | { kind: 'hermes' } | { kind: 'openclaw' } @@ -259,9 +260,10 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { switch (normalized) { case '*': case 'all': + return { kind: 'all-launchers' }; case 'launchers': case 'launcher': - return { kind: 'all-launchers' }; + return { kind: 'launchers' }; case 'hermes': case 'hermes-agent': return { kind: 'hermes' }; @@ -565,18 +567,21 @@ export async function installMcp( return; } - if (resolvedAgent.kind === 'hermes') { - await installHermesMcp(runtimeEnv, keyless, Boolean(options.quiet)); + if (resolvedAgent.kind === 'hermes' || resolvedAgent.kind === 'openclaw') { + // Routed through the same reporter as every other target so the keyless + // fallback is stated rather than implied by a bare installer log line. + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + resolvedAgent.kind, + ]); return; } - if (resolvedAgent.kind === 'openclaw') { - await installOpenClawMcp(runtimeEnv, keyless, Boolean(options.quiet)); + if (resolvedAgent.kind === 'launchers') { + await installMcpClients({ ...options, yes: true }, runtimeEnv, [ + ...ALL_MCP_LAUNCHER_IDS, + ]); return; } if (resolvedAgent.kind === 'all-launchers') { - // Fails closed before touching anything: this path reaches launchers that - // hand the credential to a subprocess. - assertSubprocessSafeCredential(apiKey, runtimeEnv); await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { includeAllLaunchers: true, }); @@ -636,12 +641,19 @@ async function setupMcpLauncher( }; try { - if (id === 'hermes') { - await installHermesMcp(runtimeEnv, keyless, true); - result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); - } else { - await installOpenClawMcp(runtimeEnv, keyless, true); - result.mcpDetail = 'via the openclaw CLI'; + switch (id) { + case 'hermes': + await installHermesMcp(runtimeEnv, keyless, true); + result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); + break; + case 'openclaw': + await installOpenClawMcp(runtimeEnv, keyless, true); + result.mcpDetail = 'via the openclaw CLI'; + break; + default: { + const unreachable: never = id; + throw new Error(`No installer for launcher ${String(unreachable)}`); + } } result.mcpStatus = 'configured'; } catch (error) { diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 7ee913f076..1fbf8d41ee 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -168,9 +168,9 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { /** * Keys under which agents store their MCP server map: `mcpServers` for Claude - * Code, Cursor, and Windsurf; `servers` for VS Code. + * Code, Cursor, and Windsurf; `servers` for VS Code; `mcp` for OpenCode. */ -const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers']); +const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'mcp']); /** * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that From aced17e4a2c9ad848ac44d416a88c3c4aa2f64da Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 12:43:55 -0700 Subject: [PATCH 09/19] fix(cli): close the remaining review findings on MCP setup Four defects, each reported against this branch and each confirmed against the code before changing it: * `resolveMcpClientId` read `__proto__` and `constructor` off the alias object's prototype and returned something truthy, so those two names resolved to a bogus agent and crashed the run instead of being rejected as unknown. `toString` and the rest are already rejected because lowercasing them stops matching an inherited key. * VS Code was detected only through `Code/User`, which is created on first launch. The picker offers detected agents alone, so an install that had not been launched was invisible rather than merely unselected. Detection now uses the same two markers doctor already uses. * The TOML editor scanned lines for table headers with no awareness of multi-line strings, so a `[table]` written inside one was treated as a real header and the surrounding edit could corrupt the file. The scan now tracks string state, and a multi-line string left open at the end of the file is reported as a per-agent failure instead of being appended to, which matches how the JSON path already treats a config it cannot parse. * `hasFirecrawlMcpEntry` matched `servers` and `mcp` at any depth, so an unrelated nested object holding a `firecrawl` property made doctor report the server as registered. Those two keys only ever sit at the root of the configs that are scanned; `mcpServers` keeps matching at any depth because Claude Code nests a per-project map under `projects`. Full TOML validation is deliberately not attempted. It would need a parser dependency, and a line-based validator would reject valid configs, since a TOML array may legally span several lines. --- src/__tests__/commands/doctor.test.ts | 8 +++ src/__tests__/utils/mcp-install.test.ts | 42 +++++++++++++ src/utils/agents.ts | 58 +++++++++-------- src/utils/mcp-clients.ts | 15 ++++- src/utils/mcp-install.ts | 82 ++++++++++++++++++++++--- 5 files changed, 170 insertions(+), 35 deletions(-) diff --git a/src/__tests__/commands/doctor.test.ts b/src/__tests__/commands/doctor.test.ts index a66a98adf8..506531e2f4 100644 --- a/src/__tests__/commands/doctor.test.ts +++ b/src/__tests__/commands/doctor.test.ts @@ -78,6 +78,14 @@ describe('hasFirecrawlMcpEntry', () => { }) ).toBe(true); }); + + it('ignores a nested servers map that is not the agent MCP config', () => { + expect( + hasFirecrawlMcpEntry({ + 'someExtension.config': { servers: { firecrawl: {} } }, + }) + ).toBe(false); + }); }); describe('runChecks', () => { diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 20f1b91609..21143c6aa3 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -231,6 +231,37 @@ describe('mcp install', () => { expect(second).toBe(first); }); + + it('ignores table syntax written inside a multi-line string', () => { + const existing = [ + 'instructions = """', + '[mcp_servers.firecrawl]', + 'url = "https://not-a-table"', + '"""', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + // The string keeps its contents and the real table is appended after it. + expect(alreadyExists).toBe(false); + expect(content).toContain('url = "https://not-a-table"'); + expect(content).toMatch( + new RegExp(`\\[mcp_servers\\.firecrawl\\]\\nurl = ".*"\\n$`) + ); + }); + + it('refuses a config whose multi-line string is never closed', () => { + expect(() => + upsertTomlServer('instructions = """\nstill open\n', 'firecrawl', { + url: MCP_URL, + }) + ).toThrow('unterminated multi-line string'); + }); }); describe('appendRuleSection', () => { @@ -383,6 +414,12 @@ describe('mcp install', () => { expect(await detectMcpClients(ctx)).toEqual(['claude']); }); + + it('detects VS Code from ~/.vscode without its User directory', async () => { + mkdirSync(path.join(ctx.home, '.vscode'), { recursive: true }); + + expect(await detectMcpClients(ctx)).toEqual(['vscode']); + }); }); describe('resolveMcpClientId', () => { @@ -392,5 +429,10 @@ describe('mcp install', () => { expect(resolveMcpClientId('vs-code')).toBe('vscode'); expect(resolveMcpClientId('nope')).toBeUndefined(); }); + + it('rejects names inherited from the alias table prototype', () => { + expect(resolveMcpClientId('__proto__')).toBeUndefined(); + expect(resolveMcpClientId('constructor')).toBeUndefined(); + }); }); }); diff --git a/src/utils/agents.ts b/src/utils/agents.ts index 1fbf8d41ee..499c1ca455 100644 --- a/src/utils/agents.ts +++ b/src/utils/agents.ts @@ -166,43 +166,49 @@ async function fileHasFirecrawlMcp(filePath: string): Promise { } } +/** True when `value` is a server map holding an entry named `firecrawl`. */ +function isFirecrawlServerMap(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + Object.prototype.hasOwnProperty.call(value, 'firecrawl') + ); +} + /** - * Keys under which agents store their MCP server map: `mcpServers` for Claude - * Code, Cursor, and Windsurf; `servers` for VS Code; `mcp` for OpenCode. + * Claude Code keeps a per-project server map under `projects`, so `mcpServers` + * is the one key that has to be matched at any depth. */ -const SERVER_MAP_KEYS = new Set(['mcpServers', 'servers', 'mcp']); +function hasNestedMcpServers(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const obj = value as Record; + + if (isFirecrawlServerMap(obj.mcpServers)) return true; + return Object.values(obj).some(hasNestedMcpServers); +} /** - * Walk a parsed JSON config looking for a server map (or `mcp.servers`) that - * contains a `firecrawl` key. Exported for testing. + * Walk a parsed JSON config looking for a server map that contains a + * `firecrawl` key. Exported for testing. */ export function hasFirecrawlMcpEntry(value: unknown): boolean { if (!value || typeof value !== 'object') return false; const obj = value as Record; - for (const key of Object.keys(obj)) { - const child = obj[key]; - if (SERVER_MAP_KEYS.has(key) && child && typeof child === 'object') { - if (Object.prototype.hasOwnProperty.call(child, 'firecrawl')) { - return true; - } - } - if (key === 'mcp' && child && typeof child === 'object') { - const mcp = child as Record; - const servers = mcp.servers; - if ( - servers && - typeof servers === 'object' && - Object.prototype.hasOwnProperty.call(servers, 'firecrawl') - ) { - return true; - } - } - if (child && typeof child === 'object') { - if (hasFirecrawlMcpEntry(child)) return true; + // VS Code (`servers`, or `mcp.servers` in settings.json) and OpenCode (`mcp`) + // both keep their map at the root. Matching those keys at any depth would let + // an unrelated nested object that happens to hold a `firecrawl` property + // report the server as registered when it is not. + if (isFirecrawlServerMap(obj.servers) || isFirecrawlServerMap(obj.mcp)) { + return true; + } + if (obj.mcp && typeof obj.mcp === 'object') { + if (isFirecrawlServerMap((obj.mcp as Record).servers)) { + return true; } } - return false; + + return hasNestedMcpServers(obj); } /** diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 36328c3a5b..8d9ed320b9 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -200,7 +200,12 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(vscodeUserDir(ctx), 'prompts', 'firecrawl.instructions.md'), }, - detectPaths: (ctx) => [vscodeUserDir(ctx)], + // `User` is created on first launch, so requiring it misses an install + // that has only been unpacked. These are the markers doctor already uses. + detectPaths: (ctx) => [ + appSupportDir(ctx, 'Code'), + path.join(ctx.home, '.vscode'), + ], }, codex: { id: 'codex', @@ -335,7 +340,13 @@ const CLIENT_ALIASES: Record = { }; export function resolveMcpClientId(agent: string): McpClientId | undefined { - return CLIENT_ALIASES[agent.trim().toLowerCase()]; + const alias = agent.trim().toLowerCase(); + // An object literal inherits `__proto__` and `constructor`, so looking either + // one up returns something truthy. Without this guard those two names read as + // a resolved agent and crash later instead of being rejected as unknown. + return Object.prototype.hasOwnProperty.call(CLIENT_ALIASES, alias) + ? CLIENT_ALIASES[alias] + : undefined; } async function pathExists(target: string): Promise { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 8d18bc1f5c..d6891213b2 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -130,6 +130,60 @@ export async function writeJsonServerEntry( return { status: alreadyExists ? 'reconfigured' : 'configured' }; } +/** Advance past a single-line basic or literal string, escapes included. */ +function skipQuoted(line: string, start: number, quote: string): number { + let index = start + 1; + while (index < line.length) { + // Only basic strings honour backslash escapes; literal strings have none. + if (quote === '"' && line[index] === '\\') { + index += 2; + continue; + } + if (line[index] === quote) return index + 1; + index += 1; + } + return line.length; +} + +/** + * Mark the lines that begin outside a multi-line string, so a `[table]` written + * inside one is not mistaken for a real table header. Throws when a multi-line + * string is still open at the end, which is malformed TOML: editing a file this + * scan cannot follow would corrupt it while reporting success. + */ +function linesOutsideStrings(lines: string[]): boolean[] { + const outside: boolean[] = []; + let fence: '"""' | "'''" | null = null; + + for (const line of lines) { + outside.push(fence === null); + let index = 0; + while (index < line.length) { + if (fence) { + const close = line.indexOf(fence, index); + if (close === -1) break; + index = close + fence.length; + fence = null; + continue; + } + if (line[index] === '#') break; + if (line.startsWith('"""', index) || line.startsWith("'''", index)) { + fence = line[index] === '"' ? '"""' : "'''"; + index += 3; + continue; + } + if (line[index] === '"' || line[index] === "'") { + index = skipQuoted(line, index, line[index]); + continue; + } + index += 1; + } + } + + if (fence) throw new Error('unterminated multi-line string'); + return outside; +} + /** * Insert or replace the `[mcp_servers.]` table. Any sub-tables of that * server are consumed too, so a leftover `[mcp_servers.firecrawl.env]` from an @@ -158,8 +212,11 @@ export function upsertTomlServer( `^[ \\t]*\\[mcp_servers\\.${escaped}(\\.[^\\]]+)?\\][ \\t]*(?:#.*)?$` ); const anyTable = /^[ \t]*\[/; + const outside = linesOutsideStrings(lines); - const start = lines.findIndex((line) => ownTable.test(line)); + const start = lines.findIndex( + (line, index) => outside[index] && ownTable.test(line) + ); if (start === -1) { // Tables must follow root-level keys, so append at the end of the file. @@ -176,7 +233,13 @@ export function upsertTomlServer( let end = start + 1; while (end < lines.length) { - if (anyTable.test(lines[end]) && !ownTable.test(lines[end])) break; + if ( + outside[end] && + anyTable.test(lines[end]) && + !ownTable.test(lines[end]) + ) { + break; + } end += 1; } // Comments and blank lines directly above the next table introduce it, so @@ -255,11 +318,16 @@ async function writeMcpEntry( for (const [key, value] of Object.entries(entry)) { if (typeof value === 'string') stringEntry[key] = value; } - const { content, alreadyExists } = upsertTomlServer( - existing, - MCP_SERVER_NAME, - stringEntry - ); + let patched: { content: string; alreadyExists: boolean }; + try { + patched = upsertTomlServer(existing, MCP_SERVER_NAME, stringEntry); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `could not parse existing config at ${configPath}: ${reason}` + ); + } + const { content, alreadyExists } = patched; await writeFileEnsuringDir(configPath, content); return { status: alreadyExists ? 'reconfigured' : 'configured', From 181768abb460ce9336427c37bbbb71d991c0f60b Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 12:49:12 -0700 Subject: [PATCH 10/19] fix(cli): keep an escaped quote from ending a TOML multi-line string The scanner added in the previous commit closed a multi-line basic string at the first `"""` it found, but a basic string honours backslash escapes, so `\"""` is an escaped quote followed by two literal ones rather than the terminator. Ending the string there marked the rest of it as ordinary config, which is how a `[table]` written inside a string becomes a header the editor will replace, taking the user's content with it. Fence candidates are now skipped while the backslash run before them is odd. An even run is a real terminator, since backslashes escape each other. Literal strings are unaffected because they have no escapes at all. --- src/__tests__/utils/mcp-install.test.ts | 47 +++++++++++++++++++++++++ src/utils/mcp-install.ts | 20 ++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 21143c6aa3..baf0a40bb9 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -255,6 +255,53 @@ describe('mcp install', () => { ); }); + it('does not end a basic string on an escaped fence', () => { + const existing = [ + 'instructions = """', + String.raw`he said \""" loudly`, + '[mcp_servers.firecrawl]', + 'url = "https://not-a-table"', + '"""', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + // Everything above stays string content, so nothing in it is replaced. + expect(alreadyExists).toBe(false); + expect(content).toContain(String.raw`he said \""" loudly`); + expect(content).toContain('url = "https://not-a-table"'); + expect(content).toMatch( + new RegExp(`\\[mcp_servers\\.firecrawl\\]\\nurl = "${MCP_URL}"\\n$`) + ); + }); + + it('closes a basic string when the fence follows an escaped backslash', () => { + const existing = [ + 'instructions = """', + String.raw`trailing slash \\"""`, + '[mcp_servers.firecrawl]', + 'url = "https://old"', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertTomlServer( + existing, + 'firecrawl', + { url: MCP_URL } + ); + + // The run of backslashes is even, so the fence really does terminate and + // the table below it is a real one to replace. + expect(alreadyExists).toBe(true); + expect(content).toContain(`url = "${MCP_URL}"`); + expect(content).not.toContain('https://old'); + }); + it('refuses a config whose multi-line string is never closed', () => { expect(() => upsertTomlServer('instructions = """\nstill open\n', 'firecrawl', { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index d6891213b2..34656496b3 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -130,6 +130,18 @@ export async function writeJsonServerEntry( return { status: alreadyExists ? 'reconfigured' : 'configured' }; } +/** + * True when the character at `index` is escaped. Backslashes escape each other, + * so only an odd run of them before the position leaves it escaped. + */ +function isEscaped(line: string, index: number): boolean { + let backslashes = 0; + for (let at = index - 1; at >= 0 && line[at] === '\\'; at -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + /** Advance past a single-line basic or literal string, escapes included. */ function skipQuoted(line: string, start: number, quote: string): number { let index = start + 1; @@ -160,7 +172,13 @@ function linesOutsideStrings(lines: string[]): boolean[] { let index = 0; while (index < line.length) { if (fence) { - const close = line.indexOf(fence, index); + let close = line.indexOf(fence, index); + // A basic string honours escapes, so `\"""` is an escaped quote + // followed by two literal ones rather than the terminator. Literal + // strings have no escapes, so their fence always closes. + while (close !== -1 && fence === '"""' && isEscaped(line, close)) { + close = line.indexOf(fence, close + 1); + } if (close === -1) break; index = close + fence.length; fence = null; From 5e74423b18a9bac9f6577965cbb0e9196d6ca1a3 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 13:16:54 -0700 Subject: [PATCH 11/19] feat(cli): configure Hermes Agent through the shared MCP engine Hermes was classified as a launcher, which implied it owns its MCP config and has to be shelled out to. It does not: it reads plain YAML from ~/.hermes/config.yaml under `mcp_servers`, with `url` and a `headers` mapping for an HTTP server, and it expands `${VAR}` in any string value in a server entry. All of that is documented by Nous Research and matches what we already emit, so Hermes is a config-file client and now goes through the same engine as the editors. That fixes a real defect. The old writer round-tripped the file through parse/stringify, so a hand-written config.yaml came back with every comment, inline note, and blank line removed. Edits now go through the YAML document tree, which keeps comments, key order, and formatting, and a file that does not parse is reported as a per-agent failure instead of being rewritten. A config we create is owner-only; one the user already has keeps the permissions they gave it, rather than being chmod-ed on every run. Hermes gets no rules. It reads AGENTS.md from the project directory and setup only ever writes global config, so there is no global rule file to own, and the summary says so rather than implying one was written. OpenClaw stays a launcher, and the type now says why. Its config is JSON5, which the JSONC editor we patch JSON with cannot read, so writing that file directly would either corrupt it or refuse a valid config. `openclaw mcp set` is the vendor-documented path and normalises the entry on the way in. --- src/__tests__/commands/setup.test.ts | 33 +++++++++--- src/__tests__/utils/mcp-install.test.ts | 69 +++++++++++++++++++++++++ src/commands/setup.ts | 52 +------------------ src/utils/mcp-clients.ts | 54 ++++++++++++++----- src/utils/mcp-install.ts | 52 ++++++++++++++++++- 5 files changed, 190 insertions(+), 70 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index a377bdf3f2..b43df162c2 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -15,7 +15,6 @@ import { handleMakeDefaultCommand, handleSetupCommand, installMcp, - installHermesMcp, installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; @@ -449,7 +448,7 @@ describe('handleSetupCommand', () => { }); it('detects an installed launcher so the picker can pre-select it', async () => { - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.openclaw'), { recursive: true }); const { detectMcpLaunchers } = await import('../../utils/mcp-clients'); expect( @@ -460,7 +459,26 @@ describe('handleSetupCommand', () => { env: { PATH: '' }, auth: 'keyless', }) - ).toContain('hermes'); + ).toContain('openclaw'); + }); + + it('detects Hermes as a config-file client, not a launcher', async () => { + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + const { detectMcpClients, detectMcpLaunchers } = + await import('../../utils/mcp-clients'); + const ctx = { + home: sandboxHome, + cwd: process.cwd(), + platform: process.platform, + // Hermes is matched on its config directory alone. An unrelated + // JavaScript engine of the same name ships on many machines. + env: { PATH: '' }, + auth: 'keyless' as const, + }; + + expect(await detectMcpClients(ctx)).toContain('hermes'); + expect(detectMcpLaunchers(ctx)).not.toContain('hermes'); }); it('keeps a failing launcher from taking down the other agents', async () => { @@ -573,7 +591,7 @@ describe('handleSetupCommand', () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; try { - await installHermesMcp(); + await installMcp({ agent: 'hermes' }); const config = readFileSync( path.join(home, '.hermes', 'config.yaml'), @@ -684,10 +702,13 @@ describe('handleSetupCommand', () => { it('treats --agent launchers as the launchers, not as every agent', async () => { await handleSetupCommand('mcp', { agent: 'launchers', yes: true }); + // OpenClaw is the only launcher; it is configured through its own CLI. + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(config).toContain(MCP_URL); + expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( - true + false ); - expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); }); it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index baf0a40bb9..70064ff87a 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -13,10 +13,12 @@ import { resolveMcpClientId, type McpContext, } from '../../utils/mcp-clients'; +import { parse as parseYaml } from 'yaml'; import { appendRuleSection, setupMcpClient, upsertTomlServer, + upsertYamlServer, writeJsonServerEntry, } from '../../utils/mcp-install'; @@ -311,6 +313,73 @@ describe('mcp install', () => { }); }); + describe('upsertYamlServer', () => { + it('keeps the comments and formatting around an added server', () => { + const existing = [ + '# Hermes configuration', + 'model: anthropic/claude-opus-4.6 # my preferred model', + '', + 'mcp_servers:', + ' github:', + ' command: npx', + '', + ].join('\n'); + + const { content, alreadyExists } = upsertYamlServer( + existing, + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(content).toContain('# Hermes configuration'); + expect(content).toContain('# my preferred model'); + expect(content).toContain('command: npx'); + expect(content).toContain(`url: ${MCP_URL}`); + }); + + it('builds the server map when the file is empty', () => { + const { content, alreadyExists } = upsertYamlServer( + '', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(parseYaml(content)).toEqual({ + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + + it('reports an existing entry as already present and replaces it', () => { + const existing = 'mcp_servers:\n firecrawl:\n url: https://old\n'; + + const { content, alreadyExists } = upsertYamlServer( + existing, + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(true); + expect(content).toContain(MCP_URL); + expect(content).not.toContain('https://old'); + }); + + it('refuses a config that does not parse', () => { + expect(() => + upsertYamlServer( + 'model: "unterminated\nother: 1\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ) + ).toThrow(/quote/i); + }); + }); + describe('appendRuleSection', () => { it('keeps existing content and replaces only the fenced section', async () => { const file = path.join(root, 'AGENTS.md'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 16c76f3b86..bcd3756016 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -4,17 +4,10 @@ */ import { execFileSync, execSync } from 'child_process'; -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'fs'; +import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; import readline from 'readline'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getApiKey } from '../utils/config'; import { buildSkillsInstallArgs, @@ -57,7 +50,6 @@ type ResolvedMcpAgent = | { kind: 'clients'; ids?: McpTargetId[] } | { kind: 'launchers' } | { kind: 'skills-only'; agent: string } - | { kind: 'hermes' } | { kind: 'openclaw' } | { kind: 'all-launchers' }; @@ -264,9 +256,6 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { case 'launchers': case 'launcher': return { kind: 'launchers' }; - case 'hermes': - case 'hermes-agent': - return { kind: 'hermes' }; case 'openclaw': return { kind: 'openclaw' }; default: { @@ -567,7 +556,7 @@ export async function installMcp( return; } - if (resolvedAgent.kind === 'hermes' || resolvedAgent.kind === 'openclaw') { + if (resolvedAgent.kind === 'openclaw') { // Routed through the same reporter as every other target so the keyless // fallback is stated rather than implied by a bare installer log line. await installMcpClients({ ...options, yes: true }, runtimeEnv, [ @@ -642,10 +631,6 @@ async function setupMcpLauncher( try { switch (id) { - case 'hermes': - await installHermesMcp(runtimeEnv, keyless, true); - result.mcpDetail = path.join(ctx.home, '.hermes', 'config.yaml'); - break; case 'openclaw': await installOpenClawMcp(runtimeEnv, keyless, true); result.mcpDetail = 'via the openclaw CLI'; @@ -857,39 +842,6 @@ function firecrawlMcpConfig( }; } -export async function installHermesMcp( - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false, - /** Suppress standalone logging when a caller renders its own summary. */ - quiet = false -): Promise { - const config = firecrawlMcpConfig('hermes', runtimeEnv, keyless); - const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); - mkdirSync(path.dirname(configPath), { recursive: true }); - - const existing = existsSync(configPath) - ? readFileSync(configPath, 'utf-8') - : ''; - const root = (parseYaml(existing || '{}') ?? {}) as Record; - const mcpServers = - typeof root.mcp_servers === 'object' && - root.mcp_servers !== null && - !Array.isArray(root.mcp_servers) - ? (root.mcp_servers as Record) - : {}; - - mcpServers.firecrawl = config; - root.mcp_servers = mcpServers; - writeFileSync(configPath, stringifyYaml(root), { - encoding: 'utf-8', - mode: 0o600, - }); - if (process.platform !== 'win32') { - chmodSync(configPath, 0o600); - } - if (!quiet) console.log(`Hermes Agent MCP configured at ${configPath}.`); -} - export async function installOpenClawMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, keyless = false, diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 8d9ed320b9..523096a55e 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -19,13 +19,23 @@ export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; export const MCP_SERVER_NAME = 'firecrawl'; export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; -export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; +export type McpClientId = + | 'claude' + | 'cursor' + | 'vscode' + | 'codex' + | 'opencode' + | 'hermes'; /** * Agent launchers that own their MCP configuration rather than reading a file * we write. They are offered alongside the editors but installed differently. + * + * OpenClaw is the only one: its config is JSON5, which the editor we patch JSON + * with cannot read, and `openclaw mcp set` is the vendor-documented path that + * also normalises the entry. Hermes reads plain YAML, so it is a client. */ -export type McpLauncherId = 'hermes' | 'openclaw'; +export type McpLauncherId = 'openclaw'; export type McpTargetId = McpClientId | McpLauncherId; @@ -58,10 +68,15 @@ export interface McpRuleSpec { export interface McpClient { id: McpClientId; name: string; - format: 'json' | 'toml'; + format: 'json' | 'toml' | 'yaml'; /** Key of the map holding MCP servers in this agent's config. */ serversKey: string; globalConfigPath: (ctx: McpContext) => string; + /** + * Mode for a config file we create. Only applied on creation, so a file the + * user already owns keeps the permissions they gave it. + */ + createMode?: number; buildEntry: (ctx: McpContext) => Record; /** Absent when the agent has no rules mechanism. */ rule?: McpRuleSpec; @@ -247,6 +262,23 @@ export const MCP_CLIENTS: Record = { }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, + hermes: { + id: 'hermes', + name: 'Hermes Agent', + format: 'yaml', + serversKey: 'mcp_servers', + globalConfigPath: (ctx) => path.join(ctx.home, '.hermes', 'config.yaml'), + // Hermes keeps secrets in ~/.hermes/.env rather than here, but the rest of + // this file is the user's, so a file we create starts owner-only. + createMode: 0o600, + // Documented HTTP server shape: `url` plus a `headers` mapping. Hermes + // expands `${VAR}` in any string value in a server entry. + buildEntry: (ctx) => + withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.shell), + // No `rule`: Hermes reads AGENTS.md from the project directory, and setup + // only ever writes global config, so there is no global rule file to own. + detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], + }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -255,17 +287,14 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', + 'hermes', ]; export const MCP_LAUNCHER_NAMES: Record = { - hermes: 'Hermes Agent', openclaw: 'OpenClaw', }; -export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = [ - 'hermes', - 'openclaw', -]; +export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = ['openclaw']; export const ALL_MCP_TARGET_IDS: readonly McpTargetId[] = [ ...ALL_MCP_CLIENT_IDS, @@ -306,12 +335,11 @@ function binaryOnPath(name: string, ctx: McpContext): boolean { * lists agents that look installed, so a miss means the user passes a flag * (`--cursor`) instead of seeing an agent they do not have. * - * `hermes` is therefore matched on its config directory alone. The name is also - * used by an unrelated JavaScript engine that ships with common toolchains, so - * a PATH lookup reports it present on machines that do not have this agent. + * Hermes is detected by its config directory alone, through `detectPaths`. Its + * name is also used by an unrelated JavaScript engine that ships with common + * toolchains, so a PATH lookup reports it present on machines without it. */ const LAUNCHER_DETECT: Record boolean> = { - hermes: (ctx) => existsSync(path.join(ctx.home, '.hermes')), openclaw: (ctx) => existsSync(path.join(ctx.home, '.openclaw')) || binaryOnPath('openclaw', ctx), @@ -337,6 +365,8 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', + hermes: 'hermes', + 'hermes-agent': 'hermes', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 34656496b3..64447e0dc3 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -12,6 +12,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; +import { parseDocument } from 'yaml'; import { MCP_CLIENTS, MCP_SERVER_NAME, @@ -59,10 +60,12 @@ async function readIfExists(filePath: string): Promise { async function writeFileEnsuringDir( filePath: string, - content: string + content: string, + /** Applied by the OS only when the file is created, never to an existing one. */ + createMode?: number ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, 'utf8'); + await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); } function escapeRegExp(value: string): string { @@ -130,6 +133,28 @@ export async function writeJsonServerEntry( return { status: alreadyExists ? 'reconfigured' : 'configured' }; } +/** + * Insert or replace `serversKey.serverName` in a YAML config. The document is + * edited as a tree rather than reserialised from plain objects, so comments, + * key order, and the user's formatting survive. Throws on a document that does + * not parse, matching how the JSON path treats a config it cannot read. + */ +export function upsertYamlServer( + content: string, + serversKey: string, + serverName: string, + entry: Record +): { content: string; alreadyExists: boolean } { + const doc = parseDocument(content); + if (doc.errors.length > 0) { + throw new Error(doc.errors[0].message); + } + + const alreadyExists = doc.hasIn([serversKey, serverName]); + doc.setIn([serversKey, serverName], entry); + return { content: doc.toString(), alreadyExists }; +} + /** * True when the character at `index` is escaped. Backslashes escape each other, * so only an odd run of them before the position leaves it escaped. @@ -330,6 +355,29 @@ async function writeMcpEntry( const configPath = client.globalConfigPath(ctx); const entry = client.buildEntry(ctx); + if (client.format === 'yaml') { + const existing = (await readIfExists(configPath)) ?? ''; + let patched: { content: string; alreadyExists: boolean }; + try { + patched = upsertYamlServer( + existing, + client.serversKey, + MCP_SERVER_NAME, + entry + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `could not parse existing config at ${configPath}: ${reason}` + ); + } + await writeFileEnsuringDir(configPath, patched.content, client.createMode); + return { + status: patched.alreadyExists ? 'reconfigured' : 'configured', + configPath, + }; + } + if (client.format === 'toml') { const existing = (await readIfExists(configPath)) ?? ''; const stringEntry: Record = {}; From ff479954d76d82cd8bb771f0a80e15dfd2310c10 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 14:05:02 -0700 Subject: [PATCH 12/19] fix(cli): preserve YAML file details the document tree drops Two defects in the new Hermes writer, both reported against the last commit: * A server key with nothing under it, `mcp_servers:` on its own, parses as a null scalar. Setting a path through that refuses to descend, so setup failed on a valid config. The key has to be replaced with a collection node first; assigning a plain object raises the same error one level down, because the value is stored as-is rather than converted. * Serialising the document tree drops a leading byte order mark and rewrites every line with LF. Both belong to the user's file, so they are captured from the input and restored on write, which is how the JSON and TOML writers already treat them. --- src/__tests__/utils/mcp-install.test.ts | 35 +++++++++++++++++++++++++ src/utils/mcp-install.ts | 18 ++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 70064ff87a..83e8402271 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -368,6 +368,41 @@ describe('mcp install', () => { expect(content).not.toContain('https://old'); }); + it('fills in a server section that exists but is empty', () => { + const { content, alreadyExists } = upsertYamlServer( + 'model: opus\nmcp_servers:\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(alreadyExists).toBe(false); + expect(parseYaml(content)).toEqual({ + model: 'opus', + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + + it('keeps a byte order mark and CRLF line endings', () => { + const existing = + '\uFEFFmodel: opus\r\nterminal:\r\n backend: docker\r\n'; + + const { content } = upsertYamlServer( + existing, + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(content.startsWith('\uFEFF')).toBe(true); + expect(content).toContain('\r\n'); + expect(/[^\r]\n/.test(content)).toBe(false); + expect(parseYaml(content.slice(1))).toMatchObject({ + model: 'opus', + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + it('refuses a config that does not parse', () => { expect(() => upsertYamlServer( diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 64447e0dc3..0f93482953 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -151,8 +151,24 @@ export function upsertYamlServer( } const alreadyExists = doc.hasIn([serversKey, serverName]); + // A key with nothing under it parses as a null scalar, and setting a path + // through that refuses to descend. It has to become a collection node: + // assigning a plain object leaves the same error one level down. An absent + // key needs none of this, since setIn creates the path itself. + if (doc.getIn([serversKey]) === null) { + doc.setIn([serversKey], doc.createNode({})); + } doc.setIn([serversKey, serverName], entry); - return { content: doc.toString(), alreadyExists }; + + // Serialising the tree drops a byte order mark and normalises line endings. + // Both belong to the user's file, so they are restored on the way out. + const bom = content.startsWith('\uFEFF') ? '\uFEFF' : ''; + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const serialized = doc.toString().replace(/^\uFEFF/, ''); + return { + content: `${bom}${serialized.replace(/\r?\n/g, eol)}`, + alreadyExists, + }; } /** From d62a3e861494eeac2b49df7b319fc02f9424b87d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 14:24:14 -0700 Subject: [PATCH 13/19] feat(cli): install the Firecrawl rule for OpenClaw OpenClaw was reported as having no rules mechanism. It has one: the workspace AGENTS.md is a bootstrap file that OpenClaw injects into the system prompt on every turn, treats as instruction context, and passes down to sub-agent sessions. It is workspace-level rather than project-level, so it is reachable from a global setup, and the rule now goes there fenced by markers like any file the user also writes to. The workspace can move, so the path follows OPENCLAW_WORKSPACE_DIR and the profile suffix before falling back to ~/.openclaw/workspace. An explicit agents.defaults.workspace in the config wins over both, but that file is JSON5 and out of reach here; landing the rule in an unused workspace is inert, unlike a misplaced server entry. The rule is only written when that AGENTS.md already exists. OpenClaw seeds the file with its own instructions on first run, and creating it first would leave the user with our section instead of those. Writing a rule and registering the server are separate concerns, so a launcher can take one without giving up its own MCP registration. Hermes still gets no rule: it reads AGENTS.md from the working directory and deliberately ignores one in $HOME, and its only global context file is SOUL.md, which is the user's agent identity rather than a place for tool routing. --- src/__tests__/commands/setup.test.ts | 69 +++++++++++++++++++++++++++- src/commands/setup.ts | 33 +++++++++++-- src/utils/mcp-clients.ts | 27 +++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index b43df162c2..df273f7a4c 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -21,7 +21,11 @@ import { import { ALL_SKILL_REPOS } from '../../commands/skills-install'; import { configureWebDefaults } from '../../utils/web-defaults'; import { getApiKey } from '../../utils/config'; -import { MCP_CLIENTS, type McpClientId } from '../../utils/mcp-clients'; +import { + MCP_CLIENTS, + RULE_MARKER, + type McpClientId, +} from '../../utils/mcp-clients'; const MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; @@ -710,6 +714,69 @@ describe('handleSetupCommand', () => { false ); }); + it('fences the rule into an existing OpenClaw workspace AGENTS.md', async () => { + const workspace = path.join(sandboxHome, '.openclaw', 'workspace'); + mkdirSync(workspace, { recursive: true }); + const agentsFile = path.join(workspace, 'AGENTS.md'); + writeFileSync(agentsFile, '# My workspace\n\nKeep this text.\n'); + + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + const written = readFileSync(agentsFile, 'utf-8'); + expect(written).toContain('# My workspace'); + expect(written).toContain('Keep this text.'); + expect(written).toContain('firecrawl_search'); + + // A rerun replaces the fenced section rather than adding a second copy. + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + const rerun = readFileSync(agentsFile, 'utf-8'); + expect(rerun.match(new RegExp(RULE_MARKER, 'g'))).toHaveLength(2); + expect(rerun).toBe(written); + }); + + it('leaves the OpenClaw rule alone until its workspace exists', async () => { + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + // Creating AGENTS.md before OpenClaw bootstraps it would cost the user the + // instructions the launcher seeds that file with. + expect( + existsSync(path.join(sandboxHome, '.openclaw', 'workspace', 'AGENTS.md')) + ).toBe(false); + }); + + it('follows OPENCLAW_WORKSPACE_DIR when the workspace has moved', async () => { + const moved = path.join(sandboxHome, 'elsewhere'); + mkdirSync(moved, { recursive: true }); + writeFileSync(path.join(moved, 'AGENTS.md'), '# Moved\n'); + process.env.OPENCLAW_WORKSPACE_DIR = moved; + + try { + await handleSetupCommand('mcp', { + clients: ['openclaw'], + yes: true, + rules: true, + } as never); + + expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( + 'firecrawl_search' + ); + } finally { + delete process.env.OPENCLAW_WORKSPACE_DIR; + } + }); + it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); // Make several agents detectable so --agent all has editors to configure. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index bcd3756016..86d0fddf45 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -33,6 +33,7 @@ import { detectMcpClients, detectMcpLaunchers, isMcpLauncherId, + MCP_LAUNCHER_RULES, mcpTargetName, resolveMcpClientId, type McpAuthMode, @@ -40,7 +41,11 @@ import { type McpLauncherId, type McpTargetId, } from '../utils/mcp-clients'; -import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; +import { + appendRuleSection, + setupMcpClient, + type McpClientResult, +} from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; @@ -616,7 +621,8 @@ async function pickMcpClients( async function setupMcpLauncher( id: McpLauncherId, ctx: McpContext, - runtimeEnv: NodeJS.ProcessEnv + runtimeEnv: NodeJS.ProcessEnv, + rules: boolean ): Promise { const keyless = ctx.auth !== 'env'; const result: McpClientResult = { @@ -644,6 +650,27 @@ async function setupMcpLauncher( } catch (error) { result.mcpDetail = error instanceof Error ? error.message : String(error); } + + const rule = MCP_LAUNCHER_RULES[id]; + if (!rules || !rule) return result; + + const rulePath = rule.globalPath(ctx); + // The launcher creates this file itself on first run, seeded with its own + // instructions. Creating it here first would leave the user with our section + // and none of that, so the rule waits for a workspace that exists. + if (!existsSync(rulePath)) { + result.ruleStatus = 'skipped'; + result.ruleDetail = rulePath; + return result; + } + + try { + result.ruleStatus = await appendRuleSection(rulePath, rule.content); + result.ruleDetail = rulePath; + } catch (error) { + result.ruleStatus = 'failed'; + result.ruleDetail = error instanceof Error ? error.message : String(error); + } return result; } @@ -722,7 +749,7 @@ async function installMcpClients( for (const id of selected) { results.push( isMcpLauncherId(id) - ? await setupMcpLauncher(id, ctx, runtimeEnv) + ? await setupMcpLauncher(id, ctx, runtimeEnv, rules) : await setupMcpClient(id, { rules, ctx }) ); } diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 523096a55e..2fc89e3e09 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -294,6 +294,33 @@ export const MCP_LAUNCHER_NAMES: Record = { openclaw: 'OpenClaw', }; +/** + * OpenClaw keeps its bootstrap files in a workspace directory, which the user + * can move. An explicit config value wins over the environment, but that config + * is JSON5 and out of reach here, so this covers the documented defaults only. + */ +function openclawWorkspaceDir(ctx: McpContext): string { + const explicit = ctx.env.OPENCLAW_WORKSPACE_DIR; + if (explicit && explicit !== '') return explicit; + const profile = ctx.env.OPENCLAW_PROFILE; + const suffix = + profile && profile !== '' && profile !== 'default' ? `-${profile}` : ''; + return path.join(ctx.home, '.openclaw', `workspace${suffix}`); +} + +/** + * A launcher owns its MCP registration but can still read an instruction file + * we write. OpenClaw injects its workspace `AGENTS.md` into the system prompt + * on every turn, so the rule belongs there, fenced like any shared file. + */ +export const MCP_LAUNCHER_RULES: Partial> = { + openclaw: { + kind: 'append', + content: RULE_BODY, + globalPath: (ctx) => path.join(openclawWorkspaceDir(ctx), 'AGENTS.md'), + }, +}; + export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = ['openclaw']; export const ALL_MCP_TARGET_IDS: readonly McpTargetId[] = [ From d565a7f67206c97c3679d98626212517e45107c6 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 16:39:56 -0700 Subject: [PATCH 14/19] feat(cli): add the browser sign-in lane to setup mcp Setup could carry a key or run anonymously, but not sign in, so the one path the docs present to a person at a terminal had no command behind it. `--oauth` writes the sign-in endpoint instead of a credential, and each agent starts the browser flow itself the first time it connects. Sign-in is a different server URL rather than a different header, so it replaces the credential rather than travelling beside it: an exported key is ignored under --oauth and no Authorization header is written. The two are separate endpoints, so combining --oauth with --keyless is rejected instead of silently preferring one. A URL alone is not enough for every agent. Hermes starts the flow only when the entry carries `auth: oauth`, and OpenClaw ignores a static Authorization header unless `auth: "oauth"` is set, which is also what gates its login command. Those fields live beside the agent in the registry, next to the environment syntax, and an agent without a verified sign-in shape gets no entry rather than one that reports success and then exposes no tools. No agent signs in during setup, and each one starts the flow differently, so the summary prints the step per agent rather than one footer: `/mcp` in Claude Code, `codex mcp login firecrawl`, Cursor Settings, and a browser on first use for the rest. --- README.md | 12 +++++ src/__tests__/commands/setup.test.ts | 57 ++++++++++++++++++++++ src/commands/setup.ts | 73 +++++++++++++++++++++++----- src/index.ts | 2 + src/utils/mcp-clients.ts | 66 +++++++++++++++++++++---- src/utils/mcp-install.ts | 6 ++- 6 files changed, 193 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3e9b44da36..6c4147f4ca 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ to that variable in the syntax it understands. Otherwise setup stays keyless, which still serves search, scrape, and parse under an anonymous rate limit. Use `--keyless` to force the anonymous path even when a key is available. +To sign in from the agent instead of carrying a key, use `--oauth`: + +```bash +firecrawl setup mcp --oauth # sign in from each agent's browser +``` + +This writes the sign-in endpoint rather than a credential, and each agent starts +the browser flow itself the first time it connects. Setup prints the step each +agent needs, since they differ: `/mcp` in Claude Code, `codex mcp login +firecrawl` for Codex, Cursor Settings, and a browser on first use elsewhere. +`--oauth` and `--keyless` are different endpoints, so pass only one. + To make Firecrawl the default web provider for supported AI agents: ```bash diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index df273f7a4c..724a0b798b 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -777,6 +777,63 @@ describe('handleSetupCommand', () => { } }); + it('points every agent at the sign-in endpoint with --oauth', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + for (const dir of ['.claude', '.cursor', '.codex', '.hermes']) { + mkdirSync(path.join(sandboxHome, dir), { recursive: true }); + } + + await handleSetupCommand('mcp', { oauth: true, yes: true } as never); + + const claude = readFileSync( + path.join(sandboxHome, '.claude.json'), + 'utf-8' + ); + expect(claude).toContain('/v2/mcp-oauth'); + // Sign-in replaces the credential rather than travelling beside it. + expect(claude).not.toContain('Authorization'); + expect(claude).not.toContain('fc-test-key'); + + // Codex takes a bare URL; its sign-in is a separate login command. + expect( + readFileSync(path.join(sandboxHome, '.codex', 'config.toml'), 'utf-8') + ).toContain('/v2/mcp-oauth'); + }); + + it('arms the sign-in flow for agents that need more than a URL', async () => { + mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + + await handleSetupCommand('mcp', { + clients: ['hermes', 'openclaw'], + oauth: true, + yes: true, + } as never); + + // Hermes only starts the flow when the entry opts in. + expect( + readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') + ).toContain('auth: oauth'); + + // OpenClaw ignores a static header once this is set, and its login + // command only runs for servers configured with it. + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(JSON.parse(config)).toMatchObject({ + url: `${MCP_URL}-oauth`, + auth: 'oauth', + }); + }); + + it('refuses to combine sign-in with keyless', async () => { + await expect( + handleSetupCommand('mcp', { + clients: ['cursor'], + oauth: true, + keyless: true, + yes: true, + } as never) + ).rejects.toThrow(/either --oauth or --keyless/); + }); + it('uses each client native environment binding with --agent all', async () => { const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-all-env-test-')); // Make several agents detectable so --agent all has editors to configure. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 86d0fddf45..e112b54d77 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -32,7 +32,10 @@ import { ALL_MCP_TARGET_IDS, detectMcpClients, detectMcpLaunchers, + FIRECRAWL_MCP_OAUTH_URL, isMcpLauncherId, + MCP_CLIENTS, + MCP_LAUNCHER_OAUTH, MCP_LAUNCHER_RULES, mcpTargetName, resolveMcpClientId, @@ -70,6 +73,8 @@ export interface SetupOptions { quiet?: boolean; /** Configure the anonymous hosted MCP path even when a stored key exists. */ keyless?: boolean; + /** Point agents at the sign-in endpoint instead of sending a credential. */ + oauth?: boolean; /** Agents chosen by flag (`--claude`, `--cursor`, ...); skips the picker. */ clients?: McpTargetId[]; /** Force the Firecrawl web rules on or off instead of prompting. */ @@ -194,8 +199,8 @@ function runClientCommand( execFileSync(comspec, ['/d', '/s', '/c', `"${line}"`], windowsOptions); } -function firecrawlHostedMcpUrl(): string { - return 'https://mcp.firecrawl.dev/v2/mcp'; +function firecrawlHostedMcpUrl(oauth = false): string { + return oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; } function isEnvironmentBackedApiKey( @@ -638,7 +643,12 @@ async function setupMcpLauncher( try { switch (id) { case 'openclaw': - await installOpenClawMcp(runtimeEnv, keyless, true); + await installOpenClawMcp( + runtimeEnv, + keyless, + true, + ctx.auth === 'oauth' + ); result.mcpDetail = 'via the openclaw CLI'; break; default: { @@ -689,12 +699,22 @@ async function installMcpClients( explicitIds?: McpTargetId[], { includeAllLaunchers = false } = {} ): Promise { - const apiKey = options.keyless ? undefined : getApiKey(); - // A stored key cannot be written into agent config, so authenticated setup - // requires the variable to be exported where the agent will read it. - const auth: McpAuthMode = isEnvironmentBackedApiKey(apiKey, runtimeEnv) - ? 'env' - : 'keyless'; + if (options.oauth && options.keyless) { + throw new Error( + 'Choose either --oauth or --keyless. Signing in and running anonymously are different endpoints.' + ); + } + + const apiKey = options.oauth || options.keyless ? undefined : getApiKey(); + // Sign-in is a different endpoint rather than a different credential, so it + // overrides the key lookup entirely. Otherwise a stored key cannot be written + // into agent config, so authenticated setup requires the variable to be + // exported where the agent will read it. + const auth: McpAuthMode = options.oauth + ? 'oauth' + : isEnvironmentBackedApiKey(apiKey, runtimeEnv) + ? 'env' + : 'keyless'; const ctx: McpContext = { // Resolved so path comparisons hold even for an unnormalized HOME. @@ -786,6 +806,12 @@ function authNotes( const succeeded = results.filter((result) => result.mcpStatus !== 'failed'); if (succeeded.length === 0) return []; + if (ctx.auth === 'oauth') { + return [ + 'Each agent signs in through your browser the first time it connects.', + ]; + } + if (!hasApiKey) { return [ `Running keyless (search, scrape, parse). Export ${ENV_API_KEY} where your agents run, then rerun to authenticate.`, @@ -801,6 +827,22 @@ function authNotes( return []; } +/** + * What the person still has to do for this agent. Setup can register the + * server but no agent signs in on its behalf, and each one starts the flow + * differently, so a single footer would leave most agents unexplained. + */ +function signInLine( + result: McpClientResult, + ctx: McpContext +): string | undefined { + if (ctx.auth !== 'oauth' || result.mcpStatus === 'failed') return undefined; + const spec = isMcpLauncherId(result.id) + ? MCP_LAUNCHER_OAUTH[result.id] + : MCP_CLIENTS[result.id].oauth; + return spec ? ` Sign in ${dim}${spec.nextStep}${reset}` : undefined; +} + function reportMcpResults( results: McpClientResult[], ctx: McpContext, @@ -833,6 +875,8 @@ function reportMcpResults( ? ` ${red}MCP failed${reset} ${result.mcpDetail}` : ` MCP ${result.mcpStatus} ${dim}${displayPath(result.mcpDetail, ctx)}${reset}` ); + const signIn = signInLine(result, ctx); + if (signIn) console.log(signIn); const rules = ruleLine(result, ctx); if (rules) console.log(rules); } @@ -853,14 +897,15 @@ function reportMcpResults( function firecrawlMcpConfig( agent?: string, runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false + keyless = false, + oauth = false ): { url: string; headers?: Record; transport?: string; } { return { - url: firecrawlHostedMcpUrl(), + url: firecrawlHostedMcpUrl(oauth), headers: firecrawlMcpHeaders( agent, keyless ? undefined : getApiKey(), @@ -873,11 +918,13 @@ export async function installOpenClawMcp( runtimeEnv: NodeJS.ProcessEnv = process.env, keyless = false, /** Suppress standalone logging when a caller renders its own summary. */ - quiet = false + quiet = false, + oauth = false ): Promise { const config = { - ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless), + ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless, oauth), transport: 'streamable-http', + ...(oauth ? MCP_LAUNCHER_OAUTH.openclaw?.entry : undefined), }; if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); diff --git a/src/index.ts b/src/index.ts index 2819ffdf77..aa8ff14fc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2254,6 +2254,7 @@ const setupCommand = program '--keyless', 'Configure anonymous hosted MCP even when an API key is stored' ) + .option('--oauth', 'Point agents at the sign-in endpoint instead (mcp)') .option( '--undo', 'Undo setup defaults by re-enabling native web tools where supported' @@ -2278,6 +2279,7 @@ setupCommand ` Examples: $ firecrawl setup mcp # pick agents, then choose rules + $ firecrawl setup mcp --oauth # sign in from each agent's browser $ firecrawl setup mcp --claude --cursor # skip the picker $ firecrawl setup mcp --yes # every detected agent, MCP only $ firecrawl setup mcp --yes --rules # every detected agent, with rules diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 2fc89e3e09..13b45f7a7d 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -16,6 +16,11 @@ import { existsSync, promises as fs } from 'fs'; import path from 'path'; export const FIRECRAWL_MCP_URL = 'https://mcp.firecrawl.dev/v2/mcp'; +/** + * Browser sign-in endpoint. A different server URL, not a different header, so + * choosing it is what puts an agent into the sign-in flow. + */ +export const FIRECRAWL_MCP_OAUTH_URL = 'https://mcp.firecrawl.dev/v2/mcp-oauth'; export const MCP_SERVER_NAME = 'firecrawl'; export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; @@ -42,9 +47,11 @@ export type McpTargetId = McpClientId | McpLauncherId; /** * `env` writes an indirect reference to `FIRECRAWL_API_KEY`, which only works * when that variable is exported in the environment the agent runs under. - * `keyless` writes no credential at all. + * `keyless` writes no credential at all. `oauth` writes no credential either + * and points the agent at the sign-in endpoint, which it authenticates against + * through a browser flow the person completes in the agent itself. */ -export type McpAuthMode = 'env' | 'keyless'; +export type McpAuthMode = 'env' | 'keyless' | 'oauth'; export interface McpContext { home: string; @@ -65,6 +72,18 @@ export interface McpRuleSpec { globalPath: (ctx: McpContext) => string; } +/** + * How an agent is put into the browser sign-in flow. Absent when that flow is + * not verified for the agent, which keeps `--oauth` from writing an entry that + * reports success and then exposes no tools. + */ +export interface McpOauthSpec { + /** Entry fields the agent needs before it will start the flow. */ + entry?: Record; + /** What the person does next, since no agent signs in during setup. */ + nextStep: string; +} + export interface McpClient { id: McpClientId; name: string; @@ -78,6 +97,8 @@ export interface McpClient { */ createMode?: number; buildEntry: (ctx: McpContext) => Record; + /** Absent when browser sign-in is not verified for this agent. */ + oauth?: McpOauthSpec; /** Absent when the agent has no rules mechanism. */ rule?: McpRuleSpec; /** Paths whose existence means the agent is installed. */ @@ -150,6 +171,11 @@ function vscodeUserDir(ctx: McpContext): string { return path.join(appSupportDir(ctx, 'Code'), 'User'); } +/** Sign-in uses a separate endpoint, so the URL follows the auth mode. */ +export function firecrawlMcpUrl(ctx: McpContext): string { + return ctx.auth === 'oauth' ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; +} + /** Attach the agent's env-reference header when authenticating that way. */ function withEnvAuth( ctx: McpContext, @@ -170,7 +196,7 @@ export const MCP_CLIENTS: Record = { buildEntry: (ctx) => withEnvAuth( ctx, - { type: 'http', url: FIRECRAWL_MCP_URL }, + { type: 'http', url: firecrawlMcpUrl(ctx) }, ENV_HEADER.shell ), rule: { @@ -179,6 +205,7 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(claudeConfigDir(ctx), 'rules', 'firecrawl.md'), }, + oauth: { nextStep: 'run /mcp in Claude Code to sign in' }, detectPaths: (ctx) => [claudeConfigDir(ctx), claudeGlobalConfigPath(ctx)], }, cursor: { @@ -188,13 +215,14 @@ export const MCP_CLIENTS: Record = { serversKey: 'mcpServers', globalConfigPath: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), buildEntry: (ctx) => - withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.editor), + withEnvAuth(ctx, { url: firecrawlMcpUrl(ctx) }, ENV_HEADER.editor), rule: { kind: 'file', content: CURSOR_RULE, globalPath: (ctx) => path.join(ctx.home, '.cursor', 'rules', 'firecrawl.mdc'), }, + oauth: { nextStep: 'open Cursor Settings, select MCP, and sign in' }, detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], }, vscode: { @@ -206,7 +234,7 @@ export const MCP_CLIENTS: Record = { buildEntry: (ctx) => withEnvAuth( ctx, - { type: 'http', url: FIRECRAWL_MCP_URL }, + { type: 'http', url: firecrawlMcpUrl(ctx) }, ENV_HEADER.editor ), rule: { @@ -217,6 +245,7 @@ export const MCP_CLIENTS: Record = { }, // `User` is created on first launch, so requiring it misses an install // that has only been unpacked. These are the markers doctor already uses. + oauth: { nextStep: 'sign in from the MCP view in VS Code' }, detectPaths: (ctx) => [ appSupportDir(ctx, 'Code'), path.join(ctx.home, '.vscode'), @@ -232,13 +261,15 @@ export const MCP_CLIENTS: Record = { // so it authenticates without a header template. buildEntry: (ctx) => ctx.auth === 'env' - ? { url: FIRECRAWL_MCP_URL, bearer_token_env_var: API_KEY_ENV_VAR } - : { url: FIRECRAWL_MCP_URL }, + ? { url: firecrawlMcpUrl(ctx), bearer_token_env_var: API_KEY_ENV_VAR } + : { url: firecrawlMcpUrl(ctx) }, rule: { kind: 'append', content: RULE_BODY, globalPath: (ctx) => path.join(ctx.home, '.codex', 'AGENTS.md'), }, + // Codex registers the server but does not start the flow on its own. + oauth: { nextStep: 'run codex mcp login firecrawl' }, detectPaths: (ctx) => [path.join(ctx.home, '.codex')], }, opencode: { @@ -251,7 +282,7 @@ export const MCP_CLIENTS: Record = { buildEntry: (ctx) => withEnvAuth( ctx, - { type: 'remote', url: FIRECRAWL_MCP_URL, enabled: true }, + { type: 'remote', url: firecrawlMcpUrl(ctx), enabled: true }, ENV_HEADER.brace ), rule: { @@ -260,6 +291,7 @@ export const MCP_CLIENTS: Record = { globalPath: (ctx) => path.join(ctx.home, '.config', 'opencode', 'AGENTS.md'), }, + oauth: { nextStep: 'OpenCode opens the browser on first use' }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, hermes: { @@ -274,9 +306,14 @@ export const MCP_CLIENTS: Record = { // Documented HTTP server shape: `url` plus a `headers` mapping. Hermes // expands `${VAR}` in any string value in a server entry. buildEntry: (ctx) => - withEnvAuth(ctx, { url: FIRECRAWL_MCP_URL }, ENV_HEADER.shell), + withEnvAuth(ctx, { url: firecrawlMcpUrl(ctx) }, ENV_HEADER.shell), // No `rule`: Hermes reads AGENTS.md from the project directory, and setup // only ever writes global config, so there is no global rule file to own. + // Hermes only starts the flow when the entry opts into it. + oauth: { + entry: { auth: 'oauth' }, + nextStep: 'Hermes opens the browser on first use', + }, detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], }, }; @@ -313,6 +350,17 @@ function openclawWorkspaceDir(ctx: McpContext): string { * we write. OpenClaw injects its workspace `AGENTS.md` into the system prompt * on every turn, so the rule belongs there, fenced like any shared file. */ +/** Sign-in support for launchers, held apart because they take no config write. */ +export const MCP_LAUNCHER_OAUTH: Partial> = + { + openclaw: { + // A static Authorization header is ignored once this is set, and the + // login command only runs for servers configured with it. + entry: { auth: 'oauth' }, + nextStep: 'run openclaw mcp login firecrawl', + }, + }; + export const MCP_LAUNCHER_RULES: Partial> = { openclaw: { kind: 'append', diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 0f93482953..456a560e1b 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -369,7 +369,11 @@ async function writeMcpEntry( ctx: McpContext ): Promise<{ status: 'configured' | 'reconfigured'; configPath: string }> { const configPath = client.globalConfigPath(ctx); - const entry = client.buildEntry(ctx); + // Some agents will not start the sign-in flow from a URL alone. + const entry = + ctx.auth === 'oauth' && client.oauth?.entry + ? { ...client.buildEntry(ctx), ...client.oauth.entry } + : client.buildEntry(ctx); if (client.format === 'yaml') { const existing = (await readIfExists(configPath)) ?? ''; From ada3954038b03eed6b687cddf23d6d61d1b98757 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 16:43:47 -0700 Subject: [PATCH 15/19] fix(cli): keep user content and report rules accurately Four defects reported against the sign-in commit, each confirmed against the code before changing it: * Replacing an empty `mcp_servers:` key dropped an inline comment sitting on it. The comment belongs to the null value being replaced; a block map has no inline slot on its key, so it now moves to the head of the section instead of disappearing with the node it was attached to. Preserved and relocated beats silently deleted. * A marker-fenced rule section was written with LF regardless of the file it joined, so updating a CRLF AGENTS.md left mixed endings. The section now adopts the line endings of the file it is written into, which is how the JSON, TOML, and YAML writers already behave. * The OpenClaw workspace was resolved from the environment and the documented defaults alone, so a workspace moved through config took the rule to a path the launcher never reads. OpenClaw is now asked where its workspace is, since its config is JSON5 and out of reach; the previous resolution stays as the fallback for when the CLI cannot answer. * Declining rules reported OpenClaw as not supporting them, which stopped being true when it gained a rule. It reports skipped now, and unsupported is left for agents that genuinely have nowhere to put one. --- src/__tests__/utils/mcp-install.test.ts | 27 ++++++++++++++++ src/commands/setup.ts | 42 +++++++++++++++++++++++-- src/utils/mcp-install.ts | 20 +++++++++--- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 83e8402271..535ae5a002 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -383,6 +383,21 @@ describe('mcp install', () => { }); }); + it('keeps a comment that sat on the empty section', () => { + const { content } = upsertYamlServer( + 'model: opus\nmcp_servers: # servers live here\n', + 'mcp_servers', + 'firecrawl', + { url: MCP_URL } + ); + + expect(content).toContain('# servers live here'); + expect(parseYaml(content)).toEqual({ + model: 'opus', + mcp_servers: { firecrawl: { url: MCP_URL } }, + }); + }); + it('keeps a byte order mark and CRLF line endings', () => { const existing = '\uFEFFmodel: opus\r\nterminal:\r\n backend: docker\r\n'; @@ -446,6 +461,18 @@ describe('mcp install', () => { }); }); + it('keeps the line endings of a CRLF rule file', async () => { + const file = path.join(root, 'AGENTS.md'); + writeFileSync(file, '# Title\r\n\r\nBody line.\r\n'); + + await appendRuleSection(file, 'RULE ONE\nRULE TWO\n'); + + const written = read(file); + expect(written).toContain('\r\n'); + expect(/[^\r]\n/.test(written)).toBe(false); + expect(written).toContain('Body line.'); + }); + describe('setupMcpClient', () => { it('writes the keyless URL with no credentials', async () => { const result = await setupMcpClient('cursor', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index e112b54d77..8c4eb6e35a 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -618,6 +618,38 @@ async function pickMcpClients( }); } +/** + * Ask OpenClaw where its workspace is. Config can move it, the environment can + * move it, and a profile changes it again, but that config file is JSON5 and + * out of reach here, so the launcher itself is the authority. Falls back to the + * documented defaults whenever the CLI cannot answer. + */ +function openclawConfiguredWorkspace( + runtimeEnv: NodeJS.ProcessEnv, + id: McpLauncherId +): string | undefined { + if (id !== 'openclaw') return undefined; + try { + const stdout = execFileSync( + 'openclaw', + ['config', 'get', 'agents.defaults.workspace', '--json'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: cleanNpmEnv(), + } + ); + const value: unknown = JSON.parse(stdout); + if (typeof value !== 'string' || value === '') return undefined; + const expanded = value.startsWith('~') + ? path.join(os.homedir(), value.slice(1)) + : value; + return path.join(expanded, 'AGENTS.md'); + } catch { + return undefined; + } +} + /** * Launchers own their MCP configuration, so they are installed through their * own routine instead of a config write. Failures stay scoped to the one @@ -662,9 +694,15 @@ async function setupMcpLauncher( } const rule = MCP_LAUNCHER_RULES[id]; - if (!rules || !rule) return result; + if (!rule) return result; + if (!rules) { + // The launcher does take rules; the run just did not ask for them. + result.ruleStatus = 'skipped'; + return result; + } - const rulePath = rule.globalPath(ctx); + const rulePath = + openclawConfiguredWorkspace(runtimeEnv, id) ?? rule.globalPath(ctx); // The launcher creates this file itself on first run, seeded with its own // instructions. Creating it here first would leave the user with our section // and none of that, so the rule waits for a workspace that exists. diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 456a560e1b..cb58423697 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -156,7 +156,13 @@ export function upsertYamlServer( // assigning a plain object leaves the same error one level down. An absent // key needs none of this, since setIn creates the path itself. if (doc.getIn([serversKey]) === null) { - doc.setIn([serversKey], doc.createNode({})); + const empty = doc.getIn([serversKey], true) as { comment?: string | null }; + const section = doc.createNode({}); + // That comment belongs to the null value being replaced. A block map has + // no inline slot on its key, so it moves to the head of the section + // rather than being dropped with the node it was attached to. + if (empty?.comment) section.commentBefore = empty.comment; + doc.setIn([serversKey], section); } doc.setIn([serversKey, serverName], entry); @@ -343,8 +349,11 @@ export async function appendRuleSection( filePath: string, content: string ): Promise<'installed' | 'updated'> { - const section = `${RULE_MARKER}\n${content}${RULE_MARKER}`; const existing = (await readIfExists(filePath)) ?? ''; + // The file belongs to the user, so the section adopts its line endings + // instead of mixing LF into a CRLF document. + const eol = existing.includes('\r\n') ? '\r\n' : '\n'; + const section = `${RULE_MARKER}${eol}${content.replace(/\r?\n/g, eol)}${RULE_MARKER}`; const marker = escapeRegExp(RULE_MARKER); const fenced = new RegExp(`${marker}\\r?\\n[\\s\\S]*?${marker}`); @@ -359,8 +368,11 @@ export async function appendRuleSection( } const separator = - existing.length === 0 ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; - await writeFileEnsuringDir(filePath, `${existing}${separator}${section}\n`); + existing.length === 0 ? '' : existing.endsWith('\n') ? eol : `${eol}${eol}`; + await writeFileEnsuringDir( + filePath, + `${existing}${separator}${section}${eol}` + ); return 'installed'; } From 22a9e3988f066295f381e4c08987b1942eadb88b Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 16:58:59 -0700 Subject: [PATCH 16/19] fix(cli): keep credentials off the sign-in endpoint installOpenClawMcp read the stored key whenever keyless was not also set, so calling it with sign-in alone produced an OAuth entry carrying an Authorization header. OpenClaw ignores a static header once auth is oauth, so the result was inert rather than harmful, but writing credential configuration into a sign-in entry is wrong either way. The CLI never reached this: setup derives keyless from the auth mode, and sign-in is not env, so the header was already dropped. The helper is exported though, and the credential helper beside it is written to be safe in isolation for the same reason, so sign-in now drops the key in the config builder where every caller passes through. --- src/__tests__/commands/setup.test.ts | 16 ++++++++++++++++ src/commands/setup.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 724a0b798b..21843eddee 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -823,6 +823,22 @@ describe('handleSetupCommand', () => { }); }); + it('keeps credential configuration off the sign-in endpoint', async () => { + process.env.FIRECRAWL_API_KEY = 'fc-test-key'; + + // Called directly with sign-in but without keyless, the shape a caller + // outside this file could reach. + await installOpenClawMcp(process.env, false, true, true); + + const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; + expect(JSON.parse(config)).toEqual({ + url: `${MCP_URL}-oauth`, + transport: 'streamable-http', + auth: 'oauth', + }); + expect(config).not.toContain('Authorization'); + }); + it('refuses to combine sign-in with keyless', async () => { await expect( handleSetupCommand('mcp', { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8c4eb6e35a..06efde6f02 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -944,9 +944,13 @@ function firecrawlMcpConfig( } { return { url: firecrawlHostedMcpUrl(oauth), + // Sign-in replaces the credential rather than travelling beside it, so the + // key is dropped here too. Callers already choose one or the other, but a + // helper this public must not put credential configuration on the sign-in + // endpoint just because it was called directly. headers: firecrawlMcpHeaders( agent, - keyless ? undefined : getApiKey(), + keyless || oauth ? undefined : getApiKey(), runtimeEnv ), }; From 72c65e275e92ade46554f4185fabec40904b6284 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 17:27:21 -0700 Subject: [PATCH 17/19] refactor(cli): write MCP config only for agents that share one contract setup mcp promises a global entry in a file we can parse, never a literal key, plus an optional rule file we own. Claude Code, Cursor, VS Code, and Codex meet that as registry data: the only difference is the file format. OpenCode is the same kind of write with its own map key and header form, so it stays. Hermes and OpenClaw were each a second product. Hermes needed a YAML writer, an owner-only create mode, an extra entry field for sign-in, and had no global rule file to own. OpenClaw needed a subprocess, a JSON5 config we cannot parse, a workspace probe, a duplicate credential builder, and a rule that could only be written if its AGENTS.md already existed. Neither shaped the product; both shaped the code around them. They stay supported and stop being written. `--hermes`, `--openclaw`, and their names on --agent print the server URL and succeed, the way an agent we install skills for but write no MCP config for already did. Skills and firecrawl launch are untouched, and the URL still works for both. Removing the two takes the whole launcher concept with them: the subprocess runner and its Windows argv escaping, the second credential path, launcher detection, the YAML writer, and the target/client split that existed only because launchers were not clients. Five agents, one contract, one writer. --- README.md | 9 +- src/__tests__/commands/setup.test.ts | 537 +++--------------------- src/__tests__/utils/mcp-install.test.ts | 119 ------ src/commands/setup.ts | 430 +++---------------- src/index.ts | 16 +- src/utils/mcp-clients.ts | 170 ++------ src/utils/mcp-install.ts | 77 +--- 7 files changed, 179 insertions(+), 1179 deletions(-) diff --git a/README.md b/README.md index 6c4147f4ca..1dcdce4c71 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,13 @@ firecrawl setup mcp This detects which agents you have installed, lists those in a picker (already selected), and asks whether to add rules telling those agents to -prefer Firecrawl for web search and scraping. Supported agents are Claude Code, -Cursor, VS Code, Codex, OpenCode, Hermes Agent, and OpenClaw. +prefer Firecrawl for web search and scraping. Setup writes config for Claude +Code, Cursor, VS Code, Codex, and OpenCode. + +Hermes Agent and OpenClaw are supported without being configured: each keeps +MCP somewhere setup cannot edit safely, so `--hermes` and `--openclaw` print +the server URL and succeed rather than editing their files. Skills and +`firecrawl launch` cover both as before. Setup writes to your global agent settings, so one command puts Firecrawl on every agent you already use. Pass agent flags to skip the picker, or `-y` to diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 21843eddee..479eeed435 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -15,7 +15,6 @@ import { handleMakeDefaultCommand, handleSetupCommand, installMcp, - installOpenClawMcp, installSkillsForAgent, } from '../../commands/setup'; import { ALL_SKILL_REPOS } from '../../commands/skills-install'; @@ -405,17 +404,9 @@ describe('handleSetupCommand', () => { } }); - it('offers launchers in the picker and configures Hermes by flag', async () => { - await handleSetupCommand('mcp', { clients: ['hermes'], yes: true }); - - expect( - readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') - ).toContain('firecrawl:'); - }); - it('lists only detected agents in the picker, already selected', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + mkdirSync(path.join(sandboxHome, '.codex'), { recursive: true }); const { checkbox, confirm } = await import('@inquirer/prompts'); vi.mocked(checkbox).mockResolvedValue(['cursor']); @@ -434,7 +425,7 @@ describe('handleSetupCommand', () => { expect(vi.mocked(checkbox).mock.calls[0]?.[0]).toMatchObject({ choices: [ { value: 'cursor', checked: true }, - { value: 'hermes', checked: true }, + { value: 'codex', checked: true }, ], }); expect(existsSync(path.join(sandboxHome, '.cursor', 'mcp.json'))).toBe( @@ -451,59 +442,6 @@ describe('handleSetupCommand', () => { } }); - it('detects an installed launcher so the picker can pre-select it', async () => { - mkdirSync(path.join(sandboxHome, '.openclaw'), { recursive: true }); - - const { detectMcpLaunchers } = await import('../../utils/mcp-clients'); - expect( - detectMcpLaunchers({ - home: sandboxHome, - cwd: process.cwd(), - platform: process.platform, - env: { PATH: '' }, - auth: 'keyless', - }) - ).toContain('openclaw'); - }); - - it('detects Hermes as a config-file client, not a launcher', async () => { - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); - - const { detectMcpClients, detectMcpLaunchers } = - await import('../../utils/mcp-clients'); - const ctx = { - home: sandboxHome, - cwd: process.cwd(), - platform: process.platform, - // Hermes is matched on its config directory alone. An unrelated - // JavaScript engine of the same name ships on many machines. - env: { PATH: '' }, - auth: 'keyless' as const, - }; - - expect(await detectMcpClients(ctx)).toContain('hermes'); - expect(detectMcpLaunchers(ctx)).not.toContain('hermes'); - }); - - it('keeps a failing launcher from taking down the other agents', async () => { - mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); - // OpenClaw shells out; a missing binary must stay scoped to OpenClaw. - vi.mocked(execFileSync).mockImplementation(() => { - throw new Error('ENOENT'); - }); - - await handleSetupCommand('mcp', { - clients: ['cursor', 'openclaw'], - yes: true, - }); - - expect( - JSON.parse( - readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') - ).mcpServers.firecrawl.url - ).toBe(MCP_URL); - }); - it('surfaces total failure even in quiet mode', async () => { mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); writeFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), '{ broken'); @@ -553,82 +491,6 @@ describe('handleSetupCommand', () => { ).rejects.toThrow('Unknown agent'); }); - it('falls back to keyless Hermes MCP when only a stored key exists', async () => { - const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hermes-test-')); - process.env.HOME = home; - const configPath = path.join(home, '.hermes', 'config.yaml'); - mkdirSync(path.dirname(configPath), { recursive: true }); - writeFileSync( - configPath, - 'theme: dark\nmcp_servers:\n existing:\n url: https://example.com/mcp\n', - { mode: 0o600 } - ); - - try { - await handleSetupCommand('mcp', { - agent: 'hermes', - global: true, - yes: true, - }); - - const config = readFileSync(configPath, 'utf-8'); - expect(config).toContain('theme: dark'); - expect(config).toContain('existing:'); - expect(config).toContain('firecrawl:'); - expect(config).toContain(MCP_URL); - expect(config).not.toContain('Authorization'); - expect(config).not.toContain('fc-test-key'); - expect(execFileSync).not.toHaveBeenCalled(); - if (process.platform !== 'win32') { - expect(statSync(configPath).mode & 0o777).toBe(0o600); - } - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); - - it('keeps an environment-backed key indirect in Hermes config', async () => { - const home = mkdtempSync( - path.join(os.tmpdir(), 'firecrawl-hermes-env-test-') - ); - process.env.HOME = home; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - try { - await installMcp({ agent: 'hermes' }); - - const config = readFileSync( - path.join(home, '.hermes', 'config.yaml'), - 'utf-8' - ); - expect(config).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); - expect(config).not.toContain('Bearer fc-test-key'); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); - - 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('suppresses Hermes installer logs in quiet mode', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -642,212 +504,101 @@ describe('handleSetupCommand', () => { } }); - it('rejects a stored key before invoking the OpenClaw CLI', async () => { - await expect(installOpenClawMcp()).rejects.toThrow( - 'Export FIRECRAWL_API_KEY' - ); - expect(execFileSync).not.toHaveBeenCalled(); - }); - - it('falls back to keyless OpenClaw MCP when only a stored key exists', async () => { - await installMcp({ agent: 'openclaw' }); - - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(config).toContain(MCP_URL); - expect(config).not.toContain('Authorization'); - expect(config).not.toContain('fc-test-key'); - }); - it('uses OpenClaw environment expansion instead of persisting an env-backed key', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - await installOpenClawMcp(); - - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(config).toContain('Bearer ${FIRECRAWL_API_KEY}'); - 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 () => { + it('points every agent at the sign-in endpoint with --oauth', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - vi.mocked(execFileSync).mockImplementationOnce(() => { - throw new Error('spawn failed with Authorization: Bearer fc-test-key'); - }); - - await expect(installOpenClawMcp()).rejects.toThrow( - 'Failed to configure Firecrawl MCP for OpenClaw. Verify that OpenClaw is installed and available on PATH.' - ); - }); + for (const dir of ['.claude', '.cursor', '.codex', '.hermes']) { + mkdirSync(path.join(sandboxHome, dir), { recursive: true }); + } - it('falls back to keyless for a stored key on every launch integration', async () => { - // One rule everywhere: a stored key is never written, and --agent all - // configures keyless rather than aborting the way it used to. - await handleSetupCommand('mcp', { agent: 'all', yes: true }); + await handleSetupCommand('mcp', { oauth: true, yes: true } as never); - const hermes = readFileSync( - path.join(sandboxHome, '.hermes', 'config.yaml'), + const claude = readFileSync( + path.join(sandboxHome, '.claude.json'), 'utf-8' ); - expect(hermes).toContain('firecrawl:'); - expect(hermes).not.toContain('fc-test-key'); - expect( - readFileSync(path.join(sandboxHome, '.cursor', 'mcp.json'), 'utf-8') - ).not.toContain('fc-test-key'); - }); - - it('treats --agent launchers as the launchers, not as every agent', async () => { - await handleSetupCommand('mcp', { agent: 'launchers', yes: true }); - - // OpenClaw is the only launcher; it is configured through its own CLI. - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(config).toContain(MCP_URL); - expect(existsSync(globalConfigPath('cursor', sandboxHome))).toBe(false); - expect(existsSync(path.join(sandboxHome, '.hermes', 'config.yaml'))).toBe( - false - ); - }); - it('fences the rule into an existing OpenClaw workspace AGENTS.md', async () => { - const workspace = path.join(sandboxHome, '.openclaw', 'workspace'); - mkdirSync(workspace, { recursive: true }); - const agentsFile = path.join(workspace, 'AGENTS.md'); - writeFileSync(agentsFile, '# My workspace\n\nKeep this text.\n'); - - await handleSetupCommand('mcp', { - clients: ['openclaw'], - yes: true, - rules: true, - } as never); - - const written = readFileSync(agentsFile, 'utf-8'); - expect(written).toContain('# My workspace'); - expect(written).toContain('Keep this text.'); - expect(written).toContain('firecrawl_search'); + expect(claude).toContain('/v2/mcp-oauth'); + // Sign-in replaces the credential rather than travelling beside it. + expect(claude).not.toContain('Authorization'); + expect(claude).not.toContain('fc-test-key'); - // A rerun replaces the fenced section rather than adding a second copy. - await handleSetupCommand('mcp', { - clients: ['openclaw'], - yes: true, - rules: true, - } as never); - const rerun = readFileSync(agentsFile, 'utf-8'); - expect(rerun.match(new RegExp(RULE_MARKER, 'g'))).toHaveLength(2); - expect(rerun).toBe(written); + // Codex takes a bare URL; its sign-in is a separate login command. + expect( + readFileSync(path.join(sandboxHome, '.codex', 'config.toml'), 'utf-8') + ).toContain('/v2/mcp-oauth'); }); - it('leaves the OpenClaw rule alone until its workspace exists', async () => { - await handleSetupCommand('mcp', { - clients: ['openclaw'], - yes: true, - rules: true, - } as never); - - // Creating AGENTS.md before OpenClaw bootstraps it would cost the user the - // instructions the launcher seeds that file with. - expect( - existsSync(path.join(sandboxHome, '.openclaw', 'workspace', 'AGENTS.md')) - ).toBe(false); + it('refuses to combine sign-in with keyless', async () => { + await expect( + handleSetupCommand('mcp', { + clients: ['cursor'], + oauth: true, + keyless: true, + yes: true, + } as never) + ).rejects.toThrow(/either --oauth or --keyless/); }); - it('follows OPENCLAW_WORKSPACE_DIR when the workspace has moved', async () => { - const moved = path.join(sandboxHome, 'elsewhere'); - mkdirSync(moved, { recursive: true }); - writeFileSync(path.join(moved, 'AGENTS.md'), '# Moved\n'); - process.env.OPENCLAW_WORKSPACE_DIR = moved; + it('prints the server URL for an agent it does not configure', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { + // Naming one is not an error, and nothing is written for it. await handleSetupCommand('mcp', { - clients: ['openclaw'], + urlOnly: ['hermes'], yes: true, - rules: true, } as never); - expect(readFileSync(path.join(moved, 'AGENTS.md'), 'utf-8')).toContain( - 'firecrawl_search' - ); + expect(log.mock.calls.flat().join(' ')).toContain(MCP_URL); + expect(existsSync(path.join(sandboxHome, '.hermes'))).toBe(false); } finally { - delete process.env.OPENCLAW_WORKSPACE_DIR; + log.mockRestore(); } }); - it('points every agent at the sign-in endpoint with --oauth', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - for (const dir of ['.claude', '.cursor', '.codex', '.hermes']) { - mkdirSync(path.join(sandboxHome, dir), { recursive: true }); - } - - await handleSetupCommand('mcp', { oauth: true, yes: true } as never); + it('accepts those agents by name as well as by flag', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const claude = readFileSync( - path.join(sandboxHome, '.claude.json'), - 'utf-8' - ); - expect(claude).toContain('/v2/mcp-oauth'); - // Sign-in replaces the credential rather than travelling beside it. - expect(claude).not.toContain('Authorization'); - expect(claude).not.toContain('fc-test-key'); + try { + for (const agent of ['hermes', 'hermes-agent', 'openclaw']) { + await handleSetupCommand('mcp', { agent, yes: true }); + } - // Codex takes a bare URL; its sign-in is a separate login command. - expect( - readFileSync(path.join(sandboxHome, '.codex', 'config.toml'), 'utf-8') - ).toContain('/v2/mcp-oauth'); + expect(log.mock.calls.flat().join(' ')).toContain(MCP_URL); + expect(existsSync(path.join(sandboxHome, '.openclaw'))).toBe(false); + } finally { + log.mockRestore(); + } }); - it('arms the sign-in flow for agents that need more than a URL', async () => { - mkdirSync(path.join(sandboxHome, '.hermes'), { recursive: true }); + it('still configures the writers when both kinds are named', async () => { + mkdirSync(path.join(sandboxHome, '.cursor'), { recursive: true }); await handleSetupCommand('mcp', { - clients: ['hermes', 'openclaw'], - oauth: true, + clients: ['cursor'], + urlOnly: ['openclaw'], yes: true, } as never); - // Hermes only starts the flow when the entry opts in. expect( - readFileSync(path.join(sandboxHome, '.hermes', 'config.yaml'), 'utf-8') - ).toContain('auth: oauth'); - - // OpenClaw ignores a static header once this is set, and its login - // command only runs for servers configured with it. - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(JSON.parse(config)).toMatchObject({ - url: `${MCP_URL}-oauth`, - auth: 'oauth', - }); + JSON.parse(readFileSync(globalConfigPath('cursor', sandboxHome), 'utf-8')) + .mcpServers.firecrawl.url + ).toBe(MCP_URL); }); - it('keeps credential configuration off the sign-in endpoint', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - // Called directly with sign-in but without keyless, the shape a caller - // outside this file could reach. - await installOpenClawMcp(process.env, false, true, true); - - const config = vi.mocked(execFileSync).mock.calls[0]?.[1]?.[3] as string; - expect(JSON.parse(config)).toEqual({ - url: `${MCP_URL}-oauth`, - transport: 'streamable-http', - auth: 'oauth', - }); - expect(config).not.toContain('Authorization'); - }); + it('sends the sign-in URL for an agent it does not configure', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - it('refuses to combine sign-in with keyless', async () => { - await expect( - handleSetupCommand('mcp', { - clients: ['cursor'], + try { + await handleSetupCommand('mcp', { + urlOnly: ['openclaw'], oauth: true, - keyless: true, yes: true, - } as never) - ).rejects.toThrow(/either --oauth or --keyless/); + } as never); + + expect(log.mock.calls.flat().join(' ')).toContain(`${MCP_URL}-oauth`); + } finally { + log.mockRestore(); + } }); it('uses each client native environment binding with --agent all', async () => { @@ -883,9 +634,8 @@ describe('handleSetupCommand', () => { }); expect(codex).toContain('bearer_token_env_var = "FIRECRAWL_API_KEY"'); expect(`${claude}${cursor}${codex}`).not.toContain('fc-test-key'); - expect( - readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') - ).toContain('Authorization: Bearer ${FIRECRAWL_API_KEY}'); + // `all` covers every agent setup writes for, and nothing else. + expect(existsSync(path.join(home, '.hermes', 'config.yaml'))).toBe(false); } finally { rmSync(home, { recursive: true, force: true }); } @@ -902,9 +652,9 @@ describe('handleSetupCommand', () => { yes: true, }); - expect( - readFileSync(path.join(home, '.hermes', 'config.yaml'), 'utf-8') - ).toContain(MCP_URL); + expect(readFileSync(globalConfigPath('cursor', home), 'utf-8')).toContain( + MCP_URL + ); } finally { rmSync(home, { recursive: true, force: true }); } @@ -996,45 +746,6 @@ describe('handleSetupCommand', () => { } }); - it('does not print a stored OpenClaw credential when setup is rejected', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - await expect(installOpenClawMcp()).rejects.toThrow( - 'Export FIRECRAWL_API_KEY' - ); - - expect(log.mock.calls.flat().join(' ')).not.toContain('fc-test-key'); - }); - - it('never persists or prints stored credentials containing hostile characters', async () => { - const hostileKey = 'fc-$(touch /tmp/firecrawl-pwned)`echo bad`"\\n$HOME'; - vi.mocked(getApiKey).mockReturnValue(hostileKey); - const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-hostile-')); - process.env.HOME = home; - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const error = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); - - try { - await handleSetupCommand('mcp', { - agent: 'claude-code', - global: true, - yes: true, - }); - - expect( - readFileSync(path.join(home, '.claude.json'), 'utf-8') - ).not.toContain(hostileKey); - expect(execFileSync).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); - expect(log.mock.calls.flat().join(' ')).not.toContain(hostileKey); - expect(error.mock.calls.flat().join(' ')).not.toContain(hostileKey); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); - it('writes MCP into global agent config', async () => { vi.mocked(getApiKey).mockReturnValue(undefined); const home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-global-')); @@ -1053,120 +764,6 @@ describe('handleSetupCommand', () => { // --- Windows: launch .cmd/.exe shims correctly (execFileSync cannot) --- - it('launches a .cmd shim via the shell on win32 with cmd-escaped args', async () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-')); - const bin = path.join(root, 'Program Files', 'nodejs'); - mkdirSync(bin, { recursive: true }); - writeFileSync(path.join(bin, 'openclaw.CMD'), '@exit /b 0\r\n'); - const originalPlatform = Object.getOwnPropertyDescriptor( - process, - 'platform' - ); - const originalPath = process.env.PATH; - const originalPathext = process.env.PATHEXT; - const originalComspec = process.env.ComSpec; - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32', - }); - process.env.PATH = bin; - process.env.PATHEXT = '.EXE;.CMD'; - process.env.ComSpec = 'cmd.exe'; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - try { - await handleSetupCommand('mcp', { - agent: 'openclaw', - global: true, - yes: true, - }); - - const call = vi.mocked(execFileSync).mock.calls[0]; - const command = call?.[0] as string; - const passthruArgs = call?.[1] as string[]; - const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; - - expect(command).toBe('cmd.exe'); - expect(passthruArgs.slice(0, 3)).toEqual(['/d', '/s', '/c']); - expect(opts?.windowsVerbatimArguments).toBe(true); - expect(passthruArgs[3]).toContain( - `^\"${path.join(bin, 'openclaw.CMD')}^\"` - ); - expect(passthruArgs[3]).toContain('Bearer ${FIRECRAWL_API_KEY}'); - expect(passthruArgs[3]).not.toContain('fc-test-key'); - } finally { - if (originalPlatform) - Object.defineProperty(process, 'platform', originalPlatform); - if (originalPath === undefined) delete process.env.PATH; - else process.env.PATH = originalPath; - if (originalPathext === undefined) delete process.env.PATHEXT; - else process.env.PATHEXT = originalPathext; - if (originalComspec === undefined) delete process.env.ComSpec; - else process.env.ComSpec = originalComspec; - rmSync(root, { recursive: true, force: true }); - } - }); - - it('launches a native executable directly on win32', async () => { - const bin = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-win-bin-')); - const openclawExe = path.join(bin, 'openclaw.EXE'); - writeFileSync(openclawExe, ''); - const originalPlatform = Object.getOwnPropertyDescriptor( - process, - 'platform' - ); - const originalPath = process.env.PATH; - const originalPathext = process.env.PATHEXT; - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32', - }); - process.env.PATH = bin; - process.env.PATHEXT = '.EXE;.CMD'; - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - - try { - await handleSetupCommand('mcp', { - agent: 'openclaw', - global: true, - yes: true, - }); - - const call = vi.mocked(execFileSync).mock.calls[0]; - const command = call?.[0] as string; - const args = call?.[1] as string[]; - const opts = call?.[2] as { windowsVerbatimArguments?: boolean }; - expect(command).toBe(openclawExe); - expect(args.join(' ')).toContain('Bearer ${FIRECRAWL_API_KEY}'); - expect(opts?.windowsVerbatimArguments).toBeUndefined(); - } finally { - if (originalPlatform) - Object.defineProperty(process, 'platform', originalPlatform); - if (originalPath === undefined) delete process.env.PATH; - else process.env.PATH = originalPath; - if (originalPathext === undefined) delete process.env.PATHEXT; - else process.env.PATHEXT = originalPathext; - rmSync(bin, { recursive: true, force: true }); - } - }); - - it('still spawns bare argv with no shell on non-win32', async () => { - process.env.FIRECRAWL_API_KEY = 'fc-test-key'; - // Sanity: the POSIX path stays argv-safe with no shell interpolation. - await handleSetupCommand('mcp', { - agent: 'openclaw', - global: true, - yes: true, - }); - - const call = vi.mocked(execFileSync).mock.calls[0]; - expect(call?.[0]).toBe('openclaw'); - expect( - Array.isArray(call?.[1]) && (call?.[1] as string[]).length - ).toBeGreaterThan(0); - expect((call?.[2] as { shell?: boolean })?.shell).toBeUndefined(); - }); - it('strips inherited npm_* env vars before nested npx calls', async () => { // Reproduces the bug where running this CLI under `npx -y firecrawl-cli@VERSION` // leaks npm_command/npm_lifecycle_event/npm_execpath into nested diff --git a/src/__tests__/utils/mcp-install.test.ts b/src/__tests__/utils/mcp-install.test.ts index 535ae5a002..70bd76ad4e 100644 --- a/src/__tests__/utils/mcp-install.test.ts +++ b/src/__tests__/utils/mcp-install.test.ts @@ -13,12 +13,10 @@ import { resolveMcpClientId, type McpContext, } from '../../utils/mcp-clients'; -import { parse as parseYaml } from 'yaml'; import { appendRuleSection, setupMcpClient, upsertTomlServer, - upsertYamlServer, writeJsonServerEntry, } from '../../utils/mcp-install'; @@ -313,123 +311,6 @@ describe('mcp install', () => { }); }); - describe('upsertYamlServer', () => { - it('keeps the comments and formatting around an added server', () => { - const existing = [ - '# Hermes configuration', - 'model: anthropic/claude-opus-4.6 # my preferred model', - '', - 'mcp_servers:', - ' github:', - ' command: npx', - '', - ].join('\n'); - - const { content, alreadyExists } = upsertYamlServer( - existing, - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ); - - expect(alreadyExists).toBe(false); - expect(content).toContain('# Hermes configuration'); - expect(content).toContain('# my preferred model'); - expect(content).toContain('command: npx'); - expect(content).toContain(`url: ${MCP_URL}`); - }); - - it('builds the server map when the file is empty', () => { - const { content, alreadyExists } = upsertYamlServer( - '', - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ); - - expect(alreadyExists).toBe(false); - expect(parseYaml(content)).toEqual({ - mcp_servers: { firecrawl: { url: MCP_URL } }, - }); - }); - - it('reports an existing entry as already present and replaces it', () => { - const existing = 'mcp_servers:\n firecrawl:\n url: https://old\n'; - - const { content, alreadyExists } = upsertYamlServer( - existing, - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ); - - expect(alreadyExists).toBe(true); - expect(content).toContain(MCP_URL); - expect(content).not.toContain('https://old'); - }); - - it('fills in a server section that exists but is empty', () => { - const { content, alreadyExists } = upsertYamlServer( - 'model: opus\nmcp_servers:\n', - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ); - - expect(alreadyExists).toBe(false); - expect(parseYaml(content)).toEqual({ - model: 'opus', - mcp_servers: { firecrawl: { url: MCP_URL } }, - }); - }); - - it('keeps a comment that sat on the empty section', () => { - const { content } = upsertYamlServer( - 'model: opus\nmcp_servers: # servers live here\n', - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ); - - expect(content).toContain('# servers live here'); - expect(parseYaml(content)).toEqual({ - model: 'opus', - mcp_servers: { firecrawl: { url: MCP_URL } }, - }); - }); - - it('keeps a byte order mark and CRLF line endings', () => { - const existing = - '\uFEFFmodel: opus\r\nterminal:\r\n backend: docker\r\n'; - - const { content } = upsertYamlServer( - existing, - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ); - - expect(content.startsWith('\uFEFF')).toBe(true); - expect(content).toContain('\r\n'); - expect(/[^\r]\n/.test(content)).toBe(false); - expect(parseYaml(content.slice(1))).toMatchObject({ - model: 'opus', - mcp_servers: { firecrawl: { url: MCP_URL } }, - }); - }); - - it('refuses a config that does not parse', () => { - expect(() => - upsertYamlServer( - 'model: "unterminated\nother: 1\n', - 'mcp_servers', - 'firecrawl', - { url: MCP_URL } - ) - ).toThrow(/quote/i); - }); - }); - describe('appendRuleSection', () => { it('keeps existing content and replaces only the fenced section', async () => { const file = path.join(root, 'AGENTS.md'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 06efde6f02..8194bfdb56 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -3,7 +3,7 @@ * Installs firecrawl skill files and MCP server into AI coding agents */ -import { execFileSync, execSync } from 'child_process'; +import { execSync } from 'child_process'; import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; @@ -28,38 +28,32 @@ import { import { ALL_MCP_CLIENT_IDS, FIRECRAWL_MCP_URL, - ALL_MCP_LAUNCHER_IDS, ALL_MCP_TARGET_IDS, detectMcpClients, - detectMcpLaunchers, FIRECRAWL_MCP_OAUTH_URL, - isMcpLauncherId, MCP_CLIENTS, - MCP_LAUNCHER_OAUTH, - MCP_LAUNCHER_RULES, mcpTargetName, resolveMcpClientId, type McpAuthMode, type McpContext, - type McpLauncherId, - type McpTargetId, + MCP_URL_ONLY_IDS, + MCP_URL_ONLY_NAMES, + resolveMcpUrlOnlyId, + type McpClientId, + type McpUrlOnlyId, } from '../utils/mcp-clients'; -import { - appendRuleSection, - setupMcpClient, - type McpClientResult, -} from '../utils/mcp-install'; +import { setupMcpClient, type McpClientResult } from '../utils/mcp-install'; export type SetupSubcommand = 'skills' | 'workflows' | 'mcp' | 'defaults'; type SetupIntegration = SetupSubcommand; type ResolvedMcpAgent = - | { kind: 'clients'; ids?: McpTargetId[] } - | { kind: 'launchers' } + | { kind: 'clients'; ids?: McpClientId[] } | { kind: 'skills-only'; agent: string } - | { kind: 'openclaw' } - | { kind: 'all-launchers' }; + /** Supported, but setup prints the URL instead of editing their config. */ + | { kind: 'url-only'; ids: McpUrlOnlyId[] } + | { kind: 'all' }; export interface SetupOptions { global?: boolean; @@ -76,7 +70,9 @@ export interface SetupOptions { /** Point agents at the sign-in endpoint instead of sending a credential. */ oauth?: boolean; /** Agents chosen by flag (`--claude`, `--cursor`, ...); skips the picker. */ - clients?: McpTargetId[]; + clients?: McpClientId[]; + /** Supported agents named by flag that setup does not configure. */ + urlOnly?: McpUrlOnlyId[]; /** Force the Firecrawl web rules on or off instead of prompting. */ rules?: boolean; } @@ -97,108 +93,6 @@ const SKILL_REPO_LABELS: Record = { function skillRepoLabel(repo: string): string { return SKILL_REPO_LABELS[repo] ?? repo; } - -const CMD_META_CHARS = /([()%!^"<>&|])/g; - -function rejectCommandControlCharacters(value: string, label: string): void { - if (/[\0\r\n]/.test(value)) { - throw new Error(`${label} contains an unsupported control character.`); - } -} - -/** Quote one argv value for cmd.exe using the same two-layer escaping model as - * established Windows spawn libraries: first the C runtime, then cmd.exe. */ -function escapeCmdArg(arg: string): string { - rejectCommandControlCharacters(arg, 'Command argument'); - const quoted = `"${arg - .replace(/(\\*)"/g, '$1$1\\"') - .replace(/(\\*)$/, '$1$1')}"`; - return quoted.replace(CMD_META_CHARS, '^$1'); -} - -function windowsPathExtensions(env: NodeJS.ProcessEnv): string[] { - const configured = env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'; - return configured - .split(';') - .map((extension) => extension.trim()) - .filter(Boolean); -} - -/** Resolve the actual Windows launcher instead of assuming every tool is a - * `.cmd` shim. Native `.exe` clients must bypass cmd.exe entirely. */ -function resolveWindowsCommand( - command: string, - env: NodeJS.ProcessEnv -): string { - rejectCommandControlCharacters(command, 'Command'); - const hasPath = /[\\/]/.test(command); - const hasExtension = path.extname(command) !== ''; - const candidates = hasExtension - ? [command] - : windowsPathExtensions(env).map((extension) => `${command}${extension}`); - const pathEntries = hasPath - ? [''] - : (env.PATH ?? env.Path ?? env.path ?? '') - .split(path.delimiter) - .map((entry) => entry.replace(/^"|"$/g, '')) - .filter(Boolean); - - for (const directory of pathEntries) { - for (const candidate of candidates) { - const resolved = directory ? path.join(directory, candidate) : candidate; - if (existsSync(resolved)) return resolved; - } - } - - // Let CreateProcess perform its normal resolution for native executables. - // Crucially, do not silently rewrite an unknown command to `.cmd`. - return command; -} - -/** - * Cross-platform, injection-safe replacement for `execFileSync`. - * - * On win32, external tools ship as `.cmd`/`.bat` shims (npx.cmd, npm.cmd, - * codex.cmd, openclaw.cmd). Node's `execFile`/`execFileSync` calls CreateProcess - * directly and CANNOT launch a `.cmd`/`.bat` file — it throws ENOENT/EINVAL. The - * only reliable way is to route through the shell (cmd.exe). To keep the argv - * safety this file relies on (secrets must never be shell-interpreted), we - * escape every argument for cmd.exe ourselves instead of letting the shell - * re-split a joined string. - * - * On every other platform we spawn the binary directly with no shell, exactly as - * `execFileSync` did before. - */ -function runClientCommand( - command: string, - args: string[], - options: Parameters[2] -): void { - rejectCommandControlCharacters(command, 'Command'); - for (const arg of args) - rejectCommandControlCharacters(arg, 'Command argument'); - - if (process.platform !== 'win32') { - execFileSync(command, args, options); - return; - } - - const env = options?.env ?? process.env; - const resolved = resolveWindowsCommand(command, env); - if (!/\.(?:cmd|bat)$/i.test(resolved)) { - execFileSync(resolved, args, options); - return; - } - - const line = [escapeCmdArg(resolved), ...args.map(escapeCmdArg)].join(' '); - const comspec = env.ComSpec ?? env.COMSPEC ?? 'cmd.exe'; - const windowsOptions = { - ...options, - windowsVerbatimArguments: true, - } as Parameters[2]; - execFileSync(comspec, ['/d', '/s', '/c', `"${line}"`], windowsOptions); -} - function firecrawlHostedMcpUrl(oauth = false): string { return oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; } @@ -210,51 +104,6 @@ function isEnvironmentBackedApiKey( return Boolean(apiKey && runtimeEnv[ENV_API_KEY] === apiKey); } -function assertSubprocessSafeCredential( - apiKey?: string, - runtimeEnv: NodeJS.ProcessEnv = process.env -): void { - if (apiKey && !isEnvironmentBackedApiKey(apiKey, runtimeEnv)) { - throw 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.' - ); - } -} - -function environmentHeaderForAgent(agent?: string): string | undefined { - switch (agent) { - case 'claude-code': - case 'hermes': - case 'openclaw': - return `Bearer \${${ENV_API_KEY}}`; - case 'cursor': - case 'vscode': - return `Bearer \${env:${ENV_API_KEY}}`; - case 'opencode': - return `Bearer {env:${ENV_API_KEY}}`; - default: - return undefined; - } -} - -function firecrawlMcpHeaders( - agent?: 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, runtimeEnv); - const environmentHeader = environmentHeaderForAgent(agent); - if (environmentHeader) return { Authorization: environmentHeader }; - throw new Error( - 'This MCP client does not have a verified environment-variable syntax. Choose a supported --agent, use --agent all, or configure the client manually so FIRECRAWL_API_KEY is not persisted as a literal.' - ); -} - function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { if (!agent) return { kind: 'clients' }; @@ -262,22 +111,19 @@ function resolveMcpAgent(agent: string | undefined): ResolvedMcpAgent { switch (normalized) { case '*': case 'all': - return { kind: 'all-launchers' }; - case 'launchers': - case 'launcher': - return { kind: 'launchers' }; - case 'openclaw': - return { kind: 'openclaw' }; + return { kind: 'all' }; default: { const id = resolveMcpClientId(normalized); if (id) return { kind: 'clients', ids: [id] }; + const urlOnly = resolveMcpUrlOnlyId(normalized); + if (urlOnly) return { kind: 'url-only', ids: [urlOnly] }; // A name we install skills for but write no MCP config for is not an // error; the caller may have already installed skills for it. if (isSkillsAgentName(normalized)) { return { kind: 'skills-only', agent }; } throw new Error( - `Unknown agent "${agent}" for setup mcp. Use one of: ${ALL_MCP_TARGET_IDS.join(', ')}, all.` + `Unknown agent "${agent}" for setup mcp. Use one of: ${[...ALL_MCP_CLIENT_IDS, ...MCP_URL_ONLY_IDS].join(', ')}, all.` ); } } @@ -544,6 +390,24 @@ export async function installSkillsForAgent( ); } +/** The endpoint this run points agents at, which sign-in changes. */ +function mcpUrlFor(options: SetupOptions): string { + return options.oauth ? FIRECRAWL_MCP_OAUTH_URL : FIRECRAWL_MCP_URL; +} + +/** + * Report the agents Firecrawl supports but does not configure. Naming one is + * not an error: the run succeeds and prints the URL so the person can point + * the agent at it themselves. + */ +function reportUrlOnly(ids: McpUrlOnlyId[], options: SetupOptions): void { + for (const id of ids) { + console.log( + `${MCP_URL_ONLY_NAMES[id]}: Firecrawl does not write its MCP config. Point it at ${mcpUrlFor(options)} to connect it yourself.` + ); + } +} + export async function installMcp( options: SetupOptions, // `firecrawl launch` may provide the exact environment inherited by the @@ -551,39 +415,33 @@ export async function installMcp( // without mutating the parent shell or exposing the key to setup commands. runtimeEnv: NodeJS.ProcessEnv = process.env ): Promise { - const apiKey = options.keyless ? undefined : getApiKey(); + // A flag naming an agent we support but do not configure is answered with + // the URL rather than treated as an error. + if (options.urlOnly?.length) { + reportUrlOnly(options.urlOnly, options); + if (!options.clients?.length && !options.agent) return; + } + const resolvedAgent = resolveMcpAgent(options.agent); - // Same rule as installMcpClients: a stored key cannot go into agent config, - // so --agent hermes/openclaw fall back to keyless just like --hermes/--openclaw. - const keyless = !isEnvironmentBackedApiKey(apiKey, runtimeEnv); if (resolvedAgent.kind === 'skills-only') { // Skills for this agent have already installed by this point; ending the // run here would fail a command that mostly succeeded. console.log( - `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${FIRECRAWL_MCP_URL} to connect it yourself.` + `Firecrawl does not write MCP config for ${resolvedAgent.agent}. Point it at ${mcpUrlFor(options)} to connect it yourself.` ); return; } - if (resolvedAgent.kind === 'openclaw') { - // Routed through the same reporter as every other target so the keyless - // fallback is stated rather than implied by a bare installer log line. - await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - resolvedAgent.kind, - ]); + if (resolvedAgent.kind === 'url-only') { + reportUrlOnly(resolvedAgent.ids, options); return; } - if (resolvedAgent.kind === 'launchers') { + if (resolvedAgent.kind === 'all') { await installMcpClients({ ...options, yes: true }, runtimeEnv, [ - ...ALL_MCP_LAUNCHER_IDS, + ...ALL_MCP_CLIENT_IDS, ]); - return; - } - if (resolvedAgent.kind === 'all-launchers') { - await installMcpClients({ ...options, yes: true }, runtimeEnv, undefined, { - includeAllLaunchers: true, - }); + reportUrlOnly([...MCP_URL_ONLY_IDS], options); return; } @@ -603,10 +461,10 @@ function displayPath(target: string, ctx: McpContext): string { } async function pickMcpClients( - detected: readonly McpTargetId[] -): Promise { + detected: readonly McpClientId[] +): Promise { const { checkbox } = await import('@inquirer/prompts'); - return checkbox({ + return checkbox({ message: 'Which agents do you want to set up?', loop: false, pageSize: detected.length, @@ -624,104 +482,6 @@ async function pickMcpClients( * out of reach here, so the launcher itself is the authority. Falls back to the * documented defaults whenever the CLI cannot answer. */ -function openclawConfiguredWorkspace( - runtimeEnv: NodeJS.ProcessEnv, - id: McpLauncherId -): string | undefined { - if (id !== 'openclaw') return undefined; - try { - const stdout = execFileSync( - 'openclaw', - ['config', 'get', 'agents.defaults.workspace', '--json'], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - env: cleanNpmEnv(), - } - ); - const value: unknown = JSON.parse(stdout); - if (typeof value !== 'string' || value === '') return undefined; - const expanded = value.startsWith('~') - ? path.join(os.homedir(), value.slice(1)) - : value; - return path.join(expanded, 'AGENTS.md'); - } catch { - return undefined; - } -} - -/** - * Launchers own their MCP configuration, so they are installed through their - * own routine instead of a config write. Failures stay scoped to the one - * launcher: a missing binary must not cost the user the agents that worked. - */ -async function setupMcpLauncher( - id: McpLauncherId, - ctx: McpContext, - runtimeEnv: NodeJS.ProcessEnv, - rules: boolean -): Promise { - const keyless = ctx.auth !== 'env'; - const result: McpClientResult = { - id, - name: mcpTargetName(id), - mcpStatus: 'failed', - mcpDetail: '', - auth: keyless ? 'keyless' : 'env', - ruleStatus: 'unsupported', - ruleDetail: '', - }; - - try { - switch (id) { - case 'openclaw': - await installOpenClawMcp( - runtimeEnv, - keyless, - true, - ctx.auth === 'oauth' - ); - result.mcpDetail = 'via the openclaw CLI'; - break; - default: { - const unreachable: never = id; - throw new Error(`No installer for launcher ${String(unreachable)}`); - } - } - result.mcpStatus = 'configured'; - } catch (error) { - result.mcpDetail = error instanceof Error ? error.message : String(error); - } - - const rule = MCP_LAUNCHER_RULES[id]; - if (!rule) return result; - if (!rules) { - // The launcher does take rules; the run just did not ask for them. - result.ruleStatus = 'skipped'; - return result; - } - - const rulePath = - openclawConfiguredWorkspace(runtimeEnv, id) ?? rule.globalPath(ctx); - // The launcher creates this file itself on first run, seeded with its own - // instructions. Creating it here first would leave the user with our section - // and none of that, so the rule waits for a workspace that exists. - if (!existsSync(rulePath)) { - result.ruleStatus = 'skipped'; - result.ruleDetail = rulePath; - return result; - } - - try { - result.ruleStatus = await appendRuleSection(rulePath, rule.content); - result.ruleDetail = rulePath; - } catch (error) { - result.ruleStatus = 'failed'; - result.ruleDetail = error instanceof Error ? error.message : String(error); - } - return result; -} - async function confirmMcpRules(): Promise { const { confirm } = await import('@inquirer/prompts'); return confirm({ @@ -734,7 +494,7 @@ async function confirmMcpRules(): Promise { async function installMcpClients( options: SetupOptions, runtimeEnv: NodeJS.ProcessEnv, - explicitIds?: McpTargetId[], + explicitIds?: McpClientId[], { includeAllLaunchers = false } = {} ): Promise { if (options.oauth && options.keyless) { @@ -765,15 +525,10 @@ async function installMcpClients( // Prompts only make sense when someone is there to answer them. const nonInteractive = Boolean(options.yes) || !process.stdin.isTTY; - let selected = includeAllLaunchers - ? [...ALL_MCP_CLIENT_IDS] - : (explicitIds ?? options.clients); + let selected = explicitIds ?? options.clients; if (!selected || selected.length === 0) { - const detected: McpTargetId[] = [ - ...(await detectMcpClients(ctx)), - ...detectMcpLaunchers(ctx), - ]; - if (detected.length === 0 && !includeAllLaunchers) { + const detected: McpClientId[] = await detectMcpClients(ctx); + if (detected.length === 0) { throw new Error( 'No coding agents detected. Pass an agent flag such as --claude or --cursor.' ); @@ -789,15 +544,6 @@ async function installMcpClients( } } - // `--agent all` reaches every integration whether or not it looks installed, - // which is what the flag has always meant. - if (includeAllLaunchers) { - selected = [ - ...selected.filter((id) => !isMcpLauncherId(id)), - ...ALL_MCP_LAUNCHER_IDS, - ]; - } - // `-y` stays MCP-only so automation never rewrites instruction files by // surprise; the flags are there when a script does want the rules. const rules = @@ -805,11 +551,7 @@ async function installMcpClients( const results: McpClientResult[] = []; for (const id of selected) { - results.push( - isMcpLauncherId(id) - ? await setupMcpLauncher(id, ctx, runtimeEnv, rules) - : await setupMcpClient(id, { rules, ctx }) - ); + results.push(await setupMcpClient(id, { rules, ctx })); } reportMcpResults(results, ctx, options, Boolean(apiKey)); @@ -875,9 +617,7 @@ function signInLine( ctx: McpContext ): string | undefined { if (ctx.auth !== 'oauth' || result.mcpStatus === 'failed') return undefined; - const spec = isMcpLauncherId(result.id) - ? MCP_LAUNCHER_OAUTH[result.id] - : MCP_CLIENTS[result.id].oauth; + const spec = MCP_CLIENTS[result.id].oauth; return spec ? ` Sign in ${dim}${spec.nextStep}${reset}` : undefined; } @@ -931,57 +671,3 @@ function reportMcpResults( throw new Error('Failed to configure Firecrawl MCP.'); } } - -function firecrawlMcpConfig( - agent?: string, - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false, - oauth = false -): { - url: string; - headers?: Record; - transport?: string; -} { - return { - url: firecrawlHostedMcpUrl(oauth), - // Sign-in replaces the credential rather than travelling beside it, so the - // key is dropped here too. Callers already choose one or the other, but a - // helper this public must not put credential configuration on the sign-in - // endpoint just because it was called directly. - headers: firecrawlMcpHeaders( - agent, - keyless || oauth ? undefined : getApiKey(), - runtimeEnv - ), - }; -} - -export async function installOpenClawMcp( - runtimeEnv: NodeJS.ProcessEnv = process.env, - keyless = false, - /** Suppress standalone logging when a caller renders its own summary. */ - quiet = false, - oauth = false -): Promise { - const config = { - ...firecrawlMcpConfig('openclaw', runtimeEnv, keyless, oauth), - transport: 'streamable-http', - ...(oauth ? MCP_LAUNCHER_OAUTH.openclaw?.entry : undefined), - }; - if (!quiet) console.log('Configuring Firecrawl MCP for OpenClaw...\n'); - - try { - runClientCommand( - 'openclaw', - ['mcp', 'set', 'firecrawl', JSON.stringify(config)], - { - stdio: 'pipe', - env: cleanNpmEnv(), - } - ); - } catch { - throw new Error( - 'Failed to configure Firecrawl MCP for OpenClaw. Verify that OpenClaw is installed and available on PATH.' - ); - } -} diff --git a/src/index.ts b/src/index.ts index aa8ff14fc4..576ff10a0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,7 +60,12 @@ import { } from './commands/init'; import { handleMakeDefaultCommand, handleSetupCommand } from './commands/setup'; import type { SetupSubcommand } from './commands/setup'; -import { ALL_MCP_TARGET_IDS, mcpTargetName } from './utils/mcp-clients'; +import { + ALL_MCP_TARGET_IDS, + MCP_URL_ONLY_IDS, + MCP_URL_ONLY_NAMES, + mcpTargetName, +} from './utils/mcp-clients'; import { handleEnvPullCommand } from './commands/env'; import { handleStatusCommand } from './commands/status'; import { handleDoctorCommand } from './commands/doctor'; @@ -2264,6 +2269,14 @@ const setupCommand = program for (const id of ALL_MCP_TARGET_IDS) { setupCommand.option(`--${id}`, `Set up ${mcpTargetName(id)} (mcp)`); } +// Supported agents we do not configure still take a flag, so naming one +// succeeds with the server URL instead of failing as unknown. +for (const id of MCP_URL_ONLY_IDS) { + setupCommand.option( + `--${id}`, + `Show the MCP URL for ${MCP_URL_ONLY_NAMES[id]} (mcp)` + ); +} // `-g` is the old way to ask for the global scope that is now the default. // Kept so existing scripts keep running, hidden because it does nothing. @@ -2294,6 +2307,7 @@ Examples: await handleSetupCommand(subcommand, { ...options, clients: ALL_MCP_TARGET_IDS.filter((id) => options[id] === true), + urlOnly: MCP_URL_ONLY_IDS.filter((id) => options[id] === true), }); }); diff --git a/src/utils/mcp-clients.ts b/src/utils/mcp-clients.ts index 13b45f7a7d..e4e8ea9758 100644 --- a/src/utils/mcp-clients.ts +++ b/src/utils/mcp-clients.ts @@ -24,25 +24,35 @@ export const FIRECRAWL_MCP_OAUTH_URL = 'https://mcp.firecrawl.dev/v2/mcp-oauth'; export const MCP_SERVER_NAME = 'firecrawl'; export const API_KEY_ENV_VAR = 'FIRECRAWL_API_KEY'; -export type McpClientId = - | 'claude' - | 'cursor' - | 'vscode' - | 'codex' - | 'opencode' - | 'hermes'; +export type McpClientId = 'claude' | 'cursor' | 'vscode' | 'codex' | 'opencode'; /** - * Agent launchers that own their MCP configuration rather than reading a file - * we write. They are offered alongside the editors but installed differently. - * - * OpenClaw is the only one: its config is JSON5, which the editor we patch JSON - * with cannot read, and `openclaw mcp set` is the vendor-documented path that - * also normalises the entry. Hermes reads plain YAML, so it is a client. + * Agents Firecrawl supports without configuring. Setup writes a global entry + * to a file it can parse, never a literal key, plus an optional rule file it + * owns. These agents do not share that contract: each needs its own writer, + * its own credential shape, or a subprocess. Setup prints the server URL for + * them instead, and skills and `firecrawl launch` are unaffected. */ -export type McpLauncherId = 'openclaw'; +export const MCP_URL_ONLY_IDS = ['hermes', 'openclaw'] as const; +export type McpUrlOnlyId = (typeof MCP_URL_ONLY_IDS)[number]; + +export const MCP_URL_ONLY_NAMES: Record = { + hermes: 'Hermes Agent', + openclaw: 'OpenClaw', +}; -export type McpTargetId = McpClientId | McpLauncherId; +const URL_ONLY_ALIASES: Record = { + hermes: 'hermes', + 'hermes-agent': 'hermes', + openclaw: 'openclaw', +}; + +export function resolveMcpUrlOnlyId(agent: string): McpUrlOnlyId | undefined { + const alias = agent.trim().toLowerCase(); + return Object.prototype.hasOwnProperty.call(URL_ONLY_ALIASES, alias) + ? URL_ONLY_ALIASES[alias] + : undefined; +} /** * `env` writes an indirect reference to `FIRECRAWL_API_KEY`, which only works @@ -87,15 +97,10 @@ export interface McpOauthSpec { export interface McpClient { id: McpClientId; name: string; - format: 'json' | 'toml' | 'yaml'; + format: 'json' | 'toml'; /** Key of the map holding MCP servers in this agent's config. */ serversKey: string; globalConfigPath: (ctx: McpContext) => string; - /** - * Mode for a config file we create. Only applied on creation, so a file the - * user already owns keeps the permissions they gave it. - */ - createMode?: number; buildEntry: (ctx: McpContext) => Record; /** Absent when browser sign-in is not verified for this agent. */ oauth?: McpOauthSpec; @@ -294,28 +299,6 @@ export const MCP_CLIENTS: Record = { oauth: { nextStep: 'OpenCode opens the browser on first use' }, detectPaths: (ctx) => [path.join(ctx.home, '.config', 'opencode')], }, - hermes: { - id: 'hermes', - name: 'Hermes Agent', - format: 'yaml', - serversKey: 'mcp_servers', - globalConfigPath: (ctx) => path.join(ctx.home, '.hermes', 'config.yaml'), - // Hermes keeps secrets in ~/.hermes/.env rather than here, but the rest of - // this file is the user's, so a file we create starts owner-only. - createMode: 0o600, - // Documented HTTP server shape: `url` plus a `headers` mapping. Hermes - // expands `${VAR}` in any string value in a server entry. - buildEntry: (ctx) => - withEnvAuth(ctx, { url: firecrawlMcpUrl(ctx) }, ENV_HEADER.shell), - // No `rule`: Hermes reads AGENTS.md from the project directory, and setup - // only ever writes global config, so there is no global rule file to own. - // Hermes only starts the flow when the entry opts into it. - oauth: { - entry: { auth: 'oauth' }, - nextStep: 'Hermes opens the browser on first use', - }, - detectPaths: (ctx) => [path.join(ctx.home, '.hermes')], - }, }; export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ @@ -324,105 +307,12 @@ export const ALL_MCP_CLIENT_IDS: readonly McpClientId[] = [ 'vscode', 'codex', 'opencode', - 'hermes', ]; -export const MCP_LAUNCHER_NAMES: Record = { - openclaw: 'OpenClaw', -}; +export const ALL_MCP_TARGET_IDS: readonly McpClientId[] = ALL_MCP_CLIENT_IDS; -/** - * OpenClaw keeps its bootstrap files in a workspace directory, which the user - * can move. An explicit config value wins over the environment, but that config - * is JSON5 and out of reach here, so this covers the documented defaults only. - */ -function openclawWorkspaceDir(ctx: McpContext): string { - const explicit = ctx.env.OPENCLAW_WORKSPACE_DIR; - if (explicit && explicit !== '') return explicit; - const profile = ctx.env.OPENCLAW_PROFILE; - const suffix = - profile && profile !== '' && profile !== 'default' ? `-${profile}` : ''; - return path.join(ctx.home, '.openclaw', `workspace${suffix}`); -} - -/** - * A launcher owns its MCP registration but can still read an instruction file - * we write. OpenClaw injects its workspace `AGENTS.md` into the system prompt - * on every turn, so the rule belongs there, fenced like any shared file. - */ -/** Sign-in support for launchers, held apart because they take no config write. */ -export const MCP_LAUNCHER_OAUTH: Partial> = - { - openclaw: { - // A static Authorization header is ignored once this is set, and the - // login command only runs for servers configured with it. - entry: { auth: 'oauth' }, - nextStep: 'run openclaw mcp login firecrawl', - }, - }; - -export const MCP_LAUNCHER_RULES: Partial> = { - openclaw: { - kind: 'append', - content: RULE_BODY, - globalPath: (ctx) => path.join(openclawWorkspaceDir(ctx), 'AGENTS.md'), - }, -}; - -export const ALL_MCP_LAUNCHER_IDS: readonly McpLauncherId[] = ['openclaw']; - -export const ALL_MCP_TARGET_IDS: readonly McpTargetId[] = [ - ...ALL_MCP_CLIENT_IDS, - ...ALL_MCP_LAUNCHER_IDS, -]; - -export function isMcpLauncherId(id: McpTargetId): id is McpLauncherId { - return (ALL_MCP_LAUNCHER_IDS as readonly string[]).includes(id); -} - -export function mcpTargetName(id: McpTargetId): string { - return isMcpLauncherId(id) ? MCP_LAUNCHER_NAMES[id] : MCP_CLIENTS[id].name; -} - -/** - * Look for an executable across PATH without spawning it. Launchers are CLIs, - * so their presence on PATH is the signal, but running `--version` during a - * picker would be slow and have side effects. - */ -function binaryOnPath(name: string, ctx: McpContext): boolean { - const extensions = - ctx.platform === 'win32' - ? (ctx.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) - : ['']; - const entries = (ctx.env.PATH ?? ctx.env.Path ?? '') - .split(path.delimiter) - .filter(Boolean); - for (const entry of entries) { - for (const extension of extensions) { - if (existsSync(path.join(entry, `${name}${extension}`))) return true; - } - } - return false; -} - -/** - * Detection prefers a false negative to a false positive: the picker only - * lists agents that look installed, so a miss means the user passes a flag - * (`--cursor`) instead of seeing an agent they do not have. - * - * Hermes is detected by its config directory alone, through `detectPaths`. Its - * name is also used by an unrelated JavaScript engine that ships with common - * toolchains, so a PATH lookup reports it present on machines without it. - */ -const LAUNCHER_DETECT: Record boolean> = { - openclaw: (ctx) => - existsSync(path.join(ctx.home, '.openclaw')) || - binaryOnPath('openclaw', ctx), -}; - -/** Launchers present on this machine, in registry order. */ -export function detectMcpLaunchers(ctx: McpContext): McpLauncherId[] { - return ALL_MCP_LAUNCHER_IDS.filter((id) => LAUNCHER_DETECT[id](ctx)); +export function mcpTargetName(id: McpClientId): string { + return MCP_CLIENTS[id].name; } /** Aliases accepted by `--agent`, including the names `firecrawl launch` uses. */ @@ -440,8 +330,6 @@ const CLIENT_ALIASES: Record = { 'codex-gui': 'codex', opencode: 'opencode', 'open-code': 'opencode', - hermes: 'hermes', - 'hermes-agent': 'hermes', }; export function resolveMcpClientId(agent: string): McpClientId | undefined { diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index cb58423697..d0674b05f6 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -12,7 +12,6 @@ import { promises as fs } from 'fs'; import path from 'path'; import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; -import { parseDocument } from 'yaml'; import { MCP_CLIENTS, MCP_SERVER_NAME, @@ -21,7 +20,6 @@ import { type McpClient, type McpClientId, type McpContext, - type McpTargetId, } from './mcp-clients'; export type McpStatus = 'configured' | 'reconfigured' | 'failed'; @@ -33,7 +31,7 @@ export type RuleStatus = | 'failed'; export interface McpClientResult { - id: McpTargetId; + id: McpClientId; name: string; mcpStatus: McpStatus; /** Config path on success, error message on failure. */ @@ -60,12 +58,10 @@ async function readIfExists(filePath: string): Promise { async function writeFileEnsuringDir( filePath: string, - content: string, - /** Applied by the OS only when the file is created, never to an existing one. */ - createMode?: number + content: string ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, { encoding: 'utf8', mode: createMode }); + await fs.writeFile(filePath, content, 'utf8'); } function escapeRegExp(value: string): string { @@ -133,50 +129,6 @@ export async function writeJsonServerEntry( return { status: alreadyExists ? 'reconfigured' : 'configured' }; } -/** - * Insert or replace `serversKey.serverName` in a YAML config. The document is - * edited as a tree rather than reserialised from plain objects, so comments, - * key order, and the user's formatting survive. Throws on a document that does - * not parse, matching how the JSON path treats a config it cannot read. - */ -export function upsertYamlServer( - content: string, - serversKey: string, - serverName: string, - entry: Record -): { content: string; alreadyExists: boolean } { - const doc = parseDocument(content); - if (doc.errors.length > 0) { - throw new Error(doc.errors[0].message); - } - - const alreadyExists = doc.hasIn([serversKey, serverName]); - // A key with nothing under it parses as a null scalar, and setting a path - // through that refuses to descend. It has to become a collection node: - // assigning a plain object leaves the same error one level down. An absent - // key needs none of this, since setIn creates the path itself. - if (doc.getIn([serversKey]) === null) { - const empty = doc.getIn([serversKey], true) as { comment?: string | null }; - const section = doc.createNode({}); - // That comment belongs to the null value being replaced. A block map has - // no inline slot on its key, so it moves to the head of the section - // rather than being dropped with the node it was attached to. - if (empty?.comment) section.commentBefore = empty.comment; - doc.setIn([serversKey], section); - } - doc.setIn([serversKey, serverName], entry); - - // Serialising the tree drops a byte order mark and normalises line endings. - // Both belong to the user's file, so they are restored on the way out. - const bom = content.startsWith('\uFEFF') ? '\uFEFF' : ''; - const eol = content.includes('\r\n') ? '\r\n' : '\n'; - const serialized = doc.toString().replace(/^\uFEFF/, ''); - return { - content: `${bom}${serialized.replace(/\r?\n/g, eol)}`, - alreadyExists, - }; -} - /** * True when the character at `index` is escaped. Backslashes escape each other, * so only an odd run of them before the position leaves it escaped. @@ -387,29 +339,6 @@ async function writeMcpEntry( ? { ...client.buildEntry(ctx), ...client.oauth.entry } : client.buildEntry(ctx); - if (client.format === 'yaml') { - const existing = (await readIfExists(configPath)) ?? ''; - let patched: { content: string; alreadyExists: boolean }; - try { - patched = upsertYamlServer( - existing, - client.serversKey, - MCP_SERVER_NAME, - entry - ); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `could not parse existing config at ${configPath}: ${reason}` - ); - } - await writeFileEnsuringDir(configPath, patched.content, client.createMode); - return { - status: patched.alreadyExists ? 'reconfigured' : 'configured', - configPath, - }; - } - if (client.format === 'toml') { const existing = (await readIfExists(configPath)) ?? ''; const stringEntry: Record = {}; From 414e8ab4254186609014eb2a407c88336eda5a70 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 17:57:58 -0700 Subject: [PATCH 18/19] fix(cli): check the auth modes before any early return, drop the unused yaml dep Two defects reported against the scope change: * The URL-only branch returns before the writers run, and the mutual-exclusion check lived with the writers, so `--hermes --oauth --keyless` printed a sign-in URL and exited zero while `--cursor --oauth --keyless` was rejected. The check now runs at the top of installMcp, ahead of everything that reports or returns. * Removing the Hermes writer took the last import of the yaml package with it. Nothing under src/ imports it now, so it is dropped from the manifest and the lockfile rather than shipped unused. A third finding, that a test leaks FIRECRAWL_API_KEY into later tests, does not hold: beforeEach deletes that variable and afterEach restores the original, so each test starts without it. The assignment is also load-bearing where it is, since the assertion it supports is that sign-in drops a key that was present. --- package.json | 1 - pnpm-lock.yaml | 3 --- src/__tests__/commands/setup.test.ts | 12 ++++++++++++ src/commands/setup.ts | 14 ++++++++------ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index ef0c8f1ed6..78fb57ca30 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "commander": "^14.0.2", "firecrawl": "4.24.0", "jsonc-parser": "3.3.1", - "yaml": "^2.9.0", "zod-to-json-schema": "3.24.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b056306794..0650b2bfcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: jsonc-parser: specifier: 3.3.1 version: 3.3.1 - yaml: - specifier: ^2.9.0 - version: 2.9.0 zod-to-json-schema: specifier: 3.24.6 version: 3.24.6(zod@3.25.76) diff --git a/src/__tests__/commands/setup.test.ts b/src/__tests__/commands/setup.test.ts index 479eeed435..6d0c8563e9 100644 --- a/src/__tests__/commands/setup.test.ts +++ b/src/__tests__/commands/setup.test.ts @@ -538,6 +538,18 @@ describe('handleSetupCommand', () => { ).rejects.toThrow(/either --oauth or --keyless/); }); + it('rejects that combination for an agent it only prints a URL for', async () => { + // This path returns early, so the check has to run ahead of it. + await expect( + handleSetupCommand('mcp', { + urlOnly: ['hermes'], + oauth: true, + keyless: true, + yes: true, + } as never) + ).rejects.toThrow(/either --oauth or --keyless/); + }); + it('prints the server URL for an agent it does not configure', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8194bfdb56..b4ccabf8a2 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -415,6 +415,14 @@ export async function installMcp( // without mutating the parent shell or exposing the key to setup commands. runtimeEnv: NodeJS.ProcessEnv = process.env ): Promise { + // Checked before anything else reports or returns, so an agent we only print + // a URL for cannot accept a combination the writers reject. + if (options.oauth && options.keyless) { + throw new Error( + 'Choose either --oauth or --keyless. Signing in and running anonymously are different endpoints.' + ); + } + // A flag naming an agent we support but do not configure is answered with // the URL rather than treated as an error. if (options.urlOnly?.length) { @@ -497,12 +505,6 @@ async function installMcpClients( explicitIds?: McpClientId[], { includeAllLaunchers = false } = {} ): Promise { - if (options.oauth && options.keyless) { - throw new Error( - 'Choose either --oauth or --keyless. Signing in and running anonymously are different endpoints.' - ); - } - const apiKey = options.oauth || options.keyless ? undefined : getApiKey(); // Sign-in is a different endpoint rather than a different credential, so it // overrides the key lookup entirely. Otherwise a stored key cannot be written From 554a3c382ca0cca874a421bd656d223c21fee25d Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Thu, 13 Aug 2026 18:03:20 -0700 Subject: [PATCH 19/19] docs(readme): fold the MCP setup changes into the existing prose The sign-in mode had its own lead-in, code block, and paragraph, and the two agents setup no longer configures had a paragraph explaining why. Both restate structure the section already has: one paragraph names the supported agents, and one paragraph covers how the credential is handled. Sign-in now sits in that credential paragraph beside keyless, and the two unwritten agents sit in the sentence that already lists the supported set. Same facts, no new sections, nine fewer lines. The harness table above is scoped to skills and stays accurate as written. --- README.md | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1dcdce4c71..7062f29fb6 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,9 @@ firecrawl setup mcp This detects which agents you have installed, lists those in a picker (already selected), and asks whether to add rules telling those agents to -prefer Firecrawl for web search and scraping. Setup writes config for Claude -Code, Cursor, VS Code, Codex, and OpenCode. - -Hermes Agent and OpenClaw are supported without being configured: each keeps -MCP somewhere setup cannot edit safely, so `--hermes` and `--openclaw` print -the server URL and succeed rather than editing their files. Skills and -`firecrawl launch` cover both as before. +prefer Firecrawl for web search and scraping. Setup configures Claude Code, +Cursor, VS Code, Codex, and OpenCode. `--hermes` and `--openclaw` print the +server URL instead, for agents Firecrawl supports but does not configure. Setup writes to your global agent settings, so one command puts Firecrawl on every agent you already use. Pass agent flags to skip the picker, or `-y` to @@ -108,19 +104,10 @@ Your API key is never written into an agent config. When `FIRECRAWL_API_KEY` is exported in the environment your agents run under, each agent gets a reference to that variable in the syntax it understands. Otherwise setup stays keyless, which still serves search, scrape, and parse under an anonymous rate limit. Use -`--keyless` to force the anonymous path even when a key is available. - -To sign in from the agent instead of carrying a key, use `--oauth`: - -```bash -firecrawl setup mcp --oauth # sign in from each agent's browser -``` - -This writes the sign-in endpoint rather than a credential, and each agent starts -the browser flow itself the first time it connects. Setup prints the step each -agent needs, since they differ: `/mcp` in Claude Code, `codex mcp login -firecrawl` for Codex, Cursor Settings, and a browser on first use elsewhere. -`--oauth` and `--keyless` are different endpoints, so pass only one. +`--keyless` to force the anonymous path even when a key is available, or +`--oauth` to sign in from the agent instead. Sign-in writes a different +endpoint, so each agent runs the browser flow itself on first use and setup +prints the step it needs. Pass either `--oauth` or `--keyless`, not both. To make Firecrawl the default web provider for supported AI agents: