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'; const TOOLS = [ 'get_view', 'screenshot', 'list_assets', 'get_asset', 'get_graph', 'catalog', 'run_commands', 'open', 'select', 'play', 'undo', 'redo', ]; /** * 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. It builds a walking object in one call, * plays it, and one Ctrl+Z of the user takes it all back; deleting waits for the user's answer. * 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, holdKey, entity } = 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 its tools, and hears no editor is on', init.status === 200 && init.body?.result?.serverInfo?.name === 'feuerwerk' && JSON.stringify(tools) === JSON.stringify(TOOLS) && 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. One call builds an object that walks left while the left arrow is held: a sprite, a behavior // placed under it, three nodes and three edges, as one undo step. const sceneId = await json('__gm.state.sceneToPlay()?.id'); const assetsBefore = await json('JSON.stringify(__gm.state.project.assets)'); const built = await callTool('run_commands', { label: 'walk left', steps: [ { command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} hero` }, as: 'hero' }, { command: 'create_asset', args: { type: 'behavior', name: `${TOKEN_NAME} walk` }, as: 'walk' }, { command: 'place_asset', args: { in: sceneId, asset: '$hero', x: 0, y: 0 }, as: 'player' }, { command: 'place_asset', args: { in: sceneId, asset: '$walk', x: 0, y: 0, parent: '$player' } }, { command: 'add_node', args: { asset: '$walk', node: 'OnKeyHeld', params: { key: 'ArrowLeft' } }, as: 'key', }, { command: 'add_node', args: { asset: '$walk', node: 'Move', params: { dx: -300 } }, as: 'move' }, { command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' }, ...[ ['$key', 'exec', 'exec'], ['$key', 'dt', 'dt'], ['$owner', 'object', 'object'], ].map(([node, from, to]) => ({ command: 'connect', args: { asset: '$walk', from: { node, port: from }, to: { node: '$move', port: to } }, })), ], }); const made = built?.structuredContent?.made ?? []; const walkId = made.find((entry) => entry.step === 2)?.id; const playerId = made.find((entry) => entry.step === 3)?.id; const graph = walkId ? (await callTool('get_graph', { asset: walkId }))?.structuredContent : null; check( 'run_commands builds a sprite with a walking behavior in one call, one undo step named by the agent', built?.isError === false && (await json('__gm.state.commands.undoLabel')) === 'Agent: walk left' && graph?.nodes?.map((node) => node.type).join() === 'OnKeyHeld,Move,Owner' && graph.edges.length === 3, JSON.stringify({ built: built?.content?.[0]?.text?.slice(0, 300), graph: graph?.edges?.length }), ); // 71h. Played by the agent, the object walks left while the user holds the arrow. const started = await callTool('play', { action: 'play' }); await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' }); await sleep(300); const x0 = (await entity(playerId))?.x; await holdKey('ArrowLeft', 400); const x1 = (await entity(playerId))?.x; const stopped = await callTool('play', { action: 'stop' }); await waitUntil('__gm.state.mode === "edit"', { label: 'the game stopped' }); check( 'play started by the agent runs its behavior: the object walks left while ArrowLeft is held', started?.isError === false && stopped?.isError === false && x0 === 0 && x1 < -40, JSON.stringify({ started: started?.content, x0, x1 }), ); // 71i. One Ctrl+Z of the user takes the agent's whole step back. await press('z', { ctrl: true }); await settle(); check( 'one Ctrl+Z takes back everything the call made', (await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore, JSON.stringify({ undo: await json('__gm.state.commands.undoLabel') }), ); // 71j. Deleting asks the user: the call waits for the answer, and a no leaves the asset. const temp = await callTool('run_commands', { steps: [{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} temp` } }], }); const tempId = temp?.structuredContent?.made?.[0]?.id; const deleting = callTool('run_commands', { steps: [{ command: 'delete_asset', args: { asset: tempId } }], }); const asks = `The agent asks: Delete "${TOKEN_NAME} temp"?`; await waitUntil(`Boolean(__gm.locate(${JSON.stringify(asks)}, false))`, { label: 'the question is open', }); await shot('71-agent-delete'); await clickText('Cancel'); const refused = await deleting; const kept = await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(tempId)})`); await callTool('undo'); check( 'deleting asks the user first; the call waits, and on Cancel the asset stays', refused?.isError === true && /said no/.test(refused.content[0].text) && kept === true && (await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore, JSON.stringify({ refused: refused?.content, kept }), ); // 71k. 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(' | ')); } catch (error) { // The clean-up below can fail on a page that hangs, and its error would hide this one. console.error('71 stopped:', error); throw error; } finally { await stream?.cancel().catch(() => {}); await t.evaluate('__gm.switchAgent?.(false)').catch(() => {}); await sweep(); // An unsaved project holds the page with "Leave site?", which no one here answers. await waitUntil('__gm.state.saveStatus === "saved"', { timeout: 10000, label: 'saved' }).catch(() => {}); if (site !== baseUrl) await navigate(); } }