Files
feuerwerk/tests/mcp/relay.test.ts
T
adminandClaude Opus 5.5 48ec722660
feuerwerk / gate (push) Successful in 5m41s
feuerwerk / suite (push) Successful in 18m44s
Let an AI agent see the open editor through MCP (#429)
Claude Code should be able to work inside the user's open editor tab.
This first stage connects the two and lets the agent look: what the
user sees and a picture of it. Changing the project comes in stage 2.

- An agent token per account opens only /mcp, no API route. The
  dialog "Agent tokens..." in the account menu creates it once, shows
  the ready "claude mcp add" line and revokes it. The server keeps
  only its sha256 (api/lib/AgentTokens.php).
- Wrong tokens count into the sign-in brake from #427. Changing the
  password, renaming or deleting an account ends its tokens.
- The relay (tools/mcp/) is a small Node service without packages,
  WebSocket server included. It runs in the container feuerwerk-mcp
  because PHP-FPM cannot hold connections. Whether a token or a tab
  ticket is valid is still decided by the PHP API, through two routes
  that answer only with the relay key.
- The tab joins over WebSocket on the same origin with the normal
  session, an Origin check and a one-time ticket. It connects only
  after "Agent" is switched on in the account menu. One tab per
  account is active, a new one takes over. Guests get nothing.
- Tools: get_view (project, tab, document, selection with names,
  camera, play state, open dialog) and screenshot. The resource
  feuerwerk://view can be subscribed. Claude Code does not act on
  that reliably, so a changed view is also noted on the next answer.
- A badge next to the account menu shows the connected agents. It
  counts only agents with an open stream or a request in the last
  two minutes.
- CI starts the relay in front of php -S (tools/ci/serve.sh), and
  e2e 71 runs the whole path in the browser.

Found on the live test: the badge showed four agents because old
sessions of revoked tokens were still counted.

Tests:
- Gate on atx: 316 files, 4888 tests
- relay protocol test with fake API and fake tabs: 23/23
- get_view, tools, socketUrl: 16/16
- PHP agents 29, rights 103 checks
- e2e 71 on atx 10/10, against the live site 10/10, no CSP violation
- real Claude Code against the live site: get_view answered
- ZAP against /mcp: nothing at High or Medium
- every new check fails when the guard it covers is taken out (6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:39:34 +02:00

478 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer, type Server } from 'node:http';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type ApiAnswer, CLOSE, type Relay, startRelay, VIEW_URI } from '../../tools/mcp/relay.mjs';
/**
* The MCP relay (#429) between a fake API, agents speaking MCP over HTTP, and fake editor tabs on
* WebSockets: no browser, no PHP.
*/
const ORIGIN = 'https://editor.test';
const TOOLS = [
{ name: 'get_view', description: 'view', inputSchema: { type: 'object' } },
{ name: 'screenshot', description: 'picture', inputSchema: { type: 'object' } },
];
const TOKENS: Record<string, string> = { fwa_alice: 'alice', fwa_bob: 'bob', fwa_alice2: 'Alice' };
let relay!: Relay;
let running = false;
let tickets: Map<string, string>;
let addresses: string[];
let throttled: boolean;
const open: WebSocket[] = [];
async function start(extra: Partial<Parameters<typeof startRelay>[0]> = {}): Promise<void> {
if (running) await relay.close();
relay = await startRelay({
api: {
verifyToken: (token: string, address: string): Promise<ApiAnswer> => {
addresses.push(address);
if (throttled) return Promise.resolve({ status: 429, retryAfter: '600', error: 'Too many' });
const user = TOKENS[token];
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
},
redeemTicket: (ticket: string): Promise<ApiAnswer> => {
const user = tickets.get(ticket);
tickets.delete(ticket);
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
},
},
tools: () => Promise.resolve(TOOLS),
origins: [ORIGIN],
...extra,
});
running = true;
}
beforeEach(async () => {
tickets = new Map();
addresses = [];
throttled = false;
await start();
});
afterEach(async () => {
for (const socket of open.splice(0)) socket.close();
await relay.close();
running = false;
});
const url = () => `http://127.0.0.1:${relay.port}/mcp`;
interface Content {
type: string;
text?: string;
data?: string;
mimeType?: string;
}
interface ToolResult {
content: Content[];
isError?: boolean;
structuredContent?: object;
}
interface Reply {
result?: Record<string, unknown>;
error?: { code: number; message: string };
}
/** What a fake tab hears from the relay. */
interface TabMessage {
type: string;
user?: string;
count?: number;
id?: number;
tool?: string;
}
const toolResult = (body: Reply | null) => body?.result as unknown as ToolResult;
const textOf = (body: Reply | null, at = 0) => toolResult(body).content[at]?.text;
const viewText = (body: Reply | null) =>
(body?.result as unknown as { contents: { text: string }[] }).contents[0]?.text ?? '';
async function rpc(
token: string | null,
method: string,
params: object = {},
session?: string,
headers: Record<string, string> = {},
) {
const res = await fetch(url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(session ? { 'Mcp-Session-Id': session } : {}),
...headers,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const text = await res.text();
return {
status: res.status,
headers: res.headers,
body: (text ? JSON.parse(text) : null) as Reply | null,
};
}
async function initialize(token: string, protocolVersion = '2025-06-18'): Promise<string> {
const answer = await rpc(token, 'initialize', {
protocolVersion,
capabilities: {},
clientInfo: { name: 'test' },
});
expect(answer.status).toBe(200);
return answer.headers.get('mcp-session-id') ?? '';
}
interface FakeTab {
socket: WebSocket;
received: TabMessage[];
closed: Promise<number>;
/** Answers every call with what `answer` makes of it; null leaves it unanswered. */
answer: (call: TabMessage) => object | null;
next(type: string): Promise<TabMessage>;
}
function connectTab(
user: string,
origin: string | null = ORIGIN,
ticket = `ticket-${Math.random()}`,
): FakeTab {
tickets.set(ticket, user);
const socket = new WebSocket(url().replace('http', 'ws'), {
headers: origin ? { Origin: origin } : {},
} as unknown as string[]);
open.push(socket);
const tab: FakeTab = {
socket,
received: [],
// Node's client reports a refused handshake as an error alone, without the close a browser adds.
closed: new Promise((resolve) => {
socket.addEventListener('close', (event) => resolve(event.code));
socket.addEventListener('error', () => resolve(1006));
}),
answer: (call) => ({ content: [{ type: 'text', text: `${user} did ${call.tool ?? ''}` }] }),
next: (type) =>
new Promise((resolve) => {
const seen = tab.received.find((message) => message.type === type);
if (seen) {
tab.received.splice(tab.received.indexOf(seen), 1);
resolve(seen);
return;
}
const listener = (event: MessageEvent) => {
const message = JSON.parse(String(event.data)) as TabMessage;
if (message.type !== type) return;
tab.received.splice(tab.received.indexOf(message), 1);
socket.removeEventListener('message', listener);
resolve(message);
};
socket.addEventListener('message', listener);
}),
};
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'hello', ticket })));
socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data)) as TabMessage;
if (message.type === 'call') {
const result = tab.answer(message);
if (result) socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
return;
}
tab.received.push(message);
});
return tab;
}
async function onTab(user: string): Promise<FakeTab> {
const tab = connectTab(user);
expect((await tab.next('welcome')).user).toBe(user);
return tab;
}
const call = (token: string, session: string, name: string, args: object = {}) =>
rpc(token, 'tools/call', { name, arguments: args }, session);
describe('agents', () => {
it('need a token the API knows, and hear 429 while the API brakes them', async () => {
const none = await rpc(null, 'initialize');
const wrong = await rpc('fwa_nobody', 'initialize');
throttled = true;
const braked = await rpc('fwa_alice', 'initialize');
expect([none.status, wrong.status, braked.status]).toEqual([401, 401, 429]);
expect(none.headers.get('www-authenticate')).toMatch(/^Bearer/);
expect(braked.headers.get('retry-after')).toBe('600');
});
it('pass their own address to the brake, or nginx’s word for it only when told to trust it', async () => {
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
await start({ trustProxy: true });
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
expect(addresses).toHaveLength(2);
expect(addresses[0]).not.toBe('203.0.113.9');
expect(addresses[1]).toBe('203.0.113.9');
});
it('start a session in the protocol version they ask for, or the newest', async () => {
const asked = await rpc('fwa_alice', 'initialize', { protocolVersion: '2025-03-26' });
const unknown = await rpc('fwa_alice', 'initialize', { protocolVersion: '1999-01-01' });
expect(asked.body?.result).toMatchObject({
protocolVersion: '2025-03-26',
capabilities: { tools: {}, resources: { subscribe: true } },
serverInfo: { name: 'feuerwerk' },
});
expect(asked.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
expect(unknown.body?.result?.protocolVersion).toBe('2025-11-25');
});
it('get 202 for a notification and the tool list on asking', async () => {
const session = await initialize('fwa_alice');
const res = await fetch(url(), {
method: 'POST',
headers: {
Authorization: 'Bearer fwa_alice',
'Mcp-Session-Id': session,
'Content-Type': 'application/json',
},
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
expect(res.status).toBe(202);
expect((await rpc('fwa_alice', 'tools/list', {}, session)).body?.result).toEqual({ tools: TOOLS });
});
it('cannot use a session of another account, nor one that is not there', async () => {
const session = await initialize('fwa_alice');
expect((await rpc('fwa_bob', 'tools/list', {}, session)).status).toBe(404);
expect((await rpc('fwa_alice', 'tools/list', {}, 'no-such-session')).status).toBe(404);
expect((await rpc('fwa_alice2', 'tools/list', {}, session)).status).toBe(200);
});
it('are refused from a page of another site, not from the editor’s own', async () => {
const foreign = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: 'https://evil.test' });
const own = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: ORIGIN });
expect([foreign.status, own.status]).toEqual([403, 200]);
});
it('hear about unknown tools and methods in JSON-RPC terms', async () => {
const session = await initialize('fwa_alice');
expect((await call('fwa_alice', session, 'rm_rf')).body?.error?.code).toBe(-32602);
expect((await rpc('fwa_alice', 'prompts/list', {}, session)).body?.error?.code).toBe(-32601);
});
it('end a session with DELETE', async () => {
const session = await initialize('fwa_alice');
const res = await fetch(url(), {
method: 'DELETE',
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session },
});
expect(res.status).toBe(204);
expect((await rpc('fwa_alice', 'tools/list', {}, session)).status).toBe(404);
});
it('send no body larger than the limit', async () => {
await start({ maxBodyBytes: 1000 });
const res = await fetch(url(), {
method: 'POST',
headers: { Authorization: 'Bearer fwa_alice', 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { pad: 'x'.repeat(2000) },
}),
});
expect(res.status).toBe(413);
});
});
describe('tabs', () => {
it('get a call through to the tab of the same account and its answer back', async () => {
await onTab('alice');
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'get_view');
expect(answer.body?.result).toEqual({
content: [{ type: 'text', text: 'alice did get_view' }],
isError: false,
});
});
it('without a tab, a call says so and how to fix it', async () => {
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'get_view');
expect(toolResult(answer.body).isError).toBe(true);
expect(textOf(answer.body)).toMatch(/No Feuerwerk editor is connected.*"Agent"/);
});
it('never reach the tab of another account', async () => {
const bobs = await onTab('bob');
const calls: string[] = [];
bobs.answer = (message) => {
calls.push(message.tool ?? '');
return { content: [{ type: 'text', text: 'bob' }] };
};
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'get_view');
expect(toolResult(answer.body).isError).toBe(true);
expect(calls).toEqual([]);
});
it('are refused from another origin or none, before a ticket is asked for', async () => {
const foreign = connectTab('alice', 'https://evil.test');
const none = connectTab('alice', null);
expect(await foreign.closed).toBe(1006);
expect(await none.closed).toBe(1006);
expect(tickets.size).toBe(2);
});
it('need a ticket the API takes, once', async () => {
const tab = connectTab('alice', ORIGIN, 'shared');
await tab.next('welcome');
tickets.delete('shared');
const again = connectTab('alice', ORIGIN, 'other');
tickets.delete('other');
expect(await again.closed).toBe(CLOSE.refused);
expect(relay.stats().tabs).toEqual(['alice']);
});
it('that say no hello in time are let go', async () => {
await start({ helloTimeoutMs: 100 });
const socket = new WebSocket(url().replace('http', 'ws'), {
headers: { Origin: ORIGIN },
} as unknown as string[]);
open.push(socket);
const code = await new Promise((resolve) =>
socket.addEventListener('close', (event) => resolve(event.code)),
);
expect(code).toBe(CLOSE.helloTimeout);
});
it('one per account: the next takes over, and a call open on the first ends', async () => {
const first = await onTab('alice');
first.answer = () => null;
const session = await initialize('fwa_alice');
const pending = call('fwa_alice', session, 'get_view');
await new Promise((resolve) => setTimeout(resolve, 50));
const second = await onTab('Alice');
expect((await first.next('replaced')).type).toBe('replaced');
expect(await first.closed).toBe(CLOSE.replaced);
expect(textOf((await pending).body)).toBe('Another editor tab took over');
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('Alice did get_view');
second.socket.close();
});
it('that do not answer in time end the call with an error', async () => {
await start({ callTimeoutMs: 100 });
const tab = await onTab('alice');
tab.answer = () => null;
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'screenshot');
expect(answer.body?.result).toEqual({
content: [{ type: 'text', text: 'The editor did not answer within 0 s' }],
isError: true,
});
});
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
const tab = await onTab('alice');
const data = Buffer.alloc(300_000, 7).toString('base64');
tab.answer = () => ({
content: [
{ type: 'image', data, mimeType: 'image/png' },
{ type: 'script', text: 'x' },
],
structuredContent: { width: 10 },
});
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'screenshot');
expect(toolResult(answer.body).content).toEqual([{ type: 'image', data, mimeType: 'image/png' }]);
expect(toolResult(answer.body).structuredContent).toEqual({ width: 10 });
});
it('learn how many agents their account has on, and an agent gone quiet counts out', async () => {
await start({ agentRecentMs: 150, pingMs: 50 });
await initialize('fwa_alice');
await new Promise((resolve) => setTimeout(resolve, 200));
const tab = await onTab('alice');
expect((await tab.next('agents')).count).toBe(0);
await initialize('fwa_alice');
expect((await tab.next('agents')).count).toBe(1);
expect((await tab.next('agents')).count).toBe(0);
});
});
describe('the view', () => {
it('is read as a resource, and a subscribed agent hears when it changes', async () => {
const tab = await onTab('alice');
const session = await initialize('fwa_alice');
expect((await rpc('fwa_alice', 'resources/list', {}, session)).body?.result).toMatchObject({
resources: [{ uri: VIEW_URI }],
});
expect((await rpc('fwa_alice', 'resources/subscribe', { uri: VIEW_URI }, session)).body?.result).toEqual(
{},
);
const stream = await fetch(url(), {
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
});
expect(stream.headers.get('content-type')).toBe('text/event-stream');
const reader = stream.body!.getReader();
const heard = (async () => {
let text = '';
while (!text.includes('notifications/resources/updated')) {
const { value, done } = await reader.read();
if (done) break;
text += new TextDecoder().decode(value);
}
return text;
})();
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
expect(await heard).toContain(`"params":{"uri":"${VIEW_URI}"}`);
await reader.cancel();
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
expect(JSON.parse(viewText(read.body))).toEqual({ tab: 'graph' });
});
it('read without a tab says it is not connected', async () => {
const session = await initialize('fwa_alice');
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
expect(JSON.parse(viewText(read.body))).toMatchObject({ connected: false });
});
it('changed since the agent last looked is told with the next answer, once', async () => {
const tab = await onTab('alice');
const session = await initialize('fwa_alice');
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'scene' }, summary: 'tab scene' }));
await new Promise((resolve) => setTimeout(resolve, 50));
await call('fwa_alice', session, 'get_view');
const same = await call('fwa_alice', session, 'screenshot');
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
await new Promise((resolve) => setTimeout(resolve, 50));
const changed = await call('fwa_alice', session, 'screenshot');
const after = await call('fwa_alice', session, 'screenshot');
expect(toolResult(same.body).content).toHaveLength(1);
expect(textOf(changed.body, 1)).toBe("The user's view has changed since you last looked: tab graph");
expect(toolResult(after.body).content).toHaveLength(1);
});
});
describe('in front of a plain server', () => {
let backend: Server;
afterEach(() => new Promise<void>((resolve) => backend.close(() => resolve())));
it('passes everything off its own path on', async () => {
backend = createServer((req, res) =>
res.end(`backend ${req.method} ${req.url} ${req.headers.range ?? ''}`),
);
await new Promise<void>((resolve) => backend.listen(0, '127.0.0.1', () => resolve()));
const port = (backend.address() as { port: number }).port;
await start({ backend: `http://127.0.0.1:${port}` });
const page = await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/data/x.mp4?y=1`, {
headers: { Range: 'bytes=0-9' },
});
expect(await page.text()).toBe('backend GET /feuerwerk/data/x.mp4?y=1 bytes=0-9');
expect((await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/mcp`, { method: 'POST' })).status).toBe(
401,
);
});
});