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.
This commit is contained in:
Elie Habib
2026-03-20 17:23:51 +04:00
committed by GitHub
parent c6099d785a
commit 1c099e8a5a
3 changed files with 750 additions and 48 deletions

View File

@@ -17,6 +17,83 @@ export interface McpPreset {
} }
export const MCP_PRESETS: 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 <EXA_API_KEY> (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 <TAVILY_API_KEY> (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 <PERIGON_API_KEY> (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 <LUNARCRUSH_API_KEY> (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 <ALPHA_VANTAGE_API_KEY> (free at alphavantage.co)',
defaultTool: 'get_quote',
defaultArgs: { symbol: 'SPY' },
defaultTitle: 'Market Data',
},
{ {
name: 'GitHub', name: 'GitHub',
icon: '🐙', icon: '🐙',
@@ -31,7 +108,7 @@ export const MCP_PRESETS: McpPreset[] = [
name: 'Slack', name: 'Slack',
icon: '💬', icon: '💬',
description: 'Your team channels, messages, and workspace activity', 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 <SLACK_BOT_TOKEN> (xoxb-...)', authNote: 'Requires Authorization: Bearer <SLACK_BOT_TOKEN> (xoxb-...)',
defaultTool: 'slack_get_channel_history', defaultTool: 'slack_get_channel_history',
defaultArgs: { channel_name: 'general', limit: 20 }, defaultArgs: { channel_name: 'general', limit: 20 },
@@ -42,6 +119,7 @@ export const MCP_PRESETS: McpPreset[] = [
icon: '🌐', icon: '🌐',
description: 'Live internet traffic, outages, BGP anomalies, and attack trends', description: 'Live internet traffic, outages, BGP anomalies, and attack trends',
serverUrl: 'https://radar.mcp.cloudflare.com/sse', serverUrl: 'https://radar.mcp.cloudflare.com/sse',
authNote: 'Requires Authorization: Bearer <CF_API_TOKEN> (from Cloudflare dashboard)',
defaultTool: 'get_summary_attacks', defaultTool: 'get_summary_attacks',
defaultArgs: { limit: 10 }, defaultArgs: { limit: 10 },
defaultTitle: 'Internet Radar', defaultTitle: 'Internet Radar',
@@ -50,8 +128,8 @@ export const MCP_PRESETS: McpPreset[] = [
name: 'Google Maps', name: 'Google Maps',
icon: '🗺️', icon: '🗺️',
description: 'Location search, place details, directions, and geocoding', description: 'Location search, place details, directions, and geocoding',
serverUrl: 'https://maps.mcp.cloudflare.com/mcp', serverUrl: 'https://mapstools.googleapis.com/mcp',
authNote: 'Requires Authorization: Bearer <GOOGLE_MAPS_API_KEY>', authNote: 'Requires X-Goog-Api-Key: <GOOGLE_MAPS_API_KEY>',
defaultTool: 'maps_search_places', defaultTool: 'maps_search_places',
defaultArgs: { query: 'airports near Beirut', radius: 100000 }, defaultArgs: { query: 'airports near Beirut', radius: 100000 },
defaultTitle: 'Maps', defaultTitle: 'Maps',
@@ -67,10 +145,11 @@ export const MCP_PRESETS: McpPreset[] = [
defaultTitle: 'My Database', defaultTitle: 'My Database',
}, },
{ {
name: 'Web Fetch', name: 'Browser Fetch',
icon: '📄', icon: '📄',
description: 'Fetch and read content from any public URL as plain text', description: 'Fetch and read any public URL as plain text or markdown via Cloudflare Browser Rendering',
serverUrl: 'https://mcp-fetch.cloudflare.com/mcp', serverUrl: 'https://browser.mcp.cloudflare.com/mcp',
authNote: 'Requires Authorization: Bearer <CF_API_TOKEN> (from Cloudflare dashboard)',
defaultTool: 'fetch', defaultTool: 'fetch',
defaultArgs: { url: 'https://example.com', maxLength: 5000 }, defaultArgs: { url: 'https://example.com', maxLength: 5000 },
defaultTitle: 'Web Fetch', defaultTitle: 'Web Fetch',
@@ -99,8 +178,8 @@ export const MCP_PRESETS: McpPreset[] = [
name: 'Datadog', name: 'Datadog',
icon: '📈', icon: '📈',
description: 'Metrics, monitors, dashboards, and infrastructure alerts', description: 'Metrics, monitors, dashboards, and infrastructure alerts',
serverUrl: 'https://mcp.datadoghq.com/mcp', serverUrl: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp',
authNote: 'Requires DD-API-KEY and DD-APPLICATION-KEY headers', authNote: 'Requires DD-API-KEY: <KEY> and DD-APPLICATION-KEY: <KEY> headers (from Datadog → Organization Settings → API Keys)',
defaultTool: 'get_active_monitors', defaultTool: 'get_active_monitors',
defaultArgs: { tags: [], count: 20 }, defaultArgs: { tags: [], count: 20 },
defaultTitle: 'Datadog Monitors', defaultTitle: 'Datadog Monitors',
@@ -115,36 +194,6 @@ export const MCP_PRESETS: McpPreset[] = [
defaultArgs: {}, defaultArgs: {},
defaultTitle: 'Stripe Balance', 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: <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 <PERPLEXITY_API_KEY>',
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 <POLYGON_API_KEY>',
defaultTool: 'get_snapshot_all_tickers',
defaultArgs: { tickers: ['AAPL', 'MSFT', 'NVDA', 'TSLA'], include_otc: false },
defaultTitle: 'Market Snapshot',
},
{ {
name: 'Notion', name: 'Notion',
icon: '📝', icon: '📝',
@@ -165,16 +214,6 @@ export const MCP_PRESETS: McpPreset[] = [
defaultArgs: { baseId: 'appXXXXXXXXXXXXXX', tableId: 'tblXXXXXXXXXXXXXX', maxRecords: 20 }, defaultArgs: { baseId: 'appXXXXXXXXXXXXXX', tableId: 'tblXXXXXXXXXXXXXX', maxRecords: 20 },
defaultTitle: 'Airtable Records', 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: <KEY> (free at smithery.ai) and Authorization: Bearer <SHODAN_API_KEY>',
defaultTool: 'search_hosts',
defaultArgs: { query: 'port:22 country:IR', facets: 'org', page: 1 },
defaultTitle: 'Shodan Search',
},
]; ];
export interface McpToolDef { export interface McpToolDef {

243
tests/mcp-presets.test.mjs Normal file
View File

@@ -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`,
);
});
}
});

420
tests/mcp-proxy.test.mjs Normal file
View File

@@ -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');
});
});
});