Files
feuerwerk/tools/e2e/71-mcp.mjs
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

227 lines
9.6 KiB
JavaScript
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 { authHeaders, SESSION_COOKIE, sessionToken } from '../lib/auth.mjs';
/** The tokens this step makes; a run that died halfway leaves them for the next to revoke. */
const TOKEN_NAME = 'e2e 71';
/**
* An agent in the editor over the MCP relay (#429): a token from the account menu's dialog, the
* agent switched on there, and an MCP client that reads what the user sees – the tab, the
* selection, a screenshot – and hears when it changes. Switched off at the badge, the agent gets
* no further.
*
* In CI the relay stands in front of the PHP server on a port of its own (`FEUERWERK_MCP_BASE_URL`,
* see tools/ci/serve.sh); on stein2 nginx serves it beside the editor.
*/
export async function mcp(t) {
const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t;
const { clickText, menuTicked, exceptions, navigate, shot } = t;
const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl;
const endpoint = `${site}mcp`;
const api = async (method, route, body) =>
fetch(`${baseUrl}api/index.php?r=${route}`, {
method,
headers: { ...(await authHeaders(baseUrl)), 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const sweep = async () => {
for (const token of await (await api('GET', 'agent-tokens')).json()) {
if (token.name.startsWith(TOKEN_NAME)) await api('DELETE', `agent-tokens/${token.id}`);
}
};
let token = '';
let session = '';
const rpc = async (method, params = {}, as = token) => {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${as}`,
...(session ? { 'Mcp-Session-Id': session } : {}),
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(40_000),
});
const text = await res.text();
return {
status: res.status,
session: res.headers.get('mcp-session-id'),
body: text ? JSON.parse(text) : null,
};
};
const callTool = async (name, args = {}) =>
(await rpc('tools/call', { name, arguments: args })).body?.result;
const viewNow = async () => (await callTool('get_view'))?.structuredContent;
const openAccountMenu = async () => {
await clickText('Account');
await sleep(200);
};
await sweep();
let stream = null;
try {
if (site !== baseUrl) {
await cdp.send('Network.setCookie', {
name: SESSION_COOKIE,
value: await sessionToken(baseUrl),
url: site,
});
await cdp.send('Page.navigate', { url: `${site}?project=${projectId}` });
await waitUntil('Boolean(window.__gm?.state?.project?.id)', {
timeout: 20000,
label: 'the editor behind the relay',
});
await sleep(1000);
}
// 71a. The account menu has the switch and the tokens; the dialog makes a token and shows it once,
// with the line for Claude Code.
await openAccountMenu();
const menu = await menuTicked();
await clickText('Agent tokens…');
await waitUntil('__gm.agentTokens() !== null', { label: 'the token dialog is open' });
await t.typeText(TOKEN_NAME);
await press('Enter');
await waitUntil('__gm.agentTokens()?.made != null', { label: 'the dialog shows the new token' });
const dialog = await json('__gm.agentTokens()');
token = dialog.made;
await shot('71-agent-tokens');
await press('Escape');
await settle();
const listed = await (await api('GET', 'agent-tokens')).json();
check(
'the account menu switches the agent and opens the tokens; a token made there is shown once with the Claude Code line',
menu.some((row) => row?.label === 'Agent' && row.ticked === false) &&
menu.some((row) => row?.label === 'Agent tokens…') &&
/^fwa_[0-9a-f]{64}$/.test(token) &&
dialog.tokens.includes(TOKEN_NAME) &&
dialog.command ===
`claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"` &&
listed.some((row) => row.name === TOKEN_NAME && row.token === undefined),
JSON.stringify({
menu,
dialog: { ...dialog, made: Boolean(dialog.made), command: dialog.command?.slice(0, 60) },
}),
);
// 71b. Before the switch, the agent hears there is no editor.
const init = await rpc('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'e2e' },
});
session = init.session ?? '';
const tools = (await rpc('tools/list')).body?.result?.tools.map((tool) => tool.name);
const lonely = await callTool('get_view');
check(
'an agent with the token starts a session, lists get_view and screenshot, and hears no editor is on',
init.status === 200 &&
init.body?.result?.serverInfo?.name === 'feuerwerk' &&
JSON.stringify(tools) === '["get_view","screenshot"]' &&
lonely?.isError === true &&
/No Feuerwerk editor is connected/.test(lonely.content[0].text),
JSON.stringify({ status: init.status, tools, lonely }),
);
// 71c. Switched on in the menu, the tab gets onto the relay; the badge counts the agent.
await openAccountMenu();
await clickText('Agent');
await waitUntil('__gm.agent().status === "on"', { timeout: 15000, label: 'the agent link is on' });
await waitUntil('__gm.agent().badge === "Agent · 1"', {
timeout: 5000,
label: 'the badge counts the agent',
});
await openAccountMenu();
const ticked = (await menuTicked()).find((row) => row?.label === 'Agent')?.ticked;
await press('Escape');
const agent = await json('__gm.agent()');
check(
'switched on in the account menu, the tab is on the relay and the badge says one agent is connected',
agent.status === 'on' && agent.badge === 'Agent · 1' && ticked === true,
JSON.stringify({ agent: { ...agent, view: undefined }, ticked }),
);
// 71d. get_view is what the tab shows.
await t.evaluate('__gm.showTab("scene")');
await settle();
const scene = await viewNow();
check(
'get_view names the project and the tab the user sees',
scene?.project?.id === projectId && scene?.tab?.id === 'scene' && scene.play === 'editing',
JSON.stringify(scene),
);
// 71e. Subscribed, the agent hears when the user switches the tab, and get_view follows it.
await rpc('resources/subscribe', { uri: 'feuerwerk://view' });
const events = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}`, 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
});
stream = events.body.getReader();
const heard = (async () => {
let text = '';
const until = Date.now() + 10_000;
while (!text.includes('notifications/resources/updated') && Date.now() < until) {
const { value, done } = await stream.read();
if (done) break;
text += new TextDecoder().decode(value);
}
return text;
})();
await clickText('Game');
await settle();
const note = await heard;
const game = await viewNow();
check(
'a tab switched by the user reaches the subscribed agent, and get_view says the Game tab',
note.includes('"uri":"feuerwerk://view"') &&
game?.tab?.id === 'game' &&
(await json('__gm.tab()')) === 'game',
JSON.stringify({ note: note.slice(-160), tab: game?.tab }),
);
// 71f. The screenshot is the editor as drawn, scaled to the width asked for; an agent that has
// not looked since the user moved on is told so with it.
await t.evaluate('__gm.showTab("scene")');
await settle();
await sleep(800);
const shownTab = await json('__gm.tab()');
const picture = await callTool('screenshot', { maxWidth: 640 });
const png = Buffer.from(picture?.content?.[0]?.data ?? '', 'base64');
const width = png.length > 24 ? png.readUInt32BE(16) : 0;
const height = png.length > 24 ? png.readUInt32BE(20) : 0;
const canvas = await json('({ w: __gm.app.canvas.width, h: __gm.app.canvas.height })');
const told =
picture?.content?.find((item) => item.type === 'text' && item.text.includes('has changed'))?.text ?? '';
check(
'screenshot answers a PNG of the editor at the width asked for, and tells the agent the view changed',
png.subarray(1, 4).toString() === 'PNG' &&
width === 640 &&
Math.abs(height - Math.round((canvas.h * 640) / canvas.w)) <= 1 &&
told.includes('tab Scene'),
JSON.stringify({ shownTab, width, height, canvas, bytes: png.length, told }),
);
// 71g. The badge is the off switch: the agent gets no further, and a revoked token not even in.
const badge = await locate('Agent · 1');
await click(badge.x, badge.y);
await waitUntil('__gm.agent().status === "off"', { label: 'the agent is off' });
const after = await callTool('get_view');
await api('DELETE', `agent-tokens/${listed.find((row) => row.name === TOKEN_NAME).id}`);
const revoked = await rpc('tools/list');
check(
'a click on the badge switches the agent off; the agent hears no editor, and after revoking it is refused',
(await json('__gm.agent().badge')) === null && after?.isError === true && revoked.status === 401,
JSON.stringify({ after, revoked: revoked.status }),
);
check('the agent session threw nothing', exceptions().length === 0, exceptions().join(' | '));
} finally {
await stream?.cancel().catch(() => {});
await t.evaluate('__gm.switchAgent?.(false)').catch(() => {});
await sweep();
if (site !== baseUrl) await navigate();
}
}