Files
worldmonitor/api/mcp.ts
Elie Habib 14a31c4283 feat(mcp): OAuth 2.0 Authorization Server for claude.ai connector (#2418)
* feat(mcp): add OAuth 2.0 Authorization Server for claude.ai connector

Implements spec-compliant MCP authentication so claude.ai's remote connector
(which requires OAuth Client ID + Secret, no custom headers) can authenticate.

- public/.well-known/oauth-authorization-server: RFC 8414 discovery document
- api/oauth/token.js: client_credentials grant, issues UUID Bearer token in Redis TTL 3600s
- api/_oauth-token.js: resolveApiKeyFromBearer() looks up token in Redis
- api/mcp.ts: 3-tier auth (Bearer OAuth first, then ?key=, then X-WorldMonitor-Key);
  switch to getPublicCorsHeaders; surface error messages in catch
- vercel.json: rewrite /oauth/token, exclude oauth from SPA, CORS headers
- tests: update SPA no-cache pattern

Supersedes PR #2417. Usage: URL=worldmonitor.app/mcp, Client ID=worldmonitor, Client Secret=<API key>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: fix markdown lint in OAuth plan (blank lines around lists)

* fix(oauth): address all P1+P2 code review findings for MCP OAuth endpoint

- Add per-IP rate limiting (10 req/min) to /oauth/token via Upstash slidingWindow
- Return HTTP 401 + WWW-Authenticate header when Bearer token is invalid/expired
- Add Cache-Control: no-store + Pragma: no-cache to token response (RFC 6749 §5.1)
- Simplify _oauth-token.js to delegate to readJsonFromUpstash (removes duplicated Redis boilerplate)
- Remove dead code from token.js: parseBasicAuth, JSON body path, clientId/issuedAt fields
- Add Content-Type: application/json header for /.well-known/oauth-authorization-server
- Remove response_types_supported (only applies to authorization endpoint, not client_credentials)

Closes: todos 075, 076, 077, 078, 079

🤖 Generated with claude-sonnet-4-6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0

Co-Authored-By: claude-sonnet-4-6 (200K context) <noreply@anthropic.com>

* chore(review): fresh review findings — todos 081-086, mark 075/077/078/079 complete

* fix(mcp): remove ?key= URL param auth + mask internal errors

- Remove ?key= query param auth path — API keys in URLs appear in
  Vercel/CF access logs, browser history, Referer headers. OAuth
  client_credentials (same PR) already covers clients that cannot
  set custom headers. Only two auth paths remain: Bearer OAuth and
  X-WorldMonitor-Key header.

- Revert err.message disclosure: catch block was accidentally exposing
  internal service URLs/IPs via err.message. Restore original hardcoded
  string, add console.error for server-side visibility.

Resolves: todos 081, 082

* fix(oauth): resolve all P2/P3 review findings (todos 076, 080, 083-086)

- 076: no-credentials path in mcp.ts now returns HTTP 401 + WWW-Authenticate instead of rpcError (200)
- 080: store key fingerprint (sha256 first 16 hex chars) in Redis, not plaintext key
- 083: replace Array.includes() with timingSafeIncludes() (constant-time HMAC comparison) in token.js and mcp.ts
- 084: resolveApiKeyFromBearer uses direct fetch that throws on Redis errors (500 not 401 on infra failure)
- 085: token.js imports getClientIp, getPublicCorsHeaders, jsonResponse from shared helpers; removes local duplicates
- 086: mcp.ts auth chain restructured to check Bearer header first, passes token string to resolveApiKeyFromBearer (eliminates double header read + unconditional await)

* test(mcp): update auth test to expect HTTP 401 for missing credentials

Align with todo 076 fix: no-credentials path now returns 401 + WWW-Authenticate
instead of JSON-RPC 200 response. Also asserts WWW-Authenticate header presence.

* chore: mark todos 076, 080, 083-086 complete

* fix(mcp): harden OAuth error paths and fix rate limit cross-user collision

- Wrap resolveApiKeyFromBearer() in try/catch in mcp.ts; Redis/network
  errors now return 503 + Retry-After: 5 instead of crashing the handler
- Wrap storeToken() fetch in try/catch in oauth/token.js; network errors
  return false so the existing if (!stored) path returns 500 cleanly
- Re-key token endpoint rate limit by sha256(clientSecret).slice(0,8)
  instead of IP; prevents cross-user 429s when callers share Anthropic's
  shared outbound IPs (Claude remote MCP connector)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:53:32 +04:00

545 lines
23 KiB
TypeScript

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// @ts-expect-error — JS module, no declaration file
import { getPublicCorsHeaders } from './_cors.js';
// @ts-expect-error — JS module, no declaration file
import { jsonResponse } from './_json-response.js';
// @ts-expect-error — JS module, no declaration file
import { readJsonFromUpstash } from './_upstash-json.js';
// @ts-expect-error — JS module, no declaration file
import { resolveApiKeyFromBearer } from './_oauth-token.js';
// @ts-expect-error — JS module, no declaration file
import { timingSafeIncludes } from './_crypto.js';
export const config = { runtime: 'edge' };
const MCP_PROTOCOL_VERSION = '2025-03-26';
const SERVER_NAME = 'worldmonitor';
const SERVER_VERSION = '1.0';
// ---------------------------------------------------------------------------
// Per-key rate limiter (60 calls/min per PRO API key)
// ---------------------------------------------------------------------------
let mcpRatelimit: Ratelimit | null = null;
function getMcpRatelimit(): Ratelimit | null {
if (mcpRatelimit) return mcpRatelimit;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
mcpRatelimit = new Ratelimit({
redis: new Redis({ url, token }),
limiter: Ratelimit.slidingWindow(60, '60 s'),
prefix: 'rl:mcp',
analytics: false,
});
return mcpRatelimit;
}
// ---------------------------------------------------------------------------
// Tool registry
// ---------------------------------------------------------------------------
interface BaseToolDef {
name: string;
description: string;
inputSchema: { type: string; properties: Record<string, unknown>; required: string[] };
}
// Cache-read tool: reads one or more Redis keys and returns them with staleness info.
interface CacheToolDef extends BaseToolDef {
_cacheKeys: string[];
_seedMetaKey: string;
_maxStaleMin: number;
_execute?: never;
}
// AI inference tool: calls an internal RPC endpoint and returns the raw response.
interface RpcToolDef extends BaseToolDef {
_cacheKeys?: never;
_seedMetaKey?: never;
_maxStaleMin?: never;
_execute: (params: Record<string, unknown>, base: string, apiKey: string) => Promise<unknown>;
}
type ToolDef = CacheToolDef | RpcToolDef;
const TOOL_REGISTRY: ToolDef[] = [
{
name: 'get_market_data',
description: 'Real-time equity quotes, commodity prices, crypto prices, sector performance, ETF flows, and Gulf market quotes from WorldMonitor\'s curated bootstrap cache.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: [
'market:stocks-bootstrap:v1',
'market:commodities-bootstrap:v1',
'market:crypto:v1',
'market:sectors:v1',
'market:etf-flows:v1',
'market:gulf-quotes:v1',
'market:fear-greed:v1',
],
_seedMetaKey: 'seed-meta:market:stocks',
_maxStaleMin: 30,
},
{
name: 'get_conflict_events',
description: 'Active armed conflict events (UCDP, Iran), unrest events with geo-coordinates, and country risk scores. Covers ongoing conflicts, protests, and instability indices worldwide.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: [
'conflict:ucdp-events:v1',
'conflict:iran-events:v1',
'unrest:events:v1',
'risk:scores:sebuf:stale:v1',
],
_seedMetaKey: 'seed-meta:conflict:ucdp-events',
_maxStaleMin: 30,
},
{
name: 'get_aviation_status',
description: 'Airport delays, NOTAM airspace closures, and tracked military aircraft. Covers FAA delay data and active airspace restrictions.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['aviation:delays-bootstrap:v1'],
_seedMetaKey: 'seed-meta:aviation:faa',
_maxStaleMin: 90,
},
{
name: 'get_news_intelligence',
description: 'AI-classified geopolitical threat news summaries, GDELT intelligence signals, cross-source signals, and security advisories from WorldMonitor\'s intelligence layer.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: [
'news:insights:v1',
'intelligence:gdelt-intel:v1',
'intelligence:cross-source-signals:v1',
'intelligence:advisories-bootstrap:v1',
],
_seedMetaKey: 'seed-meta:news:insights',
_maxStaleMin: 30,
},
{
name: 'get_natural_disasters',
description: 'Recent earthquakes (USGS), active wildfires (NASA FIRMS), and natural hazard events. Includes magnitude, location, and threat severity.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: [
'seismology:earthquakes:v1',
'wildfire:fires:v1',
'natural:events:v1',
],
_seedMetaKey: 'seed-meta:seismology:earthquakes',
_maxStaleMin: 30,
},
{
name: 'get_military_posture',
description: 'Theater posture assessment and military risk scores. Reflects aggregated military positioning and escalation signals across global theaters.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['theater_posture:sebuf:stale:v1'],
_seedMetaKey: 'seed-meta:intelligence:risk-scores',
_maxStaleMin: 120,
},
{
name: 'get_cyber_threats',
description: 'Active cyber threat intelligence: malware IOCs (URLhaus, Feodotracker), CISA known exploited vulnerabilities, and active command-and-control infrastructure.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['cyber:threats-bootstrap:v2'],
_seedMetaKey: 'seed-meta:cyber:threats',
_maxStaleMin: 240,
},
{
name: 'get_economic_data',
description: 'Macro economic indicators: Fed Funds rate (FRED), economic calendar events, fuel prices, ECB FX rates, EU yield curve, earnings calendar, COT positioning, and energy storage data.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: [
'economic:fred:v1:FEDFUNDS:0',
'economic:econ-calendar:v1',
'economic:fuel-prices:v1',
'economic:ecb-fx-rates:v1',
'economic:yield-curve-eu:v1',
'economic:spending:v1',
'market:earnings-calendar:v1',
'market:cot:v1',
],
_seedMetaKey: 'seed-meta:economic:econ-calendar',
_maxStaleMin: 1440,
},
{
name: 'get_prediction_markets',
description: 'Active Polymarket event contracts with current probabilities. Covers geopolitical, economic, and election prediction markets.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['prediction:markets-bootstrap:v1'],
_seedMetaKey: 'seed-meta:prediction:markets',
_maxStaleMin: 90,
},
{
name: 'get_sanctions_data',
description: 'OFAC SDN sanctioned entities list and sanctions pressure scores by country. Useful for compliance screening and geopolitical pressure analysis.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['sanctions:entities:v1', 'sanctions:pressure:v1'],
_seedMetaKey: 'seed-meta:sanctions:entities',
_maxStaleMin: 1440,
},
{
name: 'get_climate_data',
description: 'Climate anomalies (Open-Meteo temperature/precipitation deviations), weather alerts, and natural environmental events from NASA EONET.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['climate:anomalies:v1', 'weather:alerts:v1'],
_seedMetaKey: 'seed-meta:climate:anomalies',
_maxStaleMin: 120,
},
{
name: 'get_infrastructure_status',
description: 'Internet infrastructure health: Cloudflare Radar outages and service status for major cloud providers and internet services.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['infra:outages:v1'],
_seedMetaKey: 'seed-meta:infra:outages',
_maxStaleMin: 30,
},
{
name: 'get_supply_chain_data',
description: 'Dry bulk shipping stress index, customs revenue flows, and COMTRADE bilateral trade data. Tracks global supply chain pressure and trade disruptions.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: [
'supply_chain:shipping_stress:v1',
'trade:customs-revenue:v1',
'comtrade:flows:v1',
],
_seedMetaKey: 'seed-meta:trade:customs-revenue',
_maxStaleMin: 2880,
},
{
name: 'get_positive_events',
description: 'Positive geopolitical events: diplomatic agreements, humanitarian aid, development milestones, and peace initiatives worldwide.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['positive_events:geo-bootstrap:v1'],
_seedMetaKey: 'seed-meta:positive-events:geo',
_maxStaleMin: 60,
},
{
name: 'get_radiation_data',
description: 'Radiation observation levels from global monitoring stations. Flags anomalous readings that may indicate nuclear incidents.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['radiation:observations:v1'],
_seedMetaKey: 'seed-meta:radiation:observations',
_maxStaleMin: 30,
},
{
name: 'get_research_signals',
description: 'Tech and research event signals: emerging technology events bootstrap data from curated research feeds.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['research:tech-events-bootstrap:v1'],
_seedMetaKey: 'seed-meta:research:tech-events',
_maxStaleMin: 480,
},
{
name: 'get_forecast_predictions',
description: 'AI-generated geopolitical and economic forecasts from WorldMonitor\'s predictive models. Covers upcoming risk events and probability assessments.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['forecast:predictions:v2'],
_seedMetaKey: 'seed-meta:forecast:predictions',
_maxStaleMin: 90,
},
// -------------------------------------------------------------------------
// Social velocity — cache read (Reddit signals, seeded by relay)
// -------------------------------------------------------------------------
{
name: 'get_social_velocity',
description: 'Reddit geopolitical social velocity: top posts from worldnews, geopolitics, and related subreddits with engagement scores and trend signals.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['intelligence:social:reddit:v1'],
_seedMetaKey: 'seed-meta:intelligence:social-reddit',
_maxStaleMin: 30,
},
// -------------------------------------------------------------------------
// AI inference tools — call LLM endpoints, not cached Redis reads
// -------------------------------------------------------------------------
{
name: 'get_world_brief',
description: 'AI-generated world intelligence brief. Fetches the latest geopolitical headlines and produces an LLM-summarized brief. Supply an optional geo_context to focus on a region or topic.',
inputSchema: {
type: 'object',
properties: {
geo_context: { type: 'string', description: 'Optional focus context (e.g. "Middle East tensions", "US-China trade war")' },
},
required: [],
},
_execute: async (params, base, apiKey) => {
const UA = 'worldmonitor-mcp-edge/1.0';
// Step 1: fetch current geopolitical headlines (budget: 6 s, leaves ~24 s for LLM)
const digestRes = await fetch(`${base}/api/news/v1/list-feed-digest?variant=geo&lang=en`, {
headers: { 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA },
signal: AbortSignal.timeout(6_000),
});
if (!digestRes.ok) throw new Error(`feed-digest HTTP ${digestRes.status}`);
type DigestPayload = { categories?: Record<string, { items?: { title?: string }[] }> };
const digest = await digestRes.json() as DigestPayload;
const headlines = Object.values(digest.categories ?? {})
.flatMap(cat => cat.items ?? [])
.map(item => item.title ?? '')
.filter(Boolean)
.slice(0, 10);
// Step 2: summarize with LLM (budget: 18 s — combined 24 s, well under 30 s edge ceiling)
const briefRes = await fetch(`${base}/api/news/v1/summarize-article`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA },
body: JSON.stringify({
provider: 'openrouter',
headlines,
mode: 'brief',
geoContext: String(params.geo_context ?? ''),
variant: 'geo',
lang: 'en',
}),
signal: AbortSignal.timeout(18_000),
});
if (!briefRes.ok) throw new Error(`summarize-article HTTP ${briefRes.status}`);
return briefRes.json();
},
},
{
name: 'get_country_brief',
description: 'AI-generated per-country intelligence brief. Produces an LLM-analyzed geopolitical and economic assessment for the given country. Supports analytical frameworks for structured lenses.',
inputSchema: {
type: 'object',
properties: {
country_code: { type: 'string', description: 'ISO 3166-1 alpha-2 country code, e.g. "US", "DE", "CN", "IR"' },
framework: { type: 'string', description: 'Optional analytical framework instructions to shape the analysis lens (e.g. Ray Dalio debt cycle, PMESII-PT)' },
},
required: ['country_code'],
},
_execute: async (params, base, apiKey) => {
const res = await fetch(`${base}/api/intelligence/v1/get-country-intel-brief`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
body: JSON.stringify({ country_code: String(params.country_code ?? ''), framework: String(params.framework ?? '') }),
signal: AbortSignal.timeout(25_000),
});
if (!res.ok) throw new Error(`get-country-intel-brief HTTP ${res.status}`);
return res.json();
},
},
{
name: 'analyze_situation',
description: 'AI geopolitical situation analysis (DeductionPanel). Provide a query and optional geo-political context; returns an LLM-powered analytical deduction with confidence and supporting signals.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'The question or situation to analyze, e.g. "What are the implications of the Taiwan strait escalation for semiconductor supply chains?"' },
context: { type: 'string', description: 'Optional additional geo-political context to include in the analysis' },
framework: { type: 'string', description: 'Optional analytical framework instructions to shape the analysis lens (e.g. Ray Dalio debt cycle, PMESII-PT, Porter\'s Five Forces)' },
},
required: ['query'],
},
_execute: async (params, base, apiKey) => {
const res = await fetch(`${base}/api/intelligence/v1/deduct-situation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
body: JSON.stringify({ query: String(params.query ?? ''), geoContext: String(params.context ?? ''), framework: String(params.framework ?? '') }),
signal: AbortSignal.timeout(25_000),
});
if (!res.ok) throw new Error(`deduct-situation HTTP ${res.status}`);
return res.json();
},
},
{
name: 'generate_forecasts',
description: 'Generate live AI geopolitical and economic forecasts. Unlike get_forecast_predictions (pre-computed cache), this calls the forecasting model directly for fresh probability estimates. Note: slower than cache tools.',
inputSchema: {
type: 'object',
properties: {
domain: { type: 'string', description: 'Forecast domain: "geopolitical", "economic", "military", "climate", or empty for all domains' },
region: { type: 'string', description: 'Geographic region filter, e.g. "Middle East", "Europe", "Asia Pacific", or empty for global' },
},
required: [],
},
_execute: async (params, base, apiKey) => {
// 25 s — stays within Vercel Edge's ~30 s hard ceiling (was 60 s, which exceeded the limit)
const res = await fetch(`${base}/api/forecast/v1/get-forecasts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
body: JSON.stringify({ domain: String(params.domain ?? ''), region: String(params.region ?? '') }),
signal: AbortSignal.timeout(25_000),
});
if (!res.ok) throw new Error(`get-forecasts HTTP ${res.status}`);
return res.json();
},
},
];
// Public shape for tools/list (strip internal _-prefixed fields)
const TOOL_LIST_RESPONSE = TOOL_REGISTRY.map(({ name, description, inputSchema }) => ({
name,
description,
inputSchema,
}));
// ---------------------------------------------------------------------------
// JSON-RPC helpers
// ---------------------------------------------------------------------------
function rpcOk(id: unknown, result: unknown, extraHeaders: Record<string, string> = {}): Response {
return jsonResponse({ jsonrpc: '2.0', id: id ?? null, result }, 200, extraHeaders);
}
function rpcError(id: unknown, code: number, message: string): Response {
return jsonResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }, 200);
}
// ---------------------------------------------------------------------------
// Tool execution
// ---------------------------------------------------------------------------
async function executeTool(tool: CacheToolDef): Promise<{ cached_at: string | null; stale: boolean; data: Record<string, unknown> }> {
const reads = tool._cacheKeys.map(k => readJsonFromUpstash(k));
const metaRead = readJsonFromUpstash(tool._seedMetaKey);
const [results, meta] = await Promise.all([Promise.all(reads), metaRead]);
let cached_at: string | null = null;
let stale = true;
if (meta && typeof meta === 'object' && 'fetchedAt' in meta) {
const fetchedAt = (meta as { fetchedAt: number }).fetchedAt;
cached_at = new Date(fetchedAt).toISOString();
stale = (Date.now() - fetchedAt) / 60_000 > tool._maxStaleMin;
}
const data: Record<string, unknown> = {};
// Walk backward through ':'-delimited segments, skipping non-informative suffixes
// (version tags, bare numbers, internal format names) to produce a readable label.
const NON_LABEL = /^(v\d+|\d+|stale|sebuf)$/;
tool._cacheKeys.forEach((key, i) => {
const parts = key.split(':');
let label = '';
for (let idx = parts.length - 1; idx >= 0; idx--) {
const seg = parts[idx] ?? '';
if (!NON_LABEL.test(seg)) { label = seg; break; }
}
data[label || (parts[0] ?? key)] = results[i];
});
return { cached_at, stale, data };
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export default async function handler(req: Request): Promise<Response> {
// MCP is a public API endpoint secured by API key — allow all origins (claude.ai, Claude Desktop, custom agents)
const corsHeaders = getPublicCorsHeaders('POST, OPTIONS');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
// Auth chain (in priority order):
// 1. Authorization: Bearer <oauth_token> — issued by /oauth/token (spec-compliant OAuth 2.0)
// 2. X-WorldMonitor-Key header — direct API key (curl, custom integrations)
let apiKey = '';
const authHeader = req.headers.get('Authorization') ?? '';
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();
let bearerApiKey: string | null;
try {
bearerApiKey = await resolveApiKeyFromBearer(token);
} catch {
// Redis/network error — return 503 so clients know to retry, not re-authenticate
return new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }),
{ status: 503, headers: { 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders } }
);
}
if (bearerApiKey) {
apiKey = bearerApiKey;
} else {
// Bearer token present but unresolvable — expired or invalid UUID
return new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired OAuth token. Re-authenticate via /oauth/token.' } }),
{ status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer realm="worldmonitor", error="invalid_token"', ...corsHeaders } }
);
}
} else {
const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? '';
if (!candidateKey) {
return new Response(
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Authentication required. Use OAuth (/oauth/token) or pass your API key via X-WorldMonitor-Key header.' } }),
{ status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer realm="worldmonitor"', ...corsHeaders } }
);
}
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
if (!await timingSafeIncludes(candidateKey, validKeys)) {
return rpcError(null, -32001, 'Invalid API key');
}
apiKey = candidateKey;
}
// Per-key rate limit
const rl = getMcpRatelimit();
if (rl) {
try {
const { success } = await rl.limit(`key:${apiKey}`);
if (!success) {
return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per API key.');
}
} catch {
// Upstash unavailable — allow through (graceful degradation)
}
}
// Parse body
let body: { jsonrpc?: string; id?: unknown; method?: string; params?: unknown };
try {
body = await req.json();
} catch {
return rpcError(null, -32600, 'Invalid request: malformed JSON');
}
if (!body || typeof body.method !== 'string') {
return rpcError(body?.id ?? null, -32600, 'Invalid request: missing method');
}
const { id, method, params } = body;
// Dispatch
switch (method) {
case 'initialize': {
const sessionId = crypto.randomUUID();
return rpcOk(id, {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
}, { 'Mcp-Session-Id': sessionId, ...corsHeaders });
}
case 'notifications/initialized':
return new Response(null, { status: 202, headers: corsHeaders });
case 'tools/list':
return rpcOk(id, { tools: TOOL_LIST_RESPONSE }, corsHeaders);
case 'tools/call': {
const p = params as { name?: string; arguments?: Record<string, unknown> } | null;
if (!p || typeof p.name !== 'string') {
return rpcError(id, -32602, 'Invalid params: missing tool name');
}
const tool = TOOL_REGISTRY.find(t => t.name === p.name);
if (!tool) {
return rpcError(id, -32602, `Unknown tool: ${p.name}`);
}
try {
let result: unknown;
if (tool._execute) {
const origin = new URL(req.url).origin;
result = await tool._execute(p.arguments ?? {}, origin, apiKey);
} else {
result = await executeTool(tool);
}
return rpcOk(id, {
content: [{ type: 'text', text: JSON.stringify(result) }],
}, corsHeaders);
} catch (err: unknown) {
console.error('[mcp] tool execution error:', err);
return rpcError(id, -32603, 'Internal error: data fetch failed');
}
}
default:
return rpcError(id, -32601, `Method not found: ${method}`);
}
}