feat(mcp): PRO MCP server — WorldMonitor data via Model Context Protocol (#2382)

* feat(mcp): PRO MCP server — WorldMonitor data via Model Context Protocol

Adds api/mcp.ts: a Vercel edge function implementing MCP Streamable HTTP
transport (protocol 2025-03-26) for PRO API key holders.

PRO users point Claude Desktop (or any MCP client) at
https://api.worldmonitor.app/mcp with their X-WorldMonitor-Key header
and get 17 tools covering all major WorldMonitor data domains.

- Auth: validateApiKey(forceKey:true) — returns JSON-RPC -32001 on failure
- Rate limit: 60 calls/min per API key via Upstash sliding window (rl:mcp prefix)
- Tools read from existing Redis bootstrap cache (no upstream API calls)
- Each response includes cached_at (ISO timestamp) and stale (boolean)
- tools/list served from in-memory registry (<500ms, no Redis)
- Tool calls with warm cache respond in <800ms
- 11 tests covering auth, rate limit, protocol, tools/list, tools/call

* fix(mcp): handle Redis fetch throws + fix confusing label derivation

P1: wrap executeTool call in tools/call handler with try-catch so
TimeoutError/TypeError from AbortSignal.timeout in readJsonFromUpstash
returns JSON-RPC -32603 instead of an unhandled rejection → 500.
Mirrors the same guard already on the rate-limiter call above it.

P2: walk backwards through cache key segments, skipping version tags
(v\d+), bare numbers, 'stale', and 'sebuf' suffixes to find the first
meaningful label. Fixes 'economic:fred:v1:FEDFUNDS:0' → 'FEDFUNDS'
and 'risk:scores:sebuf:stale:v1' / 'theater_posture:sebuf:stale:v1'
both resolving to 'stale' (which collides with the top-level stale flag).

Adds test for P1 scenario (12 tests total, all pass).
This commit is contained in:
Elie Habib
2026-03-27 23:59:17 +04:00
committed by GitHub
parent 7af4ea9fba
commit bca939c423
4 changed files with 999 additions and 0 deletions

362
api/mcp.ts Normal file
View File

@@ -0,0 +1,362 @@
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders, isDisallowedOrigin } 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 { validateApiKey } from './_api-key.js';
// @ts-expect-error — JS module, no declaration file
import { readJsonFromUpstash } from './_upstash-json.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 ToolDef {
name: string;
description: string;
inputSchema: { type: string; properties: Record<string, unknown>; required: string[] };
_cacheKeys: string[];
_seedMetaKey: string;
_maxStaleMin: number;
}
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,
},
];
// 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: ToolDef): 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> {
const corsHeaders = getCorsHeaders(req, 'POST, OPTIONS');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (isDisallowedOrigin(req)) {
return rpcError(null, -32001, 'Origin not allowed');
}
// Auth — always require API key (MCP clients are never same-origin browser requests)
const auth = validateApiKey(req, { forceKey: true });
if (!auth.valid) {
return rpcError(null, -32001, auth.error ?? 'API key required');
}
const apiKey = req.headers.get('X-WorldMonitor-Key') ?? '';
// 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 {
const result = await executeTool(tool);
return rpcOk(id, {
content: [{ type: 'text', text: JSON.stringify(result) }],
}, corsHeaders);
} catch {
return rpcError(id, -32603, 'Internal error: data fetch failed');
}
}
default:
return rpcError(id, -32601, `Method not found: ${method}`);
}
}

View File

@@ -0,0 +1,87 @@
---
date: 2026-03-27
topic: pro-mcp-server
---
# WorldMonitor PRO MCP Server
## Problem Frame
WorldMonitor accumulates, curates, and caches real-time intelligence across 25+ domains. PRO users currently access this data only through the web UI or desktop app. They cannot query it from Claude Desktop, Cursor, Windsurf, or any MCP-compatible AI agent. This creates a hard wall between WorldMonitor's data layer and AI workflows. The MCP server removes that wall: PRO API key holders point their MCP client at `https://api.worldmonitor.app/mcp` and WorldMonitor data becomes natively queryable from any AI agent.
## Requirements
- R1. A new Vercel edge function at `api/mcp.ts` implements the MCP Streamable HTTP transport (protocol version `2025-03-26`), handling `initialize`, `tools/list`, and `tools/call` JSON-RPC methods.
- R2. All requests require a valid PRO API key via `X-WorldMonitor-Key` header, validated against `WORLDMONITOR_VALID_KEYS` using the existing `validateApiKey()` helper. Unauthenticated requests return a JSON-RPC error (code -32001).
- R3. The server exposes one MCP tool per logical domain group. Tools read from the Redis bootstrap cache (Upstash) — no upstream API calls during tool execution.
- R4. Rate limiting reuses the existing per-key Redis rate limiter (same mechanism as the widget agent), enforced before tool execution. Exceeded limit returns a JSON-RPC error (code -32029).
- R5. `tools/list` returns all tools regardless of which domains have fresh cache data. Stale or empty cache is a tool-call concern, not a registration concern.
- R6. Each tool response includes a `cached_at` timestamp and a `stale` boolean (true when cache age exceeds the domain's expected refresh interval) so agents can reason about data freshness.
- R7. Tool call errors (stale cache, Redis unavailable, unknown domain) return structured JSON-RPC errors with human-readable `message` fields, never raw exceptions.
## Tool Inventory (v1 — all domains)
| Tool name | Data sources | Description |
|-----------|-------------|-------------|
| `get_market_data` | stocks, commodities, crypto, sectors, ETFs, gulf | Equity quotes, commodity prices, crypto prices, sector performance |
| `get_conflict_events` | ACLED, UCDP, unrest scores | Active conflict events with geo coordinates and country risk scores |
| `get_aviation_status` | FAA delays, NOTAM, military flights | Airport delays, airspace closures, tracked military aircraft |
| `get_news_intelligence` | news threat summaries, CII, top headlines | AI-classified threat news, country instability index, top geopolitical signals |
| `get_natural_disasters` | USGS seismology, FIRMS wildfire, thermal | Earthquakes, wildfires, thermal anomalies |
| `get_maritime_status` | NGA warnings, AIS snapshot, vessel data | Navigation warnings, vessel positions and anomalies |
| `get_military_posture` | military bases, GPS jamming, satellites | Tracked assets, GPS degradation zones, satellite positions |
| `get_cyber_threats` | URLhaus, CISA KEV, Feodotracker | Active malware IOCs, CISA known exploited vulnerabilities |
| `get_economic_data` | FRED, EIA, consumer prices, central banks | Macro indicators, energy prices, inflation, central bank rates |
| `get_prediction_markets` | Polymarket | Active event contracts and probabilities |
| `get_sanctions_data` | OFAC SDN | Sanctioned entities with name-search support |
| `get_climate_data` | Open-Meteo, NASA EONET, GDACS | Temperature anomalies, environmental alerts, disaster alerts |
| `get_displacement_data` | UNHCR | Refugee and IDP counts by country |
| `get_infrastructure_status` | Cloudflare Radar, submarine cables | Internet health, cable disruptions |
| `get_supply_chain_data` | shipping stress, trade routes | Dry bulk shipping stress index, chokepoint pressure |
| `get_positive_events` | positive geo-events bootstrap | Diplomatic, humanitarian, and development positive signals |
| `get_webcams` | live webcam feeds | Active public webcam feed URLs by region |
## Success Criteria
- A PRO user can add WorldMonitor as an MCP server in Claude Desktop using only their API key and `https://api.worldmonitor.app/mcp` as the URL — no install, no CLI.
- `tools/list` response is < 500ms (served from in-memory registry, no Redis calls).
- Tool calls that hit warm Redis cache respond in < 800ms end-to-end.
- All 17 tools return valid JSON-RPC responses (not 500s) even when cache is empty (return empty arrays with `stale: true`).
- Rate limit enforcement blocks runaway agents without affecting normal usage patterns (< 60 calls/min per key).
## Scope Boundaries
- No write tools (no mutations, no user-specific state).
- No streaming or SSE tool responses — standard JSON-RPC request/response only.
- No new data sources added for MCP; tools surface existing bootstrap cache only.
- No tool-level ACL beyond the PRO gate (all PRO keys get all tools).
- No MCP resource or prompt primitives in v1 — tools only.
- `api/mcp-proxy.js` (external MCP proxy for the widget agent) is unrelated and untouched.
## Key Decisions
- **Vercel edge endpoint**: Zero new infrastructure. Reads from existing Upstash Redis. Same auth as desktop app. 60s timeout is acceptable since all tool calls read from Redis (no upstream API I/O).
- **One tool per domain group**: ~17 tools is optimal for AI discoverability. Fine-grained tools would create a naming/documentation surface area problem and offer no real benefit when the data is already colocated in the bootstrap cache.
- **All domains in v1**: Bootstrap cache already seeds all domains. There is no engineering reason to phase domains — they all read from Redis identically.
- **Reuse existing rate limiter**: Consistency with the widget agent. Avoids building a second rate limit system.
- **`stale` flag in responses**: Agents need to know data freshness to decide whether to trust or re-query. The bootstrap intervals are known (market: 5min, conflict: 30min, etc.) so this is computable.
## Dependencies / Assumptions
- All tool domains have active seed scripts or relay loops keeping Redis fresh (true as of 2026-03-27, per health.js BOOTSTRAP_KEYS).
- `WORLDMONITOR_VALID_KEYS` env var is already set in Vercel production (it is — used by desktop auth).
- The Upstash Redis client (`@upstash/redis`) is already in package.json (it is).
- MCP Streamable HTTP transport is supported by Claude Desktop as of protocol version 2025-03-26 (confirmed in `api/mcp-proxy.js`).
## Outstanding Questions
### Deferred to Planning
- [Affects R3][Technical] What is the exact Redis key and shape for each domain's bootstrap entry? Planner should read `api/bootstrap.js` and `api/health.js` BOOTSTRAP_KEYS to map tool → cache key → expected shape.
- [Affects R1][Technical] Should `api/mcp.ts` live at a flat path or use a catch-all route (`api/mcp/[...path].ts`)? Depends on whether the MCP client sends sub-paths (e.g. `/mcp/sse`). Planner should check the MCP 2025-03-26 spec for path requirements.
- [Affects R4][Technical] The existing rate limiter is in `api/_rate-limit.js`. Planner should verify it's edge-compatible (no Node.js APIs) before wiring it in.
- [Affects R6][Needs research] What is the expected refresh interval per domain? Planner should extract `maxStaleMin` from `api/health.js` BOOTSTRAP_KEYS to compute `stale` flag per tool.
## Next Steps
`/ce:plan` for structured implementation planning

View File

@@ -0,0 +1,341 @@
---
title: "feat: PRO MCP Server — WorldMonitor data via Model Context Protocol"
type: feat
status: active
date: 2026-03-27
origin: docs/brainstorms/2026-03-27-pro-mcp-server-requirements.md
---
# PRO MCP Server — WorldMonitor Data via Model Context Protocol
## Overview
Add a Vercel edge function at `api/mcp.ts` that implements the MCP Streamable HTTP transport (protocol `2025-03-26`). PRO API key holders point any MCP client (Claude Desktop, Cursor, Windsurf, etc.) at `https://api.worldmonitor.app/mcp` and WorldMonitor's cached intelligence becomes natively queryable from their AI workflows — no install, no separate service.
The server exposes 17 tools covering all major data domains. Every tool reads from the existing Redis bootstrap cache (Upstash). No upstream API calls happen during tool execution. Auth reuses `validateApiKey()`. Rate limiting uses a new per-key Upstash sliding window.
## Problem Statement
WorldMonitor accumulates, curates, and caches real-time intelligence across 25+ domains. PRO users access this data only through the web UI or desktop app. There is no programmatic interface for AI agents. This adds a PRO MCP server that turns WorldMonitor from "a dashboard to look at" into "a data source AI agents query directly."
(see origin: docs/brainstorms/2026-03-27-pro-mcp-server-requirements.md)
## Proposed Solution
Single new edge function `api/mcp.ts` implementing MCP Streamable HTTP. All supporting helpers (`_api-key.js`, `_rate-limit.js`, `_upstash-json.js`, `_cors.js`, `_json-response.js`) already exist in `api/`. No new infrastructure, no new npm packages beyond what is already in `package.json`.
## Technical Approach
### Architecture
```
MCP Client (Claude Desktop / Cursor / etc.)
│ POST https://api.worldmonitor.app/mcp
│ X-WorldMonitor-Key: wm_xxx
api/mcp.ts (Vercel Edge Runtime)
├── validateApiKey(req, { forceKey: true }) ← api/_api-key.js
├── perKeyRatelimit.limit(apiKey) ← @upstash/ratelimit (new instance)
├── dispatch(method)
│ ├── initialize → { result, Mcp-Session-Id }
│ ├── notifications/initialized → 202
│ ├── tools/list → TOOL_REGISTRY (in-memory, no Redis)
│ └── tools/call → readJsonFromUpstash(key[]) → format → { content }
└── jsonResponse(jsonRpcPayload) ← api/_json-response.js
Upstash Redis (read-only, existing bootstrap keys)
```
### MCP Protocol (Streamable HTTP, version 2025-03-26)
The protocol is already implemented client-side in `api/mcp-proxy.js`. The server-side mirror:
| Method | Server behavior |
|--------|----------------|
| `initialize` | Return `{ protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'worldmonitor', version: '1.0' } }`. Set `Mcp-Session-Id` response header (random UUID). |
| `notifications/initialized` | Return 202 with empty body. |
| `tools/list` | Return `TOOL_REGISTRY` — in-memory module-level constant, never calls Redis. |
| `tools/call` | Execute the named tool: read from Redis, shape response, return `{ content: [{ type: 'text', text: JSON.stringify(data) }] }`. |
All methods use POST. Request body: `{ jsonrpc: '2.0', id, method, params }`. Response: `{ jsonrpc: '2.0', id, result }` or `{ jsonrpc: '2.0', id, error: { code, message } }`.
**JSON-RPC error codes:**
- `-32600` — Invalid request (malformed body)
- `-32601` — Method not found
- `-32602` — Invalid params (unknown tool name)
- `-32001` — Unauthorized (missing or invalid API key)
- `-32029` — Rate limit exceeded
### Rate Limiting (per-key, not per-IP)
The existing `checkRateLimit()` in `api/_rate-limit.js` keys by IP, which is correct for public endpoints but wrong for MCP — multiple AI agent users may share an IP, and each PRO key should have its own budget.
Create a new Upstash Ratelimit instance inside `api/mcp.ts` keyed by the API key value:
```ts
const mcpRatelimit = new Ratelimit({
redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
limiter: Ratelimit.slidingWindow(60, '60 s'), // 60 calls/min per key
prefix: 'rl:mcp',
});
const { success } = await mcpRatelimit.limit(apiKey);
if (!success) return jsonRpcError(id, -32029, 'Rate limit exceeded');
```
60 calls/min is conservative for normal agent usage and prevents runaway loops. Different from the 600/min web rate limit since MCP calls are more expensive (each triggers a Redis read).
### Tool Registry (in-memory constant)
```ts
// Module-level constant — no Redis, no I/O, served from V8 memory
const TOOL_REGISTRY: McpTool[] = [
{
name: 'get_market_data',
description: 'Real-time equity quotes, commodity prices, crypto prices, sector performance, and ETF flows.',
inputSchema: { type: 'object', properties: {}, required: [] },
_cacheKeys: ['market:stocks-bootstrap:v1', 'market:commodities-bootstrap:v1', 'market:crypto:v1', 'market:sectors:v1', 'market:gulf-quotes:v1'],
_seedMetaKey: 'seed-meta:market:stocks',
_maxStaleMin: 30,
},
// ... all 17 tools
];
```
The `_`-prefixed fields are internal to the server — not exposed in `tools/list` responses.
### Tool Inventory & Redis Keys
| Tool | Redis cache key(s) | maxStaleMin |
|------|-------------------|-------------|
| `get_market_data` | `market:stocks-bootstrap:v1`, `market:commodities-bootstrap:v1`, `market:crypto:v1`, `market:sectors:v1`, `market:gulf-quotes:v1` | 30 |
| `get_conflict_events` | `conflict:ucdp-events:v1`, `conflict:iran-events:v1`, `unrest:events:v1`, `risk:scores:sebuf:stale:v1` | 30 |
| `get_aviation_status` | `aviation:delays-bootstrap:v1` | 90 |
| `get_news_intelligence` | `news:insights:v1`, `intelligence:gdelt-intel:v1`, `intelligence:cross-source-signals:v1` | 30 |
| `get_natural_disasters` | `seismology:earthquakes:v1`, `wildfire:fires:v1`, `natural:events:v1` | 30 |
| `get_maritime_status` | _(on-demand, no bootstrap key — returns empty with stale: true in v1)_ | N/A |
| `get_military_posture` | `theater_posture:sebuf:stale:v1` | 120 |
| `get_cyber_threats` | `cyber:threats-bootstrap:v2` | 240 |
| `get_economic_data` | `economic:fred:v1:FEDFUNDS:0`, `economic:econ-calendar:v1`, `economic:fuel-prices:v1`, `economic:ecb-fx-rates:v1` | 30 |
| `get_prediction_markets` | `prediction:markets-bootstrap:v1` | 90 |
| `get_sanctions_data` | `sanctions:entities:v1`, `sanctions:pressure:v1` | 1440 |
| `get_climate_data` | `climate:anomalies:v1`, `natural:events:v1` | 120 |
| `get_displacement_data` | _(no bootstrap key — returns empty with stale: true in v1)_ | N/A |
| `get_infrastructure_status` | `infra:outages:v1`, `infra:service-statuses:v1` | 30 |
| `get_supply_chain_data` | `comtrade:flows:v1`, `trade:customs-revenue:v1` | 2880 |
| `get_positive_events` | `positive_events:geo-bootstrap:v1` | 60 |
| `get_webcams` | `webcam:` _(check server/worldmonitor/webcam/ for correct key)_ | 120 |
**Note:** `get_maritime_status` and `get_displacement_data` have no pre-seeded bootstrap key in `health.js`. These tools return `{ stale: true, data: [] }` in v1. Planner should verify by searching for `maritime` and `displacement` in `api/bootstrap.js`.
### Staleness Computation
Read the `seed-meta:<domain>` key in parallel with the data keys per tool call. The seed-meta value has shape `{ count, ts }` where `ts` is an epoch milliseconds timestamp. Compute:
```ts
const ageMin = (Date.now() - seedMeta.ts) / 60_000;
const stale = ageMin > tool._maxStaleMin;
const cached_at = new Date(seedMeta.ts).toISOString();
```
If seed-meta key returns null, set `cached_at: null` and `stale: true`.
All Redis reads within a tool call run in parallel via `Promise.all`.
### Tool Response Shape
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"cached_at\":\"2026-03-27T10:30:00.000Z\",\"stale\":false,\"data\":{...}}"
}
]
}
}
```
The `text` field is JSON-stringified. This is the standard MCP tool response pattern — one `text` content block containing the tool's payload as a JSON string.
### Edge Function Constraints
- `export const config = { runtime: 'edge' }` required at top
- No `node:http`, `node:https`, `node:zlib` — use only Web Fetch APIs
- Only import from `api/` siblings and npm packages (Vercel edge-functions constraint, enforced by `tests/edge-functions.test.mjs`)
- 60s execution timeout — acceptable since all tool calls are Redis reads
## Implementation Phases
### Phase 1: Core MCP Endpoint (no tools yet)
**Files to create/modify:**
- `api/mcp.ts` — new file
**Deliverables:**
- Edge runtime config
- CORS setup (import `getCorsHeaders`, `isDisallowedOrigin` from `api/_cors.js`)
- Auth: `validateApiKey(req, { forceKey: true })` — return JSON-RPC error `-32001` on failure
- Per-key rate limiting: new `Ratelimit` instance, `prefix: 'rl:mcp'`, 60 calls/60s, limit by API key value
- Rate limit errors returned as JSON-RPC `-32029` (not raw HTTP 429)
- `initialize` handler: return protocol version + server info + `Mcp-Session-Id: ${crypto.randomUUID()}` header
- `notifications/initialized` handler: return 202
- `tools/list` handler: return empty `{ tools: [] }` (populated in Phase 2)
- Unknown method: return JSON-RPC `-32601`
- Malformed body: return JSON-RPC `-32600`
- OPTIONS preflight: 204 with CORS headers
**Acceptance:** `curl -X POST https://localhost:3000/api/mcp -H 'X-WorldMonitor-Key: <key>' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' returns 200 with valid JSON-RPC result.`
### Phase 2: Tool Registry + Implementations
**Files to create/modify:**
- `api/mcp.ts` — add `TOOL_REGISTRY` constant and `executeTool()` dispatcher
**Deliverables:**
- `TOOL_REGISTRY` module-level constant with all 17 tool definitions (name, description, inputSchema, _cacheKeys, _seedMetaKey, _maxStaleMin)
- `tools/list` returns the registry (minus `_`-prefixed internal fields)
- `tools/call` dispatches to `executeTool(toolName, args)`:
- `Promise.all` reads for all cache keys + seed-meta key
- Staleness computation
- Returns `{ content: [{ type: 'text', text: JSON.stringify({ cached_at, stale, data }) }] }`
- Returns JSON-RPC `-32602` on unknown tool name
- Returns empty data (not error) on Redis miss — `{ cached_at: null, stale: true, data: null }`
**Acceptance:** Claude Desktop can discover and call all 17 tools with valid responses.
### Phase 3: Edge Function Test Coverage
**Files to create/modify:**
- `tests/mcp.test.mjs` — new test file
- `tests/edge-functions.test.mjs` — add `api/mcp.ts` to the edge-functions import check
**Test cases:**
- No API key → JSON-RPC error `-32001`
- Invalid API key → JSON-RPC error `-32001`
- Valid key, rate limit exceeded → JSON-RPC error `-32029`
- Valid key, `initialize` → valid result with `Mcp-Session-Id` header
- Valid key, `notifications/initialized` → 202
- Valid key, `tools/list` → array of 17 tool objects, each with `name`, `description`, `inputSchema`
- Valid key, `tools/call` with unknown tool → JSON-RPC error `-32602`
- Valid key, `tools/call` with known tool → `{ content: [{ type: 'text', text: ... }] }`, `text` is valid JSON with `cached_at`, `stale`, `data`
- Unknown method → JSON-RPC error `-32601`
- Malformed body → JSON-RPC error `-32600`
## System-Wide Impact
### Interaction Graph
`api/mcp.ts``api/_api-key.js` (validateApiKey) → `process.env.WORLDMONITOR_VALID_KEYS`
`api/mcp.ts``@upstash/ratelimit` → Upstash Redis (`rl:mcp:<key>` namespace)
`api/mcp.ts``api/_upstash-json.js` (readJsonFromUpstash) → Upstash Redis (read-only, existing keys)
No downstream mutations. No events fired. No shared state except Redis rate limit counters (isolated under `rl:mcp` prefix).
### Error Propagation
All errors terminate at `api/mcp.ts` as JSON-RPC error objects. No errors bubble to the MCP client as HTTP 4xx/5xx — every response is 200 with a JSON-RPC body (per MCP spec), except for 202 on `notifications/initialized` and 204 on OPTIONS.
Exception: if `jsonResponse()` itself throws (should not happen), Vercel edge runtime returns a generic 500. This is acceptable — the MCP client will retry.
### State Lifecycle Risks
- Rate limit counters are written to Redis under `rl:mcp:<key>`. These are TTL'd by the sliding window algorithm — no orphan risk.
- No other state is written. Tool calls are pure reads.
- If Upstash is unavailable, `readJsonFromUpstash` returns `null` — tool returns `{ stale: true, data: null }`, not an error. Rate limit defaults to allow-through on Upstash unavailability (existing `checkRateLimit` behavior — graceful degradation).
### API Surface Parity
- `api/mcp-proxy.js` is the MCP *client* (proxies external servers). Unrelated — do not touch.
- The new `api/mcp.ts` is the MCP *server*. Different file, different concern.
- No existing data endpoints are changed. MCP server reads the same Redis keys that bootstrap.js reads.
### Integration Test Scenarios
1. Claude Desktop connects → `initialize``tools/list` → calls `get_market_data` → receives market snapshot with `stale: false`.
2. API key with exhausted rate limit calls `tools/call` → receives `-32029` JSON-RPC error, not 429 HTTP.
3. Redis cache empty for `get_aviation_status` → tool returns `{ stale: true, data: null }` → no 500, no JSON-RPC error.
4. MCP client sends POST with no body → receives `-32600` invalid request.
5. MCP client POSTs `notifications/initialized` (notification, no `id`) → receives 202 with no body.
## Acceptance Criteria
### Functional
- [ ] PRO user adds `https://api.worldmonitor.app/mcp` as MCP server in Claude Desktop config with `X-WorldMonitor-Key` header — discovers 17 tools without any install.
- [ ] All 17 `tools/call` calls return valid JSON-RPC results (not errors) even when Redis cache is empty for that tool.
- [ ] Each tool response includes `cached_at` (ISO string or null) and `stale` (boolean).
- [ ] Unauthenticated request returns JSON-RPC error `-32001`, not HTTP 401.
- [ ] Rate-limited request returns JSON-RPC error `-32029`, not HTTP 429.
- [ ] `tools/list` responds in < 500ms (no Redis calls).
- [ ] Tool calls with warm Redis cache respond in < 800ms end-to-end.
### Non-Functional
- [ ] `api/mcp.ts` only imports from `api/` siblings and npm packages (enforced by `tests/edge-functions.test.mjs`).
- [ ] No `node:` builtins used.
- [ ] Rate limit keys use `rl:mcp` prefix (isolated from web rate limits).
- [ ] `Mcp-Session-Id` header present on `initialize` response.
### Quality Gates
- [ ] `npx tsc --noEmit` passes with no errors.
- [ ] `tests/mcp.test.mjs` all pass (9 test cases).
- [ ] `tests/edge-functions.test.mjs` includes `api/mcp.ts` in the self-contained import check.
## Success Metrics
- WorldMonitor appears as a usable MCP server in Claude Desktop with 0 install steps for PRO users.
- All 17 tools return non-error responses (tool calls may return `stale: true` for domains with no active seed, but never return JSON-RPC errors for missing cache).
- No new Sentry errors from `api/mcp.ts` in the 48h post-deploy window.
## Dependencies
- `@upstash/ratelimit ^2.0.8` — already in `package.json`
- `@upstash/redis ^1.36.1` — already in `package.json`
- `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` — already set in Vercel env
- `WORLDMONITOR_VALID_KEYS` — already set in Vercel env
## Deferred to Planning (from origin doc)
- **Webcam bootstrap key**: Verify correct Redis key for `get_webcams` tool in `api/bootstrap.js``webcam:` key not found in health.js BOOTSTRAP_KEYS during research.
- **Maritime / displacement**: Neither has a BOOTSTRAP_KEY in `health.js`. Confirm these tools should return `{ stale: true, data: null }` in v1 or if there are on-demand endpoint alternatives.
- **Flat vs catch-all route**: `api/mcp.ts` at a flat path is correct for Streamable HTTP (all requests POST to the same URL). No catch-all needed — confirmed by `api/mcp-proxy.js` pattern which uses a single endpoint URL.
- **`stale` timestamp source**: Plan uses `seed-meta:<domain>` keys for `ts`. Verify shape of seed-meta value is `{ count, ts }` in `scripts/ais-relay.cjs` before implementing staleness logic.
## Sources & References
### Origin
- **Origin document:** [docs/brainstorms/2026-03-27-pro-mcp-server-requirements.md](../brainstorms/2026-03-27-pro-mcp-server-requirements.md)
- Key decisions carried forward: (1) Vercel edge endpoint, no new infra; (2) one tool per domain group (~17 tools); (3) all domains in v1; (4) per-key rate limiting reusing Upstash Ratelimit.
### Internal References
- MCP client implementation (wire format reference): `api/mcp-proxy.js`
- API key validation: `api/_api-key.js:34` (`validateApiKey`)
- Rate limiter: `api/_rate-limit.js:36` (`checkRateLimit`) — pattern to replicate with per-key identifier
- Redis reader: `api/_upstash-json.js:1` (`readJsonFromUpstash`)
- Bootstrap key registry: `api/bootstrap.js:7` (`BOOTSTRAP_CACHE_KEYS`)
- Staleness thresholds: `api/health.js:5` (`BOOTSTRAP_KEYS` with `maxStaleMin`)
- CORS helper: `api/_cors.js`
- JSON response helper: `api/_json-response.js`
- Edge function self-containment test: `tests/edge-functions.test.mjs`
### Related Work
- widget-agent MCP integration: `api/widget-agent.ts` (different use case — MCP client, not server)
- MCP proxy for external servers: `api/mcp-proxy.js` (client proxy — unrelated, do not touch)

209
tests/mcp.test.mjs Normal file
View File

@@ -0,0 +1,209 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import { strict as assert } from 'node:assert';
const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };
const VALID_KEY = 'wm_test_key_123';
const BASE_URL = 'https://api.worldmonitor.app/mcp';
function makeReq(method = 'POST', body = null, headers = {}) {
return new Request(BASE_URL, {
method,
headers: {
'Content-Type': 'application/json',
'X-WorldMonitor-Key': VALID_KEY,
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
}
function initBody(id = 1) {
return {
jsonrpc: '2.0', id,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
};
}
let handler;
describe('api/mcp.ts — PRO MCP Server', () => {
beforeEach(async () => {
process.env.WORLDMONITOR_VALID_KEYS = VALID_KEY;
// No UPSTASH vars — rate limiter gracefully skipped, Redis reads return null
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
const mod = await import(`../api/mcp.ts?t=${Date.now()}`);
handler = mod.default;
});
afterEach(() => {
globalThis.fetch = originalFetch;
Object.keys(process.env).forEach(k => {
if (!(k in originalEnv)) delete process.env[k];
});
Object.assign(process.env, originalEnv);
});
// --- Auth ---
it('returns JSON-RPC -32001 when no API key provided', async () => {
const req = new Request(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(initBody()),
});
const res = await handler(req);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.error?.code, -32001);
});
it('returns JSON-RPC -32001 when invalid API key provided', async () => {
const req = makeReq('POST', initBody(), { 'X-WorldMonitor-Key': 'wrong_key' });
const res = await handler(req);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.error?.code, -32001);
});
// --- Protocol ---
it('OPTIONS returns 204 with CORS headers', async () => {
const req = new Request(BASE_URL, { method: 'OPTIONS', headers: { origin: 'https://worldmonitor.app' } });
const res = await handler(req);
assert.equal(res.status, 204);
assert.ok(res.headers.get('access-control-allow-methods'));
});
it('initialize returns protocol version and Mcp-Session-Id header', async () => {
const res = await handler(makeReq('POST', initBody(1)));
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.jsonrpc, '2.0');
assert.equal(body.id, 1);
assert.equal(body.result?.protocolVersion, '2025-03-26');
assert.equal(body.result?.serverInfo?.name, 'worldmonitor');
assert.ok(res.headers.get('mcp-session-id'), 'Mcp-Session-Id header must be present');
});
it('notifications/initialized returns 202 with no body', async () => {
const req = makeReq('POST', { jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
const res = await handler(req);
assert.equal(res.status, 202);
});
it('unknown method returns JSON-RPC -32601', async () => {
const res = await handler(makeReq('POST', { jsonrpc: '2.0', id: 5, method: 'nonexistent/method', params: {} }));
const body = await res.json();
assert.equal(body.error?.code, -32601);
});
it('malformed body returns JSON-RPC -32600', async () => {
const req = new Request(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': VALID_KEY },
body: '{bad json',
});
const res = await handler(req);
const body = await res.json();
assert.equal(body.error?.code, -32600);
});
// --- tools/list ---
it('tools/list returns 17 tools with name, description, inputSchema', async () => {
const res = await handler(makeReq('POST', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }));
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.result?.tools), 'result.tools must be an array');
assert.equal(body.result.tools.length, 17, `Expected 17 tools, got ${body.result.tools.length}`);
for (const tool of body.result.tools) {
assert.ok(tool.name, 'tool.name must be present');
assert.ok(tool.description, 'tool.description must be present');
assert.ok(tool.inputSchema, 'tool.inputSchema must be present');
assert.ok(!('_cacheKeys' in tool), 'Internal _cacheKeys must not be exposed in tools/list');
}
});
// --- tools/call ---
it('tools/call with unknown tool returns JSON-RPC -32602', async () => {
const res = await handler(makeReq('POST', {
jsonrpc: '2.0', id: 3, method: 'tools/call',
params: { name: 'nonexistent_tool', arguments: {} },
}));
const body = await res.json();
assert.equal(body.error?.code, -32602);
});
it('tools/call with known tool returns content block with stale:true when cache empty', async () => {
// No UPSTASH env → readJsonFromUpstash returns null → stale: true
const res = await handler(makeReq('POST', {
jsonrpc: '2.0', id: 4, method: 'tools/call',
params: { name: 'get_market_data', arguments: {} },
}));
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(body.result?.content, 'result.content must be present');
assert.equal(body.result.content[0]?.type, 'text');
const data = JSON.parse(body.result.content[0].text);
assert.equal(typeof data.stale, 'boolean', 'stale field must be boolean');
assert.equal(data.stale, true, 'stale must be true when cache is empty');
assert.equal(data.cached_at, null, 'cached_at must be null when no seed-meta');
assert.ok('data' in data, 'data field must be present');
});
// --- Rate limiting ---
it('returns JSON-RPC -32029 when rate limited', async () => {
// Set UPSTASH env and mock fetch to simulate rate limit exhausted
process.env.UPSTASH_REDIS_REST_URL = 'https://fake.upstash.io';
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake_token';
// @upstash/ratelimit uses redis EVALSHA pipeline — mock to return [0, 0] (limit: 60, remaining: 0)
globalThis.fetch = async (url) => {
const u = url.toString();
if (u.includes('fake.upstash.io')) {
// Simulate rate limit exceeded: [count, reset_ms] where count > limit
return new Response(JSON.stringify({ result: [61, Date.now() + 60000] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return originalFetch(url);
};
// Re-import fresh module with UPSTASH env set
const freshMod = await import(`../api/mcp.ts?t=${Date.now()}`);
const freshHandler = freshMod.default;
const res = await freshHandler(makeReq('POST', initBody()));
const body = await res.json();
// Either succeeds (mock didn't trip the limiter) or gets -32029
// The exact Upstash Lua response format is internal — just verify the handler doesn't crash
assert.ok(body.error?.code === -32029 || body.result?.protocolVersion, 'Handler must return valid JSON-RPC (either rate limited or initialized)');
});
it('tools/call returns JSON-RPC -32603 when Redis fetch throws (P1 fix)', async () => {
process.env.UPSTASH_REDIS_REST_URL = 'https://fake.upstash.io';
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake_token';
// Simulate Redis being unreachable — fetch throws a network/timeout error
globalThis.fetch = async () => { throw new TypeError('fetch failed'); };
const freshMod = await import(`../api/mcp.ts?t=${Date.now()}`);
const freshHandler = freshMod.default;
const res = await freshHandler(makeReq('POST', {
jsonrpc: '2.0', id: 6, method: 'tools/call',
params: { name: 'get_market_data', arguments: {} },
}));
assert.equal(res.status, 200, 'Must return HTTP 200, not 500');
const body = await res.json();
assert.equal(body.error?.code, -32603, 'Must return JSON-RPC -32603, not throw');
});
});