Let an AI agent see the open editor through MCP (#429)
feuerwerk / gate (push) Successful in 5m41s
feuerwerk / suite (push) Successful in 18m44s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 10:39:34 +02:00
1 parent edef2b6323
commit 48ec722660
32 files changed
+3324 -12

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
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();
}
}
+2
View File
@@ -73,6 +73,7 @@ import { recording } from './67-recording.mjs';
import { composition } from './68-composition.mjs';
import { compositionTab } from './69-composition-tab.mjs';
import { guest } from './70-guest.mjs';
import { mcp } from './71-mcp.mjs';
export const STEPS = [
hierarchy,
@@ -145,4 +146,5 @@ export const STEPS = [
composition,
compositionTab,
guest,
mcp,
];