storebound/tests/mcp-http.test.ts

105 lines
7.5 KiB
TypeScript

import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { answer, insideRoots, introspectionOk, metadataUrl, originAllowed, projectFor, startHttp, tokenFrom } from '../cli/mcp-http';
const root = mkdtempSync(join(tmpdir(), 'mcph-'));
const app = mkdtempSync(join(root, 'app-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('pure parts', () => {
test('roots and project', () => {
expect(insideRoots(app, [root])).toBe(true);
expect(insideRoots(join(root, '..'), [root])).toBe(false);
expect(projectFor(undefined, { roots: [root], project: null })).toEqual({ error: 'project: required (absolute path of the app folder)' });
expect(projectFor(undefined, { roots: [root], project: app })).toEqual({ dir: app });
expect(projectFor('rel/dir', { roots: [root], project: null })).toEqual({ error: 'project: must be an absolute path' });
expect('error' in projectFor(tmpdir(), { roots: [root], project: null })).toBe(true);
expect('error' in projectFor(join(root, 'missing'), { roots: [root], project: null })).toBe(true);
expect(projectFor(app, { roots: [root], project: null })).toEqual({ dir: app });
});
test('origin, metadata url, introspection', () => {
expect(originAllowed(undefined, [])).toBe(true);
expect(originAllowed('http://localhost:3000', [])).toBe(true);
expect(originAllowed('https://evil.example.com', [])).toBe(false);
expect(originAllowed('https://app.example.com', ['https://app.example.com'])).toBe(true);
expect(metadataUrl('https://sb.example.com/mcp')).toBe('https://sb.example.com/.well-known/oauth-protected-resource/mcp');
const r = 'https://sb.example.com/mcp';
expect(introspectionOk({ active: true, scope: 'mcp apps:read', aud: [r] }, r)).toBe(true);
expect(introspectionOk({ active: true, scope: 'apps:read', aud: [r] }, r)).toBe(false);
expect(introspectionOk({ active: true, scope: 'mcp', aud: ['https://other.example.com/mcp'] }, r)).toBe(false);
expect(introspectionOk({ active: false }, r)).toBe(false);
});
test('token file is made once, mode 600 off Windows', () => {
const f = join(root, 'tok', 'mcp-token');
const t = tokenFrom(f);
expect(t).toMatch(/^[0-9a-f]{64}$/);
expect(tokenFrom(f)).toBe(t);
if (process.platform !== 'win32') expect(statSync(f).mode & 0o777).toBe(0o600);
});
test('tools/list gains a required project; tools/call runs in it and drops it from the arguments', async () => {
const list = await answer({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { roots: [root], project: null });
const check = ((list!.result as { tools: { name: string; inputSchema: { properties: Record<string, unknown>; required?: string[] } }[] }).tools).find(t => t.name === 'check')!;
expect(check.inputSchema.properties.project).toBeDefined();
expect(check.inputSchema.required).toContain('project');
let seen = null as { argv: string[]; cwd: string } | null;
const r = await answer({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'check', arguments: { project: app } } }, { roots: [root], project: null },
async (argv, cwd) => { seen = { argv, cwd }; return { code: 0, out: 'ok' }; });
expect(seen).toEqual({ argv: ['check'], cwd: app });
expect((r!.result as { isError: boolean }).isError).toBe(false);
const bad = await answer({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check', arguments: {} } }, { roots: [root], project: null }, async () => { throw new Error('must not run'); });
expect((bad!.result as { isError: boolean }).isError).toBe(true);
});
});
describe('http server', () => {
const post = (port: number, body: unknown, headers: Record<string, string> = {}) =>
fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers }, body: JSON.stringify(body) });
test('token auth: 401 without, initialize with, 202 for a notification, 405 for GET, 403 for a foreign origin', async () => {
const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: null, allowOrigins: [], auth: { kind: 'token', token: 'a'.repeat(64) } });
try {
const auth = { authorization: `Bearer ${'a'.repeat(64)}` };
expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' })).status).toBe(401);
expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { authorization: 'Bearer nope' })).status).toBe(401);
const init = await post(s.port, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } } }, auth);
expect(init.status).toBe(200);
expect(((await init.json()) as { result: { protocolVersion: string } }).result.protocolVersion).toBe('2025-06-18');
expect((await post(s.port, { jsonrpc: '2.0', method: 'notifications/initialized' }, auth)).status).toBe(202);
expect((await fetch(`http://127.0.0.1:${s.port}/mcp`, { headers: auth })).status).toBe(405);
expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { ...auth, origin: 'https://evil.example.com' })).status).toBe(403);
expect((await post(s.port, [{ jsonrpc: '2.0', id: 1, method: 'ping' }], auth)).status).toBe(400);
} finally { s.close(); }
});
test('introspect auth: metadata document, 401 names it, a valid token passes, a token without mcp scope does not', async () => {
const resource = 'https://sb.example.com/mcp';
let calls = 0;
const idp = createServer((req, res) => {
calls++;
let b = ''; req.on('data', c => (b += c)); req.on('end', () => {
const tok = new URLSearchParams(b).get('token');
const ok = req.headers.authorization === 'Bearer server-key';
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(!ok ? { active: false } : tok === 'good' ? { active: true, scope: 'mcp', aud: [resource] } : tok === 'noscope' ? { active: true, scope: 'apps', aud: [resource] } : { active: false }));
});
});
await new Promise<void>(r => idp.listen(0, '127.0.0.1', () => r()));
const ip = (idp.address() as { port: number }).port;
const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: app, allowOrigins: [],
auth: { kind: 'introspect', url: `http://127.0.0.1:${ip}/introspect`, key: 'server-key', resource, issuer: 'https://idp.example.com' } });
try {
const meta = await fetch(`http://127.0.0.1:${s.port}/.well-known/oauth-protected-resource/mcp`);
expect(await meta.json()).toMatchObject({ resource, authorization_servers: ['https://idp.example.com'], scopes_supported: ['mcp'] });
const no = await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' });
expect(no.status).toBe(401);
expect(no.headers.get('www-authenticate')).toBe('Bearer resource_metadata="https://sb.example.com/.well-known/oauth-protected-resource/mcp"');
expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { authorization: 'Bearer good' })).status).toBe(200);
expect((await post(s.port, { jsonrpc: '2.0', id: 2, method: 'ping' }, { authorization: 'Bearer good' })).status).toBe(200);
expect(calls).toBe(1); // cached
expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { authorization: 'Bearer noscope' })).status).toBe(401);
} finally { s.close(); idp.close(); }
});
});