From 5f73fcbcf4e36d33fcd779a8982d384475ddf17e Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 29 Oct 2026 16:19:53 +0700 Subject: [PATCH] mcp --http: one Streamable HTTP server for every session (token or OAuth introspection auth, project argument per call, roots, origin check) --- cli/commands.ts | 2 +- cli/data/flags.json | 27 +++++- cli/mcp-cmd.ts | 13 +++ cli/mcp-http.ts | 214 +++++++++++++++++++++++++++++++++++++++++ tests/mcp-http.test.ts | 105 ++++++++++++++++++++ 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 cli/mcp-cmd.ts create mode 100644 cli/mcp-http.ts create mode 100644 tests/mcp-http.test.ts diff --git a/cli/commands.ts b/cli/commands.ts index 6e05b40..f644811 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -10,7 +10,7 @@ export const PLAIN: Record Promise, string]> = { 'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'], 'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'], 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], - 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], + 'mcp': [() => import('./mcp-cmd'), 'MCP server wrapping these commands (for any MCP-capable AI tool): stdio, or --http [--port 7787] [--roots ] [--auth token|introspect] for one server every session shares'], 'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook|ingest; test sends a test event; --dry, --dir , --json (read-only on the stores)'], 'status': [() => import('./status'), '[--app ] [--json]: per platform the live and next version, build, track, review state and rollout %, without uploading (read-only; read by ship pipelines, docs/ship-step.md)'], 'events': [() => import('./events'), '[--since 24h] [--app ] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 3a24466..d813c82 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -645,7 +645,21 @@ "to", "translate" ], - "mcp": [], + "mcp": [ + "allow-origin", + "allow-remote-token", + "auth", + "host", + "http", + "introspect-key-file", + "introspect-url", + "issuer", + "port", + "project", + "resource", + "roots", + "token-file" + ], "onboard": [ "accounts", "all", @@ -1159,7 +1173,9 @@ "allow-availability-changes", "allow-device-gap", "allow-existing", + "allow-origin", "allow-price-changes", + "allow-remote-token", "allow-ssh", "allow-stale-web", "allow-version-mismatch", @@ -1172,6 +1188,7 @@ "apps", "archive", "aso", + "auth", "base", "base-url", "beta", @@ -1243,6 +1260,8 @@ "headless", "home", "hook", + "host", + "http", "icon", "icons", "identity", @@ -1251,9 +1270,12 @@ "install", "internal", "internal-only", + "introspect-key-file", + "introspect-url", "invite", "ios", "issue", + "issuer", "issues", "jobs", "keep", @@ -1350,10 +1372,12 @@ "render", "repair-stuck", "resolve-iaps", + "resource", "result-file", "resume", "review-sent", "root", + "roots", "scan-paths", "scenes", "scheduled", @@ -1387,6 +1411,7 @@ "threshold", "timeout", "to", + "token-file", "top", "track", "tracking-domains", diff --git a/cli/mcp-cmd.ts b/cli/mcp-cmd.ts new file mode 100644 index 0000000..390dcb2 --- /dev/null +++ b/cli/mcp-cmd.ts @@ -0,0 +1,13 @@ +// `storebound mcp`: stdio by default (one process per agent session), or `--http` for one shared server +// (cli/mcp-http.ts: every session of the machine, or a team behind OAuth). +import type { parseArgs } from './lib/args'; + +export async function run(a: ReturnType): Promise { + if (!a.has('http')) return (await import('./mcp')).serve(); + const { serveHttp } = await import('./mcp-http'); + await serveHttp({ + host: a.get('host'), port: a.get('port'), roots: a.get('roots'), project: a.get('project'), allowOrigin: a.get('allow-origin'), + auth: a.get('auth'), tokenFile: a.get('token-file'), introspectUrl: a.get('introspect-url'), introspectKeyFile: a.get('introspect-key-file'), + resource: a.get('resource'), issuer: a.get('issuer'), allowRemoteToken: a.has('allow-remote-token'), + }); +} diff --git a/cli/mcp-http.ts b/cli/mcp-http.ts new file mode 100644 index 0000000..0c30e08 --- /dev/null +++ b/cli/mcp-http.ts @@ -0,0 +1,214 @@ +// `storebound mcp --http`: the same MCP server over Streamable HTTP, so one process serves every agent session on a +// machine (or one host serves a whole team) instead of one stdio process per session. +// storebound mcp --http [--host 127.0.0.1] [--port 7787] [--roots ] [--project ] +// [--auth token|introspect] [--token-file ~/.storebound/mcp-token] +// [--introspect-url --introspect-key-file --resource --issuer ] +// Transport (https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http): one endpoint, +// /mcp. POST carries one JSON-RPC message and gets one JSON answer (202 for a notification); GET and DELETE are 405 +// (no server-initiated stream, no session state: every call is independent, so no Mcp-Session-Id is issued). The +// Origin header, when sent, must be a localhost origin unless --allow-origin names it (DNS rebinding). +// Project: every tool gets a `project` argument, the absolute path of the app folder the CLI runs in. It must be inside +// --roots (default: the home folder); --project (or STOREBOUND_PROJECT) is the default when a call has none. +// Auth (every request to /mcp): +// token (default): `Authorization: Bearer `, the token in --token-file (made with mode 600 on first start; +// never printed). Clients: claude mcp add --transport http storebound http://127.0.0.1:7787/mcp +// --header "Authorization: Bearer $(cat ~/.storebound/mcp-token)". +// introspect: an OAuth 2.0 bearer token checked at --introspect-url (RFC 7662; this server authenticates with the +// key in --introspect-key-file). The token must be active, carry the `mcp` scope and name --resource in aud +// (RFC 8707). Without a valid token: 401 with WWW-Authenticate: Bearer resource_metadata=".../.well-known/ +// oauth-protected-resource/mcp" (RFC 9728), which names --issuer as the authorization server. Answers are cached +// for 60 s per token (hashed); the token itself is never logged. +import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import pkg from '../package.json' with { type: 'json' }; +import { handle, LEGACY, PROTOCOL } from './mcp'; +import { runCommand } from './lib/runner'; + +export const DEFAULT_PORT = 7787; +const MAX_BODY = 4 * 1024 * 1024; + +export interface HttpOptions { + host: string; + port: number; + roots: string[]; + project: string | null; + allowOrigins: string[]; + auth: { kind: 'token'; token: string } | { kind: 'introspect'; url: string; key: string; resource: string; issuer: string }; +} + +export const PROJECT_PROP = { type: 'string', description: 'Absolute path of the app folder (the one with storebound.config.ts) to run in. Required unless the server was started with --project.' } as const; + +/** Inside one of the roots (path compare, case-insensitive on Windows); no "..", no other drive. */ +export function insideRoots(dir: string, roots: string[]): boolean { + const norm = (p: string) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p)); + return roots.some(r => { const rel = relative(norm(r), norm(dir)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); }); +} + +/** The folder a call runs in, or the reason it cannot run. */ +export function projectFor(arg: unknown, o: Pick): { dir: string } | { error: string } { + if (arg === undefined || arg === null || arg === '') return o.project ? { dir: o.project } : { error: 'project: required (absolute path of the app folder)' }; + if (typeof arg !== 'string' || !isAbsolute(arg)) return { error: 'project: must be an absolute path' }; + if (!insideRoots(arg, o.roots)) return { error: `project: ${arg} is outside the server's roots (${o.roots.join(', ')})` }; + try { if (!statSync(arg).isDirectory()) return { error: `project: ${arg} is not a folder` }; } catch { return { error: `project: ${arg} does not exist` }; } + return { dir: resolve(arg) }; +} + +/** Localhost origins are always fine; others only when listed. No Origin header (non-browser clients) is fine. */ +export function originAllowed(origin: string | undefined, allow: string[]): boolean { + if (!origin) return true; + if (allow.includes(origin)) return true; + try { const u = new URL(origin); return ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname); } catch { return false; } +} + +/** The protected resource metadata URL for a resource URL (RFC 9728 section 3: the path goes after the well-known part). */ +export function metadataUrl(resource: string): string { + const u = new URL(resource); + return `${u.origin}/.well-known/oauth-protected-resource${u.pathname === '/' ? '' : u.pathname}`; +} + +/** Introspection answer -> allowed? (active, `mcp` scope, our resource in aud). */ +export function introspectionOk(j: unknown, resource: string): boolean { + if (!j || typeof j !== 'object') return false; + const r = j as { active?: unknown; scope?: unknown; aud?: unknown }; + if (r.active !== true) return false; + const scopes = typeof r.scope === 'string' ? r.scope.split(/\s+/) : []; + if (!scopes.includes('mcp')) return false; + const aud = Array.isArray(r.aud) ? r.aud : typeof r.aud === 'string' ? [r.aud] : []; + return aud.includes(resource); +} + +const bearer = (req: IncomingMessage): string | null => { const h = req.headers.authorization ?? ''; const m = /^Bearer\s+(\S+)$/i.exec(h); return m ? m[1]! : null; }; + +const cache = new Map(); +async function authorized(req: IncomingMessage, o: HttpOptions): Promise { + const t = bearer(req); + if (!t) return false; + if (o.auth.kind === 'token') { + const a = createHash('sha256').update(t).digest(), b = createHash('sha256').update(o.auth.token).digest(); + return a.equals(b); + } + const key = createHash('sha256').update(t).digest('hex'); + const hit = cache.get(key); + if (hit && hit.until > Date.now()) return hit.ok; + let ok = false; + try { + const r = await fetch(o.auth.url, { method: 'POST', signal: AbortSignal.timeout(10_000), + headers: { authorization: `Bearer ${o.auth.key}`, 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams({ token: t }).toString() }); + ok = r.ok && introspectionOk(await r.json(), o.auth.resource); + } catch { return false; } // no cache on a network error + cache.set(key, { ok, until: Date.now() + 60_000 }); + return ok; +} + +function send(res: ServerResponse, status: number, body?: unknown, headers: Record = {}): void { + res.writeHead(status, { 'cache-control': 'no-store', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers }); + res.end(body === undefined ? undefined : JSON.stringify(body)); +} + +async function readBody(req: IncomingMessage): Promise { + let size = 0; const parts: Buffer[] = []; + for await (const c of req) { size += (c as Buffer).length; if (size > MAX_BODY) return null; parts.push(c as Buffer); } + return Buffer.concat(parts).toString('utf8'); +} + +/** One POST body -> the answer (null for a notification). tools/list gains `project`; tools/call runs in it. */ +export async function answer(msg: Record, o: Pick, + run: (argv: string[], cwd: string) => Promise<{ code: number; out: string }> = runIn): Promise | null> { + const params = (msg.params ?? {}) as Record; + if (msg.method === 'tools/call') { + const args = { ...((params.arguments ?? {}) as Record) }; + const p = projectFor(args.project, o); + if ('error' in p) return { jsonrpc: '2.0', id: msg.id ?? null, result: { content: [{ type: 'text', text: `Invalid arguments: ${p.error}` }], isError: true } }; + delete args.project; + return handle({ ...msg, params: { ...params, arguments: args } } as never, argv => run(argv, p.dir)); + } + const r = await handle(msg as never); + if (msg.method === 'tools/list' && r && typeof r.result === 'object') { + const tools = (r.result as { tools?: { inputSchema: { properties: Record; required?: string[] } }[] }).tools ?? []; + for (const t of tools) { + t.inputSchema.properties = { project: PROJECT_PROP, ...t.inputSchema.properties }; + if (!o.project) t.inputSchema.required = ['project', ...(t.inputSchema.required ?? [])]; + } + } + return r; +} + +async function runIn(argv: string[], cwd: string): Promise<{ code: number; out: string }> { + const r = runCommand(argv, { cwd, env: { STOREBOUND_MCP: '1' } }); + const { code, out } = await r.result; + return { code, out }; +} + +export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: number }> { + const resourceMeta = o.auth.kind === 'introspect' ? metadataUrl(o.auth.resource) : null; + const server = createServer(async (req, res) => { + const path = (req.url ?? '/').split('?')[0]; + if (resourceMeta && o.auth.kind === 'introspect' && req.method === 'GET' && path === new URL(resourceMeta).pathname) + return send(res, 200, { resource: o.auth.resource, authorization_servers: [o.auth.issuer], bearer_methods_supported: ['header'], scopes_supported: ['mcp'], resource_name: 'storebound' }); + if (path === '/health' && req.method === 'GET') return send(res, 200, { ok: true, version: pkg.version }); + if (path !== '/mcp') return send(res, 404, { error: 'not found' }); + if (!originAllowed(req.headers.origin, o.allowOrigins)) return send(res, 403, { error: 'origin not allowed' }); + if (!(await authorized(req, o))) + return send(res, 401, { error: 'unauthorized' }, { 'www-authenticate': resourceMeta ? `Bearer resource_metadata="${resourceMeta}"` : 'Bearer' }); + if (req.method !== 'POST') return send(res, 405, { error: 'POST only (no server-initiated stream)' }, { allow: 'POST' }); + const body = await readBody(req); + if (body === null) return send(res, 413, { error: 'body too large' }); + let msg: unknown; + try { msg = JSON.parse(body); } catch { return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); } + if (Array.isArray(msg) || typeof msg !== 'object' || msg === null) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'One JSON-RPC message per request' } }); + try { + const r = await answer(msg as Record, o); + return r ? send(res, 200, r) : send(res, 202); + } catch (e) { return send(res, 500, { jsonrpc: '2.0', id: (msg as { id?: unknown }).id ?? null, error: { code: -32603, message: String((e as Error).message) } }); } + }); + return new Promise((ok, fail) => { + server.once('error', fail); + server.listen(o.port, o.host, () => { + const a = server.address(); const port = typeof a === 'object' && a ? a.port : o.port; + ok({ close: () => server.close(), port }); + }); + }); +} + +/** The local bearer token: read, or made (32 random bytes, hex, mode 600) on first start. */ +export function tokenFrom(file: string): string { + if (existsSync(file)) { const t = readFileSync(file, 'utf8').trim(); if (t.length >= 32) return t; } + mkdirSync(dirname(file), { recursive: true }); + const t = randomBytes(32).toString('hex'); + writeFileSync(file, `${t}\n`, { mode: 0o600 }); + try { chmodSync(file, 0o600); } catch { /* Windows */ } + return t; +} + +const home = (p: string) => (p.startsWith('~') ? join(homedir(), p.slice(1)) : p); + +export interface HttpFlags { host?: string; port?: string; roots?: string; project?: string; allowOrigin?: string; auth?: string; tokenFile?: string; + introspectUrl?: string; introspectKeyFile?: string; resource?: string; issuer?: string; allowRemoteToken?: boolean } + +export async function serveHttp(f: HttpFlags): Promise { + const host = f.host ?? '127.0.0.1'; + const port = Number(f.port ?? DEFAULT_PORT); + const roots = (f.roots ?? homedir()).split(',').map(s => resolve(home(s.trim()))).filter(Boolean); + const projectArg = f.project ?? process.env.STOREBOUND_PROJECT; + const project = projectArg ? resolve(home(projectArg)) : null; + const allowOrigins = (f.allowOrigin ?? '').split(',').map(s => s.trim()).filter(Boolean); + const kind = f.auth ?? 'token'; + let auth: HttpOptions['auth']; + if (kind === 'introspect') { + const { introspectUrl: url, introspectKeyFile: keyFile, resource, issuer } = f; + if (!url || !keyFile || !resource || !issuer) throw new Error('--auth introspect needs --introspect-url, --introspect-key-file, --resource and --issuer'); + auth = { kind, url, key: readFileSync(home(keyFile), 'utf8').trim(), resource, issuer }; + } else if (kind === 'token') { + const file = home(f.tokenFile ?? '~/.storebound/mcp-token'); + auth = { kind, token: tokenFrom(file) }; + console.error(`[storebound mcp] bearer token in ${file}`); + } else throw new Error('--auth token | introspect'); + if (!['127.0.0.1', 'localhost', '::1'].includes(host) && kind === 'token' && !f.allowRemoteToken) + throw new Error(`--host ${host} listens beyond this machine: use --auth introspect (or --allow-remote-token behind your own TLS proxy)`); + const s = await startHttp({ host, port, roots, project, allowOrigins, auth }); + console.error(`[storebound mcp] ${pkg.version} on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), auth ${kind}, roots ${roots.join(', ')}${project ? `, default project ${project}` : ''}`); +} diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts new file mode 100644 index 0000000..af1f031 --- /dev/null +++ b/tests/mcp-http.test.ts @@ -0,0 +1,105 @@ +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; 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 = {}) => + 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(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(); } + }); +});