From 1c099e8a5ad05d1dd18592c073d2dec5971bcf42 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 20 Mar 2026 17:23:51 +0400 Subject: [PATCH] feat(mcp): validate and expand MCP preset catalog + add proxy tests (#1929) Fix broken/invalid MCP server URLs discovered during testing, add 8 new validated presets, and introduce a full test suite for the proxy handler. URL fixes: Slack (cloudflare-hosted variant), Google Maps (mapstools.googleapis.com + X-Goog-Api-Key header), Datadog (/api/unstable/ mcp-server/mcp path), Browser Fetch (browser.mcp.cloudflare.com). Removed 4 dead/stdio-only presets (Perplexity, Polygon.io, Overpass, Shodan). Added 8 new validated presets: Exa Search, Tavily Search, Perigon News, Robtex (free), Pyth Price Feeds (free), LunarCrush, Weather Forensics (free), Alpha Vantage. Fixed tool names: WeatherForensics defaultTool and LunarCrush defaultTool. Tests: 32 proxy unit tests (SSRF, method guards, CRLF injection, SSE transport, timeouts) and 16 preset static-analysis tests (URL validity, banned URLs, field pinning). Live connectivity suite opt-in via LIVE_MCP_TESTS=1. --- src/services/mcp-store.ts | 135 +++++++----- tests/mcp-presets.test.mjs | 243 +++++++++++++++++++++ tests/mcp-proxy.test.mjs | 420 +++++++++++++++++++++++++++++++++++++ 3 files changed, 750 insertions(+), 48 deletions(-) create mode 100644 tests/mcp-presets.test.mjs create mode 100644 tests/mcp-proxy.test.mjs diff --git a/src/services/mcp-store.ts b/src/services/mcp-store.ts index 22cf743ad..1c77d1a9b 100644 --- a/src/services/mcp-store.ts +++ b/src/services/mcp-store.ts @@ -17,6 +17,83 @@ export interface McpPreset { } export const MCP_PRESETS: McpPreset[] = [ + { + name: 'Exa Search', + icon: '🔍', + description: 'Real-time web search with clean, LLM-ready content from top results', + serverUrl: 'https://mcp.exa.ai/mcp', + authNote: 'Requires Authorization: Bearer (free tier at exa.ai)', + defaultTool: 'web_search_exa', + defaultArgs: { query: 'latest geopolitical developments Middle East', numResults: 5 }, + defaultTitle: 'Web Intelligence', + }, + { + name: 'Tavily Search', + icon: '🕵️', + description: 'AI-optimized web search with source citations and real-time answers', + serverUrl: 'https://mcp.tavily.com/mcp/', + authNote: 'Requires Authorization: Bearer (free tier at tavily.com)', + defaultTool: 'tavily_search', + defaultArgs: { query: 'breaking news today', search_depth: 'advanced', max_results: 5 }, + defaultTitle: 'Tavily Search', + }, + { + name: 'Perigon News', + icon: '📰', + description: 'Real-time global news search with journalist, company, and topic filters', + serverUrl: 'https://mcp.perigon.io/v1/mcp', + authNote: 'Requires Authorization: Bearer (from perigon.io)', + defaultTool: 'search_articles', + defaultArgs: { q: 'conflict OR crisis', sortBy: 'date', size: 10 }, + defaultTitle: 'News Feed', + }, + { + name: 'Robtex', + icon: '🛡️', + description: 'Free DNS intelligence, IP reputation, BGP routing, and network threat data', + serverUrl: 'https://mcp.robtex.com/mcp', + defaultTool: 'ip_reputation', + defaultArgs: { ip: '8.8.8.8' }, + defaultTitle: 'Network Intel', + }, + { + name: 'Pyth Price Feeds', + icon: '📡', + description: 'Free real-time price feeds for crypto, equities, and FX from Pyth Network', + serverUrl: 'https://mcp.pyth.network/mcp', + defaultTool: 'get_latest_price', + defaultArgs: { symbol: 'Crypto.BTC/USD' }, + defaultTitle: 'Pyth Prices', + }, + { + name: 'LunarCrush', + icon: '🌙', + description: 'Crypto and stock social sentiment — mentions, engagement, and influencer signals', + serverUrl: 'https://lunarcrush.ai/mcp', + authNote: 'Requires Authorization: Bearer (from lunarcrush.com)', + defaultTool: 'Cryptocurrencies', + defaultArgs: { sector: '', sort: 'social_dominance', limit: 20 }, + defaultTitle: 'Crypto Sentiment', + }, + { + name: 'Weather Forensics', + icon: '🌦️', + description: 'Free historical and current weather data — hourly, daily, and severe events', + serverUrl: 'https://weatherforensics.dev/mcp/free', + defaultTool: 'noaa_ncei_daily_weather_for_location_date', + defaultArgs: { latitude: 33.8938, longitude: 35.5018, date: '2026-03-19' }, + defaultTitle: 'Weather', + }, + { + name: 'Alpha Vantage', + icon: '📉', + description: 'Stocks, forex, crypto, and commodities — real-time and historical market data', + serverUrl: 'https://mcp.alphavantage.co/mcp', + authNote: 'Requires Authorization: Bearer (free at alphavantage.co)', + defaultTool: 'get_quote', + defaultArgs: { symbol: 'SPY' }, + defaultTitle: 'Market Data', + }, { name: 'GitHub', icon: '🐙', @@ -31,7 +108,7 @@ export const MCP_PRESETS: McpPreset[] = [ name: 'Slack', icon: '💬', description: 'Your team channels, messages, and workspace activity', - serverUrl: 'https://slack.mcp.cloudflare.com/mcp', + serverUrl: 'https://mcp.slack.com/mcp', authNote: 'Requires Authorization: Bearer (xoxb-...)', defaultTool: 'slack_get_channel_history', defaultArgs: { channel_name: 'general', limit: 20 }, @@ -42,6 +119,7 @@ export const MCP_PRESETS: McpPreset[] = [ icon: '🌐', description: 'Live internet traffic, outages, BGP anomalies, and attack trends', serverUrl: 'https://radar.mcp.cloudflare.com/sse', + authNote: 'Requires Authorization: Bearer (from Cloudflare dashboard)', defaultTool: 'get_summary_attacks', defaultArgs: { limit: 10 }, defaultTitle: 'Internet Radar', @@ -50,8 +128,8 @@ export const MCP_PRESETS: McpPreset[] = [ name: 'Google Maps', icon: '🗺️', description: 'Location search, place details, directions, and geocoding', - serverUrl: 'https://maps.mcp.cloudflare.com/mcp', - authNote: 'Requires Authorization: Bearer ', + serverUrl: 'https://mapstools.googleapis.com/mcp', + authNote: 'Requires X-Goog-Api-Key: ', defaultTool: 'maps_search_places', defaultArgs: { query: 'airports near Beirut', radius: 100000 }, defaultTitle: 'Maps', @@ -67,10 +145,11 @@ export const MCP_PRESETS: McpPreset[] = [ defaultTitle: 'My Database', }, { - name: 'Web Fetch', + name: 'Browser Fetch', icon: '📄', - description: 'Fetch and read content from any public URL as plain text', - serverUrl: 'https://mcp-fetch.cloudflare.com/mcp', + description: 'Fetch and read any public URL as plain text or markdown via Cloudflare Browser Rendering', + serverUrl: 'https://browser.mcp.cloudflare.com/mcp', + authNote: 'Requires Authorization: Bearer (from Cloudflare dashboard)', defaultTool: 'fetch', defaultArgs: { url: 'https://example.com', maxLength: 5000 }, defaultTitle: 'Web Fetch', @@ -99,8 +178,8 @@ export const MCP_PRESETS: McpPreset[] = [ name: 'Datadog', icon: '📈', description: 'Metrics, monitors, dashboards, and infrastructure alerts', - serverUrl: 'https://mcp.datadoghq.com/mcp', - authNote: 'Requires DD-API-KEY and DD-APPLICATION-KEY headers', + serverUrl: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp', + authNote: 'Requires DD-API-KEY: and DD-APPLICATION-KEY: headers (from Datadog → Organization Settings → API Keys)', defaultTool: 'get_active_monitors', defaultArgs: { tags: [], count: 20 }, defaultTitle: 'Datadog Monitors', @@ -115,36 +194,6 @@ export const MCP_PRESETS: McpPreset[] = [ defaultArgs: {}, defaultTitle: 'Stripe Balance', }, - { - name: 'Overpass (OSM)', - icon: '🛰️', - description: 'Free geospatial queries on OpenStreetMap — free Smithery API key required', - serverUrl: 'https://server.smithery.ai/@dokterbob/mcp-overpass-server/mcp', - authNote: 'Requires x-smithery-api-key: (free at smithery.ai — no Overpass API key needed)', - defaultTool: 'overpass_query', - defaultArgs: { query: '[out:json];node["amenity"="hospital"](33.7,35.4,34.0,35.7);out body 10;' }, - defaultTitle: 'OSM Query', - }, - { - name: 'Perplexity', - icon: '🔮', - description: 'AI-powered research with cited, real-time answers', - serverUrl: 'https://mcp.perplexity.ai/mcp', - authNote: 'Requires Authorization: Bearer ', - defaultTool: 'search', - defaultArgs: { query: 'latest geopolitical developments', recency_filter: 'day' }, - defaultTitle: 'Perplexity Research', - }, - { - name: 'Polygon.io', - icon: '📊', - description: 'Real-time and historical stock, options, forex, and crypto data', - serverUrl: 'https://mcp.polygon.io/mcp', - authNote: 'Requires Authorization: Bearer ', - defaultTool: 'get_snapshot_all_tickers', - defaultArgs: { tickers: ['AAPL', 'MSFT', 'NVDA', 'TSLA'], include_otc: false }, - defaultTitle: 'Market Snapshot', - }, { name: 'Notion', icon: '📝', @@ -165,16 +214,6 @@ export const MCP_PRESETS: McpPreset[] = [ defaultArgs: { baseId: 'appXXXXXXXXXXXXXX', tableId: 'tblXXXXXXXXXXXXXX', maxRecords: 20 }, defaultTitle: 'Airtable Records', }, - { - name: 'Shodan', - icon: '🔭', - description: 'Search internet-facing devices, open ports, and exposed services', - serverUrl: 'https://server.smithery.ai/@dokterbob/mcp-shodan/mcp', - authNote: 'Requires x-smithery-api-key: (free at smithery.ai) and Authorization: Bearer ', - defaultTool: 'search_hosts', - defaultArgs: { query: 'port:22 country:IR', facets: 'org', page: 1 }, - defaultTitle: 'Shodan Search', - }, ]; export interface McpToolDef { diff --git a/tests/mcp-presets.test.mjs b/tests/mcp-presets.test.mjs new file mode 100644 index 000000000..dabe07320 --- /dev/null +++ b/tests/mcp-presets.test.mjs @@ -0,0 +1,243 @@ +/** + * Static validation for MCP preset definitions in src/services/mcp-store.ts. + * + * These tests run in CI without network access and catch: + * - Missing required fields + * - Private/invalid serverUrls + * - Duplicate serverUrls + * - Known-dead or outdated URLs + * - Invalid defaultArgs structure + * + * Live connectivity tests are skipped unless LIVE_MCP_TESTS=1 is set. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +// Parse presets directly from the TypeScript source (no compilation needed). +// Extract the MCP_PRESETS array by reading between the markers. +const src = readFileSync(resolve(root, 'src/services/mcp-store.ts'), 'utf-8'); + +// Extract presets from the MCP_PRESETS array in the TS source. +// Splits on object boundaries delimited by ' {' at the start of each preset block. +function extractPresets(src) { + const start = src.indexOf('export const MCP_PRESETS'); + const end = src.indexOf('\nexport interface McpToolDef'); + if (start === -1 || end === -1) throw new Error('Could not locate MCP_PRESETS in mcp-store.ts'); + const arrSrc = src.slice(start, end); + const blocks = arrSrc.split(/\n \{/).slice(1); + return blocks + .map(block => ({ + name: /name: '([^']+)'/.exec(block)?.[1] ?? null, + serverUrl: /serverUrl: '([^']+)'/.exec(block)?.[1] ?? null, + defaultTool: /defaultTool: '([^']+)'/.exec(block)?.[1] ?? null, + })) + .filter(p => p.name && p.serverUrl); +} + +const presets = extractPresets(src); + +// Known-dead or moved URLs that must NOT appear in presets +const BANNED_URLS = [ + 'https://slack.mcp.cloudflare.com/mcp', // wrong — Cloudflare-hosted Slack doesn't exist + 'https://maps.mcp.cloudflare.com/mcp', // wrong — Cloudflare-hosted Maps doesn't exist + 'https://mcp-fetch.cloudflare.com/mcp', // wrong — old Browser Fetch URL + 'https://server.smithery.ai/@amadevs/mcp-server-overpass/mcp', // 404 on Smithery +]; + +// Private/RFC1918 host patterns (SSRF risk) +const BLOCKED_HOST_PATTERNS = [ + /^localhost$/i, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, + /^::1$/, + /^fd[0-9a-f]{2}:/i, + /^fe80:/i, +]; + +describe('MCP Presets — static validation', () => { + it('extracts a non-empty preset list from mcp-store.ts', () => { + assert.ok(presets.length >= 10, `Expected at least 10 presets, got ${presets.length}`); + }); + + it('all presets have required string fields: name, serverUrl, defaultTool', () => { + const missing = presets.filter(p => !p.name || !p.serverUrl || !p.defaultTool); + assert.deepEqual(missing, [], `Presets missing required fields: ${missing.map(p => p.name).join(', ')}`); + }); + + it('all serverUrls use https:// protocol', () => { + const nonHttps = presets.filter(p => { + try { return new URL(p.serverUrl).protocol !== 'https:'; } + catch { return true; } + }); + assert.deepEqual(nonHttps, [], `Presets with non-https serverUrl: ${nonHttps.map(p => p.name).join(', ')}`); + }); + + it('no duplicate serverUrls', () => { + const seen = new Set(); + const dupes = []; + for (const p of presets) { + if (seen.has(p.serverUrl)) dupes.push(p.name); + seen.add(p.serverUrl); + } + assert.deepEqual(dupes, [], `Duplicate serverUrls for: ${dupes.join(', ')}`); + }); + + it('no serverUrls point to private/RFC1918 hosts', () => { + const ssrf = presets.filter(p => { + try { + const host = new URL(p.serverUrl).hostname; + return BLOCKED_HOST_PATTERNS.some(pat => pat.test(host)); + } catch { return false; } + }); + assert.deepEqual(ssrf, [], `Presets with private-host serverUrls (SSRF risk): ${ssrf.map(p => p.name).join(', ')}`); + }); + + it('no known-dead or banned serverUrls', () => { + const dead = presets.filter(p => BANNED_URLS.includes(p.serverUrl)); + assert.deepEqual(dead, [], `Presets using known-dead URLs: ${dead.map(p => p.name).join(', ')}`); + }); + + it('all serverUrls are parseable URLs', () => { + const broken = presets.filter(p => { try { new URL(p.serverUrl); return false; } catch { return true; } }); + assert.deepEqual(broken, [], `Presets with unparseable serverUrls: ${broken.map(p => p.name).join(', ')}`); + }); + + it('expected free presets are present (Robtex, Pyth, WeatherForensics)', () => { + const names = new Set(presets.map(p => p.name)); + for (const expected of ['Robtex', 'Pyth Price Feeds', 'Weather Forensics']) { + assert.ok(names.has(expected), `Expected preset "${expected}" not found`); + } + }); + + it('expected commercial presets are present', () => { + const names = new Set(presets.map(p => p.name)); + for (const expected of ['Exa Search', 'Tavily Search', 'Slack', 'GitHub', 'Stripe', 'Sentry', 'Datadog', 'Linear']) { + assert.ok(names.has(expected), `Expected preset "${expected}" not found`); + } + }); + + it('Slack serverUrl points to mcp.slack.com (not cloudflare)', () => { + const slack = presets.find(p => p.name === 'Slack'); + assert.ok(slack, 'Slack preset not found'); + assert.equal(slack.serverUrl, 'https://mcp.slack.com/mcp'); + }); + + it('Google Maps serverUrl points to mapstools.googleapis.com', () => { + const maps = presets.find(p => p.name === 'Google Maps'); + assert.ok(maps, 'Google Maps preset not found'); + assert.equal(maps.serverUrl, 'https://mapstools.googleapis.com/mcp'); + }); + + it('Datadog serverUrl includes /api/unstable/mcp-server/mcp', () => { + const dd = presets.find(p => p.name === 'Datadog'); + assert.ok(dd, 'Datadog preset not found'); + assert.ok(dd.serverUrl.includes('/api/unstable/mcp-server/mcp'), `Datadog URL is outdated: ${dd.serverUrl}`); + }); + + it('Browser Fetch serverUrl points to browser.mcp.cloudflare.com', () => { + const bf = presets.find(p => p.name === 'Browser Fetch'); + assert.ok(bf, 'Browser Fetch preset not found'); + assert.equal(bf.serverUrl, 'https://browser.mcp.cloudflare.com/mcp'); + }); + + it('WeatherForensics defaultTool is noaa_ncei_daily_weather_for_location_date (not get_current_weather)', () => { + const wf = presets.find(p => p.name === 'Weather Forensics'); + assert.ok(wf, 'Weather Forensics preset not found'); + assert.equal(wf.defaultTool, 'noaa_ncei_daily_weather_for_location_date'); + }); + + it('LunarCrush defaultTool is Cryptocurrencies (not List)', () => { + const lc = presets.find(p => p.name === 'LunarCrush'); + assert.ok(lc, 'LunarCrush preset not found'); + assert.equal(lc.defaultTool, 'Cryptocurrencies'); + }); + + it('Cloudflare Radar serverUrl points to radar.mcp.cloudflare.com/sse', () => { + const cf = presets.find(p => p.name === 'Cloudflare Radar'); + assert.ok(cf, 'Cloudflare Radar preset not found'); + assert.equal(cf.serverUrl, 'https://radar.mcp.cloudflare.com/sse'); + }); +}); + +// ── Live connectivity tests (opt-in) ───────────────────────────────────────── +// Run with: LIVE_MCP_TESTS=1 npm run test:data -- tests/mcp-presets.test.mjs + +const LIVE = process.env.LIVE_MCP_TESTS === '1'; + +describe(`MCP Presets — live connectivity (${LIVE ? 'ENABLED' : 'SKIPPED — set LIVE_MCP_TESTS=1'})`, { skip: !LIVE }, () => { + const EXPECTED_LIVE_URLS = [ + // Free presets expected to respond (no auth needed for initialize) + { name: 'Robtex', url: 'https://mcp.robtex.com/mcp' }, + { name: 'Pyth Price Feeds', url: 'https://mcp.pyth.network/mcp' }, + { name: 'Weather Forensics', url: 'https://weatherforensics.dev/mcp/free' }, + ]; + + // Auth-gated presets — expect 401 on initialize (not DNS failure / 404) + const EXPECTED_AUTH_URLS = [ + { name: 'Exa Search', url: 'https://mcp.exa.ai/mcp' }, + { name: 'Tavily Search', url: 'https://mcp.tavily.com/mcp/' }, + { name: 'LunarCrush', url: 'https://lunarcrush.ai/mcp' }, + { name: 'Alpha Vantage', url: 'https://mcp.alphavantage.co/mcp' }, + { name: 'Slack', url: 'https://mcp.slack.com/mcp' }, + { name: 'GitHub', url: 'https://api.githubcopilot.com/mcp/' }, + { name: 'Linear', url: 'https://mcp.linear.app/mcp' }, + { name: 'Sentry', url: 'https://mcp.sentry.dev/mcp' }, + { name: 'Stripe', url: 'https://mcp.stripe.com/' }, + { name: 'Notion', url: 'https://mcp.notion.com/mcp' }, + { name: 'Airtable', url: 'https://mcp.airtable.com/mcp' }, + { name: 'Perigon News', url: 'https://mcp.perigon.io/v1/mcp' }, + { name: 'Datadog', url: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp' }, + ]; + + const TIMEOUT_MS = 15_000; + + async function postMcpInit(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'User-Agent': 'WorldMonitor-MCP-Proxy/1.0', + }, + body: JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'worldmonitor', version: '1.0' } }, + }), + signal: controller.signal, + }); + return resp.status; + } finally { + clearTimeout(timer); + } + } + + for (const { name, url } of EXPECTED_LIVE_URLS) { + it(`${name} (${url}) responds 200 to initialize`, async () => { + const status = await postMcpInit(url); + assert.equal(status, 200, `${name}: expected 200, got ${status}`); + }); + } + + for (const { name, url } of EXPECTED_AUTH_URLS) { + it(`${name} (${url}) responds 200 or 401 (not DNS failure or 404)`, async () => { + const status = await postMcpInit(url); + assert.ok( + [200, 401, 403].includes(status), + `${name}: expected 200/401/403, got ${status} — URL may be dead or changed`, + ); + }); + } +}); diff --git a/tests/mcp-proxy.test.mjs b/tests/mcp-proxy.test.mjs new file mode 100644 index 000000000..b36dd5bd2 --- /dev/null +++ b/tests/mcp-proxy.test.mjs @@ -0,0 +1,420 @@ +import { strict as assert } from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; + +const originalFetch = globalThis.fetch; + +function makeGetRequest(params = {}, origin = 'https://worldmonitor.app') { + const url = new URL('https://worldmonitor.app/api/mcp-proxy'); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) url.searchParams.set(k, typeof v === 'string' ? v : JSON.stringify(v)); + } + return new Request(url.toString(), { + method: 'GET', + headers: { origin }, + }); +} + +function makePostRequest(body = {}, origin = 'https://worldmonitor.app') { + return new Request('https://worldmonitor.app/api/mcp-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin }, + body: JSON.stringify(body), + }); +} + +function makeOptionsRequest(origin = 'https://worldmonitor.app') { + return new Request('https://worldmonitor.app/api/mcp-proxy', { + method: 'OPTIONS', + headers: { origin }, + }); +} + +// Minimal MCP server stub — returns valid JSON-RPC responses +function makeMcpFetch({ initStatus = 200, listStatus = 200, callStatus = 200, tools = [], callResult = { content: [] } } = {}) { + return async (url, opts) => { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === 'initialize' || body.method === 'notifications/initialized') { + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'test', version: '1' } } }), { + status: initStatus, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (body.method === 'tools/list') { + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { tools } }), { + status: listStatus, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (body.method === 'tools/call') { + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: callResult }), { + status: callStatus, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; +} + +let handler; + +describe('api/mcp-proxy', () => { + beforeEach(async () => { + const mod = await import(`../api/mcp-proxy.js?t=${Date.now()}`); + handler = mod.default; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + // ── CORS / method guards ────────────────────────────────────────────────── + + describe('CORS and method handling', () => { + it('returns 403 for disallowed origin', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' }, 'https://evil.com')); + assert.equal(res.status, 403); + }); + + it('returns 204 for OPTIONS preflight', async () => { + const res = await handler(makeOptionsRequest()); + assert.equal(res.status, 204); + }); + + it('returns 405 for DELETE', async () => { + const res = await handler(new Request('https://worldmonitor.app/api/mcp-proxy', { + method: 'DELETE', + headers: { origin: 'https://worldmonitor.app' }, + })); + assert.equal(res.status, 405); + }); + + it('returns 405 for PUT', async () => { + const res = await handler(new Request('https://worldmonitor.app/api/mcp-proxy', { + method: 'PUT', + headers: { origin: 'https://worldmonitor.app' }, + body: '{}', + })); + assert.equal(res.status, 405); + }); + }); + + // ── GET — list tools ────────────────────────────────────────────────────── + + describe('GET /api/mcp-proxy (list tools)', () => { + it('returns 400 when serverUrl is missing', async () => { + const res = await handler(makeGetRequest()); + assert.equal(res.status, 400); + const data = await res.json(); + assert.match(data.error, /serverUrl/i); + }); + + it('returns 400 for non-http(s) protocol', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'ftp://mcp.example.com/mcp' })); + assert.equal(res.status, 400); + const data = await res.json(); + assert.match(data.error, /invalid serverUrl/i); + }); + + it('returns 400 for localhost', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'http://localhost/mcp' })); + assert.equal(res.status, 400); + }); + + it('returns 400 for 127.x.x.x', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'http://127.0.0.1:8080/mcp' })); + assert.equal(res.status, 400); + }); + + it('returns 400 for 10.x.x.x (RFC1918)', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'http://10.0.0.1/mcp' })); + assert.equal(res.status, 400); + }); + + it('returns 400 for 192.168.x.x (RFC1918)', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'http://192.168.1.1/mcp' })); + assert.equal(res.status, 400); + }); + + it('returns 400 for 172.16.x.x (RFC1918)', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'http://172.16.0.1/mcp' })); + assert.equal(res.status, 400); + }); + + it('returns 400 for link-local 169.254.x.x (cloud metadata)', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'http://169.254.169.254/latest/meta-data/' })); + assert.equal(res.status, 400); + }); + + it('returns 400 for garbled URL', async () => { + const res = await handler(makeGetRequest({ serverUrl: 'not a url at all' })); + assert.equal(res.status, 400); + }); + + it('returns 200 with tools array on successful list', async () => { + const sampleTools = [{ name: 'search', description: 'Web search', inputSchema: {} }]; + globalThis.fetch = makeMcpFetch({ tools: sampleTools }); + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 200); + const data = await res.json(); + assert.ok(Array.isArray(data.tools)); + assert.equal(data.tools.length, 1); + assert.equal(data.tools[0].name, 'search'); + }); + + it('returns empty tools array when server returns none', async () => { + globalThis.fetch = makeMcpFetch({ tools: [] }); + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 200); + const data = await res.json(); + assert.deepEqual(data.tools, []); + }); + + it('returns 422 when upstream returns non-ok status', async () => { + globalThis.fetch = makeMcpFetch({ initStatus: 401 }); + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 422); + }); + + it('returns 422 when upstream returns JSON-RPC error', async () => { + globalThis.fetch = async () => new Response( + JSON.stringify({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 422); + const data = await res.json(); + assert.match(data.error, /Method not found/i); + }); + + it('returns 504 on fetch timeout', async () => { + globalThis.fetch = async () => { + const err = new Error('The operation timed out.'); + err.name = 'TimeoutError'; + throw err; + }; + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 504); + const data = await res.json(); + assert.match(data.error, /timed out/i); + }); + + it('ignores invalid JSON in headers param', async () => { + globalThis.fetch = makeMcpFetch({ tools: [] }); + const url = new URL('https://worldmonitor.app/api/mcp-proxy'); + url.searchParams.set('serverUrl', 'https://mcp.example.com/mcp'); + url.searchParams.set('headers', 'not json'); + const req = new Request(url.toString(), { method: 'GET', headers: { origin: 'https://worldmonitor.app' } }); + const res = await handler(req); + assert.equal(res.status, 200); + }); + + it('passes custom headers to upstream', async () => { + let capturedHeaders = {}; + globalThis.fetch = async (url, opts) => { + capturedHeaders = Object.fromEntries(Object.entries(opts?.headers || {})); + return makeMcpFetch({ tools: [] })(url, opts); + }; + const res = await handler(makeGetRequest({ + serverUrl: 'https://mcp.example.com/mcp', + headers: JSON.stringify({ Authorization: 'Bearer test-key' }), + })); + assert.equal(res.status, 200); + assert.equal(capturedHeaders['Authorization'], 'Bearer test-key'); + }); + + it('strips CRLF from injected headers', async () => { + let capturedHeaders = {}; + globalThis.fetch = async (url, opts) => { + capturedHeaders = Object.fromEntries(Object.entries(opts?.headers || {})); + return makeMcpFetch({ tools: [] })(url, opts); + }; + const res = await handler(makeGetRequest({ + serverUrl: 'https://mcp.example.com/mcp', + headers: JSON.stringify({ 'X-Evil\r\nInjected': 'bad' }), + })); + assert.equal(res.status, 200); + for (const k of Object.keys(capturedHeaders)) { + assert.ok(!k.includes('\r') && !k.includes('\n'), `Header key contains CRLF: ${JSON.stringify(k)}`); + } + }); + }); + + // ── POST — call tool ────────────────────────────────────────────────────── + + describe('POST /api/mcp-proxy (call tool)', () => { + it('returns 400 when serverUrl is missing', async () => { + const res = await handler(makePostRequest({ toolName: 'search' })); + assert.equal(res.status, 400); + const data = await res.json(); + assert.match(data.error, /serverUrl/i); + }); + + it('returns 400 when toolName is missing', async () => { + const res = await handler(makePostRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 400); + const data = await res.json(); + assert.match(data.error, /toolName/i); + }); + + it('returns 400 for blocked host in POST body', async () => { + const res = await handler(makePostRequest({ + serverUrl: 'http://localhost/mcp', + toolName: 'search', + })); + assert.equal(res.status, 400); + }); + + it('returns 200 with result on successful tool call', async () => { + const callResult = { content: [{ type: 'text', text: 'Hello' }] }; + globalThis.fetch = makeMcpFetch({ callResult }); + const res = await handler(makePostRequest({ + serverUrl: 'https://mcp.example.com/mcp', + toolName: 'search', + toolArgs: { query: 'test' }, + })); + assert.equal(res.status, 200); + const data = await res.json(); + assert.deepEqual(data.result, callResult); + }); + + it('returns 422 when tools/call returns non-ok status', async () => { + globalThis.fetch = makeMcpFetch({ callStatus: 403 }); + const res = await handler(makePostRequest({ + serverUrl: 'https://mcp.example.com/mcp', + toolName: 'search', + })); + assert.equal(res.status, 422); + }); + + it('returns 422 when tools/call returns JSON-RPC error', async () => { + globalThis.fetch = async (url, opts) => { + const body = JSON.parse(opts.body); + if (body.method === 'tools/call') { + return new Response( + JSON.stringify({ jsonrpc: '2.0', id: body.id, error: { code: -32602, message: 'Unknown tool' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return makeMcpFetch()(url, opts); + }; + const res = await handler(makePostRequest({ + serverUrl: 'https://mcp.example.com/mcp', + toolName: 'nonexistent_tool', + })); + assert.equal(res.status, 422); + const data = await res.json(); + assert.match(data.error, /Unknown tool/i); + }); + + it('returns 504 on timeout during tool call', async () => { + globalThis.fetch = async () => { + const err = new Error('signal timed out'); + err.name = 'TimeoutError'; + throw err; + }; + const res = await handler(makePostRequest({ + serverUrl: 'https://mcp.example.com/mcp', + toolName: 'search', + })); + assert.equal(res.status, 504); + }); + + it('includes Cache-Control: no-store on success', async () => { + globalThis.fetch = makeMcpFetch({ callResult: { content: [] } }); + const res = await handler(makePostRequest({ + serverUrl: 'https://mcp.example.com/mcp', + toolName: 'search', + })); + assert.equal(res.status, 200); + assert.equal(res.headers.get('Cache-Control'), 'no-store'); + }); + }); + + // ── SSE transport detection ─────────────────────────────────────────────── + + describe('SSE transport routing', () => { + it('uses SSE transport when URL path ends with /sse', async () => { + let connectCalled = false; + globalThis.fetch = async (url, opts) => { + const u = typeof url === 'string' ? url : url.toString(); + // SSE connect — GET with Accept: text/event-stream + if (opts?.headers?.['Accept']?.includes('text/event-stream') || !opts?.body) { + connectCalled = true; + // Return SSE stream with endpoint event then close + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: endpoint\ndata: /messages\n\n')); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + // SSE transport returns 422 because the endpoint is /messages which resolves relative to the SSE URL domain + // and the subsequent JSON-RPC calls over SSE will fail (no real SSE server) + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/sse' })); + assert.ok(connectCalled, 'Expected SSE connect to be called'); + // Result is 422 (stream closed before endpoint or RPC error) — not a node: DNS failure + assert.ok([200, 422, 504].includes(res.status), `Unexpected status: ${res.status}`); + }); + }); + + // ── SSE SSRF protection ─────────────────────────────────────────────────── + + describe('SSE endpoint SSRF protection', () => { + it('rejects SSE endpoint event that redirects to private IP', async () => { + globalThis.fetch = async (url, opts) => { + const u = typeof url === 'string' ? url : url.toString(); + // First call = SSE connect + if (!opts?.body) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Malicious server tries to redirect to internal IP + controller.enqueue(encoder.encode('event: endpoint\ndata: http://192.168.1.100/steal\n\n')); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/sse' })); + assert.equal(res.status, 422); + const data = await res.json(); + assert.match(data.error, /blocked|SSRF|endpoint/i); + }); + }); + + // ── SSE response parsing ────────────────────────────────────────────────── + + describe('SSE content-type response parsing', () => { + it('parses JSON-RPC result from SSE response body', async () => { + const sseTools = [{ name: 'web_search', description: 'Search', inputSchema: {} }]; + globalThis.fetch = async (url, opts) => { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === 'initialize') { + const sseData = `data: ${JSON.stringify({ jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-03-26', capabilities: {} } })}\n\n`; + return new Response(sseData, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); + } + if (body.method === 'tools/list') { + const sseData = `data: ${JSON.stringify({ jsonrpc: '2.0', id: 2, result: { tools: sseTools } })}\n\n`; + return new Response(sseData, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + const res = await handler(makeGetRequest({ serverUrl: 'https://mcp.example.com/mcp' })); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.tools[0].name, 'web_search'); + }); + }); +});