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

+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { agentView, type ViewFacts, viewSummary } from '../../src/editor/agent/agentView';
import { runTool } from '../../src/editor/agent/agentTools';
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
import TOOLS from '../../src/editor/agent/tools.json';
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
/** What the agent learns of the editor (#429): `get_view`, its one line, and the tools around it. */
function facts(fixture: ReturnType<typeof makeSpriteScene>, extra: Partial<ViewFacts> = {}): ViewFacts {
return {
project: fixture.project,
mode: 'edit',
paused: false,
saveStatus: 'saved',
tab: { id: 'scene', label: 'Scene' },
document: null,
windows: [],
selection: null,
popups: [],
...extra,
};
}
describe('get_view', () => {
it('names the selected objects, with the asset’s name where an object has none', () => {
const fixture = makeSpriteScene('Crate');
const a = place(fixture, fixture.spriteId, { x: 0, y: 0 });
const b = place(fixture, fixture.spriteId, { x: 10, y: 0 });
fixture.object(b).name = 'Lid';
const view = agentView(
facts(fixture, {
selection: { kind: 'object', holderId: fixture.sceneId, objectId: b, objectIds: [a, b] },
}),
);
expect(view.selection).toEqual({
kind: 'object',
in: { id: fixture.sceneId, name: 'Level', type: 'scene' },
primary: b,
objects: [
{ id: a, name: 'Crate', asset: fixture.spriteId },
{ id: b, name: 'Lid', asset: fixture.spriteId },
],
});
expect(viewSummary(view)).toBe(
'project "P"; tab Scene; selection: 2 objects ("Crate", "Lid") in "Level"; editing',
);
});
it('names the selected nodes by type, and the document a tab shows', () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Jump');
const node = graphEditor(fixture, behavior).addNode('OnStart');
const view = agentView(
facts(fixture, {
tab: { id: 'graph', label: 'Behavior' },
document: `asset:${behavior.id}`,
selection: {
kind: 'node',
assetId: behavior.id,
nodeId: node.id,
nodeIds: [node.id],
annotationIds: [],
edgeIds: [],
},
mode: 'play',
paused: true,
}),
);
expect(view.document).toEqual({
id: `asset:${behavior.id}`,
asset: { id: behavior.id, name: 'Jump', type: 'behavior' },
});
expect(view.selection).toMatchObject({
kind: 'node',
nodes: [{ id: node.id, type: 'OnStart' }],
edges: [],
});
expect(view.play).toBe('paused');
expect(viewSummary(view)).toBe(
'project "P"; tab Behavior showing behavior "Jump"; selection: 1 node (OnStart) in "Jump"; paused',
);
});
it('keeps the ids of the other kinds, says what an asset is, and the dialog on top', () => {
const fixture = makeSpriteScene();
const texture = new CreateAssetCommand('texture', 'Tiles');
fixture.stack.execute(texture);
const frame = agentView(
facts(fixture, { selection: { kind: 'frame', assetId: texture.asset.id, frameId: 'f1' } }),
);
expect(frame.selection).toEqual({
kind: 'frame',
in: { id: texture.asset.id, name: 'Tiles', type: 'texture' },
frameId: 'f1',
});
const asset = agentView(
facts(fixture, {
selection: { kind: 'asset', assetId: fixture.spriteId },
popups: [
{ kind: 'menu', title: 'New project' },
{ kind: 'dialog', title: 'Agent tokens' },
],
}),
);
expect(viewSummary(asset)).toBe(
'project "P"; tab Scene; selection: sprite "Box"; editing; dialog "Agent tokens" is open',
);
});
it('has a line that does not move with the camera', () => {
const fixture = makeSpriteScene();
const view = agentView(facts(fixture));
expect(viewSummary({ ...view, camera: { x: 1, y: 2, zoom: 3 } })).toBe(viewSummary(view));
});
});
describe('the tools', () => {
const host = {
view: () => agentView(facts(makeSpriteScene())),
screenshot: (maxWidth: number) =>
Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }),
};
it('are the ones the relay lists, each with a schema', () => {
expect(TOOLS.map((tool) => tool.name)).toEqual(['get_view', 'screenshot']);
for (const tool of TOOLS) expect(tool.inputSchema.type).toBe('object');
});
it('get_view answers the view as text and as structured content', async () => {
const result = await runTool('get_view', {}, host);
expect(result.structuredContent).toMatchObject({ tab: { id: 'scene' }, play: 'editing' });
expect(result.content[0]).toEqual({ type: 'text', text: JSON.stringify(result.structuredContent) });
});
it('screenshot answers a PNG at 1280 pixels unless told otherwise, and refuses a width out of range', async () => {
const shot = await runTool('screenshot', {}, host);
expect(shot.content[0]).toEqual({ type: 'image', data: 'iVBOR', mimeType: 'image/png' });
expect(shot.content[1]).toEqual({
type: 'text',
text: 'The editor as the user sees it, 1280x900 pixels.',
});
expect((await runTool('screenshot', { maxWidth: 100 }, host)).isError).toBe(true);
expect((await runTool('screenshot', { maxWidth: 'big' }, host)).isError).toBe(true);
});
it('an unknown tool says to reload', async () => {
const answer = (await runTool('run_command', {}, host)).content[0];
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
});
});
describe('agent tokens', () => {
it('are listed by day made, day of expiry and last use', () => {
const day = Date.UTC(2026, 9, 17) / 1000;
expect(tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 90 * 86400, usedAt: null })).toBe(
'made 2026-10-17 · expires 2027-01-15 · never used',
);
expect(
tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 86400, usedAt: day + 3600 }),
).toBe('made 2026-10-17 · expires 2026-10-18 · last used 2026-10-17');
});
it('are set up in Claude Code with one line', () => {
expect(claudeCommand('https://feuerwerk.nockal.com/mcp', 'fwa_00')).toBe(
'claude mcp add --transport http feuerwerk https://feuerwerk.nockal.com/mcp --header "Authorization: Bearer fwa_00"',
);
});
});