storebound/tests/mcp-http.test.ts

161 lines
12 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 { basename, 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, or a folder name under the roots' });
expect(projectFor(basename(app), { roots: [root], project: null })).toEqual({ dir: app });
expect('error' in projectFor('nope', { roots: [root], project: null })).toBe(true);
expect(projectFor(app.split(/[\/]/).pop(), { roots: [root], project: null })).toEqual({ dir: app });
expect('error' in projectFor('nope', { roots: [root], project: null })).toBe(true);
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(); }
});
});
describe('read-only and broker profiles', () => {
test('read-only lists only read tools and refuses a write tool', async () => {
const list = await answer({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { roots: [root], project: app, readOnly: true });
const tools = (list!.result as { tools: { name: string; annotations: { readOnlyHint: boolean } }[] }).tools;
expect(tools.length).toBeGreaterThan(5);
expect(tools.every(t => t.annotations.readOnlyHint)).toBe(true);
const w = await answer({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'play_listing', arguments: {} } }, { roots: [root], project: app, readOnly: true }, async () => { throw new Error('must not run'); });
expect((w as { error: { message: string } }).error.message).toContain('read-only');
});
test('broker store credentials become STOREBOUND_PROFILE_<NAME>_* env', async () => {
const { brokerProfileEnv } = await import('../cli/lib/broker');
const srv = createServer((req, res) => {
res.setHeader('content-type', 'application/json');
const m = /^\/api\/store-credentials\/([^/]+)\/([^/]+)$/.exec(req.url ?? '');
if (req.headers.authorization !== 'Bearer kkkkkkkk') { res.statusCode = 401; return res.end('{}'); }
if (m?.[1] === 'teamA' && m[2] === 'asc-api-key') return res.end(JSON.stringify({ secret: '-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----', metadata: { issuerId: 'iss', keyId: 'KEY' } }));
if (m?.[1] === 'teamA' && m[2] === 'play-service-account') return res.end(JSON.stringify({ secret: '{"client_email":"a@example.com"}', metadata: {} }));
res.statusCode = 404; res.end('{"error":"err_not_found"}');
});
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()));
const port = (srv.address() as { port: number }).port;
const keyFile = join(root, 'broker.key');
(await import('node:fs')).writeFileSync(keyFile, 'kkkkkkkk', { mode: 0o600 });
try {
const env = await brokerProfileEnv({ url: `http://127.0.0.1:${port}`, keyFile, account: null, from: 'test' }, ['teamA', 'other']);
expect(Object.keys(env).sort()).toEqual(['STOREBOUND_PROFILE_TEAMA_ASC_ISSUER_ID', 'STOREBOUND_PROFILE_TEAMA_ASC_KEY_ID', 'STOREBOUND_PROFILE_TEAMA_ASC_P8', 'STOREBOUND_PROFILE_TEAMA_PLAY_SERVICE_ACCOUNT']);
expect(env.STOREBOUND_PROFILE_TEAMA_ASC_KEY_ID).toBe('KEY');
} finally { srv.close(); }
});
});
describe('long calls over SSE', () => {
test('a tools/call that accepts text/event-stream gets keepalive comments, then the answer as one event', async () => {
const { setKeepaliveMs } = await import('../cli/mcp-http');
setKeepaliveMs(50);
const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: app, allowOrigins: [], auth: { kind: 'token', token: 'b'.repeat(64) } });
try {
const r = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'POST',
headers: { authorization: `Bearer ${'b'.repeat(64)}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'check', arguments: { project: '/nope' } } }) });
expect(r.headers.get('content-type')).toBe('text/event-stream');
const text = await r.text();
expect(text.startsWith(': started')).toBe(true);
const data = /event: message\ndata: (.*)\n\n$/.exec(text)!;
expect(JSON.parse(data[1]!)).toMatchObject({ id: 9, result: { isError: true } });
// JSON for everything else
const p = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'POST', headers: { authorization: `Bearer ${'b'.repeat(64)}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) });
expect(p.headers.get('content-type')).toBe('application/json');
} finally { s.close(); setKeepaliveMs(20_000); }
});
});