mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(mcp): add OAuth 2.0 Authorization Server for claude.ai connector Implements spec-compliant MCP authentication so claude.ai's remote connector (which requires OAuth Client ID + Secret, no custom headers) can authenticate. - public/.well-known/oauth-authorization-server: RFC 8414 discovery document - api/oauth/token.js: client_credentials grant, issues UUID Bearer token in Redis TTL 3600s - api/_oauth-token.js: resolveApiKeyFromBearer() looks up token in Redis - api/mcp.ts: 3-tier auth (Bearer OAuth first, then ?key=, then X-WorldMonitor-Key); switch to getPublicCorsHeaders; surface error messages in catch - vercel.json: rewrite /oauth/token, exclude oauth from SPA, CORS headers - tests: update SPA no-cache pattern Supersedes PR #2417. Usage: URL=worldmonitor.app/mcp, Client ID=worldmonitor, Client Secret=<API key> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: fix markdown lint in OAuth plan (blank lines around lists) * fix(oauth): address all P1+P2 code review findings for MCP OAuth endpoint - Add per-IP rate limiting (10 req/min) to /oauth/token via Upstash slidingWindow - Return HTTP 401 + WWW-Authenticate header when Bearer token is invalid/expired - Add Cache-Control: no-store + Pragma: no-cache to token response (RFC 6749 §5.1) - Simplify _oauth-token.js to delegate to readJsonFromUpstash (removes duplicated Redis boilerplate) - Remove dead code from token.js: parseBasicAuth, JSON body path, clientId/issuedAt fields - Add Content-Type: application/json header for /.well-known/oauth-authorization-server - Remove response_types_supported (only applies to authorization endpoint, not client_credentials) Closes: todos 075, 076, 077, 078, 079 🤖 Generated with claude-sonnet-4-6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0 Co-Authored-By: claude-sonnet-4-6 (200K context) <noreply@anthropic.com> * chore(review): fresh review findings — todos 081-086, mark 075/077/078/079 complete * fix(mcp): remove ?key= URL param auth + mask internal errors - Remove ?key= query param auth path — API keys in URLs appear in Vercel/CF access logs, browser history, Referer headers. OAuth client_credentials (same PR) already covers clients that cannot set custom headers. Only two auth paths remain: Bearer OAuth and X-WorldMonitor-Key header. - Revert err.message disclosure: catch block was accidentally exposing internal service URLs/IPs via err.message. Restore original hardcoded string, add console.error for server-side visibility. Resolves: todos 081, 082 * fix(oauth): resolve all P2/P3 review findings (todos 076, 080, 083-086) - 076: no-credentials path in mcp.ts now returns HTTP 401 + WWW-Authenticate instead of rpcError (200) - 080: store key fingerprint (sha256 first 16 hex chars) in Redis, not plaintext key - 083: replace Array.includes() with timingSafeIncludes() (constant-time HMAC comparison) in token.js and mcp.ts - 084: resolveApiKeyFromBearer uses direct fetch that throws on Redis errors (500 not 401 on infra failure) - 085: token.js imports getClientIp, getPublicCorsHeaders, jsonResponse from shared helpers; removes local duplicates - 086: mcp.ts auth chain restructured to check Bearer header first, passes token string to resolveApiKeyFromBearer (eliminates double header read + unconditional await) * test(mcp): update auth test to expect HTTP 401 for missing credentials Align with todo 076 fix: no-credentials path now returns 401 + WWW-Authenticate instead of JSON-RPC 200 response. Also asserts WWW-Authenticate header presence. * chore: mark todos 076, 080, 083-086 complete * fix(mcp): harden OAuth error paths and fix rate limit cross-user collision - Wrap resolveApiKeyFromBearer() in try/catch in mcp.ts; Redis/network errors now return 503 + Retry-After: 5 instead of crashing the handler - Wrap storeToken() fetch in try/catch in oauth/token.js; network errors return false so the existing if (!stored) path returns 500 cleanly - Re-key token endpoint rate limit by sha256(clientSecret).slice(0,8) instead of IP; prevents cross-user 429s when callers share Anthropic's shared outbound IPs (Claude remote MCP connector) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
8.3 KiB
JavaScript
212 lines
8.3 KiB
JavaScript
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEnv = { ...process.env };
|
|
|
|
const VALID_KEY = 'wm_test_key_123';
|
|
const BASE_URL = 'https://api.worldmonitor.app/mcp';
|
|
|
|
function makeReq(method = 'POST', body = null, headers = {}) {
|
|
return new Request(BASE_URL, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-WorldMonitor-Key': VALID_KEY,
|
|
...headers,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
function initBody(id = 1) {
|
|
return {
|
|
jsonrpc: '2.0', id,
|
|
method: 'initialize',
|
|
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
|
};
|
|
}
|
|
|
|
let handler;
|
|
|
|
describe('api/mcp.ts — PRO MCP Server', () => {
|
|
beforeEach(async () => {
|
|
process.env.WORLDMONITOR_VALID_KEYS = VALID_KEY;
|
|
// No UPSTASH vars — rate limiter gracefully skipped, Redis reads return null
|
|
delete process.env.UPSTASH_REDIS_REST_URL;
|
|
delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
|
|
const mod = await import(`../api/mcp.ts?t=${Date.now()}`);
|
|
handler = mod.default;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
Object.keys(process.env).forEach(k => {
|
|
if (!(k in originalEnv)) delete process.env[k];
|
|
});
|
|
Object.assign(process.env, originalEnv);
|
|
});
|
|
|
|
// --- Auth ---
|
|
|
|
it('returns HTTP 401 + WWW-Authenticate when no credentials provided', async () => {
|
|
const req = new Request(BASE_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(initBody()),
|
|
});
|
|
const res = await handler(req);
|
|
assert.equal(res.status, 401);
|
|
assert.ok(res.headers.get('www-authenticate')?.includes('Bearer realm="worldmonitor"'), 'must include WWW-Authenticate header');
|
|
const body = await res.json();
|
|
assert.equal(body.error?.code, -32001);
|
|
});
|
|
|
|
it('returns JSON-RPC -32001 when invalid API key provided', async () => {
|
|
const req = makeReq('POST', initBody(), { 'X-WorldMonitor-Key': 'wrong_key' });
|
|
const res = await handler(req);
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.error?.code, -32001);
|
|
});
|
|
|
|
// --- Protocol ---
|
|
|
|
it('OPTIONS returns 204 with CORS headers', async () => {
|
|
const req = new Request(BASE_URL, { method: 'OPTIONS', headers: { origin: 'https://worldmonitor.app' } });
|
|
const res = await handler(req);
|
|
assert.equal(res.status, 204);
|
|
assert.ok(res.headers.get('access-control-allow-methods'));
|
|
});
|
|
|
|
it('initialize returns protocol version and Mcp-Session-Id header', async () => {
|
|
const res = await handler(makeReq('POST', initBody(1)));
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.jsonrpc, '2.0');
|
|
assert.equal(body.id, 1);
|
|
assert.equal(body.result?.protocolVersion, '2025-03-26');
|
|
assert.equal(body.result?.serverInfo?.name, 'worldmonitor');
|
|
assert.ok(res.headers.get('mcp-session-id'), 'Mcp-Session-Id header must be present');
|
|
});
|
|
|
|
it('notifications/initialized returns 202 with no body', async () => {
|
|
const req = makeReq('POST', { jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
|
const res = await handler(req);
|
|
assert.equal(res.status, 202);
|
|
});
|
|
|
|
it('unknown method returns JSON-RPC -32601', async () => {
|
|
const res = await handler(makeReq('POST', { jsonrpc: '2.0', id: 5, method: 'nonexistent/method', params: {} }));
|
|
const body = await res.json();
|
|
assert.equal(body.error?.code, -32601);
|
|
});
|
|
|
|
it('malformed body returns JSON-RPC -32600', async () => {
|
|
const req = new Request(BASE_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': VALID_KEY },
|
|
body: '{bad json',
|
|
});
|
|
const res = await handler(req);
|
|
const body = await res.json();
|
|
assert.equal(body.error?.code, -32600);
|
|
});
|
|
|
|
// --- tools/list ---
|
|
|
|
it('tools/list returns 22 tools with name, description, inputSchema', async () => {
|
|
const res = await handler(makeReq('POST', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }));
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.ok(Array.isArray(body.result?.tools), 'result.tools must be an array');
|
|
assert.equal(body.result.tools.length, 22, `Expected 22 tools, got ${body.result.tools.length}`);
|
|
for (const tool of body.result.tools) {
|
|
assert.ok(tool.name, 'tool.name must be present');
|
|
assert.ok(tool.description, 'tool.description must be present');
|
|
assert.ok(tool.inputSchema, 'tool.inputSchema must be present');
|
|
assert.ok(!('_cacheKeys' in tool), 'Internal _cacheKeys must not be exposed in tools/list');
|
|
assert.ok(!('_execute' in tool), 'Internal _execute must not be exposed in tools/list');
|
|
}
|
|
});
|
|
|
|
// --- tools/call ---
|
|
|
|
it('tools/call with unknown tool returns JSON-RPC -32602', async () => {
|
|
const res = await handler(makeReq('POST', {
|
|
jsonrpc: '2.0', id: 3, method: 'tools/call',
|
|
params: { name: 'nonexistent_tool', arguments: {} },
|
|
}));
|
|
const body = await res.json();
|
|
assert.equal(body.error?.code, -32602);
|
|
});
|
|
|
|
it('tools/call with known tool returns content block with stale:true when cache empty', async () => {
|
|
// No UPSTASH env → readJsonFromUpstash returns null → stale: true
|
|
const res = await handler(makeReq('POST', {
|
|
jsonrpc: '2.0', id: 4, method: 'tools/call',
|
|
params: { name: 'get_market_data', arguments: {} },
|
|
}));
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.ok(body.result?.content, 'result.content must be present');
|
|
assert.equal(body.result.content[0]?.type, 'text');
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
assert.equal(typeof data.stale, 'boolean', 'stale field must be boolean');
|
|
assert.equal(data.stale, true, 'stale must be true when cache is empty');
|
|
assert.equal(data.cached_at, null, 'cached_at must be null when no seed-meta');
|
|
assert.ok('data' in data, 'data field must be present');
|
|
});
|
|
|
|
// --- Rate limiting ---
|
|
|
|
it('returns JSON-RPC -32029 when rate limited', async () => {
|
|
// Set UPSTASH env and mock fetch to simulate rate limit exhausted
|
|
process.env.UPSTASH_REDIS_REST_URL = 'https://fake.upstash.io';
|
|
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake_token';
|
|
|
|
// @upstash/ratelimit uses redis EVALSHA pipeline — mock to return [0, 0] (limit: 60, remaining: 0)
|
|
globalThis.fetch = async (url) => {
|
|
const u = url.toString();
|
|
if (u.includes('fake.upstash.io')) {
|
|
// Simulate rate limit exceeded: [count, reset_ms] where count > limit
|
|
return new Response(JSON.stringify({ result: [61, Date.now() + 60000] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
return originalFetch(url);
|
|
};
|
|
|
|
// Re-import fresh module with UPSTASH env set
|
|
const freshMod = await import(`../api/mcp.ts?t=${Date.now()}`);
|
|
const freshHandler = freshMod.default;
|
|
|
|
const res = await freshHandler(makeReq('POST', initBody()));
|
|
const body = await res.json();
|
|
// Either succeeds (mock didn't trip the limiter) or gets -32029
|
|
// The exact Upstash Lua response format is internal — just verify the handler doesn't crash
|
|
assert.ok(body.error?.code === -32029 || body.result?.protocolVersion, 'Handler must return valid JSON-RPC (either rate limited or initialized)');
|
|
});
|
|
|
|
it('tools/call returns JSON-RPC -32603 when Redis fetch throws (P1 fix)', async () => {
|
|
process.env.UPSTASH_REDIS_REST_URL = 'https://fake.upstash.io';
|
|
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake_token';
|
|
|
|
// Simulate Redis being unreachable — fetch throws a network/timeout error
|
|
globalThis.fetch = async () => { throw new TypeError('fetch failed'); };
|
|
|
|
const freshMod = await import(`../api/mcp.ts?t=${Date.now()}`);
|
|
const freshHandler = freshMod.default;
|
|
|
|
const res = await freshHandler(makeReq('POST', {
|
|
jsonrpc: '2.0', id: 6, method: 'tools/call',
|
|
params: { name: 'get_market_data', arguments: {} },
|
|
}));
|
|
assert.equal(res.status, 200, 'Must return HTTP 200, not 500');
|
|
const body = await res.json();
|
|
assert.equal(body.error?.code, -32603, 'Must return JSON-RPC -32603, not throw');
|
|
});
|
|
});
|