From b99dcbe139f49c2d92642300d32d10e2358fcb05 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 15:38:08 +1000 Subject: [PATCH 1/6] feat: relay the caller's own keyless signup link from the API Keyless recovery messages linked to a fixed /signin?utm_source=keyless&utm_medium=mcp URL. The API now issues each keyless identity an opaque https://firecrawl.dev/k/ link per surface, which the site resolves to MCP keyless attribution and which lets a signup be joined to the keyless identity. The server relays that link instead of the constant: - a keyless 429 from the API: its signup_url - an eligibility refusal: signupUrl from /v2/keyless/eligibility - an account-only tool on a hosted keyless session: the server asks /v2/keyless/eligibility?signup_link=1 for the caller's link Only firecrawl.dev/k links are relayed; anything else, or no link, falls back to https://firecrawl.dev/k, which the site still tags as keyless. Recovery payloads carry the link as signup_url. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/index.ts | 78 +++++++++++--- tests/helpers/exchange-mcp.mjs | 2 +- tests/mcp-alexandria-auth.test.mjs | 3 +- tests/mcp-smoke.test.mjs | 163 +++++++++++++++++++++++++++-- 5 files changed, 222 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ef889d..3d5bbb1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Changed -- Keyless recovery messages now link to `https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys` instead of `/app/api-keys`, so accounts created from them can be attributed to the MCP keyless free tier. Signed-in users still land on the API keys page. +- Keyless recovery messages now link to the caller's own short signup link, `https://firecrawl.dev/k/`, instead of `/app/api-keys`. The API issues the link (the `signup_url` of a keyless 429, or `signupUrl` from the eligibility check, which the server now also asks for when a keyless session calls an account-only tool) and the site resolves it to MCP keyless attribution. Without one the message uses `https://firecrawl.dev/k`. Recovery payloads carry the link as `signup_url`. Signed-in users who open it land on the API keys page. - The search surface (`/v2/mcp-search`) now exposes `firecrawl_find_tools` and `firecrawl_scrape` alongside its six search tools, so agents can execute the Alexandria providers that `firecrawl_search` already returns. Both carry surface-scoped descriptions that name only tools registered on that surface, and Alexandria results there omit the `firecrawl_feedback` pointer. See docs/search-profile.md. ### Fixed diff --git a/src/index.ts b/src/index.ts index 5c692454..6c9a97aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1458,11 +1458,31 @@ function isLocalKeylessStartup(): boolean { // FastMCP copies UserError.message onto both content[0].text and // structuredContent.message. Hosts forward the text block, not // structured next_actions, so bearer and OAuth recovery strings live here. -const KEYLESS_ACCOUNT_FIX = - 'Fix: Create an API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.'; -const KEYLESS_QUOTA_MESSAGE = `You've hit Firecrawl's free MCP rate limit. To continue using without limits, create a Firecrawl API key.\n\n${KEYLESS_ACCOUNT_FIX}`; -const KEYLESS_TOOL_MESSAGE = `This tool needs a Firecrawl account.\n\n${KEYLESS_ACCOUNT_FIX}`; -const KEYLESS_ACCESS_MESSAGE = `Anonymous keyless access is unavailable for this request.\n\n${KEYLESS_ACCOUNT_FIX}`; +// +// The signup link is the caller's own opaque firecrawl.dev/k/ link, issued +// by the API (the 429 body's signup_url, or signupUrl from the eligibility +// check), which resolves to keyless/mcp attribution on the site. Without one, +// the bare /k link still tags the signup as keyless. +const KEYLESS_SIGNUP_FALLBACK_URL = 'https://firecrawl.dev/k'; +const KEYLESS_SIGNUP_URL_PATTERN = + /^https:\/\/(?:www\.)?firecrawl\.dev\/k(?:\/[0-9abcdefghjkmnpqrstvwxyz]{8})?$/; + +/** An API-issued keyless signup link, or undefined for anything else. */ +function keylessSignupUrlFrom(value: unknown): string | undefined { + return typeof value === 'string' && KEYLESS_SIGNUP_URL_PATTERN.test(value) + ? value + : undefined; +} + +function keylessAccountFix(signupUrl: string): string { + return `Fix: Create an API key at ${signupUrl} and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.`; +} +const keylessQuotaMessage = (signupUrl: string) => + `You've hit Firecrawl's free MCP rate limit. To continue using without limits, create a Firecrawl API key.\n\n${keylessAccountFix(signupUrl)}`; +const keylessToolMessage = (signupUrl: string) => + `This tool needs a Firecrawl account.\n\n${keylessAccountFix(signupUrl)}`; +const keylessAccessMessage = (signupUrl: string) => + `Anonymous keyless access is unavailable for this request.\n\n${keylessAccountFix(signupUrl)}`; const INVALID_API_KEY_MESSAGE = 'The Firecrawl API key is invalid or revoked.\nFix: Replace the key on the existing Firecrawl MCP server, then start a new session. Get an API key at https://www.firecrawl.dev/app/api-keys'; const INVALID_OAUTH_MESSAGE = @@ -1559,9 +1579,10 @@ async function runWithCredentialRecovery( function recoveryPayload( code: string, requestId: string = randomUUID(), - options: { retryAfterSeconds?: number } = {} + options: { retryAfterSeconds?: number; signupUrl?: string } = {} ): Record { const retryAfterSeconds = options.retryAfterSeconds; + const signupUrl = options.signupUrl ?? KEYLESS_SIGNUP_FALLBACK_URL; const isQuotaExhausted = code === 'KEYLESS_QUOTA_EXHAUSTED' || code === 'KEYLESS_LIMIT_REACHED'; const isToolUnavailable = code === 'KEYLESS_TOOL_NOT_AVAILABLE'; @@ -1578,11 +1599,11 @@ function recoveryPayload( code === 'CREDENTIAL_INVALID' ? INVALID_API_KEY_MESSAGE : isQuotaExhausted - ? KEYLESS_QUOTA_MESSAGE + ? keylessQuotaMessage(signupUrl) : isToolUnavailable - ? KEYLESS_TOOL_MESSAGE + ? keylessToolMessage(signupUrl) : isKeylessAccessUnavailable - ? KEYLESS_ACCESS_MESSAGE + ? keylessAccessMessage(signupUrl) : isKeylessEligibilityUnavailable ? 'The anonymous keyless eligibility check is temporarily unavailable. Retry shortly.' : 'This tool requires a Firecrawl account or API key.', @@ -1596,6 +1617,7 @@ function recoveryPayload( ...(isKeylessConversion || code === 'CREDENTIAL_INVALID' ? {} : { available_tools: [...KEYLESS_TOOL_NAMES] }), + ...(isKeylessConversion ? { signup_url: signupUrl } : {}), docs_url: MCP_CONNECTION_GUIDE_URL, ...(retryAfterSeconds ? { retry_after_seconds: retryAfterSeconds } : {}), ...(isKeylessEligibilityUnavailable @@ -1745,7 +1767,12 @@ function guardHostedTool( : undefined; if (code) { const requestId = randomUUID(); - const payload = recoveryPayload(code, requestId); + const payload = recoveryPayload(code, requestId, { + signupUrl: + code === 'KEYLESS_TOOL_NOT_AVAILABLE' + ? await hostedKeylessSignupUrl(session) + : undefined, + }); if (logActions) { emitActionLog(tool.name, 'error', session, new UserError(String(payload.message), payload), requestId, code); } @@ -1796,7 +1823,9 @@ function guardHostedTool( } if (isHostedKeylessSession(invocationSession) && !keylessTool) { const code = 'KEYLESS_TOOL_NOT_AVAILABLE'; - const payload = recoveryPayload(code, requestId); + const payload = recoveryPayload(code, requestId, { + signupUrl: await hostedKeylessSignupUrl(invocationSession), + }); if (logActions) emitActionLog(tool.name, 'error', invocationSession, new UserError(String(payload.message), payload), requestId, code); throw new UserError(String(payload.message), payload); } @@ -3011,6 +3040,7 @@ type KeylessEligibility = { reason?: string; retryAfterSeconds?: number; unavailable?: boolean; + signupUrl?: string; }; function keylessQuotaReason(reason: unknown): reason is 'requests' | 'credits' { @@ -3019,13 +3049,14 @@ function keylessQuotaReason(reason: unknown): reason is 'requests' | 'credits' { async function keylessEligible( clientIp: string, - origin: string + origin: string, + { signupLink = false }: { signupLink?: boolean } = {} ): Promise { const secret = process.env.KEYLESS_PROXY_SECRET; if (!secret) return { eligible: false, unavailable: true }; try { const response = await fetch( - `${resolveApiBaseUrl()}/v2/keyless/eligibility`, + `${resolveApiBaseUrl()}/v2/keyless/eligibility${signupLink ? '?signup_link=1' : ''}`, { headers: { ...originHeaders(origin), @@ -3045,11 +3076,30 @@ async function keylessEligible( ...(Number.isFinite(json?.retryAfterSeconds) && json.retryAfterSeconds > 0 ? { retryAfterSeconds: json.retryAfterSeconds } : {}), + ...(keylessSignupUrlFrom(json?.signupUrl) + ? { signupUrl: json.signupUrl } + : {}), }; } catch { return { eligible: false, unavailable: true }; } } + +/** + * The hosted keyless caller's own signup link, for recovery the API did not + * produce (a tool keyless sessions cannot use). Undefined falls back to /k. + */ +async function hostedKeylessSignupUrl( + session?: SessionData +): Promise { + if (!session?.keylessClientIp) return undefined; + const eligibility = await keylessEligible( + session.keylessClientIp, + requestOrigin(undefined, session), + { signupLink: true } + ); + return eligibility.signupUrl; +} function isKeylessMode(session?: SessionData): boolean { if (hasCredential(session) || session?.credentialError) return false; if (process.env.CLOUD_SERVICE === 'true') { @@ -3084,6 +3134,7 @@ async function keylessPost( : 'KEYLESS_ACCESS_NOT_AVAILABLE'; const payload = recoveryPayload(code, session?.requestId, { retryAfterSeconds: eligibility.retryAfterSeconds, + signupUrl: eligibility.signupUrl, }); throw new UserError(String(payload.message), payload); } @@ -3118,6 +3169,7 @@ async function keylessPost( json.retry_after_seconds > 0 ? json.retry_after_seconds : undefined, + signupUrl: keylessSignupUrlFrom(json?.signup_url), }); const hints = readAgentHints(json); if (hints) payload.agent_hints = hints; diff --git a/tests/helpers/exchange-mcp.mjs b/tests/helpers/exchange-mcp.mjs index 856937dd..70d14f75 100644 --- a/tests/helpers/exchange-mcp.mjs +++ b/tests/helpers/exchange-mcp.mjs @@ -7,7 +7,7 @@ import { startFakeExchangeApi } from './exchange-api.mjs'; const EXCHANGE_KEY_REQUIRED_MESSAGE = 'Alexandria requires an API key on a team with Alexandria access'; const KEYLESS_TOOL_MESSAGE = - 'This tool needs a Firecrawl account.\n\nFix: Create an API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.'; + 'This tool needs a Firecrawl account.\n\nFix: Create an API key at https://firecrawl.dev/k and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.'; async function getFreePort() { const server = net.createServer(); diff --git a/tests/mcp-alexandria-auth.test.mjs b/tests/mcp-alexandria-auth.test.mjs index bd14cc11..21a8b85c 100644 --- a/tests/mcp-alexandria-auth.test.mjs +++ b/tests/mcp-alexandria-auth.test.mjs @@ -115,7 +115,8 @@ test('hosted keyless sessions never reach the Exchange; an API key header does', } assert.equal( backend.requests.some( - (request) => request.url !== '/v2/keyless/eligibility' + // The account-only recovery asks eligibility for the caller's signup link. + (request) => request.url.split('?')[0] !== '/v2/keyless/eligibility' ), false, 'keyless sessions must not reach /v2/scrape, /v2/search, or /exchange/*' diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index b6415f60..1a25c667 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -70,11 +70,18 @@ function assertServerGeneratedRequestId(payload, untrustedValues = []) { return payload.request_id; } -const KEYLESS_ACCOUNT_FIX = - 'Fix: Create an API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.'; -const KEYLESS_QUOTA_MESSAGE = `You've hit Firecrawl's free MCP rate limit. To continue using without limits, create a Firecrawl API key.\n\n${KEYLESS_ACCOUNT_FIX}`; -const KEYLESS_TOOL_MESSAGE = `This tool needs a Firecrawl account.\n\n${KEYLESS_ACCOUNT_FIX}`; -const KEYLESS_ACCESS_MESSAGE = `Anonymous keyless access is unavailable for this request.\n\n${KEYLESS_ACCOUNT_FIX}`; +// Without an API-issued link, recovery falls back to the bare /k link. +const KEYLESS_SIGNUP_FALLBACK_URL = 'https://firecrawl.dev/k'; +const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/7fq2xab9'; +const keylessAccountFix = (signupUrl = KEYLESS_SIGNUP_FALLBACK_URL) => + `Fix: Create an API key at ${signupUrl} and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.`; +const keylessQuotaMessage = (signupUrl) => + `You've hit Firecrawl's free MCP rate limit. To continue using without limits, create a Firecrawl API key.\n\n${keylessAccountFix(signupUrl)}`; +const keylessToolMessage = (signupUrl) => + `This tool needs a Firecrawl account.\n\n${keylessAccountFix(signupUrl)}`; +const KEYLESS_QUOTA_MESSAGE = keylessQuotaMessage(); +const KEYLESS_TOOL_MESSAGE = keylessToolMessage(); +const KEYLESS_ACCESS_MESSAGE = `Anonymous keyless access is unavailable for this request.\n\n${keylessAccountFix()}`; const INVALID_API_KEY_MESSAGE = 'The Firecrawl API key is invalid or revoked.\nFix: Replace the key on the existing Firecrawl MCP server, then start a new session. Get an API key at https://www.firecrawl.dev/app/api-keys'; const INVALID_OAUTH_MESSAGE = @@ -657,8 +664,8 @@ async function startFakeFirecrawlBackend(options = {}) { } // Keyless free-tier eligibility (secret-gated, read-only). - if (req.method === 'GET' && req.url === '/v2/keyless/eligibility') { - const response = keylessEligibilityResponse ?? { + if (req.method === 'GET' && req.url.split('?')[0] === '/v2/keyless/eligibility') { + const response = keylessEligibilityResponse?.(req.url) ?? { status: 200, body: { eligible: keylessEligible }, }; @@ -2282,7 +2289,7 @@ test('HTTP cloud transport serves an eligible keyless client and forwards its IP test('HTTP cloud keyless returns retry recovery when eligibility is unavailable', async (t) => { const backend = await startFakeFirecrawlBackend({ - keylessEligibilityResponse: { status: 503, body: { error: 'unavailable' } }, + keylessEligibilityResponse: () => ({ status: 503, body: { error: 'unavailable' } }), }); t.after(() => backend.close()); const port = await getFreePort(); @@ -2385,10 +2392,10 @@ test('HTTP cloud keyless quota stays 200 isError without inlining retry_after_se [ 'eligibility-exhausted', { - keylessEligibilityResponse: { + keylessEligibilityResponse: () => ({ status: 200, body: { eligible: false, reason: 'credits', retryAfterSeconds: 42 }, - }, + }), }, 'KEYLESS_QUOTA_EXHAUSTED', 42, @@ -2434,6 +2441,142 @@ test('HTTP cloud keyless quota stays 200 isError without inlining retry_after_se } }); +test('HTTP cloud keyless recovery relays the caller\'s own /k link from the API', async (t) => { + for (const [label, backendOptions] of [ + [ + 'eligibility-exhausted', + { + keylessEligibilityResponse: () => ({ + status: 200, + body: { eligible: false, reason: 'requests', signupUrl: OWN_SIGNUP_URL }, + }), + }, + ], + [ + 'core-429', + { + keylessEligible: true, + searchResponse: { + status: 429, + body: { error: 'limit', reason: 'credits', signup_url: OWN_SIGNUP_URL }, + }, + }, + ], + ]) { + const backend = await startFakeFirecrawlBackend(backendOptions); + const port = await getFreePort(); + const child = spawnServer({ + CLOUD_SERVICE: 'true', + FASTMCP_ENDPOINT: '/v2/mcp', + FIRECRAWL_API_URL: backend.url, + HTTP_STREAMABLE_SERVER: 'true', + KEYLESS_PROXY_SECRET: 'keyless-secret', + PORT: String(port), + }); + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await stopChild(child); + await backend.close(); + }; + t.after(cleanup); + await waitForHealth(port, child); + const response = await httpToolCall(port, { + id: `keyless-own-link-${label}`, + headers: { 'x-forwarded-for': '8.8.8.7' }, + params: { arguments: { limit: 1, query: 'example domain' }, name: 'firecrawl_search' }, + }); + const result = parseSseJson(await response.text()).result; + assertKeylessAccountRecovery(result, { + code: 'KEYLESS_QUOTA_EXHAUSTED', + message: keylessQuotaMessage(OWN_SIGNUP_URL), + }); + assert.equal(result.structuredContent.signup_url, OWN_SIGNUP_URL, label); + assert.doesNotMatch(result.content[0].text, /utm_/, label); + await cleanup(); + } +}); + +test('HTTP cloud keyless recovery ignores a signup link that is not a firecrawl.dev/k link', async (t) => { + const backend = await startFakeFirecrawlBackend({ + keylessEligible: true, + searchResponse: { + status: 429, + body: { + error: 'limit', + reason: 'credits', + signup_url: 'https://evil.example/k/7fq2xab9', + }, + }, + }); + t.after(() => backend.close()); + const port = await getFreePort(); + const child = spawnServer({ + CLOUD_SERVICE: 'true', + FASTMCP_ENDPOINT: '/v2/mcp', + FIRECRAWL_API_URL: backend.url, + HTTP_STREAMABLE_SERVER: 'true', + KEYLESS_PROXY_SECRET: 'keyless-secret', + PORT: String(port), + }); + t.after(() => stopChild(child)); + await waitForHealth(port, child); + + const response = await httpToolCall(port, { + id: 'keyless-untrusted-link', + headers: { 'x-forwarded-for': '8.8.8.7' }, + params: { arguments: { limit: 1, query: 'example domain' }, name: 'firecrawl_search' }, + }); + const result = parseSseJson(await response.text()).result; + assertKeylessAccountRecovery(result, { + code: 'KEYLESS_QUOTA_EXHAUSTED', + message: KEYLESS_QUOTA_MESSAGE, + }); + assert.equal(result.structuredContent.signup_url, KEYLESS_SIGNUP_FALLBACK_URL); +}); + +test('HTTP cloud keyless account-only tool asks the API for the caller\'s own link', async (t) => { + const backend = await startFakeFirecrawlBackend({ + keylessEligibilityResponse: (url) => ({ + status: 200, + body: url.includes('signup_link=1') + ? { eligible: true, signupUrl: OWN_SIGNUP_URL } + : { eligible: true }, + }), + }); + t.after(() => backend.close()); + const port = await getFreePort(); + const child = spawnServer({ + CLOUD_SERVICE: 'true', + FASTMCP_ENDPOINT: '/v2/mcp', + FIRECRAWL_API_URL: backend.url, + HTTP_STREAMABLE_SERVER: 'true', + KEYLESS_PROXY_SECRET: 'keyless-secret', + PORT: String(port), + }); + t.after(() => stopChild(child)); + await waitForHealth(port, child); + + const response = await httpToolCall(port, { + id: 'keyless-account-only-own-link', + headers: { 'x-forwarded-for': '8.8.8.7' }, + params: { arguments: { url: 'https://example.com/' }, name: 'firecrawl_crawl' }, + }); + const result = parseSseJson(await response.text()).result; + assertKeylessAccountRecovery(result, { + code: 'KEYLESS_TOOL_NOT_AVAILABLE', + message: keylessToolMessage(OWN_SIGNUP_URL), + }); + const linkRequest = backend.requests.find((r) => + r.url === '/v2/keyless/eligibility?signup_link=1' + ); + assert.ok(linkRequest); + assert.equal(linkRequest.headers['x-firecrawl-keyless-ip'], '8.8.8.7'); + assert.equal(linkRequest.headers['x-firecrawl-keyless-secret'], 'keyless-secret'); + assert.equal(backend.requests.some((r) => r.url === '/v2/crawl'), false); +}); + test('HTTP cloud keyless Parse completes both phases without credentials and forwards redactPII', async (t) => { const backend = await startFakeFirecrawlBackend({ keylessEligible: true }); t.after(() => backend.close()); From b032a030568c832842c43db757660a0816ac9e8c Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 16:29:42 +1000 Subject: [PATCH 2/6] test: cover an untrusted signupUrl from the eligibility response The invalid-link test only exercised the core 429 path. Loop it over the eligibility path too, so a regression in reading or validating signupUrl falls back to the bare /k link instead of relaying an untrusted URL. Co-Authored-By: Claude Opus 5.5 --- tests/mcp-smoke.test.mjs | 88 ++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 1a25c667..4f911e13 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -2499,41 +2499,61 @@ test('HTTP cloud keyless recovery relays the caller\'s own /k link from the API' }); test('HTTP cloud keyless recovery ignores a signup link that is not a firecrawl.dev/k link', async (t) => { - const backend = await startFakeFirecrawlBackend({ - keylessEligible: true, - searchResponse: { - status: 429, - body: { - error: 'limit', - reason: 'credits', - signup_url: 'https://evil.example/k/7fq2xab9', + const untrustedUrl = 'https://evil.example/k/7fq2xab9'; + for (const [label, backendOptions] of [ + [ + 'eligibility-exhausted', + { + keylessEligibilityResponse: () => ({ + status: 200, + body: { eligible: false, reason: 'requests', signupUrl: untrustedUrl }, + }), }, - }, - }); - t.after(() => backend.close()); - const port = await getFreePort(); - const child = spawnServer({ - CLOUD_SERVICE: 'true', - FASTMCP_ENDPOINT: '/v2/mcp', - FIRECRAWL_API_URL: backend.url, - HTTP_STREAMABLE_SERVER: 'true', - KEYLESS_PROXY_SECRET: 'keyless-secret', - PORT: String(port), - }); - t.after(() => stopChild(child)); - await waitForHealth(port, child); - - const response = await httpToolCall(port, { - id: 'keyless-untrusted-link', - headers: { 'x-forwarded-for': '8.8.8.7' }, - params: { arguments: { limit: 1, query: 'example domain' }, name: 'firecrawl_search' }, - }); - const result = parseSseJson(await response.text()).result; - assertKeylessAccountRecovery(result, { - code: 'KEYLESS_QUOTA_EXHAUSTED', - message: KEYLESS_QUOTA_MESSAGE, - }); - assert.equal(result.structuredContent.signup_url, KEYLESS_SIGNUP_FALLBACK_URL); + ], + [ + 'core-429', + { + keylessEligible: true, + searchResponse: { + status: 429, + body: { error: 'limit', reason: 'credits', signup_url: untrustedUrl }, + }, + }, + ], + ]) { + const backend = await startFakeFirecrawlBackend(backendOptions); + const port = await getFreePort(); + const child = spawnServer({ + CLOUD_SERVICE: 'true', + FASTMCP_ENDPOINT: '/v2/mcp', + FIRECRAWL_API_URL: backend.url, + HTTP_STREAMABLE_SERVER: 'true', + KEYLESS_PROXY_SECRET: 'keyless-secret', + PORT: String(port), + }); + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await stopChild(child); + await backend.close(); + }; + t.after(cleanup); + await waitForHealth(port, child); + const response = await httpToolCall(port, { + id: `keyless-untrusted-link-${label}`, + headers: { 'x-forwarded-for': '8.8.8.7' }, + params: { arguments: { limit: 1, query: 'example domain' }, name: 'firecrawl_search' }, + }); + const result = parseSseJson(await response.text()).result; + assertKeylessAccountRecovery(result, { + code: 'KEYLESS_QUOTA_EXHAUSTED', + message: KEYLESS_QUOTA_MESSAGE, + }); + assert.equal(result.structuredContent.signup_url, KEYLESS_SIGNUP_FALLBACK_URL, label); + assert.doesNotMatch(result.content[0].text, /evil\.example/, label); + await cleanup(); + } }); test('HTTP cloud keyless account-only tool asks the API for the caller\'s own link', async (t) => { From 6f5f11ef43ec54b57f8252a4bf264952a5bdc5a2 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 17:41:23 +1000 Subject: [PATCH 3/6] fix: relay the API's regular signup link and fall back to it The API now sends the regular keyless signup link (signin?utm_source=keyless&utm_medium=) when it can't give a /k link. Accept and relay it, and use the regular MCP signup link instead of the bare /k when there is no API link at all, so the MCP surface is still attributed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/index.ts | 10 +++--- tests/helpers/exchange-mcp.mjs | 2 +- tests/mcp-smoke.test.mjs | 61 +++++++++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5bbb1e..6ccb1f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Changed -- Keyless recovery messages now link to the caller's own short signup link, `https://firecrawl.dev/k/`, instead of `/app/api-keys`. The API issues the link (the `signup_url` of a keyless 429, or `signupUrl` from the eligibility check, which the server now also asks for when a keyless session calls an account-only tool) and the site resolves it to MCP keyless attribution. Without one the message uses `https://firecrawl.dev/k`. Recovery payloads carry the link as `signup_url`. Signed-in users who open it land on the API keys page. +- Keyless recovery messages now link to the caller's own short signup link, `https://firecrawl.dev/k/`, instead of `/app/api-keys`. The API issues the link (the `signup_url` of a keyless 429, or `signupUrl` from the eligibility check, which the server now also asks for when a keyless session calls an account-only tool) and the site resolves it to MCP keyless attribution. When the API can't give one it sends the regular keyless signup link, which is relayed as is; with no API link at all the message uses the regular MCP signup link (`signin?utm_source=keyless&utm_medium=mcp`). Recovery payloads carry the link as `signup_url`. Signed-in users who open it land on the API keys page. - The search surface (`/v2/mcp-search`) now exposes `firecrawl_find_tools` and `firecrawl_scrape` alongside its six search tools, so agents can execute the Alexandria providers that `firecrawl_search` already returns. Both carry surface-scoped descriptions that name only tools registered on that surface, and Alexandria results there omit the `firecrawl_feedback` pointer. See docs/search-profile.md. ### Fixed diff --git a/src/index.ts b/src/index.ts index 6c9a97aa..6fa02f36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1461,11 +1461,13 @@ function isLocalKeylessStartup(): boolean { // // The signup link is the caller's own opaque firecrawl.dev/k/ link, issued // by the API (the 429 body's signup_url, or signupUrl from the eligibility -// check), which resolves to keyless/mcp attribution on the site. Without one, -// the bare /k link still tags the signup as keyless. -const KEYLESS_SIGNUP_FALLBACK_URL = 'https://firecrawl.dev/k'; +// check), which resolves to keyless/mcp attribution on the site. When the API +// can't give one it sends the regular keyless signup link, which is relayed as +// is; without any API link, the regular MCP signup link is used. +const KEYLESS_SIGNUP_FALLBACK_URL = + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys'; const KEYLESS_SIGNUP_URL_PATTERN = - /^https:\/\/(?:www\.)?firecrawl\.dev\/k(?:\/[0-9abcdefghjkmnpqrstvwxyz]{8})?$/; + /^https:\/\/(?:(?:www\.)?firecrawl\.dev\/k(?:\/[0-9abcdefghjkmnpqrstvwxyz]{8})?|www\.firecrawl\.dev\/signin\?utm_source=keyless&utm_medium=(?:api|mcp|cli)(?:&redirect=%2Fapp%2Fapi-keys)?)$/; /** An API-issued keyless signup link, or undefined for anything else. */ function keylessSignupUrlFrom(value: unknown): string | undefined { diff --git a/tests/helpers/exchange-mcp.mjs b/tests/helpers/exchange-mcp.mjs index 70d14f75..856937dd 100644 --- a/tests/helpers/exchange-mcp.mjs +++ b/tests/helpers/exchange-mcp.mjs @@ -7,7 +7,7 @@ import { startFakeExchangeApi } from './exchange-api.mjs'; const EXCHANGE_KEY_REQUIRED_MESSAGE = 'Alexandria requires an API key on a team with Alexandria access'; const KEYLESS_TOOL_MESSAGE = - 'This tool needs a Firecrawl account.\n\nFix: Create an API key at https://firecrawl.dev/k and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.'; + 'This tool needs a Firecrawl account.\n\nFix: Create an API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.'; async function getFreePort() { const server = net.createServer(); diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 4f911e13..65415844 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -71,7 +71,10 @@ function assertServerGeneratedRequestId(payload, untrustedValues = []) { } // Without an API-issued link, recovery falls back to the bare /k link. -const KEYLESS_SIGNUP_FALLBACK_URL = 'https://firecrawl.dev/k'; +const KEYLESS_SIGNUP_FALLBACK_URL = + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys'; +const API_REGULAR_SIGNUP_URL = + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp'; const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/7fq2xab9'; const keylessAccountFix = (signupUrl = KEYLESS_SIGNUP_FALLBACK_URL) => `Fix: Create an API key at ${signupUrl} and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.`; @@ -2498,6 +2501,62 @@ test('HTTP cloud keyless recovery relays the caller\'s own /k link from the API' } }); +test('HTTP cloud keyless recovery relays the API\'s regular signup link when it has no /k link', async (t) => { + for (const [label, backendOptions] of [ + [ + 'eligibility-exhausted', + { + keylessEligibilityResponse: () => ({ + status: 200, + body: { eligible: false, reason: 'requests', signupUrl: API_REGULAR_SIGNUP_URL }, + }), + }, + ], + [ + 'core-429', + { + keylessEligible: true, + searchResponse: { + status: 429, + body: { error: 'limit', reason: 'credits', signup_url: API_REGULAR_SIGNUP_URL }, + }, + }, + ], + ]) { + const backend = await startFakeFirecrawlBackend(backendOptions); + const port = await getFreePort(); + const child = spawnServer({ + CLOUD_SERVICE: 'true', + FASTMCP_ENDPOINT: '/v2/mcp', + FIRECRAWL_API_URL: backend.url, + HTTP_STREAMABLE_SERVER: 'true', + KEYLESS_PROXY_SECRET: 'keyless-secret', + PORT: String(port), + }); + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await stopChild(child); + await backend.close(); + }; + t.after(cleanup); + await waitForHealth(port, child); + const response = await httpToolCall(port, { + id: `keyless-regular-link-${label}`, + headers: { 'x-forwarded-for': '8.8.8.7' }, + params: { arguments: { limit: 1, query: 'example domain' }, name: 'firecrawl_search' }, + }); + const result = parseSseJson(await response.text()).result; + assertKeylessAccountRecovery(result, { + code: 'KEYLESS_QUOTA_EXHAUSTED', + message: keylessQuotaMessage(API_REGULAR_SIGNUP_URL), + }); + assert.equal(result.structuredContent.signup_url, API_REGULAR_SIGNUP_URL, label); + await cleanup(); + } +}); + test('HTTP cloud keyless recovery ignores a signup link that is not a firecrawl.dev/k link', async (t) => { const untrustedUrl = 'https://evil.example/k/7fq2xab9'; for (const [label, backendOptions] of [ From 97f8d79438a1cd01617374581a505a12fcbc7319 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 18:18:04 +1000 Subject: [PATCH 4/6] feat: relay the API's encrypted /k/ keyless signup link The API now issues stateless 12-character tokens (firecrawl.dev/k/) instead of 8-character database ids, so the trusted-link pattern accepts /k/<12 chars> on either host and no longer accepts the old ids. The regular keyless signin link is still relayed, now on the bare host too, and the regular MCP signin link stays the fallback. Adds coverage for the account-only tool path: a regular link from the signup_link=1 check is relayed, and an untrusted or legacy one falls back. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/index.ts | 15 +++---- tests/mcp-smoke.test.mjs | 87 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccb1f8b..5efc3552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Changed -- Keyless recovery messages now link to the caller's own short signup link, `https://firecrawl.dev/k/`, instead of `/app/api-keys`. The API issues the link (the `signup_url` of a keyless 429, or `signupUrl` from the eligibility check, which the server now also asks for when a keyless session calls an account-only tool) and the site resolves it to MCP keyless attribution. When the API can't give one it sends the regular keyless signup link, which is relayed as is; with no API link at all the message uses the regular MCP signup link (`signin?utm_source=keyless&utm_medium=mcp`). Recovery payloads carry the link as `signup_url`. Signed-in users who open it land on the API keys page. +- Keyless recovery messages now link to the caller's own signup link, `https://firecrawl.dev/k/` (a 12-character encrypted token), instead of `/app/api-keys`. The API issues the link (the `signup_url` of a keyless 429, or `signupUrl` from the eligibility check, which the server now also asks for when a keyless session calls an account-only tool) and the site decrypts it to MCP keyless attribution. When the API can't give one it sends the regular keyless signup link, which is relayed as is; with no API link at all the message uses the regular MCP signup link (`signin?utm_source=keyless&utm_medium=mcp`). Recovery payloads carry the link as `signup_url`. Signed-in users who open it land on the API keys page. - The search surface (`/v2/mcp-search`) now exposes `firecrawl_find_tools` and `firecrawl_scrape` alongside its six search tools, so agents can execute the Alexandria providers that `firecrawl_search` already returns. Both carry surface-scoped descriptions that name only tools registered on that surface, and Alexandria results there omit the `firecrawl_feedback` pointer. See docs/search-profile.md. ### Fixed diff --git a/src/index.ts b/src/index.ts index 6fa02f36..1eca77b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1459,15 +1459,15 @@ function isLocalKeylessStartup(): boolean { // structuredContent.message. Hosts forward the text block, not // structured next_actions, so bearer and OAuth recovery strings live here. // -// The signup link is the caller's own opaque firecrawl.dev/k/ link, issued -// by the API (the 429 body's signup_url, or signupUrl from the eligibility -// check), which resolves to keyless/mcp attribution on the site. When the API -// can't give one it sends the regular keyless signup link, which is relayed as -// is; without any API link, the regular MCP signup link is used. +// The signup link is the caller's own firecrawl.dev/k/ link, issued by +// the API (the 429 body's signup_url, or signupUrl from the eligibility check): +// a 12-character encrypted token the site decrypts to keyless attribution. +// When the API has no token to give it sends the regular keyless signin link, +// which is relayed as is; without any API link, the regular MCP signin link is used. const KEYLESS_SIGNUP_FALLBACK_URL = 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys'; const KEYLESS_SIGNUP_URL_PATTERN = - /^https:\/\/(?:(?:www\.)?firecrawl\.dev\/k(?:\/[0-9abcdefghjkmnpqrstvwxyz]{8})?|www\.firecrawl\.dev\/signin\?utm_source=keyless&utm_medium=(?:api|mcp|cli)(?:&redirect=%2Fapp%2Fapi-keys)?)$/; + /^https:\/\/(?:www\.)?firecrawl\.dev\/(?:k\/[0-9abcdefghjkmnpqrstvwxyz]{12}|signin\?utm_source=keyless&utm_medium=(?:api|mcp|cli)(?:&redirect=%2Fapp%2Fapi-keys)?)$/; /** An API-issued keyless signup link, or undefined for anything else. */ function keylessSignupUrlFrom(value: unknown): string | undefined { @@ -3089,7 +3089,8 @@ async function keylessEligible( /** * The hosted keyless caller's own signup link, for recovery the API did not - * produce (a tool keyless sessions cannot use). Undefined falls back to /k. + * produce (a tool keyless sessions cannot use). Undefined falls back to the + * regular MCP signin link. */ async function hostedKeylessSignupUrl( session?: SessionData diff --git a/tests/mcp-smoke.test.mjs b/tests/mcp-smoke.test.mjs index 65415844..c674c5c5 100644 --- a/tests/mcp-smoke.test.mjs +++ b/tests/mcp-smoke.test.mjs @@ -70,12 +70,12 @@ function assertServerGeneratedRequestId(payload, untrustedValues = []) { return payload.request_id; } -// Without an API-issued link, recovery falls back to the bare /k link. +// Without an API-issued link, recovery falls back to the regular MCP signin link. const KEYLESS_SIGNUP_FALLBACK_URL = 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys'; const API_REGULAR_SIGNUP_URL = 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp'; -const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/7fq2xab9'; +const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/hrxch5c20tcs'; const keylessAccountFix = (signupUrl = KEYLESS_SIGNUP_FALLBACK_URL) => `Fix: Create an API key at ${signupUrl} and then:\n- Set the header: Authorization: Bearer YOUR_API_KEY on https://mcp.firecrawl.dev/v2/mcp\nThen start a new session.`; const keylessQuotaMessage = (signupUrl) => @@ -2558,7 +2558,7 @@ test('HTTP cloud keyless recovery relays the API\'s regular signup link when it }); test('HTTP cloud keyless recovery ignores a signup link that is not a firecrawl.dev/k link', async (t) => { - const untrustedUrl = 'https://evil.example/k/7fq2xab9'; + const untrustedUrl = 'https://evil.example/k/hrxch5c20tcs'; for (const [label, backendOptions] of [ [ 'eligibility-exhausted', @@ -2656,6 +2656,87 @@ test('HTTP cloud keyless account-only tool asks the API for the caller\'s own li assert.equal(backend.requests.some((r) => r.url === '/v2/crawl'), false); }); +test("HTTP cloud keyless account-only tool relays the API's regular link and drops an untrusted one", async (t) => { + for (const [label, signupUrl, expected] of [ + ['regular-link', API_REGULAR_SIGNUP_URL, API_REGULAR_SIGNUP_URL], + [ + 'regular-link-bare-host', + 'https://firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp', + 'https://firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp', + ], + [ + 'untrusted-host', + 'https://evil.example/k/hrxch5c20tcs', + KEYLESS_SIGNUP_FALLBACK_URL, + ], + [ + 'legacy-short-id', + 'https://firecrawl.dev/k/7fq2xab9', + KEYLESS_SIGNUP_FALLBACK_URL, + ], + ]) { + // The link comes only from the signup_link=1 check, so a relay here can + // only be the account-only path's. + const backend = await startFakeFirecrawlBackend({ + keylessEligibilityResponse: (url) => ({ + status: 200, + body: url.includes('signup_link=1') + ? { eligible: true, signupUrl } + : { eligible: true }, + }), + }); + const port = await getFreePort(); + const child = spawnServer({ + CLOUD_SERVICE: 'true', + FASTMCP_ENDPOINT: '/v2/mcp', + FIRECRAWL_API_URL: backend.url, + HTTP_STREAMABLE_SERVER: 'true', + KEYLESS_PROXY_SECRET: 'keyless-secret', + PORT: String(port), + }); + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await stopChild(child); + await backend.close(); + }; + t.after(cleanup); + await waitForHealth(port, child); + const response = await httpToolCall(port, { + id: `keyless-account-only-${label}`, + headers: { 'x-forwarded-for': '8.8.8.7' }, + params: { + arguments: { url: 'https://example.com/' }, + name: 'firecrawl_crawl', + }, + }); + const result = parseSseJson(await response.text()).result; + assertKeylessAccountRecovery(result, { + code: 'KEYLESS_TOOL_NOT_AVAILABLE', + message: keylessToolMessage(expected), + }); + assert.equal(result.structuredContent.signup_url, expected, label); + assert.ok( + backend.requests.some( + (r) => r.url === '/v2/keyless/eligibility?signup_link=1' + ), + label + ); + assert.doesNotMatch( + result.content[0].text, + /evil\.example|7fq2xab9/, + label + ); + assert.equal( + backend.requests.some((r) => r.url === '/v2/crawl'), + false, + label + ); + await cleanup(); + } +}); + test('HTTP cloud keyless Parse completes both phases without credentials and forwards redactPII', async (t) => { const backend = await startFakeFirecrawlBackend({ keylessEligible: true }); t.after(() => backend.close()); From 083825372048746b58f0cb0379e65c97d08496fe Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 18:35:54 +1000 Subject: [PATCH 5/6] fix: validate relayed keyless signup links by URL parts, log drift Replace the literal regex with a parsed check: https on firecrawl.dev or www.firecrawl.dev, either /k/<12-char token> with no query, or /signin with exactly utm_source=keyless, utm_medium=api|mcp|cli and an optional redirect=/app/api-keys, in any order and percent-encoding case. Firecrawl-hosted links that fail are logged once each, so an API format change is visible instead of silently falling back. Co-Authored-By: Claude Opus 5.5 --- src/index.ts | 26 +++++++++++--- src/keyless-signup-link.ts | 56 +++++++++++++++++++++++++++++ tests/keyless-signup-link.test.mjs | 57 ++++++++++++++++++++++++++++++ tsup.config.ts | 1 + 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 src/keyless-signup-link.ts create mode 100644 tests/keyless-signup-link.test.mjs diff --git a/src/index.ts b/src/index.ts index 1eca77b4..e8dd29f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ import { import { alexandriaOutput } from './alexandria-output'; import { registerDeveloperTools } from './developer'; import { extractSingleTrustedClientIp } from './keyless-client-ip'; +import { checkKeylessSignupUrl } from './keyless-signup-link'; import { registerMonitorTools } from './monitor'; import { registerResearchTools } from './research'; import { registerUsageTools } from './usage'; @@ -1466,14 +1467,29 @@ function isLocalKeylessStartup(): boolean { // which is relayed as is; without any API link, the regular MCP signin link is used. const KEYLESS_SIGNUP_FALLBACK_URL = 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys'; -const KEYLESS_SIGNUP_URL_PATTERN = - /^https:\/\/(?:www\.)?firecrawl\.dev\/(?:k\/[0-9abcdefghjkmnpqrstvwxyz]{12}|signin\?utm_source=keyless&utm_medium=(?:api|mcp|cli)(?:&redirect=%2Fapp%2Fapi-keys)?)$/; +// Firecrawl-hosted links that fail the check are logged once each, so a change +// in the API's link format shows up instead of silently falling back. +const droppedKeylessSignupUrls = new Set(); /** An API-issued keyless signup link, or undefined for anything else. */ function keylessSignupUrlFrom(value: unknown): string | undefined { - return typeof value === 'string' && KEYLESS_SIGNUP_URL_PATTERN.test(value) - ? value - : undefined; + const check = checkKeylessSignupUrl(value); + if (check.ok) return check.url; + if ( + check.firecrawlHost && + typeof value === 'string' && + droppedKeylessSignupUrls.size < 50 && + !droppedKeylessSignupUrls.has(value) + ) { + droppedKeylessSignupUrls.add(value); + console.warn( + '[WARN]', + new Date().toISOString(), + 'Ignoring an unrecognized Firecrawl keyless signup link from the API; using the fallback', + { signupUrl: value } + ); + } + return undefined; } function keylessAccountFix(signupUrl: string): string { diff --git a/src/keyless-signup-link.ts b/src/keyless-signup-link.ts new file mode 100644 index 00000000..6329bb7e --- /dev/null +++ b/src/keyless-signup-link.ts @@ -0,0 +1,56 @@ +// Keyless signup links the API may hand the MCP server to relay: the caller's +// own https://firecrawl.dev/k/ link, or the regular keyless signin link +// the API sends when it has no token. The URL is parsed and checked field by +// field, so parameter order and percent-encoding case don't matter, but only +// Firecrawl's own signup links are ever relayed. + +const SIGNUP_HOSTS = new Set(['firecrawl.dev', 'www.firecrawl.dev']); +const TOKEN_PATH = /^\/k\/[0-9abcdefghjkmnpqrstvwxyz]{12}$/; +const SURFACES = new Set(['api', 'mcp', 'cli']); +const SIGNIN_PARAMS = new Set(['utm_source', 'utm_medium', 'redirect']); +const SIGNIN_REDIRECT = '/app/api-keys'; + +/** Why a Firecrawl-hosted link was not relayed, for drift logging. */ +export type KeylessSignupUrlCheck = + | { ok: true; url: string } + | { ok: false; firecrawlHost: boolean }; + +export function checkKeylessSignupUrl(value: unknown): KeylessSignupUrlCheck { + if (typeof value !== 'string') return { ok: false, firecrawlHost: false }; + let url: URL; + try { + url = new URL(value); + } catch { + return { ok: false, firecrawlHost: false }; + } + const firecrawlHost = SIGNUP_HOSTS.has(url.hostname); + if ( + url.protocol !== 'https:' || + !firecrawlHost || + url.port || + url.username || + url.password || + url.hash + ) { + return { ok: false, firecrawlHost }; + } + if (TOKEN_PATH.test(url.pathname) && !url.search) { + return { ok: true, url: value }; + } + if (url.pathname === '/signin') { + const params = url.searchParams; + const keys = [...params.keys()]; + const known = + keys.every((key) => SIGNIN_PARAMS.has(key)) && + new Set(keys).size === keys.length; + if ( + known && + params.get('utm_source') === 'keyless' && + SURFACES.has(params.get('utm_medium') ?? '') && + (!params.has('redirect') || params.get('redirect') === SIGNIN_REDIRECT) + ) { + return { ok: true, url: value }; + } + } + return { ok: false, firecrawlHost }; +} diff --git a/tests/keyless-signup-link.test.mjs b/tests/keyless-signup-link.test.mjs new file mode 100644 index 00000000..d98b1b0e --- /dev/null +++ b/tests/keyless-signup-link.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { checkKeylessSignupUrl } from '../dist/keyless-signup-link.js'; + +const relayed = (value) => checkKeylessSignupUrl(value).ok; + +test('relays the caller\'s own /k token link on either Firecrawl host', () => { + assert.equal(relayed('https://firecrawl.dev/k/hrxch5c20tcs'), true); + assert.equal(relayed('https://www.firecrawl.dev/k/hrxch5c20tcs'), true); +}); + +test('relays the regular keyless signin link regardless of parameter order or encoding case', () => { + for (const url of [ + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp', + 'https://firecrawl.dev/signin?utm_source=keyless&utm_medium=api', + 'https://www.firecrawl.dev/signin?utm_medium=cli&utm_source=keyless', + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=%2Fapp%2Fapi-keys', + 'https://www.firecrawl.dev/signin?redirect=%2fapp%2fapi-keys&utm_medium=mcp&utm_source=keyless', + ]) { + assert.equal(relayed(url), true, url); + } +}); + +test('rejects anything that is not one of Firecrawl\'s own signup links', () => { + for (const url of [ + 'https://evil.example/k/hrxch5c20tcs', + 'https://firecrawl.dev.evil.example/k/hrxch5c20tcs', + 'http://firecrawl.dev/k/hrxch5c20tcs', + 'https://firecrawl.dev:8443/k/hrxch5c20tcs', + 'https://user@firecrawl.dev/k/hrxch5c20tcs', + 'https://firecrawl.dev/k/hrxch5c20tcs?x=1', + 'https://firecrawl.dev/k/hrxch5c20tcs#frag', + 'https://firecrawl.dev/k/short', + 'https://firecrawl.dev/k/HRXCH5C20TCS', + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=web', + 'https://www.firecrawl.dev/signin?utm_source=ads&utm_medium=mcp', + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&redirect=https%3A%2F%2Fevil.example', + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=mcp&next=%2Fx', + 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_source=keyless&utm_medium=mcp', + 'https://www.firecrawl.dev/pricing?utm_source=keyless&utm_medium=mcp', + 'not a url', + 42, + ]) { + assert.equal(relayed(url), false, String(url)); + } +}); + +test('flags rejected Firecrawl-hosted links so format drift can be logged', () => { + assert.deepEqual( + checkKeylessSignupUrl('https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=web'), + { ok: false, firecrawlHost: true } + ); + assert.deepEqual(checkKeylessSignupUrl('https://evil.example/k/hrxch5c20tcs'), { + ok: false, + firecrawlHost: false, + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 55954a18..c6e3234e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'src/agent-hints.ts', 'src/origin.ts', 'src/introspection-cache.ts', + 'src/keyless-signup-link.ts', ], format: ['esm'], platform: 'node', From 583a5543bb2a77bae7b4a19eedab2b2df7318116 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 18:52:14 +1000 Subject: [PATCH 6/6] chore: release firecrawl-mcp 3.27.0 Co-Authored-By: Claude Opus 5.5 --- gemini-extension.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gemini-extension.json b/gemini-extension.json index 795ae55a..a7c3c1d2 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "firecrawl", - "version": "3.26.0", + "version": "3.27.0", "description": "Official Firecrawl MCP for web search, scraping, crawling, and structured data extraction.", "mcpServers": { "firecrawl": { diff --git a/package.json b/package.json index 706cadeb..ad6f3050 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-mcp", - "version": "3.26.0", + "version": "3.27.0", "description": "MCP server for Firecrawl — search, scrape, and interact with the web, and search scientific papers. Supports both cloud and self-hosted instances. Features include web search, scraping, page interaction, batch processing, LLM-powered content analysis, and research paper search over biomedical and arXiv literature (PubMed, bioRxiv, medRxiv, arXiv) with citation-graph expansion and full-text reading.", "type": "module", "mcpName": "io.github.firecrawl/firecrawl-mcp-server",