refactor(hosted): minimize Stage 1 MCP runtime

This commit is contained in:
Himadri Mishra
2026-07-21 17:50:12 +05:30
parent 988bc2eb5a
commit 2fad69976e
10 changed files with 1153 additions and 170 deletions
+5 -2
View File
@@ -7,7 +7,8 @@ WORKDIR /app
# Enable pnpm via corepack
RUN corepack enable && corepack prepare pnpm@10.17.1 --activate
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
# Install dev dependencies for the build, but skip scripts to avoid running
# the package "prepare" script before the source code is copied.
RUN pnpm install --frozen-lockfile --ignore-scripts
@@ -27,7 +28,8 @@ RUN apk add --no-cache nginx bash curl
# Copy built app and install prod deps only
COPY --from=builder /app/dist ./dist
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
# NGINX config and entrypoint
@@ -36,6 +38,7 @@ COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV PORT=3000
ENV FASTMCP_ENDPOINT=/v2/mcp
# Second in-process instance (search surface). Internal only, reached by nginx.
# on localhost, never exposed directly.
ENV FIRECRAWL_MCP_SEARCH_PORT=3001
+64
View File
@@ -35,6 +35,48 @@ http {
proxy_pass http://app;
}
# Primary hosted Streamable HTTP identities. Each deployment configures the
# app for exactly one endpoint; ingress sends the matching public path here.
location ~ ^/v2/mcp-oauth/?$ {
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Connection "";
proxy_read_timeout 620s;
proxy_send_timeout 620s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app;
}
location ~ ^/v2/mcp/?$ {
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Connection "";
proxy_read_timeout 620s;
proxy_send_timeout 620s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app;
}
location = /mcp {
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Connection "";
proxy_read_timeout 620s;
proxy_send_timeout 620s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
rewrite ^ /v2/mcp break;
proxy_pass http://app;
}
# Search surface, header-based: exactly /v2/mcp-search (or a subpath), served
# by the second instance on :3001. The (?:/|$) boundary keeps it from
# swallowing unrelated paths like /v2/mcp-searchXYZ. Placed before the
@@ -58,6 +100,7 @@ http {
# and preserves the /v2/mcp-search path (only the key segment is stripped).
# Must precede the legacy /{apiKey}/v(1|2)/(.*) regex below.
location ~ ^/(?<apikey>[^/]+)/v2/mcp-search(?:/|$) {
access_log off;
proxy_set_header X-Firecrawl-API-Key $apikey;
proxy_http_version 1.1;
proxy_request_buffering off;
@@ -72,6 +115,25 @@ http {
proxy_pass http://app_search;
}
# Deprecated key-in-path MCP compatibility. Never log the request URI: it
# contains the credential. Move the key into a header and use the canonical
# keyless endpoint path internally.
location ~ ^/(?<apikey>[^/]+)/(?:v2/mcp|mcp)/?$ {
access_log off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Connection "";
proxy_read_timeout 620s;
proxy_send_timeout 620s;
proxy_set_header X-Firecrawl-API-Key $apikey;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
rewrite ^ /v2/mcp break;
proxy_pass http://app;
}
# Header-based with version: /v1|v2/{rest} (MUST COME BEFORE LEGACY)
location ~ ^/v(?:1|2)/(.*)$ {
proxy_buffering off;
@@ -86,6 +148,7 @@ http {
# Legacy with API key and version: /{apiKey}/v1|v2/{rest}
location ~ ^/(?<apikey>[^/]+)/v(?:1|2)/(.*)$ {
access_log off;
proxy_set_header X-Firecrawl-API-Key $apikey;
proxy_set_header Host $host;
@@ -99,6 +162,7 @@ http {
# Legacy: /{apiKey}/{rest}
location ~ ^/(?<apikey>[^/]+)/(.*)$ {
access_log off;
proxy_set_header X-Firecrawl-API-Key $apikey;
proxy_set_header Host $host;
+104
View File
@@ -0,0 +1,104 @@
diff --git a/dist/FastMCP.d.cts b/dist/FastMCP.d.cts
index 8eba099cc20f7ae5f70060bebb3871c387cfb2de..c3031da662933c366c7320171db47c246cd3191a 100644
--- a/dist/FastMCP.d.cts
+++ b/dist/FastMCP.d.cts
@@ -605,6 +605,8 @@ type Tool<T extends FastMCPSessionAuth, Params extends ToolParameters = ToolPara
streamingHint?: boolean;
} & ToolAnnotations;
canAccess?: (auth: T) => boolean;
+ beforeValidate?: (args: unknown, auth: T) => ContentResult | Promise<ContentResult | undefined> | undefined;
+ canList?: (auth: T) => boolean;
description?: string;
execute: (args: StandardSchemaV1.InferOutput<Params>, context: Context<T>) => Promise<AudioContent | ContentResult | ImageContent | ResourceContent | ResourceLink | StandardSchemaV1.InferOutput<OutputParams> | string | TextContent | void>;
name: string;
diff --git a/dist/FastMCP.d.ts b/dist/FastMCP.d.ts
index 810df0c5b6511f34685a0651e6df83acd0239e7b..5c82680c32900c1ca39679e1be25e64c3408aee9 100644
--- a/dist/FastMCP.d.ts
+++ b/dist/FastMCP.d.ts
@@ -605,6 +605,8 @@ type Tool<T extends FastMCPSessionAuth, Params extends ToolParameters = ToolPara
streamingHint?: boolean;
} & ToolAnnotations;
canAccess?: (auth: T) => boolean;
+ beforeValidate?: (args: unknown, auth: T) => ContentResult | Promise<ContentResult | undefined> | undefined;
+ canList?: (auth: T) => boolean;
description?: string;
execute: (args: StandardSchemaV1.InferOutput<Params>, context: Context<T>) => Promise<AudioContent | ContentResult | ImageContent | ResourceContent | ResourceLink | StandardSchemaV1.InferOutput<OutputParams> | string | TextContent | void>;
name: string;
diff --git a/dist/chunk-LWU5CQGW.js b/dist/chunk-LWU5CQGW.js
index 474670585c1fff7d9609d0f900d0743df14a7688..f6091cbe8be4ef30d3eaec90c94a90d5a0802bd0 100644
--- a/dist/chunk-LWU5CQGW.js
+++ b/dist/chunk-LWU5CQGW.js
@@ -986,6 +986,9 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
}
setupToolHandlers(tools) {
const toolsMap = new Map(tools.map((tool) => [tool.name, tool]));
+ const listedTools = tools.filter(
+ (tool) => tool.canList ? tool.canList(this.#auth) : true
+ );
let cachedToolsList = null;
this.#server.setRequestHandler(ListToolsRequestSchema, async () => {
if (cachedToolsList) {
@@ -994,7 +997,7 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
};
}
cachedToolsList = await Promise.all(
- tools.map(async (tool) => {
+ listedTools.map(async (tool) => {
return {
annotations: tool.annotations,
description: tool.description,
@@ -1026,6 +1029,15 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
`Unknown tool: ${request.params.name}`
);
}
+ if (tool.beforeValidate) {
+ const earlyResult = await tool.beforeValidate(
+ request.params.arguments,
+ this.#auth
+ );
+ if (earlyResult !== void 0) {
+ return ContentResultZodSchema.parse(earlyResult);
+ }
+ }
let args = void 0;
if (tool.parameters) {
const parsed = await tool.parameters["~standard"].validate(
diff --git a/dist/chunk-UYG7NPM6.cjs b/dist/chunk-UYG7NPM6.cjs
index 3b695bf493c4f54fd970e145cf14fdd8effc9c6d..8883d346f9f32943f823a10bbb72d415f394ca6d 100644
--- a/dist/chunk-UYG7NPM6.cjs
+++ b/dist/chunk-UYG7NPM6.cjs
@@ -986,6 +986,9 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
}
setupToolHandlers(tools) {
const toolsMap = new Map(tools.map((tool) => [tool.name, tool]));
+ const listedTools = tools.filter(
+ (tool) => tool.canList ? tool.canList(this.#auth) : true
+ );
let cachedToolsList = null;
this.#server.setRequestHandler(_typesjs.ListToolsRequestSchema, async () => {
if (cachedToolsList) {
@@ -994,7 +997,7 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
};
}
cachedToolsList = await Promise.all(
- tools.map(async (tool) => {
+ listedTools.map(async (tool) => {
return {
annotations: tool.annotations,
description: tool.description,
@@ -1026,6 +1029,15 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
`Unknown tool: ${request.params.name}`
);
}
+ if (tool.beforeValidate) {
+ const earlyResult = await tool.beforeValidate(
+ request.params.arguments,
+ this.#auth
+ );
+ if (earlyResult !== void 0) {
+ return ContentResultZodSchema.parse(earlyResult);
+ }
+ }
let args = void 0;
if (tool.parameters) {
const parsed = await tool.parameters["~standard"].validate(
+9 -4
View File
@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
fastmcp@4.3.2:
hash: 4ce43b72d62ea76fc9a1cc3a6244a4258d3311a6d369cbe171b5186b3139b489
path: patches/fastmcp@4.3.2.patch
importers:
.:
@@ -16,7 +21,7 @@ importers:
version: 17.2.2
fastmcp:
specifier: 4.3.2
version: 4.3.2
version: 4.3.2(patch_hash=4ce43b72d62ea76fc9a1cc3a6244a4258d3311a6d369cbe171b5186b3139b489)
zod:
specifier: ^4.1.5
version: 4.1.11
@@ -2509,7 +2514,7 @@ snapshots:
fast-uri@3.1.3: {}
fastmcp@4.3.2:
fastmcp@4.3.2(patch_hash=4ce43b72d62ea76fc9a1cc3a6244a4258d3311a6d369cbe171b5186b3139b489):
dependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@standard-schema/spec': 1.0.0
@@ -2521,7 +2526,7 @@ snapshots:
strict-event-emitter-types: 2.0.0
undici: 7.28.0
uri-templates: 0.2.0
xsschema: 0.4.4(zod-to-json-schema@3.25.2(zod@4.1.11))(zod@4.4.3)
xsschema: 0.4.4(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)
yargs: 18.0.0
zod: 4.4.3
zod-to-json-schema: 3.25.2(zod@4.4.3)
@@ -3353,7 +3358,7 @@ snapshots:
wrappy@1.0.2: {}
xsschema@0.4.4(zod-to-json-schema@3.25.2(zod@4.1.11))(zod@4.4.3):
xsschema@0.4.4(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3):
optionalDependencies:
zod: 4.4.3
zod-to-json-schema: 3.25.2(zod@4.4.3)
+3
View File
@@ -1,3 +1,6 @@
allowBuilds:
esbuild: true
tldjs: true
patchedDependencies:
fastmcp@4.3.2: patches/fastmcp@4.3.2.patch
+436 -128
View File
@@ -1,14 +1,24 @@
#!/usr/bin/env node
import FirecrawlApp from '@mendable/firecrawl-js';
import dotenv from 'dotenv';
import { FastMCP, type Logger } from 'fastmcp';
import { FastMCP, type Logger, UserError } from 'fastmcp';
import type { IncomingHttpHeaders } from 'http';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { z } from 'zod';
import { registerMonitorTools } from './monitor';
import { registerResearchTools } from './research';
import {
credentialForOutboundRequest,
CredentialValidationUnavailableError,
hasCredential,
hasManagedOAuthCredential,
requireDelegatedCredentialSigning,
setManagedOAuthApiKey,
type CredentialSession,
} from './session-credential';
dotenv.config({ debug: false, quiet: true });
@@ -17,7 +27,7 @@ const { version: packageVersion } = require('../package.json') as {
version: string;
};
interface SessionData {
interface SessionData extends CredentialSession {
/**
* FC API key (`fc-...`) or OAuth access token (`fco_...`) sent as
* `Authorization: Bearer ...` to the Firecrawl API.
@@ -29,20 +39,26 @@ interface SessionData {
* instead of the shared server IP.
*/
keylessClientIp?: string;
authType?: 'api-key' | 'oauth' | 'env' | 'keyless' | 'none';
credentialError?: 'CREDENTIAL_INVALID';
teamId?: string;
userId?: string;
apiKeyId?: string;
oauthClientId?: string;
resource?: string;
[key: string]: unknown;
}
type ToolLogger = Pick<Logger, 'debug' | 'error' | 'info' | 'warn'>;
/**
* A server profile parameterizes how a FastMCP instance is constructed. Two
* profiles run side by side in one process: the default `full` profile (the
* complete tool surface) and the `search` profile (a fixed, read-only subset).
* Only the request path decides which surface answers. Client identity is never
* inspected.
* A server profile parameterizes how a FastMCP instance is constructed. Hosted
* deployments run one primary identity (`full` or `account`) per process. The
* existing search profile remains an in-process companion of `full` until its
* deployment is migrated separately.
*/
type ServerProfile = {
id: 'full' | 'search';
id: 'full' | 'account' | 'search';
/** OAuth protected-resource display name. */
resourceName: string;
/** Server-level instructions surfaced to clients. */
@@ -55,10 +71,10 @@ type ServerProfile = {
port: number;
/** When set, only these tool names may register on this instance. */
toolAllowlist?: Set<string>;
/** Reject OAuth tokens minted for a different resource. */
enforceAudience: boolean;
/** Allow the keyless free-tier fallback (no credential required). */
allowKeyless: boolean;
/** Accept tokens minted for the legacy /v2/mcp resource during migration. */
acceptLegacyAudience?: boolean;
};
/** Registers a tool onto an instance; a subset of the FastMCP surface. */
@@ -93,6 +109,22 @@ function isFirecrawlOAuthAccessToken(token: string): boolean {
return token.startsWith('fco_');
}
function isFirecrawlApiKey(token: string): boolean {
return token.startsWith('fc-');
}
function requestShouldReceiveOAuthChallenge(
request: MCPAuthRequest | undefined
): boolean {
if (!request?.headers) return true;
const headerApiKey = normalizeHeader(
request.headers['x-firecrawl-api-key'] ?? request.headers['x-api-key']
);
if (headerApiKey) return false;
const bearer = extractBearerToken(request.headers);
return !bearer || isFirecrawlOAuthAccessToken(bearer);
}
function resolveCredentialFromEnv(): string | undefined {
return (
normalizeHeader(process.env.FIRECRAWL_OAUTH_TOKEN) ??
@@ -109,6 +141,7 @@ function isHttpStreamingTransport(): boolean {
const DEFAULT_OAUTH_ISSUER = 'https://www.firecrawl.dev';
const DEFAULT_MCP_RESOURCE_URL = 'https://mcp.firecrawl.dev/v2/mcp';
const DEFAULT_MCP_OAUTH_RESOURCE_URL = 'https://mcp.firecrawl.dev/v2/mcp-oauth';
const DEFAULT_MCP_SEARCH_RESOURCE_URL = 'https://mcp.firecrawl.dev/v2/mcp-search';
const DEFAULT_MCP_SEARCH_ENDPOINT = '/v2/mcp-search';
@@ -129,6 +162,14 @@ function getMcpResourceUrl(): string {
);
}
function getPrimaryEndpoint(): '/v2/mcp' | '/v2/mcp-oauth' {
const endpoint = normalizeHeader(process.env.FASTMCP_ENDPOINT) ?? '/v2/mcp';
if (endpoint === '/v2/mcp' || endpoint === '/v2/mcp-oauth') return endpoint;
throw new Error(
`Unsupported FASTMCP_ENDPOINT: ${endpoint}. Expected /v2/mcp or /v2/mcp-oauth.`
);
}
function getSearchMcpResourceUrl(): string {
return (
normalizeHeader(process.env.FIRECRAWL_MCP_SEARCH_RESOURCE_URL) ??
@@ -202,86 +243,169 @@ function isMcpOAuthEnabled(): boolean {
return process.env.CLOUD_SERVICE === 'true';
}
type OAuthCredentialPurpose = 'general' | 'hosted_mcp_oauth';
type OAuthIntrospectionResponse = {
active?: boolean;
api_key?: string;
/** Resource(s) the token was minted for (RFC 8707 / RFC 7662). */
aud?: string | string[];
credential_purpose?: OAuthCredentialPurpose;
scope?: string | string[];
team_id?: string;
sub?: string;
api_key_id?: string;
client_id?: string;
};
/** The resolved Firecrawl credential plus the audience it was issued for. */
type CredentialMetadata = Pick<
SessionData,
'teamId' | 'userId' | 'apiKeyId' | 'oauthClientId' | 'resource'
>;
type ResolvedCredential = {
credential: string;
aud?: string | string[];
/** True when the credential came from introspecting an OAuth access token. */
viaOAuth: boolean;
credential?: string;
managedOAuthApiKey?: string;
invalid?: boolean;
source?: 'api-key' | 'oauth' | 'env';
metadata?: CredentialMetadata;
};
async function introspectOAuthAccessToken(
token: string
): Promise<{ apiKey: string; aud?: string | string[] }> {
const introspectionSecret = getOAuthIntrospectionSecret();
if (!introspectionSecret) {
throw new Error('OAuth token introspection is not configured');
}
const MCP_GLOBAL_SCOPE = 'firecrawl:global';
const response = await fetch(getOAuthIntrospectionEndpoint(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${introspectionSecret}`,
},
body: new URLSearchParams({
token,
token_type_hint: 'access_token',
}),
});
if (!response.ok) {
throw new Error(`OAuth token introspection failed: ${response.status}`);
}
const data = (await response.json()) as OAuthIntrospectionResponse;
if (!data.active || !data.api_key) {
throw new Error('Invalid OAuth access token');
}
return { apiKey: data.api_key, aud: data.aud };
function values(value: string | string[] | undefined): string[] {
if (typeof value === 'string') return value.split(/\s+/).filter(Boolean);
return Array.isArray(value)
? value.flatMap((item) => item.split(/\s+/).filter(Boolean))
: [];
}
/**
* True when `aud` is absent (nothing to check) or names `resourceUrl`. A token
* may carry one audience or a list; a trailing slash never changes identity.
*/
function audienceMatchesResource(
aud: string | string[] | undefined,
resourceUrl: string
): boolean {
if (aud == null) return true;
const list = Array.isArray(aud) ? aud : [aud];
const target = withoutTrailingSlash(resourceUrl);
return list.some((entry) => withoutTrailingSlash(entry) === target);
return values(aud).some((entry) => withoutTrailingSlash(entry) === target);
}
function credentialMetadata(data: OAuthIntrospectionResponse): CredentialMetadata {
return {
teamId: typeof data.team_id === 'string' ? data.team_id : undefined,
userId: typeof data.sub === 'string' ? data.sub : undefined,
apiKeyId: typeof data.api_key_id === 'string' ? data.api_key_id : undefined,
oauthClientId:
typeof data.client_id === 'string' ? data.client_id : undefined,
resource: typeof data.aud === 'string' ? data.aud : undefined,
};
}
async function introspectToken(
token: string,
expectedResource: string
): Promise<OAuthIntrospectionResponse> {
const introspectionSecret = getOAuthIntrospectionSecret();
if (!introspectionSecret) throw new CredentialValidationUnavailableError();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
let response: Response;
try {
response = await fetch(getOAuthIntrospectionEndpoint(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${introspectionSecret}`,
},
body: new URLSearchParams({
resource: expectedResource,
token,
token_type_hint: 'access_token',
}),
signal: controller.signal,
});
} catch {
throw new CredentialValidationUnavailableError();
} finally {
clearTimeout(timeout);
}
if (!response.ok) throw new CredentialValidationUnavailableError();
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
if (!contentType.includes('application/json')) {
throw new CredentialValidationUnavailableError();
}
const data = (await response.json()) as OAuthIntrospectionResponse;
if (typeof data.active !== 'boolean') {
throw new CredentialValidationUnavailableError();
}
if (
data.active &&
(!data.api_key ||
!data.credential_purpose ||
!values(data.scope).includes(MCP_GLOBAL_SCOPE))
) {
throw new CredentialValidationUnavailableError();
}
return data;
}
async function resolveCredentialFromHeaders(
headers: IncomingHttpHeaders
headers: IncomingHttpHeaders,
profile: ServerProfile
): Promise<ResolvedCredential | undefined> {
const bearer = extractBearerToken(headers);
const headerApiKey = normalizeHeader(
headers['x-firecrawl-api-key'] ?? headers['x-api-key']
);
const token = headerApiKey ?? bearer;
if (!token) return undefined;
if (!isFirecrawlOAuthAccessToken(token) && !isFirecrawlApiKey(token)) {
return { invalid: true };
}
if (bearer && isFirecrawlOAuthAccessToken(bearer)) {
const { apiKey, aud } = await introspectOAuthAccessToken(bearer);
return { credential: apiKey, aud, viaOAuth: true };
let data = await introspectToken(token, profile.resourceUrl);
if (
isFirecrawlOAuthAccessToken(token) &&
!data.active &&
profile.acceptLegacyAudience
) {
data = await introspectToken(token, DEFAULT_MCP_RESOURCE_URL);
}
if (headerApiKey) {
return { credential: headerApiKey, viaOAuth: false };
if (!data.active || !data.api_key) {
if (isFirecrawlOAuthAccessToken(token)) {
throw new Error('Invalid OAuth access token');
}
return { invalid: true };
}
if (bearer) {
return { credential: bearer, viaOAuth: false };
if (isFirecrawlApiKey(token)) {
return data.credential_purpose === 'general'
? {
credential: data.api_key,
source: 'api-key',
metadata: credentialMetadata(data),
}
: { invalid: true };
}
return undefined;
const expectedAudience =
profile.acceptLegacyAudience &&
audienceMatchesResource(data.aud, DEFAULT_MCP_RESOURCE_URL)
? DEFAULT_MCP_RESOURCE_URL
: profile.resourceUrl;
if (!audienceMatchesResource(data.aud, expectedAudience)) {
throw new Error('OAuth token audience does not match this resource');
}
if (data.credential_purpose === 'hosted_mcp_oauth') {
requireDelegatedCredentialSigning();
return {
managedOAuthApiKey: data.api_key,
source: 'oauth',
metadata: credentialMetadata(data),
};
}
return {
credential: data.api_key,
source: 'oauth',
metadata: credentialMetadata(data),
};
}
async function authenticateRequest(
@@ -295,47 +419,38 @@ async function authenticateRequest(
// "Unauthorized: API key is required when not using a self-hosted
// instance" even though `FIRECRAWL_API_KEY` is set in env.
const resolved = request?.headers
? await resolveCredentialFromHeaders(request.headers)
? await resolveCredentialFromHeaders(request.headers, profile)
: undefined;
// On surfaces that opt in, an OAuth access token must be bound to this exact
// resource: reject tokens minted for a different resource AND tokens with no
// audience binding at all (fail closed; an unbound token must not unlock a
// resource-scoped surface). Plain API keys carry no audience and are a direct
// credential, so they are unaffected.
if (profile.enforceAudience && resolved?.viaOAuth) {
if (!resolved.aud || !audienceMatchesResource(resolved.aud, profile.resourceUrl)) {
throw new Error('OAuth token audience does not match this resource');
}
}
const headerCred = resolved?.credential;
const managedCred = resolved?.managedOAuthApiKey;
const envCred = resolveCredentialFromEnv();
if (process.env.CLOUD_SERVICE === 'true') {
if (!headerCred) {
// Keyless free tier over the hosted MCP: serve it only when the surface
// permits it, a forwarding secret is configured, we know the end-user's
// client IP (so the API can rate-limit per real IP, not the shared
// server IP), AND that IP still has free quota. Otherwise fall through
// to throw so FastMCP emits the OAuth 401 + WWW-Authenticate challenge.
const clientIp = extractClientIp(request);
if (
profile.allowKeyless &&
process.env.KEYLESS_PROXY_SECRET &&
clientIp &&
(await keylessEligible(clientIp))
) {
return { firecrawlApiKey: undefined, keylessClientIp: clientIp };
if (!headerCred && !managedCred) {
if (resolved?.invalid) {
return { authType: 'none', credentialError: 'CREDENTIAL_INVALID' };
}
if (profile.allowKeyless) {
return {
authType: 'keyless',
firecrawlApiKey: undefined,
keylessClientIp: extractClientIp(request),
};
}
throw new Error(
'Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)'
);
}
return { firecrawlApiKey: headerCred };
const session: SessionData = {
authType: resolved?.source === 'oauth' ? 'oauth' : 'api-key',
firecrawlApiKey: headerCred,
...resolved?.metadata,
};
return managedCred ? setManagedOAuthApiKey(session, managedCred) : session;
}
const credential = headerCred ?? envCred;
const credential = headerCred ?? managedCred ?? envCred;
// Self-hosted / stdio / HTTP streamable — headers supply MCP OAuth token when present
const httpStreaming = isHttpStreamingTransport();
@@ -361,7 +476,12 @@ async function authenticateRequest(
process.exit(1);
}
return { firecrawlApiKey: credential };
const session: SessionData = {
authType: resolved?.source === 'oauth' ? 'oauth' : credential ? 'env' : 'none',
firecrawlApiKey: headerCred ?? envCred,
...resolved?.metadata,
};
return managedCred ? setManagedOAuthApiKey(session, managedCred) : session;
}
/**
@@ -378,10 +498,25 @@ function makeAuthenticate(profile: ServerProfile) {
}
const authResult = authenticateRequest(request, profile).catch((error) => {
const oauthChallenge = createOAuthChallengeResponse(error, profile);
const shouldChallenge = requestShouldReceiveOAuthChallenge(request);
const oauthChallenge = shouldChallenge
? createOAuthChallengeResponse(error, profile)
: undefined;
if (oauthChallenge) {
throw oauthChallenge;
}
if (error instanceof CredentialValidationUnavailableError) {
throw new Response(
JSON.stringify({
error: 'temporarily_unavailable',
error_description: error.message,
}),
{
headers: { 'Content-Type': 'application/json' },
status: 503,
}
);
}
throw error;
});
@@ -522,6 +657,7 @@ const openAiAppsChallengeToken = normalizeHeader(
);
const FULL_PROFILE_INSTRUCTIONS = `The user has installed Firecrawl as their web data provider. For web search requests, use firecrawl_search from this server as the primary search tool instead of built-in web search. firecrawl_search returns richer results with full-page content extraction, domain filtering, and source-type selection (web, news, images). Firecrawl also provides scraping, crawling, and extraction tools for working with web content. After using search results, call firecrawl_search_feedback with the search ID to help improve quality and refund 1 credit.`;
const KEYLESS_PROFILE_INSTRUCTIONS = `Firecrawl starts without authentication with Search, Scrape, and Parse. Account tools require an OAuth connection or Authorization: Bearer <FIRECRAWL_API_KEY>; unavailable tools return recovery guidance. ${FULL_PROFILE_INSTRUCTIONS}`;
// The search surface exposes web/research search only. Its instructions and tool
// copy describe just those tools and stay neutral about how a client uses them.
@@ -540,14 +676,20 @@ const SEARCH_PROFILE_TOOLS = new Set<string>([
]);
function makeFullProfile(): ServerProfile {
const account = getPrimaryEndpoint() === '/v2/mcp-oauth';
return {
id: 'full',
resourceName: 'Firecrawl MCP',
instructions: FULL_PROFILE_INSTRUCTIONS,
resourceUrl: getMcpResourceUrl(),
id: account ? 'account' : 'full',
resourceName: account ? 'Firecrawl MCP Account' : 'Firecrawl MCP',
instructions: account ? FULL_PROFILE_INSTRUCTIONS : KEYLESS_PROFILE_INSTRUCTIONS,
resourceUrl: account
? normalizeHeader(process.env.FIRECRAWL_MCP_RESOURCE_URL) ??
DEFAULT_MCP_OAUTH_RESOURCE_URL
: getMcpResourceUrl(),
endpoint: account ? '/v2/mcp-oauth' : undefined,
port: Number(process.env.PORT || 3000),
enforceAudience: false,
allowKeyless: true,
allowKeyless: !account,
acceptLegacyAudience:
account && process.env.MCP_OAUTH_ACCEPT_LEGACY_V2_MCP_AUD !== 'false',
};
}
@@ -560,7 +702,6 @@ function makeSearchProfile(): ServerProfile {
endpoint: getSearchMcpEndpoint(),
port: Number(process.env.FIRECRAWL_MCP_SEARCH_PORT || 3001),
toolAllowlist: SEARCH_PROFILE_TOOLS,
enforceAudience: true,
allowKeyless: false,
};
}
@@ -593,7 +734,131 @@ function createServer(profile: ServerProfile): FastMCP<SessionData> {
});
}
const server = createServer(makeFullProfile());
const primaryProfile = makeFullProfile();
const server = createServer(primaryProfile);
const KEYLESS_TOOL_NAMES = new Set([
'firecrawl_scrape',
'firecrawl_search',
'firecrawl_parse',
]);
function isHostedKeylessSession(session?: SessionData): boolean {
return (
process.env.CLOUD_SERVICE === 'true' &&
session?.authType === 'keyless' &&
!session.firecrawlApiKey
);
}
function recoveryPayload(code: string): Record<string, unknown> {
return {
code,
auth_mode: code === 'CREDENTIAL_INVALID' ? 'credential_error' : 'keyless',
message:
code === 'CREDENTIAL_INVALID'
? 'The supplied Firecrawl credential is invalid or revoked. Replace it or reconnect the account, then retry.'
: 'This tool requires a Firecrawl account or API key. Connect an account or configure Authorization: Bearer <FIRECRAWL_API_KEY>, then retry.',
available_tools: [...KEYLESS_TOOL_NAMES],
docs_url: 'https://docs.firecrawl.dev/mcp-server',
next_actions: [
{ kind: 'connect_account', url: 'https://firecrawl.dev/connect/mcp' },
{
kind: 'configure_api_key',
header: 'Authorization: Bearer <FIRECRAWL_API_KEY>',
},
],
};
}
type ActionStatus = 'started' | 'success' | 'error';
function emitActionLog(
toolName: string,
status: ActionStatus,
session?: SessionData,
error?: unknown,
requestId = randomUUID()
): void {
if (process.env.CLOUD_SERVICE !== 'true') return;
const payload = {
team_id: session?.teamId,
user_id: session?.userId,
api_key_id: session?.apiKeyId,
oauth_client_id: session?.oauthClientId,
auth_type: session?.authType ?? 'none',
tool_name: toolName,
status,
request_id: requestId,
resource: session?.resource ?? primaryProfile.resourceUrl,
...(error
? { error_class: error instanceof Error ? error.name : typeof error }
: {}),
};
console.error('[MCP_ACTION]', JSON.stringify(payload));
const secret = normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_SECRET);
const apiUrl = normalizeHeader(process.env.FIRECRAWL_API_URL);
const endpoint =
normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_URL) ??
(apiUrl ? `${withoutTrailingSlash(apiUrl)}/v2/mcp/action-logs` : undefined);
if (!secret || !endpoint || !payload.team_id || status === 'started') return;
void fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${secret}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(1500),
}).catch(() => undefined);
}
const addTool = server.addTool.bind(server);
server.addTool = ((tool: Parameters<typeof server.addTool>[0]) => {
const keylessTool = KEYLESS_TOOL_NAMES.has(tool.name);
const execute = tool.execute;
addTool({
...tool,
canList: (session: SessionData) =>
!session?.credentialError &&
(!isHostedKeylessSession(session) || keylessTool),
beforeValidate: (_args: unknown, session: SessionData) => {
const code = session?.credentialError
? 'CREDENTIAL_INVALID'
: isHostedKeylessSession(session) && !keylessTool
? 'KEYLESS_TOOL_NOT_AVAILABLE'
: undefined;
if (!code) return undefined;
const payload = recoveryPayload(code);
return {
content: [{ type: 'text' as const, text: String(payload.message) }],
isError: true,
structuredContent: payload,
};
},
execute: async (args, context) => {
if (context.session?.credentialError) {
const payload = recoveryPayload('CREDENTIAL_INVALID');
throw new UserError(String(payload.message), payload);
}
if (isHostedKeylessSession(context.session) && !keylessTool) {
const payload = recoveryPayload('KEYLESS_TOOL_NOT_AVAILABLE');
throw new UserError(String(payload.message), payload);
}
const requestId = randomUUID();
emitActionLog(tool.name, 'started', context.session, undefined, requestId);
try {
const result = await execute(args, context);
emitActionLog(tool.name, 'success', context.session, undefined, requestId);
return result;
} catch (error) {
emitActionLog(tool.name, 'error', context.session, error, requestId);
throw error;
}
},
});
}) as typeof server.addTool;
if (openAiAppsChallengeToken) {
server
@@ -603,6 +868,29 @@ if (openAiAppsChallengeToken) {
);
}
server.getApp().get('/ready', (context) => {
if (process.env.CLOUD_SERVICE !== 'true') {
return context.json({ ok: true }, 200);
}
const missing = [
'FIRECRAWL_API_URL',
'FIRECRAWL_OAUTH_INTROSPECT_SECRET',
'FIRECRAWL_MCP_ACTION_LOG_SECRET',
...(primaryProfile.allowKeyless ? ['KEYLESS_PROXY_SECRET'] : []),
].filter((name) => !normalizeHeader(process.env[name]));
const configuredEndpoint = getPrimaryEndpoint();
if (
withoutTrailingSlash(primaryProfile.resourceUrl).endsWith(
configuredEndpoint
) === false
) {
missing.push('FIRECRAWL_MCP_RESOURCE_URL (endpoint mismatch)');
}
return missing.length
? context.json({ ok: false, missing }, 503)
: context.json({ ok: true }, 200);
});
function createClient(apiKey?: string): FirecrawlApp {
const config: any = {
...(process.env.FIRECRAWL_API_URL && {
@@ -625,25 +913,33 @@ const ORIGIN_HEADERS = { 'X-Origin': ORIGIN };
const SAFE_MODE = process.env.CLOUD_SERVICE === 'true';
function getClient(session?: SessionData): FirecrawlApp {
// For cloud service, API key is required
if (process.env.CLOUD_SERVICE === 'true') {
if (!session || !session.firecrawlApiKey) {
throw new Error('Unauthorized');
}
return createClient(session.firecrawlApiKey);
if (process.env.CLOUD_SERVICE === 'true' && !hasCredential(session)) {
throw new Error('Unauthorized');
}
// For self-hosted instances, API key is optional if FIRECRAWL_API_URL is provided
if (
!process.env.FIRECRAWL_API_URL &&
(!session || !session.firecrawlApiKey)
) {
if (!process.env.FIRECRAWL_API_URL && !hasCredential(session)) {
throw new Error(
'Unauthorized: API key is required when not using a self-hosted instance'
);
}
if (!hasManagedOAuthCredential(session)) {
return createClient(credentialForOutboundRequest(session));
}
return createClient(session?.firecrawlApiKey);
const client = createClient('request-scoped-hosted-oauth');
const axiosInstance = (client as any).http?.instance;
if (!axiosInstance?.interceptors?.request?.use) {
throw new CredentialValidationUnavailableError();
}
axiosInstance.interceptors.request.use((config: any) => {
const credential = credentialForOutboundRequest(session);
if (!credential) throw new CredentialValidationUnavailableError();
config.headers = {
...(config.headers ?? {}),
Authorization: `Bearer ${credential}`,
};
return config;
});
return client;
}
function asText(data: unknown): string {
@@ -1048,8 +1344,9 @@ async function apiPostJsonForSession(
body: Record<string, unknown>,
session: SessionData | undefined
): Promise<any> {
if (session?.firecrawlApiKey) {
return apiPostJson(pathName, body, session.firecrawlApiKey);
const credential = credentialForOutboundRequest(session);
if (credential) {
return apiPostJson(pathName, body, credential);
}
if (isKeylessMode(session)) {
@@ -1112,7 +1409,7 @@ async function executeHostedParse(
);
}
if (!session?.firecrawlApiKey && !isKeylessMode(session)) {
if (!hasCredential(session) && !isKeylessMode(session)) {
return asText({
success: false,
mode: 'hosted-upload-ref-auth-required',
@@ -1586,11 +1883,9 @@ async function keylessEligible(clientIp: string): Promise<boolean> {
}
function isKeylessMode(session?: SessionData): boolean {
if (session?.firecrawlApiKey) return false;
if (hasCredential(session) || session?.credentialError) return false;
if (process.env.CLOUD_SERVICE === 'true') {
// Hosted: keyless only for secret-gated sessions carrying the forwarded
// client IP (so the per-IP cap is meaningful, not the shared server IP).
return !!session?.keylessClientIp;
return session?.authType === 'keyless';
}
// Local/stdio against the cloud (not a self-hosted FIRECRAWL_API_URL).
return !process.env.FIRECRAWL_API_URL;
@@ -1601,6 +1896,13 @@ async function keylessPost(
body: Record<string, unknown>,
session?: SessionData
): Promise<any> {
if (
isHostedKeylessSession(session) &&
(!session?.keylessClientIp || !(await keylessEligible(session.keylessClientIp)))
) {
const payload = recoveryPayload('KEYLESS_ACCESS_NOT_AVAILABLE');
throw new UserError(String(payload.message), payload);
}
const headers: Record<string, string> = {
...ORIGIN_HEADERS,
'Content-Type': 'application/json',
@@ -1618,6 +1920,10 @@ async function keylessPost(
});
const json: any = await response.json().catch(() => ({}));
if (!response.ok) {
if (isHostedKeylessSession(session) && [401, 402, 429].includes(response.status)) {
const payload = recoveryPayload('KEYLESS_QUOTA_EXHAUSTED');
throw new UserError(String(payload.message), payload);
}
throw new Error(
json?.error || `Firecrawl request failed (HTTP ${response.status})`
);
@@ -1887,9 +2193,9 @@ Pass the \`searchId\` returned by \`firecrawl_search\` (the \`id\` field on the
...ORIGIN_HEADERS,
'Content-Type': 'application/json',
};
const apiKey = session?.firecrawlApiKey;
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
const credential = credentialForOutboundRequest(session);
if (credential) {
headers['Authorization'] = `Bearer ${credential}`;
} else if (process.env.CLOUD_SERVICE === 'true') {
throw new Error('Unauthorized: missing API key for search feedback.');
}
@@ -2011,9 +2317,9 @@ Do not store multi-MB outputs in feedback. Use concise notes, issue codes, URLs,
...ORIGIN_HEADERS,
'Content-Type': 'application/json',
};
const apiKey = session?.firecrawlApiKey;
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
const credential = credentialForOutboundRequest(session);
if (credential) {
headers['Authorization'] = `Bearer ${credential}`;
} else if (process.env.CLOUD_SERVICE === 'true') {
throw new Error('Unauthorized: missing API key for feedback.');
}
@@ -2707,9 +3013,9 @@ Add \`"parsers": ["pdf"]\` (optionally with \`pdfOptions.maxPages\`) when parsin
form.append('options', JSON.stringify(optionsPayload));
const headers: Record<string, string> = { ...ORIGIN_HEADERS };
const apiKey = session?.firecrawlApiKey;
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
const credential = credentialForOutboundRequest(session);
if (credential) {
headers['Authorization'] = `Bearer ${credential}`;
}
const endpoint = `${apiUrl.replace(/\/$/, '')}/v2/parse`;
@@ -2871,6 +3177,7 @@ if (
httpStream: {
port: PORT,
host: HOST,
endpoint: primaryProfile.endpoint,
stateless: true,
},
};
@@ -2891,6 +3198,7 @@ await server.start(args);
// untouched. Only registered in the hosted profile and when not disabled.
const searchProfileEnabled =
process.env.CLOUD_SERVICE === 'true' &&
primaryProfile.id === 'full' &&
process.env.FIRECRAWL_MCP_SEARCH_ENABLED !== 'false';
if (searchProfileEnabled) {
+95
View File
@@ -0,0 +1,95 @@
import { createHmac } from 'node:crypto';
const managedOAuthApiKey = Symbol('firecrawlManagedOAuthApiKey');
export interface CredentialSession {
/** Reusable general/API-key credential. Safe to pass directly to Core. */
firecrawlApiKey?: string;
/**
* Process-local managed credential for a hosted OAuth grant. Symbol-keyed so
* JSON/session serialization cannot expose it. Never use this value directly
* as an outbound Authorization credential.
*/
[managedOAuthApiKey]?: string;
}
export class CredentialValidationUnavailableError extends Error {
constructor() {
super('Firecrawl credential validation is temporarily unavailable');
this.name = 'CredentialValidationUnavailableError';
}
}
type McpDelegatedCredentialPayload = {
v: 1;
aud: 'firecrawl-core';
purpose: 'hosted_mcp_oauth';
api_key: string;
iat: number;
exp: number;
};
function delegationSecret(): string {
const secret = process.env.KEYLESS_PROXY_SECRET?.trim();
if (!secret) throw new CredentialValidationUnavailableError();
return secret;
}
export function requireDelegatedCredentialSigning(): void {
delegationSecret();
}
export function setManagedOAuthApiKey<T extends CredentialSession>(
session: T,
apiKey: string
): T {
Object.defineProperty(session, managedOAuthApiKey, {
configurable: false,
enumerable: false,
value: apiKey,
writable: false,
});
return session;
}
export function copyManagedOAuthApiKey(
source: CredentialSession | undefined,
target: CredentialSession
): void {
const apiKey = source?.[managedOAuthApiKey];
if (apiKey) setManagedOAuthApiKey(target, apiKey);
}
export function hasCredential(session?: CredentialSession): boolean {
return Boolean(session?.firecrawlApiKey || session?.[managedOAuthApiKey]);
}
export function hasManagedOAuthCredential(
session?: CredentialSession
): boolean {
return Boolean(session?.[managedOAuthApiKey]);
}
export function credentialForOutboundRequest(
session?: CredentialSession
): string | undefined {
const managedApiKey = session?.[managedOAuthApiKey];
if (!managedApiKey) return session?.firecrawlApiKey;
const iat = Math.floor(Date.now() / 1000);
const payload: McpDelegatedCredentialPayload = {
v: 1,
aud: 'firecrawl-core',
purpose: 'hosted_mcp_oauth',
api_key: managedApiKey,
iat,
exp: iat + 60,
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
'base64url'
);
const signature = createHmac('sha256', delegationSecret())
.update(encodedPayload)
.digest('base64url');
return `fcmcp_${encodedPayload}.${signature}`;
}
+26 -9
View File
@@ -120,14 +120,20 @@ async function startFakeBackend(options = {}) {
if (req.method === 'POST' && req.url === '/api/oauth/introspect') {
const token = body?.token ?? '';
const active = token.startsWith('fco_') && !token.includes('invalid');
const active = /^(?:fco_|fc-)/.test(token) && !token.includes('invalid');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify(
active
? {
active: true,
api_key: apiKeyFromIntrospection,
api_key: token.startsWith('fc-')
? token
: apiKeyFromIntrospection,
credential_purpose: token.startsWith('fco_')
? 'hosted_mcp_oauth'
: 'general',
scope: 'firecrawl:global',
...(introspectionAud ? { aud: introspectionAud } : {}),
}
: { active: false }
@@ -171,6 +177,8 @@ async function startFakeBackend(options = {}) {
// Spawn a hosted server with both the full and search instances running, and
// wait until the search instance is healthy. Returns ports + a stderr accessor.
async function startHostedServer(t, extraEnv = {}) {
const defaultBackend = await startFakeBackend();
t.after(() => defaultBackend.close());
const fullPort = await getFreePort();
const searchPort = await getFreePort();
const child = spawnServer({
@@ -178,6 +186,9 @@ async function startHostedServer(t, extraEnv = {}) {
HTTP_STREAMABLE_SERVER: 'true',
FASTMCP_ENDPOINT: '/v2/mcp',
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
KEYLESS_PROXY_SECRET: 'delegation-secret',
FIRECRAWL_API_URL: defaultBackend.url,
FIRECRAWL_OAUTH_ISSUER: defaultBackend.url,
PORT: String(fullPort),
FIRECRAWL_MCP_SEARCH_PORT: String(searchPort),
...extraEnv,
@@ -188,7 +199,13 @@ async function startHostedServer(t, extraEnv = {}) {
});
t.after(() => stopChild(child));
await waitForHealth(searchPort, child);
return { child, fullPort, searchPort, getStderr: () => stderr };
return {
child,
fullPort,
searchPort,
issuerUrl: extraEnv.FIRECRAWL_OAUTH_ISSUER ?? defaultBackend.url,
getStderr: () => stderr,
};
}
function jsonRpc(port, endpoint, { id, method, params = {}, headers = {} }) {
@@ -328,7 +345,7 @@ test('search firecrawl_search sends a clean body built from allowed fields only'
});
test('search surface requires authentication for tools/list', async (t) => {
const { searchPort } = await startHostedServer(t);
const { searchPort, issuerUrl } = await startHostedServer(t);
const res = await jsonRpc(searchPort, SEARCH_ENDPOINT, {
id: 5,
@@ -345,14 +362,14 @@ test('search surface requires authentication for tools/list', async (t) => {
});
test('search surface serves path-scoped protected-resource metadata', async (t) => {
const { searchPort } = await startHostedServer(t);
const { searchPort, issuerUrl } = await startHostedServer(t);
const res = await fetch(
`http://127.0.0.1:${searchPort}/.well-known/oauth-protected-resource${SEARCH_ENDPOINT}`
);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
authorization_servers: ['https://www.firecrawl.dev'],
authorization_servers: [issuerUrl],
bearer_methods_supported: ['header'],
resource: SEARCH_RESOURCE,
resource_name: 'Firecrawl Search',
@@ -429,11 +446,11 @@ test('search surface accepts a token minted for its own resource', async (t) =>
assert.notEqual(message.result?.isError, true, JSON.stringify(message));
const searchCalls = backend.requests.filter((r) => r.url === '/v2/search');
assert.equal(searchCalls.length, 1);
assert.equal(searchCalls[0].headers.authorization, 'Bearer fc-introspected');
assert.match(searchCalls[0].headers.authorization ?? '', /^Bearer fcmcp_/);
});
test('full surface still exposes its complete tool set alongside the search surface', async (t) => {
const { fullPort } = await startHostedServer(t);
const { fullPort, issuerUrl } = await startHostedServer(t);
// Full surface is reachable on its own port with all tools intact.
const names = await listTools(fullPort, '/v2/mcp', { 'x-api-key': 'fc-test' });
@@ -448,7 +465,7 @@ test('full surface still exposes its complete tool set alongside the search surf
);
assert.equal(prm.status, 200);
assert.deepEqual(await prm.json(), {
authorization_servers: ['https://www.firecrawl.dev'],
authorization_servers: [issuerUrl],
bearer_methods_supported: ['header'],
resource: 'https://mcp.firecrawl.dev/v2/mcp',
resource_name: 'Firecrawl MCP',
+355 -27
View File
@@ -76,7 +76,12 @@ async function startFakeFirecrawlApi() {
req.setEncoding('utf8');
for await (const chunk of req) body += chunk;
const parsedBody = body ? JSON.parse(body) : undefined;
const contentType = req.headers['content-type'] ?? '';
const parsedBody = body
? contentType.includes('application/x-www-form-urlencoded')
? Object.fromEntries(new URLSearchParams(body))
: JSON.parse(body)
: undefined;
requests.push({
body: parsedBody,
headers: req.headers,
@@ -84,6 +89,19 @@ async function startFakeFirecrawlApi() {
url: req.url,
});
if (req.method === 'POST' && req.url === '/api/oauth/introspect') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
active: true,
api_key: 'fc-http-test',
credential_purpose: 'general',
scope: 'firecrawl:global',
})
);
return;
}
if (req.method === 'POST' && req.url === '/v2/search') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
@@ -129,8 +147,12 @@ async function startFakeFirecrawlApi() {
// (token introspection + keyless eligibility) AND the Firecrawl API. Every
// request is recorded so tests can assert what the MCP server forwarded.
async function startFakeFirecrawlBackend(options = {}) {
const { apiKeyFromIntrospection = 'fc-from-introspection', keylessEligible = false } =
options;
const {
apiKeyFromIntrospection = 'fc-from-introspection',
introspectionHandler,
introspectionMetadata = {},
keylessEligible = false,
} = options;
const requests = [];
const server = createServer(async (req, res) => {
let raw = '';
@@ -155,11 +177,25 @@ async function startFakeFirecrawlBackend(options = {}) {
// OAuth token introspection (issuer origin).
if (req.method === 'POST' && req.url === '/api/oauth/introspect') {
const token = parsedBody?.token ?? '';
const active = token.startsWith('fco_') && !token.includes('invalid');
const active = /^(?:fco_|fc-)/.test(token) && !token.includes('invalid');
const custom = introspectionHandler?.(parsedBody);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify(
active ? { active: true, api_key: apiKeyFromIntrospection } : { active: false }
custom ?? (active
? {
active: true,
api_key: token.startsWith('fc-')
? token
: apiKeyFromIntrospection,
credential_purpose: 'general',
scope: 'firecrawl:global',
...(token.startsWith('fco_')
? { aud: 'https://mcp.firecrawl.dev/v2/mcp' }
: {}),
...introspectionMetadata,
}
: { active: false })
)
);
return;
@@ -206,8 +242,8 @@ async function startFakeFirecrawlBackend(options = {}) {
};
}
async function httpToolCall(port, { id, headers, params }) {
return fetch(`http://127.0.0.1:${port}/v2/mcp`, {
async function httpToolCall(port, { endpoint = '/v2/mcp', id, headers, params }) {
return fetch(`http://127.0.0.1:${port}${endpoint}`, {
body: JSON.stringify({ id, jsonrpc: '2.0', method: 'tools/call', params }),
headers: {
accept: 'application/json, text/event-stream',
@@ -219,12 +255,16 @@ async function httpToolCall(port, { id, headers, params }) {
}
test('HTTP cloud transport preserves Firecrawl OAuth and well-known routes', async (t) => {
const backend = await startFakeFirecrawlBackend();
t.after(() => backend.close());
const port = await getFreePort();
const child = spawnServer({
CLOUD_SERVICE: 'true',
HTTP_STREAMABLE_SERVER: 'true',
FASTMCP_ENDPOINT: '/v2/mcp',
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
FIRECRAWL_OAUTH_ISSUER: backend.url,
FIRECRAWL_API_URL: backend.url,
OPENAI_APPS_CHALLENGE_TOKEN: 'challenge-123',
PORT: String(port),
});
@@ -248,7 +288,7 @@ test('HTTP cloud transport preserves Firecrawl OAuth and well-known routes', asy
);
assert.equal(prm.status, 200);
assert.deepEqual(await prm.json(), {
authorization_servers: ['https://www.firecrawl.dev'],
authorization_servers: [backend.url],
bearer_methods_supported: ['header'],
resource: 'https://mcp.firecrawl.dev/v2/mcp',
resource_name: 'Firecrawl MCP',
@@ -262,19 +302,18 @@ test('HTTP cloud transport preserves Firecrawl OAuth and well-known routes', asy
method: 'tools/list',
params: {},
}),
headers: { 'content-type': 'application/json' },
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json',
},
method: 'POST',
});
assert.equal(unauthenticated.status, 401);
assert.equal(
unauthenticated.headers.get('www-authenticate'),
'Bearer resource_metadata="https://mcp.firecrawl.dev/.well-known/oauth-protected-resource", error="invalid_token", error_description="Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)"'
assert.equal(unauthenticated.status, 200);
const anonymousTools = parseSseJson(await unauthenticated.text()).result.tools;
assert.deepEqual(
anonymousTools.map((tool) => tool.name).sort(),
['firecrawl_parse', 'firecrawl_scrape', 'firecrawl_search']
);
assert.deepEqual(await unauthenticated.json(), {
error: 'invalid_token',
error_description:
'Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)',
});
const initialize = await fetch(`http://127.0.0.1:${port}/v2/mcp`, {
body: JSON.stringify({
@@ -337,6 +376,8 @@ test('HTTP cloud transport calls Firecrawl API with authenticated session', asyn
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp',
FIRECRAWL_API_URL: fakeApi.url,
FIRECRAWL_OAUTH_ISSUER: fakeApi.url,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
HTTP_STREAMABLE_SERVER: 'true',
PORT: String(port),
});
@@ -386,11 +427,10 @@ test('HTTP cloud transport calls Firecrawl API with authenticated session', asyn
success: true,
});
assert.equal(fakeApi.requests.length, 1);
assert.equal(fakeApi.requests[0].method, 'POST');
assert.equal(fakeApi.requests[0].url, '/v2/search');
assert.equal(fakeApi.requests[0].headers.authorization, 'Bearer fc-http-test');
assert.deepEqual(fakeApi.requests[0].body, {
const searchRequest = fakeApi.requests.find((request) => request.url === '/v2/search');
assert.equal(searchRequest.method, 'POST');
assert.equal(searchRequest.headers.authorization, 'Bearer fc-http-test');
assert.deepEqual(searchRequest.body, {
highlights: false,
limit: 1,
origin: 'mcp-fastmcp',
@@ -559,6 +599,7 @@ test('HTTP cloud transport swaps an fco_ OAuth token for its introspected API ke
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp',
FIRECRAWL_API_URL: backend.url,
FIRECRAWL_OAUTH_ISSUER: backend.url,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'introspect-secret',
FIRECRAWL_OAUTH_ISSUER: backend.url,
HTTP_STREAMABLE_SERVER: 'true',
@@ -652,6 +693,8 @@ test('HTTP cloud transport accepts the x-firecrawl-api-key header', async (t) =>
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp',
FIRECRAWL_API_URL: backend.url,
FIRECRAWL_OAUTH_ISSUER: backend.url,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
HTTP_STREAMABLE_SERVER: 'true',
PORT: String(port),
});
@@ -721,7 +764,7 @@ test('HTTP cloud transport serves an eligible keyless client and forwards its IP
assert.equal(stderr.includes('TypeError'), false, stderr);
});
test('HTTP cloud transport challenges a keyless client with no forwarded IP', async (t) => {
test('HTTP cloud transport returns recovery when keyless identity has no client IP', async (t) => {
const backend = await startFakeFirecrawlBackend({ keylessEligible: true });
t.after(() => backend.close());
@@ -742,14 +785,299 @@ test('HTTP cloud transport challenges a keyless client with no forwarded IP', as
await waitForHealth(port, child);
// No x-forwarded-for and no credential: per-IP cap is unenforceable, so the
// server must fall through to the OAuth challenge rather than grant keyless.
// Discovery remains keyless-first, but the actual call fails closed because
// the API cannot enforce the anonymous per-IP allowance.
const toolCall = await httpToolCall(port, {
id: 14,
headers: {},
params: { arguments: { limit: 1, query: 'example domain' }, name: 'firecrawl_search' },
});
assert.equal(toolCall.status, 401);
assert.equal(toolCall.status, 200);
const result = parseSseJson(await toolCall.text()).result;
assert.equal(result.isError, true);
assert.equal(result.structuredContent.code, 'KEYLESS_ACCESS_NOT_AVAILABLE');
assert.equal(backend.requests.some((r) => r.url === '/v2/search'), false);
assert.equal(stderr.includes('TypeError'), false, stderr);
});
test('account endpoint challenges anonymous clients and accepts API keys', async (t) => {
const backend = await startFakeFirecrawlBackend();
t.after(() => backend.close());
const port = await getFreePort();
const child = spawnServer({
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp-oauth',
FIRECRAWL_API_URL: backend.url,
FIRECRAWL_MCP_ACTION_LOG_SECRET: 'action-secret',
FIRECRAWL_MCP_RESOURCE_URL: 'https://mcp.firecrawl.dev/v2/mcp-oauth',
FIRECRAWL_OAUTH_ISSUER: backend.url,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
HTTP_STREAMABLE_SERVER: 'true',
PORT: String(port),
});
t.after(() => stopChild(child));
await waitForHealth(port, child);
const ready = await fetch(`http://127.0.0.1:${port}/ready`);
assert.equal(ready.status, 200);
assert.deepEqual(await ready.json(), { ok: true });
const prm = await fetch(
`http://127.0.0.1:${port}/.well-known/oauth-protected-resource/v2/mcp-oauth`
);
assert.equal(prm.status, 200);
assert.equal(
(await prm.json()).resource,
'https://mcp.firecrawl.dev/v2/mcp-oauth'
);
const anonymous = await fetch(`http://127.0.0.1:${port}/v2/mcp-oauth`, {
body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list', params: {} }),
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json',
},
method: 'POST',
});
assert.equal(anonymous.status, 401);
assert.match(
anonymous.headers.get('www-authenticate') ?? '',
/oauth-protected-resource\/v2\/mcp-oauth/
);
const authenticated = await fetch(`http://127.0.0.1:${port}/v2/mcp-oauth`, {
body: JSON.stringify({ id: 2, jsonrpc: '2.0', method: 'tools/list', params: {} }),
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json',
authorization: 'Bearer fc-account-key',
},
method: 'POST',
});
assert.equal(authenticated.status, 200);
const names = parseSseJson(await authenticated.text()).result.tools.map(
(tool) => tool.name
);
assert.ok(names.includes('firecrawl_crawl'));
assert.ok(names.length > 3);
});
test('API-key validation outages do not misdirect clients into OAuth', async (t) => {
const backend = await startFakeFirecrawlBackend();
t.after(() => backend.close());
const unavailableIssuerPort = await getFreePort();
const port = await getFreePort();
const child = spawnServer({
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp-oauth',
FIRECRAWL_API_URL: backend.url,
FIRECRAWL_MCP_RESOURCE_URL: 'https://mcp.firecrawl.dev/v2/mcp-oauth',
FIRECRAWL_OAUTH_ISSUER: `http://127.0.0.1:${unavailableIssuerPort}`,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
HTTP_STREAMABLE_SERVER: 'true',
PORT: String(port),
});
t.after(() => stopChild(child));
await waitForHealth(port, child);
const response = await fetch(`http://127.0.0.1:${port}/v2/mcp-oauth`, {
body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list', params: {} }),
headers: {
accept: 'application/json, text/event-stream',
authorization: 'Bearer fc-account-key',
'content-type': 'application/json',
},
method: 'POST',
});
assert.equal(response.status, 503);
assert.equal(response.headers.has('www-authenticate'), false);
assert.equal((await response.json()).error, 'temporarily_unavailable');
});
test('account endpoint accepts legacy OAuth one way and delegates managed keys', async (t) => {
const accountResource = 'https://mcp.firecrawl.dev/v2/mcp-oauth';
const legacyResource = 'https://mcp.firecrawl.dev/v2/mcp';
const metadata = {
active: true,
api_key: 'fc-managed-secret',
api_key_id: '42',
client_id: 'https://claude.ai/oauth/mcp-oauth-client-metadata',
credential_purpose: 'hosted_mcp_oauth',
scope: 'firecrawl:global',
sub: '00000000-0000-4000-8000-000000000001',
team_id: '00000000-0000-4000-8000-000000000002',
};
const backend = await startFakeFirecrawlBackend({
introspectionHandler: ({ resource, token }) => {
if (token === 'fco_account') return { ...metadata, aud: accountResource };
if (token === 'fco_legacy') {
return resource === legacyResource
? { ...metadata, aud: legacyResource }
: { active: false };
}
return { active: false };
},
});
t.after(() => backend.close());
const port = await getFreePort();
const child = spawnServer({
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp-oauth',
FIRECRAWL_API_URL: backend.url,
FIRECRAWL_MCP_ACTION_LOG_SECRET: 'action-secret',
FIRECRAWL_MCP_RESOURCE_URL: accountResource,
FIRECRAWL_OAUTH_ISSUER: backend.url,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
HTTP_STREAMABLE_SERVER: 'true',
KEYLESS_PROXY_SECRET: 'delegation-secret',
PORT: String(port),
});
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
t.after(() => stopChild(child));
await waitForHealth(port, child);
for (const token of ['fco_account', 'fco_legacy']) {
const response = await httpToolCall(port, {
endpoint: '/v2/mcp-oauth',
headers: { authorization: `Bearer ${token}` },
id: token,
params: {
arguments: { limit: 1, query: 'delegated credential' },
name: 'firecrawl_search',
},
});
assert.equal(response.status, 200);
assert.notEqual(parseSseJson(await response.text()).result.isError, true);
}
const searchCalls = backend.requests.filter((request) => request.url === '/v2/search');
assert.equal(searchCalls.length, 2);
for (const request of searchCalls) {
const assertion = request.headers.authorization?.replace(/^Bearer /, '');
assert.match(assertion ?? '', /^fcmcp_/);
const payload = JSON.parse(
Buffer.from(assertion.split('.')[0].slice('fcmcp_'.length), 'base64url').toString()
);
assert.equal(payload.api_key, 'fc-managed-secret');
assert.equal(payload.purpose, 'hosted_mcp_oauth');
}
const legacyAttempts = backend.requests
.filter((request) => request.url === '/api/oauth/introspect')
.filter((request) => request.body.token === 'fco_legacy')
.map((request) => request.body.resource);
assert.deepEqual(legacyAttempts, [accountResource, legacyResource]);
for (let i = 0; i < 20; i += 1) {
if (
backend.requests.filter(
(request) => request.url === '/v2/mcp/action-logs'
).length === 2
) {
break;
}
await delay(25);
}
const actionLogs = backend.requests.filter(
(request) => request.url === '/v2/mcp/action-logs'
);
assert.equal(actionLogs.length, 2);
for (const request of actionLogs) {
assert.equal(request.headers.authorization, 'Bearer action-secret');
assert.equal(request.body.auth_type, 'oauth');
assert.equal(request.body.status, 'success');
assert.equal(request.body.api_key_id, '42');
assert.equal(request.body.team_id, metadata.team_id);
assert.equal(request.body.user_id, metadata.sub);
assert.equal(request.body.oauth_client_id, metadata.client_id);
assert.equal(JSON.stringify(request.body).includes('fc-managed-secret'), false);
assert.equal(JSON.stringify(request.body).includes('fco_'), false);
}
assert.equal(stderr.includes('fc-managed-secret'), false);
assert.equal(stderr.includes('fco_account'), false);
assert.equal(stderr.includes('fco_legacy'), false);
});
test('hosted profile selection fails closed for an unsupported endpoint', async () => {
const child = spawnServer({
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/not-a-real-profile',
HTTP_STREAMABLE_SERVER: 'true',
PORT: String(await getFreePort()),
});
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
const exitCode = await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
delay(5_000).then(() => 'timeout'),
]);
if (exitCode === 'timeout') {
await stopChild(child);
assert.fail('server did not fail closed for unsupported FASTMCP_ENDPOINT');
}
assert.notEqual(exitCode, 0);
assert.match(stderr, /Unsupported FASTMCP_ENDPOINT/);
});
test('account OAuth tokens cannot replay on keyless and invalid keys get correction', async (t) => {
const accountResource = 'https://mcp.firecrawl.dev/v2/mcp-oauth';
const backend = await startFakeFirecrawlBackend({
introspectionHandler: ({ token }) =>
token === 'fco_account'
? {
active: true,
api_key: 'fc-managed-secret',
aud: accountResource,
credential_purpose: 'hosted_mcp_oauth',
scope: 'firecrawl:global',
}
: { active: false },
});
t.after(() => backend.close());
const port = await getFreePort();
const child = spawnServer({
CLOUD_SERVICE: 'true',
FASTMCP_ENDPOINT: '/v2/mcp',
FIRECRAWL_API_URL: backend.url,
FIRECRAWL_OAUTH_ISSUER: backend.url,
FIRECRAWL_OAUTH_INTROSPECT_SECRET: 'test-secret',
HTTP_STREAMABLE_SERVER: 'true',
KEYLESS_PROXY_SECRET: 'delegation-secret',
PORT: String(port),
});
t.after(() => stopChild(child));
await waitForHealth(port, child);
const replay = await httpToolCall(port, {
headers: { authorization: 'Bearer fco_account' },
id: 1,
params: { arguments: { query: 'x' }, name: 'firecrawl_search' },
});
assert.equal(replay.status, 401);
const invalidList = await fetch(`http://127.0.0.1:${port}/v2/mcp`, {
body: JSON.stringify({ id: 2, jsonrpc: '2.0', method: 'tools/list', params: {} }),
headers: {
accept: 'application/json, text/event-stream',
authorization: 'Bearer fc-invalid',
'content-type': 'application/json',
},
method: 'POST',
});
assert.equal(invalidList.status, 200);
assert.deepEqual(parseSseJson(await invalidList.text()).result.tools, []);
const invalidCall = await httpToolCall(port, {
headers: { authorization: 'Bearer fc-invalid' },
id: 3,
params: { arguments: { query: 'x' }, name: 'firecrawl_search' },
});
assert.equal(invalidCall.status, 200);
const result = parseSseJson(await invalidCall.text()).result;
assert.equal(result.isError, true);
assert.equal(result.structuredContent.code, 'CREDENTIAL_INVALID');
});
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const config = await readFile(
new URL('../docker/nginx.conf', import.meta.url),
'utf8'
);
function locationBody(pattern) {
const start = config.indexOf(pattern);
assert.notEqual(start, -1, `missing nginx location: ${pattern}`);
const open = config.indexOf('{', start);
let depth = 0;
for (let index = open; index < config.length; index += 1) {
if (config[index] === '{') depth += 1;
if (config[index] === '}') depth -= 1;
if (depth === 0) return config.slice(open + 1, index);
}
assert.fail(`unterminated nginx location: ${pattern}`);
}
test('key-bearing routes disable access logs before forwarding credentials', () => {
for (const route of [
'location ~ ^/(?<apikey>[^/]+)/v2/mcp-search(?:/|$)',
'location ~ ^/(?<apikey>[^/]+)/(?:v2/mcp|mcp)/?$',
'location ~ ^/(?<apikey>[^/]+)/v(?:1|2)/(.*)$',
'location ~ ^/(?<apikey>[^/]+)/(.*)$',
]) {
const body = locationBody(route);
assert.match(body, /access_log off;/);
assert.match(body, /proxy_set_header X-Firecrawl-API-Key \$apikey;/);
}
});
test('specific MCP identities precede generic legacy regex routes', () => {
const genericVersioned = config.indexOf(
'location ~ ^/v(?:1|2)/(.*)$'
);
const genericKeyed = config.indexOf(
'location ~ ^/(?<apikey>[^/]+)/v(?:1|2)/(.*)$'
);
for (const route of [
'location ~ ^/v2/mcp-oauth/?$',
'location ~ ^/v2/mcp/?$',
'location ~ ^/v2/mcp-search(?:/|$)',
]) {
assert.ok(config.indexOf(route) < genericVersioned, `${route} ordering`);
}
for (const route of [
'location ~ ^/(?<apikey>[^/]+)/v2/mcp-search(?:/|$)',
'location ~ ^/(?<apikey>[^/]+)/(?:v2/mcp|mcp)/?$',
]) {
assert.ok(config.indexOf(route) < genericKeyed, `${route} ordering`);
}
});