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>
This commit is contained in:
1 parent
edef2b6323
commit
48ec722660
32 files changed
+3324
-12
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user