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,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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user