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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 signup link, `https://firecrawl.dev/k/<token>` (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
Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
97 changes: 84 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1458,11 +1459,48 @@ 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 firecrawl.dev/k/<token> 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';
Comment thread
rakshith48 marked this conversation as resolved.
// 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<string>();

/** An API-issued keyless signup link, or undefined for anything else. */
function keylessSignupUrlFrom(value: unknown): string | 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 {
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 =
Expand Down Expand Up @@ -1559,9 +1597,10 @@ async function runWithCredentialRecovery<T>(
function recoveryPayload(
code: string,
requestId: string = randomUUID(),
options: { retryAfterSeconds?: number } = {}
options: { retryAfterSeconds?: number; signupUrl?: string } = {}
): Record<string, unknown> {
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';
Expand All @@ -1578,11 +1617,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.',
Expand All @@ -1596,6 +1635,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
Expand Down Expand Up @@ -1745,7 +1785,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);
}
Expand Down Expand Up @@ -1796,7 +1841,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);
}
Expand Down Expand Up @@ -3011,6 +3058,7 @@ type KeylessEligibility = {
reason?: string;
retryAfterSeconds?: number;
unavailable?: boolean;
signupUrl?: string;
};

function keylessQuotaReason(reason: unknown): reason is 'requests' | 'credits' {
Expand All @@ -3019,13 +3067,14 @@ function keylessQuotaReason(reason: unknown): reason is 'requests' | 'credits' {

async function keylessEligible(
clientIp: string,
origin: string
origin: string,
{ signupLink = false }: { signupLink?: boolean } = {}
): Promise<KeylessEligibility> {
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),
Expand All @@ -3045,11 +3094,31 @@ 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 the
* regular MCP signin link.
*/
async function hostedKeylessSignupUrl(
session?: SessionData
): Promise<string | undefined> {
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') {
Expand Down Expand Up @@ -3084,6 +3153,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);
}
Expand Down Expand Up @@ -3118,6 +3188,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;
Expand Down
56 changes: 56 additions & 0 deletions src/keyless-signup-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Keyless signup links the API may hand the MCP server to relay: the caller's
// own https://firecrawl.dev/k/<token> 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 };
}
57 changes: 57 additions & 0 deletions tests/keyless-signup-link.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
});
});
3 changes: 2 additions & 1 deletion tests/mcp-alexandria-auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/*'
Expand Down
Loading
Loading