mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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 <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',
|
||||
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 <SLACK_BOT_TOKEN> (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 <CF_API_TOKEN> (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 <GOOGLE_MAPS_API_KEY>',
|
||||
serverUrl: 'https://mapstools.googleapis.com/mcp',
|
||||
authNote: 'Requires X-Goog-Api-Key: <GOOGLE_MAPS_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 <CF_API_TOKEN> (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: <KEY> and DD-APPLICATION-KEY: <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: <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',
|
||||
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: <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 {
|
||||
|
||||
243
tests/mcp-presets.test.mjs
Normal file
243
tests/mcp-presets.test.mjs
Normal 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
420
tests/mcp-proxy.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user