mirror of
https://github.com/firecrawl/firecrawl-mcp-server.git
synced 2026-09-01 14:57:03 +08:00
refactor(hosted): minimize Stage 1 MCP runtime
This commit is contained in:
+5
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
Generated
+9
-4
@@ -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)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
tldjs: true
|
||||
|
||||
patchedDependencies:
|
||||
fastmcp@4.3.2: patches/fastmcp@4.3.2.patch
|
||||
|
||||
+436
-128
@@ -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) {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user