import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import express from 'express'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import mcp from '../src/mcp.js'; import { parseKeys } from '../src/keys.js'; describe('Ghost Browser MCP', () => { let http, url, calls; beforeAll(async () => { calls = []; const app = express(); app.use(express.json()); mcp.mount(app, { keys: parseKeys('key-one:solo'), requestApi: async (req) => { calls.push(req); if (req.path.endsWith('/screenshot')) return { ok: true, data: 'aW1hZ2U=' }; if (req.path.endsWith('/navigate')) return { ok: false, data: { error: 'private address blocked' } }; return { ok: true, data: { sessionId: 's-abc-123' } }; } }); http = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); }); url = new URL(`http://127.0.0.1:${http.address().port}/mcp`); }); afterAll(() => new Promise(resolve => http.close(resolve))); it('rejects missing or invalid credentials and browser origins', async () => { const send = (headers) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) }); expect((await send({})).status).toBe(401); expect((await send({ authorization: 'Bearer wrong' })).status).toBe(401); expect((await send({ authorization: 'Bearer key-one', origin: 'https://example.com' })).status).toBe(403); expect((await send({ authorization: 'Bearer key-one', origin: 'https://claude.ai', accept: 'application/json, text/event-stream' })).status).not.toBe(403); }); it('accepts the key as x-api-key (a browser reached through its own cluster API proxy) and forwards it', async () => { const send = (headers) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'browser_list', arguments: {} } }) }); expect((await send({ 'x-api-key': 'wrong' })).status).toBe(401); calls.length = 0; const res = await send({ 'x-api-key': 'key-one' }); expect(res.status).toBe(200); expect(calls[0].authorization).toBe('Bearer key-one'); }); it('works through the official MCP client and forwards the caller key', async () => { const client = new Client({ name: 'test', version: '1' }); const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: { authorization: 'Bearer key-one' } } }); await client.connect(transport); expect((await client.listTools()).tools.map(t => t.name)).toContain('browser_navigate'); expect((await client.listTools()).tools.map(t => t.name)).toContain('browser_extract_product'); const opened = await client.callTool({ name: 'browser_open', arguments: {} }); expect(JSON.parse(opened.content[0].text).sessionId).toBe('s-abc-123'); const blocked = await client.callTool({ name: 'browser_navigate', arguments: { sessionId: 's-abc-123', url: 'http://127.0.0.1' } }); expect(blocked.isError).toBe(true); expect(blocked.content[0].text).toContain('private address blocked'); await client.callTool({ name: 'browser_extract_product', arguments: { sessionId: 's-abc-123' } }); expect(calls.at(-1).path).toBe('/v1/sessions/s-abc-123/product'); const image = await client.callTool({ name: 'browser_screenshot', arguments: { sessionId: 's-abc-123' } }); expect(image.content[0]).toMatchObject({ type: 'image', mimeType: 'image/jpeg', data: 'aW1hZ2U=' }); expect(calls.every(c => c.authorization === 'Bearer key-one')).toBe(true); const before = calls.length; const invalid = await client.callTool({ name: 'browser_close', arguments: { sessionId: '..' } }); expect(invalid.isError).toBe(true); expect(calls.length).toBe(before); await client.close(); }); it('forwards requests to the local API, including errors and images', async () => { const backend = express(); backend.use(express.json()); backend.post('/v1/sessions', (req, res) => res.status(403).json({ error: req.get('authorization') })); backend.get('/v1/sessions/s-abc-123/screenshot', (_req, res) => res.type('jpeg').send(Buffer.from('image'))); backend.get('/v1/sessions/s-png/screenshot', (_req, res) => res.type('png').send(Buffer.from('image'))); const server = await new Promise(resolve => { const s = backend.listen(0, '127.0.0.1', () => resolve(s)); }); try { const api = mcp.localApi(server.address().port); const error = await api({ method: 'POST', path: '/v1/sessions', body: {}, authorization: 'Bearer key-one' }); expect(error).toEqual({ ok: false, data: { error: 'Bearer key-one' } }); const image = await api({ method: 'GET', path: '/v1/sessions/s-abc-123/screenshot', authorization: 'Bearer key-one' }); expect(image).toEqual({ ok: true, data: 'aW1hZ2U=', mimeType: 'image/jpeg' }); // A session handed to a phone answers in PNG: the image keeps its real type. const png = await api({ method: 'GET', path: '/v1/sessions/s-png/screenshot', authorization: 'Bearer key-one' }); expect(png).toEqual({ ok: true, data: 'aW1hZ2U=', mimeType: 'image/png' }); } finally { await new Promise(resolve => server.close(resolve)); } }); });