Skip to content

Commit 68805e7

Browse files
hmishra2250claude
andauthored
fix(mcp): make invalid API key recovery reach the agent (#365)
* fix(mcp): make invalid API key recovery reach the agent An invalid or revoked API key on /v2/mcp returned HTTP 401 at initialize and tools/list. MCP clients treat a 401 at connect as "server unavailable" and never surface the response body to the model, so the CREDENTIAL_INVALID recovery payload — the whole point of #363 for this case — was unreachable in a real agent session. The agent saw "no Firecrawl tools connected" and fell back to generic, often outdated setup advice. Fix: on the keyless+API-key endpoint, admit a supplied-but-invalid credential as a session flagged credentialError instead of throwing a 401. The connection succeeds, tools list, and every tool call returns the CREDENTIAL_INVALID recovery payload as a 200 isError result — the same agent-legible path keyless quota recovery already uses. No credential is forwarded and no tool executes, so this grants zero functional access (strictly less than the previously rejected "downgrade to keyless" option). Scope: only profiles that accept API keys as a valid auth mode (allowKeyless). OAuth-only surfaces such as /v2/mcp-search keep their hard 401 credential rejection unchanged. Verified end to end: a live claude session wired to an invalid-key server now receives the payload and relays both recovery options to the human without asking for a key in chat. Smoke suite updated to the 200/isError contract; no net-new failures vs main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(mcp): bump to 3.23.8 for the invalid-key recovery fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): stop CREDENTIAL_INVALID payload from advertising unusable keyless tools The credentialError guard in guardHostedTool's execute() runs before the keyless branch, so no tool (including keyless ones) is actually callable in a CREDENTIAL_INVALID session. recoveryPayload() was still including available_tools: KEYLESS_TOOL_NAMES for this code, which could send the agent into a retry loop against tools that just return the same recovery error. Omit available_tools when code is CREDENTIAL_INVALID, matching the existing isKeylessAccessUnavailable carve-out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): scope CREDENTIAL_INVALID tools/list to the keyless surface; pin next_actions shape canList previously returned true for every registered tool in a credentialError session, so tools/list disclosed the full authenticated tool schema (including non-keyless tools) to any request carrying an unrecognized or invalid credential -- more schema disclosure than a real keyless session gets. Scope it to the same keyless-tool surface a keyless session already lists; recovery guidance still surfaces on any listed tool call since execute() gates on credentialError before the keyless branch either way. Also tighten the invalid-key next_actions assertion in mcp-smoke.test.mjs from a loose non-empty-array check to pinning the human_reconnect_account / operator_configure_api_key kinds and their consent flags, so a regression that drops or reorders the recovery actions is caught. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2eb135a commit 68805e7

3 files changed

Lines changed: 77 additions & 36 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "firecrawl-mcp",
3-
"version": "3.23.7",
3+
"version": "3.23.8",
44
"description": "MCP server for Firecrawl — search, scrape, and interact with the web. Supports both cloud and self-hosted instances. Features include web search, scraping, page interaction, batch processing, and LLM-powered content analysis.",
55
"type": "module",
66
"mcpName": "io.github.firecrawl/firecrawl-mcp-server",

src/index.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -535,11 +535,25 @@ async function authenticateRequest(
535535
if (process.env.CLOUD_SERVICE === 'true') {
536536
if (!headerCred && !managedCred) {
537537
if (resolved?.invalid) {
538-
// A supplied credential must never silently downgrade a hosted session
539-
// to an empty tool list. MCP clients commonly stop after tools/list, so
540-
// the old in-band recovery payload was unreachable for invalid header
541-
// credentials. Keep unauthenticated requests on the keyless path; only
542-
// reject requests that actually supplied an invalid credential.
538+
// A supplied-but-invalid credential must reach the *agent*, not die as a
539+
// transport 401. MCP clients treat a 401 at initialize/tools-list as
540+
// "server unavailable" and never surface the response body to the model,
541+
// so the recovery payload in that 401 was unreachable in a real session.
542+
// On the keyless+API-key endpoint, admit the session flagged with
543+
// credentialError: the connection succeeds, tools list, and every tool
544+
// call returns the CREDENTIAL_INVALID recovery payload as a 200 isError
545+
// result — the same agent-legible path keyless quota recovery uses. No
546+
// credential is forwarded and no tool executes, so this grants zero
547+
// functional access. OAuth-only surfaces (e.g. /v2/mcp-search) keep the
548+
// hard 401 credential-rejection contract they already advertise.
549+
if (profile.allowKeyless) {
550+
return {
551+
authType: 'api-key',
552+
credentialError: 'CREDENTIAL_INVALID',
553+
firecrawlApiKey: undefined,
554+
keylessClientIp: extractClientIp(request),
555+
};
556+
}
543557
throw new InvalidFirecrawlCredentialError();
544558
}
545559
if (profile.allowKeyless) {
@@ -1128,7 +1142,12 @@ function recoveryPayload(
11281142
: isKeylessEligibilityUnavailable
11291143
? 'The anonymous keyless eligibility check is temporarily unavailable. Retry shortly.'
11301144
: `This tool requires a Firecrawl account or API key. ${HUMAN_CONNECTION_GUIDANCE}`,
1131-
...(isKeylessAccessUnavailable
1145+
// CREDENTIAL_INVALID sessions gate every tool call (including keyless
1146+
// tools) on the credentialError check before the keyless branch ever
1147+
// runs, so none of KEYLESS_TOOL_NAMES are actually callable here. Listing
1148+
// them as available_tools would send the agent into a retry loop against
1149+
// tools that will just return this same recovery payload.
1150+
...(isKeylessAccessUnavailable || code === 'CREDENTIAL_INVALID'
11321151
? {}
11331152
: { available_tools: [...KEYLESS_TOOL_NAMES] }),
11341153
docs_url: MCP_CONNECTION_GUIDE_URL,
@@ -1232,8 +1251,16 @@ function guardHostedTool(
12321251
return {
12331252
...tool,
12341253
canList: (session: SessionData) =>
1235-
!session?.credentialError &&
1236-
(!isHostedKeylessSession(session) || keylessTool) &&
1254+
// A credentialError session lists the keyless tool surface (same as a
1255+
// real keyless session, not the full authenticated schema) so the
1256+
// client proceeds past tools/list and calling any listed tool returns
1257+
// the CREDENTIAL_INVALID recovery payload (below). An empty list would
1258+
// leave MCP clients that stop after tools/list unable to ever surface
1259+
// the recovery guidance; the full non-keyless schema would over-disclose
1260+
// to a request carrying an unrecognized or invalid credential.
1261+
(session?.credentialError || isHostedKeylessSession(session)
1262+
? keylessTool
1263+
: true) &&
12371264
(canList?.(session) ?? true),
12381265
beforeValidate: async (args: unknown, session: SessionData) => {
12391266
const code = session?.credentialError

tests/mcp-smoke.test.mjs

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,24 +2081,11 @@ test('account OAuth tokens cannot replay on keyless and invalid keys get correct
20812081
});
20822082
assert.equal(replay.status, 401);
20832083

2084-
const invalidApiKeyRecovery = {
2085-
error: 'invalid_api_key',
2086-
error_description:
2087-
'The Firecrawl API key configured for this server is invalid or revoked. Ask a human or operator to replace it in this existing server configuration or secret manager outside this chat. Never ask for, accept, or put an API key in chat or in an MCP URL. After the human confirms the change, start a new client session or run and retry the original task.',
2088-
code: 'CREDENTIAL_INVALID',
2089-
auth_mode: 'api_key',
2090-
message:
2091-
'The Firecrawl API key configured for this server is invalid or revoked. Ask a human or operator to replace it in this existing server configuration or secret manager outside this chat. Never ask for, accept, or put an API key in chat or in an MCP URL. After the human confirms the change, start a new client session or run and retry the original task.',
2092-
docs_url: 'https://docs.firecrawl.dev/mcp-server',
2093-
next_actions: [{
2094-
kind: 'operator_configure_api_key',
2095-
actor: 'human_or_operator',
2096-
requires_user_consent: true,
2097-
credential_delivery: 'outside_agent_chat',
2098-
signup_url: 'https://www.firecrawl.dev/app/api-keys',
2099-
}],
2100-
};
2101-
2084+
// On the keyless+API-key endpoint an invalid key is now agent-legible: the
2085+
// session connects (200) and lists tools, so an MCP client (which commonly
2086+
// stops after a 401 at tools/list) proceeds; any tool call then returns the
2087+
// CREDENTIAL_INVALID recovery payload as a 200 isError result. A raw 401 with
2088+
// the payload in the body was unreachable to the model.
21022089
const invalidList = await fetch(`http://127.0.0.1:${port}/v2/mcp`, {
21032090
body: JSON.stringify({ id: 2, jsonrpc: '2.0', method: 'tools/list', params: {} }),
21042091
headers: {
@@ -2108,9 +2095,12 @@ test('account OAuth tokens cannot replay on keyless and invalid keys get correct
21082095
},
21092096
method: 'POST',
21102097
});
2111-
assert.equal(invalidList.status, 401);
2112-
assert.equal(invalidList.headers.has('www-authenticate'), false);
2113-
assert.deepEqual(await invalidList.json(), invalidApiKeyRecovery);
2098+
assert.equal(invalidList.status, 200);
2099+
const invalidListJson = parseSseJson(await invalidList.text());
2100+
assert.ok(
2101+
(invalidListJson.result?.tools?.length ?? 0) > 0,
2102+
'invalid key still lists tools so the client proceeds to a callable tool'
2103+
);
21142104

21152105
const invalidCall = await httpToolCall(port, {
21162106
headers: {
@@ -2120,9 +2110,33 @@ test('account OAuth tokens cannot replay on keyless and invalid keys get correct
21202110
id: 3,
21212111
params: { arguments: { query: 'x' }, name: 'firecrawl_search' },
21222112
});
2123-
assert.equal(invalidCall.status, 401);
2124-
assert.equal(invalidCall.headers.has('www-authenticate'), false);
2125-
assert.deepEqual(await invalidCall.json(), invalidApiKeyRecovery);
2113+
assert.equal(invalidCall.status, 200);
2114+
const invalidCallJson = parseSseJson(await invalidCall.text());
2115+
const invalidRecovery = invalidCallJson.result.structuredContent;
2116+
assert.equal(invalidCallJson.result.isError, true);
2117+
assert.equal(invalidRecovery.code, 'CREDENTIAL_INVALID');
2118+
assert.match(invalidRecovery.message, /invalid or revoked/);
2119+
// The consent-first boundary must survive: never route the key through chat.
2120+
assert.match(
2121+
invalidRecovery.message,
2122+
/Never ask for, accept, or put an API key in chat/
2123+
);
2124+
// Recovery pins the concrete next-action contract: reconnect (human) then
2125+
// operator-configure, each with its consent flag, not just a non-empty array.
2126+
assert.equal(invalidRecovery.next_actions[0].kind, 'human_reconnect_account');
2127+
assert.equal(invalidRecovery.next_actions[0].actor, 'human');
2128+
assert.equal(invalidRecovery.next_actions[0].requires_user_consent, true);
2129+
assert.equal(invalidRecovery.next_actions[1].kind, 'operator_configure_api_key');
2130+
assert.equal(invalidRecovery.next_actions[1].actor, 'human_or_operator');
2131+
assert.equal(invalidRecovery.next_actions[1].requires_user_consent, true);
2132+
// No tool is actually callable in a credentialError session (the
2133+
// credentialError check gates execute() before the keyless branch), so the
2134+
// payload must not advertise keyless tools as available.
2135+
assert.equal(
2136+
invalidRecovery.available_tools,
2137+
undefined,
2138+
'CREDENTIAL_INVALID must not advertise tools the agent cannot call'
2139+
);
21262140

21272141
const invalidLegacyPath = await fetch(`http://127.0.0.1:${port}/v2/mcp`, {
21282142
body: JSON.stringify({ id: 4, jsonrpc: '2.0', method: 'tools/list', params: {} }),
@@ -2134,9 +2148,9 @@ test('account OAuth tokens cannot replay on keyless and invalid keys get correct
21342148
},
21352149
method: 'POST',
21362150
});
2137-
assert.equal(invalidLegacyPath.status, 401);
2138-
assert.equal(invalidLegacyPath.headers.has('www-authenticate'), false);
2139-
assert.deepEqual(await invalidLegacyPath.json(), invalidApiKeyRecovery);
2151+
assert.equal(invalidLegacyPath.status, 200);
2152+
const invalidLegacyJson = parseSseJson(await invalidLegacyPath.text());
2153+
assert.ok((invalidLegacyJson.result?.tools?.length ?? 0) > 0);
21402154
await delay(25);
21412155
const rejectedTelemetry = stdout
21422156
.split(/\r?\n/)

0 commit comments

Comments
 (0)