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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface McpConfig {
analyticsEnabled: boolean;
sqsQueueUrl?: string;
sqsRegion: string;
// OAuth (Supabase)
oauthEnabled: boolean;
supabaseUrl: string;
supabaseOAuthClientId: string;
supabaseOAuthClientSecret: string;
supabaseServiceRoleKey: string;
mcpBaseUrl: string;
}

export function getConfig(): McpConfig {
Expand All @@ -38,5 +45,12 @@ export function getConfig(): McpConfig {
analyticsEnabled: process.env.ANALYTICS_ENABLED === 'true',
sqsQueueUrl: process.env.SQS_QUEUE_URL,
sqsRegion: process.env.SQS_REGION ?? 'us-west-2',
// OAuth (Supabase)
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
supabaseUrl: process.env.SUPABASE_URL ?? '',
supabaseOAuthClientId: process.env.SUPABASE_OAUTH_CLIENT_ID ?? '',
supabaseOAuthClientSecret: process.env.SUPABASE_OAUTH_CLIENT_SECRET ?? '',
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
mcpBaseUrl: process.env.MCP_BASE_URL ?? 'https://mcp.browserless.io',
};
}
154 changes: 125 additions & 29 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { FastMCP } from 'fastmcp';
import type { IncomingMessage } from 'node:http';
import { FastMCP, OAuthProvider } from 'fastmcp';
import { OAuthProxy } from 'fastmcp/auth';
import { getConfig } from './config.js';
import type { BrowserlessSession } from './config.js';
import { registerPowerScraperTool } from './tools/smartscraper.js';
Expand All @@ -10,45 +12,139 @@ import { registerStatusResource } from './resources/status.js';
import { registerScrapeUrlPrompt } from './prompts/scrape-url.js';
import { registerExtractContentPrompt } from './prompts/extract-content.js';
import { AmplitudeHelper } from './lib/amplitude.js';
import { resolveApiKey } from './lib/account-resolver.js';

const config = getConfig();

// Supabase OAuth tokens have a very short TTL (60s), which causes FastMCP's
// token-swap mode to issue equally short-lived JWTs and trigger constant refresh
// cycles. We intercept Supabase token responses to extend the TTL to 1 hour.
// This is safe because we only decode the JWT payload (for accountId) — we never
// use it as a bearer token against Supabase APIs.
const OAUTH_TOKEN_TTL_OVERRIDE = 3600; // 1 hour
const originalFetch = globalThis.fetch;
globalThis.fetch = async (...args: Parameters<typeof fetch>) => {
const response = await originalFetch(...args);
const url =
typeof args[0] === 'string'
? args[0]
: args[0] instanceof URL
? args[0].toString()
: (args[0] as Request).url;
if (response.ok && url.includes('/oauth/token')) {
const body = (await response.json()) as Record<string, unknown>;
if (
typeof body.expires_in === 'number' &&
body.expires_in < OAUTH_TOKEN_TTL_OVERRIDE
) {
body.expires_in = OAUTH_TOKEN_TTL_OVERRIDE;
}
return new Response(JSON.stringify(body), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
return response;
};
const amplitude = new AmplitudeHelper(
config.analyticsEnabled,
config.sqsQueueUrl,
config.sqsRegion,
);

// Passthrough OAuth provider: disables FastMCP's token-swap mode so the MCP client
// receives the raw Supabase JWT directly. This eliminates server-side token storage,
// meaning server restarts don't invalidate client sessions.
class PassthroughOAuthProvider extends OAuthProvider {
protected createProxy(): OAuthProxy {
return new OAuthProxy({
allowedRedirectUriPatterns: ['http://localhost:*', 'https://*'],
baseUrl: this.config.baseUrl,
consentRequired: false,
enableTokenSwap: false,
scopes: this.config.scopes ?? [],
upstreamAuthorizationEndpoint: this.genericConfig.authorizationEndpoint,
upstreamClientId: this.config.clientId,
upstreamClientSecret: this.config.clientSecret,
upstreamTokenEndpoint: this.genericConfig.tokenEndpoint,
upstreamTokenEndpointAuthMethod:
this.genericConfig.tokenEndpointAuthMethod ?? 'client_secret_basic',
});
}
}

const oauthProvider =
config.oauthEnabled && config.transport === 'httpStream'
? new PassthroughOAuthProvider({
baseUrl: config.mcpBaseUrl,
clientId: config.supabaseOAuthClientId,
clientSecret: config.supabaseOAuthClientSecret,
authorizationEndpoint: `${config.supabaseUrl}/auth/v1/oauth/authorize`,
tokenEndpoint: `${config.supabaseUrl}/auth/v1/oauth/token`,
scopes: ['email'],
consentRequired: true,
})
: undefined;

// Hybrid authenticate: plain API key first, then ?token=, then OAuth/JWT fallback.
// 1. Authorization header with plain API key (non-JWT) → direct token session
// 2. ?token= query param → direct token session
// 3. Authorization header with JWT (Supabase token from OAuth) → decode payload,
// resolve Browserless API key from Supabase PostgREST, return as session token
const hybridAuthenticate =
config.transport === 'httpStream'
? async (request: IncomingMessage) => {
const params = new URLSearchParams(request.url?.split('?')[1] ?? '');
const authHeader = request.headers.authorization as string | undefined;
const headerToken = authHeader?.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;

const apiUrl =
(request.headers['x-browserless-api-url'] as string) ??
params.get('browserlessUrl') ??
config.browserlessApiUrl;

// JWTs have 3 dot-separated base64url segments; plain API keys do not.
const isJwt = headerToken
? headerToken.split('.').length === 3
: false;

// 1. Authorization header with plain API key
if (headerToken && !isJwt) {
return { token: headerToken, apiUrl } as BrowserlessSession;
}

// 2. ?token= query param
const directToken = params.get('token') || undefined;
if (directToken) {
return { token: directToken, apiUrl } as BrowserlessSession;
}

// 3. Authorization header with JWT → decode Supabase token directly
if (isJwt && headerToken) {
const { apiKey } = await resolveApiKey(
config.supabaseUrl,
config.supabaseServiceRoleKey,
headerToken,
);
return { token: apiKey, apiUrl } as BrowserlessSession;
}

throw new Error(
'No Browserless API token provided. ' +
'Pass it as Authorization: Bearer <token> header, ' +
'?token= query parameter, or authenticate via OAuth.',
);
}
: undefined;

const server = new FastMCP<BrowserlessSession>({
name: 'browserless-mcp',
version: '0.1.0',
authenticate:
config.transport === 'httpStream'
? async (request) => {
const params = new URLSearchParams(request.url?.split('?')[1] ?? '');

// Token: Authorization header > ?token= query param
const authHeader = request.headers.authorization;
const headerToken = authHeader?.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;
const token = headerToken || params.get('token') || undefined;

if (!token) {
throw new Error(
'No Browserless API token provided. ' +
'Pass it as Authorization: Bearer <token> header or ?token= query parameter.',
);
}

// API URL: x-browserless-api-url header > ?browserlessUrl= query param > default
const apiUrl =
(request.headers['x-browserless-api-url'] as string) ??
params.get('browserlessUrl') ??
config.browserlessApiUrl;

return { token, apiUrl };
}
: undefined,
...(oauthProvider ? { auth: oauthProvider } : {}),
authenticate: hybridAuthenticate,
});

registerPowerScraperTool(server, config, amplitude);
Expand Down
88 changes: 88 additions & 0 deletions src/lib/account-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { ResponseCache } from './cache.js';

interface ResolvedAccount {
apiKey: string;
email: string;
}

interface SupabaseJwtPayload {
sub?: string;
email?: string;
app_metadata?: {
accountId?: string;
};
}

const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const cache = new ResponseCache(CACHE_TTL_MS);

function decodeJwtPayload(jwt: string): SupabaseJwtPayload {
const parts = jwt.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8');
return JSON.parse(payload) as SupabaseJwtPayload;
}

/**
* Resolves a Browserless API key from a Supabase access token (JWT)
* by extracting app_metadata.accountId and querying Supabase PostgREST.
*/
export async function resolveApiKey(
supabaseUrl: string,
serviceRoleKey: string,
accessToken: string,
): Promise<ResolvedAccount> {
const payload = decodeJwtPayload(accessToken);
const accountId = payload.app_metadata?.accountId;

if (!accountId) {
throw new Error(
'Supabase JWT does not contain app_metadata.accountId. ' +
'The user may not have a Browserless account.',
);
}

const cached = cache.get<ResolvedAccount>(`account:${accountId}`);
if (cached) {
return cached;
}

const url = `${supabaseUrl}/rest/v1/accounts?account_id=eq.${encodeURIComponent(accountId)}&select=api_key,email`;
const response = await fetch(url, {
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: 'application/json',
},
});

if (!response.ok) {
throw new Error(
`Supabase REST API returned ${response.status}: ${response.statusText}`,
);
}

const rows = (await response.json()) as Array<{
api_key?: string;
email?: string;
}>;
const account = rows[0];

if (!account?.api_key || !account?.email) {
throw new Error('Account not found or missing api_key/email.');
}

const resolved: ResolvedAccount = {
apiKey: account.api_key,
email: account.email,
};

cache.set(`account:${accountId}`, resolved);
return resolved;
}

export function clearResolverCache(): void {
cache.clear();
}
6 changes: 6 additions & 0 deletions test/integration/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const mockConfig: McpConfig = {
cacheTtlMs: 0,
analyticsEnabled: false,
sqsRegion: 'us-east-1',
oauthEnabled: false,
supabaseUrl: '',
supabaseOAuthClientId: '',
supabaseOAuthClientSecret: '',
supabaseServiceRoleKey: '',
mcpBaseUrl: '',
};

describe('MCP Server Integration', () => {
Expand Down
Loading
Loading