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

+31 -2
View File
@@ -2,10 +2,13 @@
# Sourced, not run: it leaves the PHP server in the caller's background and exports
# FEUERWERK_BASE_URL, which every tool reads.
#
# The build wires its assets absolutely under /feuerwerk/dist/, so the checkout has to be
# served under that path segment – hence the symlink into /srv rather than `-t .`.
# The checkout is served under /feuerwerk/ like on stein2 – hence the symlink into /srv rather
# than `-t .`.
export FEUERWERK_BASE_URL=http://127.0.0.1:8080/feuerwerk/
# The MCP relay (#429) in front of the same server, so the editor's tab reaches it on its own
# origin as it does behind nginx on stein2. Only step 71 opens the editor there.
export FEUERWERK_MCP_BASE_URL=http://127.0.0.1:8081/feuerwerk/
feuerwerk_serve() {
local root
@@ -30,11 +33,17 @@ feuerwerk_serve() {
echo >>"$root/data/e2e-admin-password.txt"
fi
php "$root/tools/make-user.php" e2e-admin "$(cat "$root/data/e2e-admin-password.txt")" admin
# The key the relay asks the API with.
if [ ! -f "$root/data/mcp-relay.key" ]; then
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' >"$root/data/mcp-relay.key"
echo >>"$root/data/mcp-relay.key"
fi
# A server from an earlier step of the same job is fine to keep. Asked of the one route that
# answers without a session, so "is it up" does not depend on being signed in.
if curl -sf "${FEUERWERK_BASE_URL}api/index.php?r=auth/session" >/dev/null 2>&1; then
echo "server already up at $FEUERWERK_BASE_URL"
feuerwerk_relay "$root"
return 0
fi
@@ -56,4 +65,24 @@ feuerwerk_serve() {
return 1
fi
echo "serving $root at $FEUERWERK_BASE_URL"
feuerwerk_relay "$root"
}
# Starts the relay unless it answers already: without a token it says 401.
feuerwerk_relay() {
local root=$1
local mcp="${FEUERWERK_MCP_BASE_URL}mcp"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$mcp")" = 401 ] && return 0
FEUERWERK_MCP_API="${FEUERWERK_BASE_URL}api/index.php" \
FEUERWERK_MCP_ORIGINS=http://127.0.0.1:8081 \
FEUERWERK_MCP_HOST=127.0.0.1 FEUERWERK_MCP_PORT=8081 \
FEUERWERK_MCP_BACKEND=http://127.0.0.1:8080 \
node "$root/tools/mcp/serve.mjs" >/tmp/mcp-relay.log 2>&1 &
for _ in $(seq 40); do
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$mcp")" = 401 ] && return 0
sleep 0.25
done
echo "the MCP relay never came up" >&2
tail -20 /tmp/mcp-relay.log >&2
return 1
}
+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,
];
+26
View File
@@ -0,0 +1,26 @@
/**
* The relay's two questions to the PHP API (#429), asked with the key from `data/mcp-relay.key`:
* whose is this agent token, whose was this ticket. See `routeRelay` in api/index.php.
*/
export function phpApi(endpoint, key) {
const ask = async (route, body) => {
const res = await fetch(`${endpoint}?r=${route}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Relay-Key': key },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const data = await res.json().catch(() => null);
return {
status: res.status,
user: data?.user,
role: data?.role,
error: data?.error,
retryAfter: res.headers.get('retry-after'),
};
};
return {
verifyToken: (token, address) => ask('agent/verify', { token, address }),
redeemTicket: (ticket) => ask('agent/redeem', { ticket }),
};
}
+40
View File
@@ -0,0 +1,40 @@
# The MCP relay on stein2 (#429), a compose project of its own beside the web stack:
#
# docker compose -f tools/mcp/compose.yml up -d
#
# nginx passes /feuerwerk/mcp on to it (the location in /_stacks/web/nginx.conf). It listens on the
# docker bridge's gateway alone, where nginx's container reaches it and nothing from outside does,
# and asks the API through the web stack's published port. Its network of its own separates
# nothing on stein2: the docker daemon runs with `iptables: false` there, so every container
# reaches every other (measured 2026-10-17, the PHP container's FastCGI port included).
#
# It reads its own code, the tool list and the key it shares with the API, nothing else of the
# checkout. A change to tools/mcp/ needs a restart; the tool list is read on every use.
name: feuerwerk-mcp
services:
feuerwerk-mcp:
container_name: feuerwerk-mcp
image: node:22-alpine
restart: unless-stopped
user: '33:33'
working_dir: /srv/feuerwerk
volumes:
- ../../tools/mcp:/srv/feuerwerk/tools/mcp:ro
- ../../src/editor/agent:/srv/feuerwerk/src/editor/agent:ro
- ../../data/mcp-relay.key:/run/feuerwerk/mcp-relay.key:ro
environment:
FEUERWERK_MCP_API: http://172.16.0.1:8080/feuerwerk/api/index.php
FEUERWERK_MCP_KEY_FILE: /run/feuerwerk/mcp-relay.key
FEUERWERK_MCP_ORIGINS: https://nockal.com,https://feuerwerk.nockal.com
FEUERWERK_MCP_TRUST_PROXY: '1'
FEUERWERK_MCP_PORT: '8090'
ports:
- 172.16.0.1:8091:8090
read_only: true
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
command: ['node', 'tools/mcp/serve.mjs']
cpus: 1
mem_limit: 256m
pids_limit: 64
+42
View File
@@ -0,0 +1,42 @@
export interface ApiAnswer {
status: number;
user?: string;
role?: string;
error?: string;
retryAfter?: string | null;
}
export interface RelayApi {
verifyToken(token: string, address: string): Promise<ApiAnswer>;
redeemTicket(ticket: string): Promise<ApiAnswer>;
}
export interface RelayOptions {
port?: number;
host?: string;
api: RelayApi;
tools: () => Promise<object[]>;
origins: string[];
trustProxy?: boolean;
backend?: string;
log?: (line: string) => void;
callTimeoutMs?: number;
helloTimeoutMs?: number;
pingMs?: number;
maxMessageBytes?: number;
maxBodyBytes?: number;
sessionIdleMs?: number;
agentRecentMs?: number;
maxSessionsPerAccount?: number;
}
export interface Relay {
port: number;
stats(): { tabs: string[]; sessions: string[] };
close(): Promise<void>;
}
export const PROTOCOL_VERSIONS: string[];
export const VIEW_URI: string;
export const CLOSE: { replaced: number; badHello: number; refused: number; helloTimeout: number };
export function startRelay(options: RelayOptions): Promise<Relay>;
+601
View File
@@ -0,0 +1,601 @@
import { randomUUID } from 'node:crypto';
import { createServer, request as httpRequest } from 'node:http';
import { acceptWebSocket } from './websocket.mjs';
/**
* The MCP relay (#429): an agent speaks MCP over Streamable HTTP, the user's editor tab is on a
* WebSocket, and the relay passes the agent's tool calls to the tab of the same account. The tools
* live in the editor; the relay knows their list (`tools`) and nothing of what they do.
*
* Who is who is the PHP API's call (`api`): an agent's token is asked about on every request, so a
* revoked one stops at the next, and a tab hands in a ticket it got with its session. One tab per
* account is on at a time; the next one takes over.
*
* With `backend` it also stands in front of a server of plain HTTP and passes everything but its own
* path on, which is how CI puts it beside `php -S` on one origin (nginx does that on stein2).
*/
/** Newest first; an unknown version asked for gets the newest. */
export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
export const VIEW_URI = 'feuerwerk://view';
const VIEW_RESOURCE = {
uri: VIEW_URI,
name: 'view',
title: 'What the user sees',
description:
"The user's editor right now: open tab and document, selection, camera, play state, open dialog. " +
'Subscribe to hear when the user switches tab or selection.',
mimeType: 'application/json',
};
const INSTRUCTIONS = [
"Feuerwerk is a 2D game editor in the browser. You work inside the user's open editor tab,",
'live: the user sees every change you make and can take it back with Ctrl+Z.',
'Start with get_view to learn what the user is looking at; screenshot shows it as a picture.',
'When a tool answers that no editor is connected, ask the user to open the editor and switch on',
'"Agent" in its account menu.',
].join(' ');
const NOT_CONNECTED =
'No Feuerwerk editor is connected for this account. Ask the user to open the editor and switch on ' +
'"Agent" in the account menu (top right), then try again.';
/** How a tab says goodbye on the WebSocket (4000–4999 are the application's). */
export const CLOSE = { replaced: 4000, badHello: 4400, refused: 4401, helloTimeout: 4408 };
/**
* @param {object} options
* @param {number} [options.port] 0 for any free one
* @param {string} [options.host]
* @param {{verifyToken(token: string, address: string): Promise<ApiAnswer>, redeemTicket(ticket: string): Promise<ApiAnswer>}} options.api
* @param {() => Promise<object[]>} options.tools the tool list, asked for on every use
* @param {string[]} options.origins the editor's origins: a tab has to come from one, an agent may
* send none, but not another
* @param {boolean} [options.trustProxy] take the agent's address from `X-Real-IP` (nginx sets it)
* @param {string} [options.backend] pass everything off the relay's path on to this server
* @param {(line: string) => void} [options.log]
*/
export async function startRelay(options) {
const {
api,
tools,
origins,
trustProxy = false,
backend,
log = () => {},
callTimeoutMs = 30_000,
helloTimeoutMs = 10_000,
pingMs = 25_000,
maxMessageBytes = 32 * 1024 * 1024,
maxBodyBytes = 4 * 1024 * 1024,
sessionIdleMs = 12 * 60 * 60 * 1000,
agentRecentMs = 2 * 60 * 1000,
maxSessionsPerAccount = 20,
} = options;
/** @type {Map<string, Tab>} by account, lower case */
const tabs = new Map();
/** @type {Map<string, Session>} by session id */
const sessions = new Map();
const accountKey = (user) => user.toLowerCase();
const sessionsOf = (key) => [...sessions.values()].filter((session) => session.key === key);
const server = createServer((req, res) => {
handle(req, res).catch((error) => {
log(`error: ${error instanceof Error ? error.message : String(error)}`);
if (!res.headersSent) reply(res, 500, { error: 'Internal relay error' });
else res.end();
});
});
server.on('upgrade', (req, socket, head) => upgrade(req, socket, head));
const keepAlive = setInterval(() => {
for (const tab of tabs.values()) tab.conn.ping();
const now = Date.now();
for (const session of sessions.values()) {
for (const stream of session.streams) stream.write(': ping\n\n');
if (session.streams.size === 0 && now - session.seenAt > sessionIdleMs) endSession(session);
}
// An agent that went quiet is counted out with time, not with a request.
for (const key of tabs.keys()) tellAgents(key);
}, pingMs);
async function handle(req, res) {
if (!isRelayPath(req.url)) {
if (backend) return passOn(req, res, backend);
return reply(res, 404, { error: 'Not found' });
}
const origin = req.headers.origin;
if (origin !== undefined && !origins.includes(origin)) {
return reply(res, 403, { error: 'Requests from other sites are refused' });
}
if (req.method === 'POST') return post(req, res);
if (req.method === 'GET') return stream(req, res);
if (req.method === 'DELETE') return remove(req, res);
return reply(res, 405, { error: 'Method not allowed' }, { Allow: 'GET, POST, DELETE' });
}
/** The account behind the agent's token, or null once the refusal has been sent. */
async function agent(req, res) {
const match = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization ?? ''));
if (!match) {
reply(res, 401, { error: 'An agent token is needed: Authorization: Bearer fwa_…' }, BEARER);
return null;
}
let answer;
try {
answer = await api.verifyToken(match[1], addressOf(req));
} catch (error) {
log(`api unreachable: ${error instanceof Error ? error.message : String(error)}`);
reply(res, 502, { error: 'The editor server did not answer' });
return null;
}
if (answer.status === 200 && typeof answer.user === 'string') return answer.user;
if (answer.status === 429) {
reply(
res,
429,
{ error: answer.error ?? 'Too many failed attempts' },
{ 'Retry-After': answer.retryAfter ?? '900' },
);
return null;
}
reply(res, 401, { error: 'Unknown, expired or revoked agent token' }, BEARER);
return null;
}
function addressOf(req) {
const forwarded = req.headers['x-real-ip'];
return trustProxy && typeof forwarded === 'string' && forwarded !== ''
? forwarded
: (req.socket.remoteAddress ?? '');
}
/** The session a request names, if it is this account's; 404 otherwise, which tells a client to start over. */
function sessionOf(req, res, user) {
const session = sessions.get(String(req.headers['mcp-session-id'] ?? ''));
if (!session || session.key !== accountKey(user)) {
reply(res, 404, { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Session not found' } });
return null;
}
session.seenAt = Date.now();
return session;
}
async function post(req, res) {
const user = await agent(req, res);
if (user === null) return;
const body = await readBody(req, maxBodyBytes);
if (body === null) return reply(res, 413, { error: 'Request body too large' });
let parsed;
try {
parsed = JSON.parse(body);
} catch {
return reply(res, 400, rpcError(null, -32700, 'Parse error'));
}
const messages = Array.isArray(parsed) ? parsed : [parsed];
if (messages.length === 0) return reply(res, 400, rpcError(null, -32600, 'Invalid request'));
let session;
if (messages.some((message) => message?.method === 'initialize')) {
if (messages.length !== 1) return reply(res, 400, rpcError(null, -32600, 'initialize comes alone'));
session = openSession(user);
} else {
session = sessionOf(req, res, user);
if (!session) return;
}
const replies = [];
for (const message of messages) {
if (message === null || typeof message !== 'object' || message.jsonrpc !== '2.0') {
replies.push(rpcError(null, -32600, 'Invalid request'));
} else if (typeof message.method === 'string' && message.id !== undefined && message.id !== null) {
replies.push(await dispatch(session, message));
}
// Notifications and the client's answers need nothing back.
}
const headers = { 'Mcp-Session-Id': session.id };
if (replies.length === 0) return reply(res, 202, undefined, headers);
reply(res, 200, Array.isArray(parsed) ? replies : replies[0], headers);
}
function openSession(user) {
const key = accountKey(user);
const own = sessionsOf(key).sort((a, b) => a.seenAt - b.seenAt);
while (own.length >= maxSessionsPerAccount) endSession(own.shift());
/** @type {Session} */
const session = {
id: randomUUID(),
user,
key,
protocolVersion: PROTOCOL_VERSIONS[0],
subscriptions: new Set(),
streams: new Set(),
seenAt: Date.now(),
lastSummary: null,
};
sessions.set(session.id, session);
log(`agent session for ${user}`);
tellAgents(key);
return session;
}
function endSession(session) {
for (const stream of session.streams) stream.end();
session.streams.clear();
sessions.delete(session.id);
tellAgents(session.key);
}
async function dispatch(session, message) {
const { id, method } = message;
const params = message.params !== null && typeof message.params === 'object' ? message.params : {};
const ok = (result) => ({ jsonrpc: '2.0', id, result });
switch (method) {
case 'initialize': {
const asked = params.protocolVersion;
session.protocolVersion = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
return ok({
protocolVersion: session.protocolVersion,
capabilities: { tools: {}, resources: { subscribe: true } },
serverInfo: { name: 'feuerwerk', title: 'Feuerwerk', version: '1.0.0' },
instructions: INSTRUCTIONS,
});
}
case 'ping':
return ok({});
case 'tools/list':
return ok({ tools: await tools() });
case 'tools/call': {
const name = params.name;
const args = params.arguments ?? {};
if (typeof name !== 'string' || !(await tools()).some((tool) => tool.name === name)) {
return rpcError(id, -32602, `Unknown tool: ${String(name)}`);
}
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
return rpcError(id, -32602, 'arguments has to be an object');
}
return ok(await callTool(session, name, args));
}
case 'resources/list':
return ok({ resources: [VIEW_RESOURCE] });
case 'resources/templates/list':
return ok({ resourceTemplates: [] });
case 'resources/read': {
if (params.uri !== VIEW_URI) return rpcError(id, -32002, 'Resource not found', { uri: params.uri });
const tab = tabs.get(session.key);
let view = tab?.view ?? null;
if (tab && view === null) view = (await tab.call('get_view', {})).structuredContent ?? null;
if (tab) session.lastSummary = tab.summary;
const text = JSON.stringify(view ?? { connected: false, hint: NOT_CONNECTED });
return ok({ contents: [{ uri: VIEW_URI, mimeType: 'application/json', text }] });
}
case 'resources/subscribe':
case 'resources/unsubscribe':
if (params.uri !== VIEW_URI) return rpcError(id, -32002, 'Resource not found', { uri: params.uri });
if (method === 'resources/subscribe') session.subscriptions.add(VIEW_URI);
else session.subscriptions.delete(VIEW_URI);
return ok({});
default:
return rpcError(id, -32601, `Method not found: ${method}`);
}
}
/**
* Hands a call to the account's tab. An agent that has not looked since the user changed what they
* see gets told so with the answer: not every client follows a subscription.
*/
async function callTool(session, name, args) {
const tab = tabs.get(session.key);
if (!tab) return { content: [{ type: 'text', text: NOT_CONNECTED }], isError: true };
const seen = session.lastSummary;
const result = await tab.call(name, args);
if (name === 'get_view') {
session.lastSummary = tab.summary;
} else if (tab.summary !== null && seen !== tab.summary) {
session.lastSummary = tab.summary;
result.content = [
...result.content,
{ type: 'text', text: `The user's view has changed since you last looked: ${tab.summary}` },
];
}
return result;
}
async function stream(req, res) {
if (!String(req.headers.accept ?? '').includes('text/event-stream')) {
return reply(res, 405, { error: 'Ask for text/event-stream' }, { Allow: 'GET, POST, DELETE' });
}
const user = await agent(req, res);
if (user === null) return;
const session = sessionOf(req, res, user);
if (!session) return;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
'Mcp-Session-Id': session.id,
});
res.write(': open\n\n');
session.streams.add(res);
tellAgents(session.key);
req.on('close', () => {
session.streams.delete(res);
tellAgents(session.key);
});
}
async function remove(req, res) {
const user = await agent(req, res);
if (user === null) return;
const session = sessionOf(req, res, user);
if (!session) return;
endSession(session);
reply(res, 204);
}
/** Tells the subscribed agents of an account that its view changed. */
function viewChanged(key) {
const note = `event: message\ndata: ${JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/resources/updated',
params: { uri: VIEW_URI },
})}\n\n`;
for (const session of sessionsOf(key)) {
if (!session.subscriptions.has(VIEW_URI)) continue;
for (const out of session.streams) out.write(note);
}
}
/**
* How many agents an account has on, for its tab to show: a session counts while its event stream
* is open or it asked something lately. A client that went away without a DELETE leaves its
* session behind, and so does a revoked token until it is used again.
*/
function tellAgents(key) {
const tab = tabs.get(key);
if (!tab) return;
const now = Date.now();
const count = sessionsOf(key).filter(
(session) => session.streams.size > 0 || now - session.seenAt < agentRecentMs,
).length;
if (count === tab.agents) return;
tab.agents = count;
tab.conn.send(JSON.stringify({ type: 'agents', count }));
}
function upgrade(req, socket, head) {
socket.on('error', () => socket.destroy());
if (!isRelayPath(req.url)) return socket.destroy();
const origin = req.headers.origin;
if (typeof origin !== 'string' || !origins.includes(origin)) {
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
return;
}
const conn = acceptWebSocket(req, socket, head, { maxMessageBytes });
if (!conn) return;
/** @type {Tab | null} */
let tab = null;
let greeted = false;
const hello = setTimeout(() => conn.close(CLOSE.helloTimeout, 'hello first'), helloTimeoutMs);
conn.onmessage = async (text) => {
let message;
try {
message = JSON.parse(text);
} catch {
return conn.close(CLOSE.badHello, 'not JSON');
}
if (tab) return tab.receive(message);
if (greeted) return;
if (message?.type !== 'hello' || typeof message.ticket !== 'string') {
return conn.close(CLOSE.badHello, 'hello first');
}
greeted = true;
clearTimeout(hello);
let answer;
try {
answer = await api.redeemTicket(message.ticket);
} catch {
answer = { status: 502 };
}
if (conn.closed) return;
if (answer.status !== 200 || typeof answer.user !== 'string') {
return conn.close(CLOSE.refused, 'ticket refused');
}
tab = attach(answer.user, conn);
};
conn.onclose = () => {
clearTimeout(hello);
if (tab) detach(tab, 'The editor tab went away');
};
}
function attach(user, conn) {
const key = accountKey(user);
const old = tabs.get(key);
if (old) {
old.conn.send(JSON.stringify({ type: 'replaced' }));
detach(old, 'Another editor tab took over');
old.conn.close(CLOSE.replaced, 'replaced');
}
const tab = new Tab(user, conn, callTimeoutMs);
tab.onView = () => viewChanged(key);
tabs.set(key, tab);
conn.send(JSON.stringify({ type: 'welcome', user }));
log(`tab on for ${user}`);
tellAgents(key);
return tab;
}
function detach(tab, why) {
const key = accountKey(tab.user);
tab.abandon(why);
if (tabs.get(key) !== tab) return;
tabs.delete(key);
log(`tab off for ${tab.user}`);
viewChanged(key);
}
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(options.port ?? 0, options.host ?? '127.0.0.1', () => resolve(undefined));
});
return {
port: /** @type {import('node:net').AddressInfo} */ (server.address()).port,
/** Accounts with a tab on, and sessions per account: for the tests. */
stats: () => ({
tabs: [...tabs.values()].map((tab) => tab.user),
sessions: [...sessions.values()].map((session) => session.user),
}),
close: () =>
new Promise((resolve) => {
clearInterval(keepAlive);
for (const tab of tabs.values()) tab.conn.close(1001, 'relay stops');
for (const session of sessions.values()) for (const out of session.streams) out.end();
server.close(() => resolve(undefined));
server.closeAllConnections();
}),
};
}
/** One editor tab on the relay: the calls it has open and the last view it reported. */
class Tab {
view = null;
/** One line of what the user sees, which the agent is told about when it changes. */
summary = null;
agents = -1;
onView = () => {};
#pending = new Map();
#next = 1;
constructor(user, conn, timeoutMs) {
this.user = user;
this.conn = conn;
this.timeoutMs = timeoutMs;
}
/** @returns {Promise<{content: object[], isError?: boolean, structuredContent?: object}>} */
call(tool, args) {
const id = this.#next++;
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.#pending.delete(id);
resolve(failure(`The editor did not answer within ${Math.round(this.timeoutMs / 1000)} s`));
}, this.timeoutMs);
this.#pending.set(id, { resolve, timer });
this.conn.send(JSON.stringify({ type: 'call', id, tool, args }));
});
}
receive(message) {
if (message?.type === 'result') {
const waiting = this.#pending.get(message.id);
if (!waiting) return;
this.#pending.delete(message.id);
clearTimeout(waiting.timer);
waiting.resolve(toolResult(message));
} else if (message?.type === 'view' && message.view !== null && typeof message.view === 'object') {
this.view = message.view;
this.summary = typeof message.summary === 'string' ? message.summary.slice(0, 500) : null;
this.onView();
}
}
abandon(why) {
for (const { resolve, timer } of this.#pending.values()) {
clearTimeout(timer);
resolve(failure(why));
}
this.#pending.clear();
}
}
/** What a tab answered, as a tool result: only content the protocol knows goes on to the agent. */
function toolResult(message) {
const content = Array.isArray(message.content)
? message.content.filter(
(item) =>
(item?.type === 'text' && typeof item.text === 'string') ||
(item?.type === 'image' && typeof item.data === 'string' && typeof item.mimeType === 'string'),
)
: [];
if (content.length === 0) return failure('The editor answered with nothing');
const result = { content, isError: message.isError === true };
if (message.structuredContent !== null && typeof message.structuredContent === 'object') {
result.structuredContent = message.structuredContent;
}
return result;
}
function failure(text) {
return { content: [{ type: 'text', text }], isError: true };
}
const BEARER = { 'WWW-Authenticate': 'Bearer realm="feuerwerk"' };
function isRelayPath(url) {
const path = new URL(url ?? '/', 'http://relay').pathname;
return path === '/mcp' || path.endsWith('/mcp');
}
function rpcError(id, code, message, data) {
return { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } };
}
function reply(res, status, body, headers = {}) {
const text = body === undefined ? '' : JSON.stringify(body);
res.writeHead(status, {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
'Cache-Control': 'no-store',
...headers,
});
res.end(text);
}
/** The request body as text, or null when it is larger than `max` bytes. */
function readBody(req, max) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > max) {
resolve(null);
req.removeAllListeners('data');
req.resume();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
/** Front mode: one request through to the backend, streamed both ways (Range requests included). */
function passOn(req, res, backend) {
const target = new URL(req.url ?? '/', backend);
const out = httpRequest(
target,
{ method: req.method, headers: { ...req.headers, host: target.host } },
(answer) => {
res.writeHead(answer.statusCode ?? 502, answer.headers);
answer.pipe(res);
},
);
out.on('error', () => {
if (!res.headersSent) res.writeHead(502);
res.end();
});
req.pipe(out);
}
/**
* @typedef {{status: number, user?: string, role?: string, error?: string, retryAfter?: string | null}} ApiAnswer
* @typedef {{id: string, user: string, key: string, protocolVersion: string, subscriptions: Set<string>,
* streams: Set<import('node:http').ServerResponse>, seenAt: number, lastSummary: string | null}} Session
*/
+46
View File
@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { phpApi } from './api.mjs';
import { startRelay } from './relay.mjs';
/**
* Starts the MCP relay (#429) from the environment. On stein2 it runs in the container
* `feuerwerk-mcp` (tools/mcp/compose.yml), in CI beside the PHP server (tools/ci/serve.sh).
*
* FEUERWERK_MCP_API the API's index.php, as the relay reaches it
* FEUERWERK_MCP_KEY_FILE the key both sides share (default data/mcp-relay.key)
* FEUERWERK_MCP_ORIGINS the editor's origins, comma-separated
* FEUERWERK_MCP_PORT, _HOST where to listen (8090 on all interfaces)
* FEUERWERK_MCP_TRUST_PROXY 1: the agent's address is nginx's X-Real-IP
* FEUERWERK_MCP_BACKEND pass everything else on to this server (CI)
*/
const env = process.env;
const keyFile = env.FEUERWERK_MCP_KEY_FILE ?? new URL('../../data/mcp-relay.key', import.meta.url);
const key = readFileSync(keyFile, 'utf8').trim();
if (key.length < 32) throw new Error('The relay key has to be at least 32 characters');
if (!env.FEUERWERK_MCP_API) throw new Error('FEUERWERK_MCP_API is not set');
const origins = (env.FEUERWERK_MCP_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
if (origins.length === 0) throw new Error('FEUERWERK_MCP_ORIGINS is not set');
// Read on every use: a deployment that brings new tools needs no restart of the relay.
const toolsFile = new URL('../../src/editor/agent/tools.json', import.meta.url);
const relay = await startRelay({
port: Number(env.FEUERWERK_MCP_PORT ?? 8090),
host: env.FEUERWERK_MCP_HOST ?? '0.0.0.0',
api: phpApi(env.FEUERWERK_MCP_API, key),
tools: async () => JSON.parse(await readFile(toolsFile, 'utf8')),
origins,
trustProxy: env.FEUERWERK_MCP_TRUST_PROXY === '1',
backend: env.FEUERWERK_MCP_BACKEND || undefined,
log: (line) => console.log(`${new Date().toISOString()} ${line}`),
});
console.log(`${new Date().toISOString()} relay on port ${relay.port} for ${origins.join(', ')}`);
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => void relay.close().then(() => process.exit(0)));
}
+200
View File
@@ -0,0 +1,200 @@
import { createHash } from 'node:crypto';
/**
* The server half of RFC 6455, as much as the relay needs (#429): text messages, fragmented or not,
* ping and pong, close. Node brings a WebSocket client of its own but no server, and the relay keeps
* without dependencies, like the rest of tools/.
*
* Binary messages are refused (1003): the tab only ever speaks JSON.
*/
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const utf8 = new TextDecoder('utf-8', { fatal: true });
export function acceptKey(key) {
return createHash('sha1')
.update(key + GUID)
.digest('base64');
}
/**
* Answers an upgrade request with 101 and hands back the connection, or refuses it with 400 and
* answers null.
*/
export function acceptWebSocket(req, socket, head, { maxMessageBytes }) {
const key = req.headers['sec-websocket-key'];
if (
String(req.headers.upgrade).toLowerCase() !== 'websocket' ||
typeof key !== 'string' ||
!/^[A-Za-z0-9+/]{22}==$/.test(key) ||
req.headers['sec-websocket-version'] !== '13'
) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
return null;
}
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
'',
'',
].join('\r\n'),
);
return new WebSocketConnection(socket, head, maxMessageBytes);
}
export class WebSocketConnection {
/** @type {(text: string) => void} */
onmessage = () => {};
/** @type {(code: number, reason: string) => void} */
onclose = () => {};
#socket;
#max;
#buffer = Buffer.alloc(0);
#fragments = [];
#fragmentBytes = 0;
#closed = false;
constructor(socket, head, maxMessageBytes) {
this.#socket = socket;
this.#max = maxMessageBytes;
socket.setNoDelay(true);
socket.on('data', (data) => this.#receive(data));
socket.on('close', () => this.#gone(1006, ''));
socket.on('error', () => socket.destroy());
if (head.length > 0) this.#receive(head);
}
get closed() {
return this.#closed;
}
send(text) {
if (!this.#closed) this.#frame(0x1, Buffer.from(text, 'utf8'));
}
ping() {
if (!this.#closed) this.#frame(0x9, Buffer.alloc(0));
}
close(code = 1000, reason = '') {
if (this.#closed) return;
const words = Buffer.from(reason.slice(0, 100), 'utf8');
const payload = Buffer.alloc(2 + words.length);
payload.writeUInt16BE(code, 0);
words.copy(payload, 2);
this.#frame(0x8, payload);
this.#socket.end();
this.#gone(code, reason);
}
#gone(code, reason) {
if (this.#closed) return;
this.#closed = true;
this.onclose(code, reason);
}
#frame(opcode, payload) {
const length = payload.length;
let header;
if (length < 126) {
header = Buffer.from([0x80 | opcode, length]);
} else if (length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
this.#socket.write(Buffer.concat([header, payload]));
}
#fail(code, reason) {
this.close(code, reason);
this.#socket.destroy();
}
#receive(data) {
if (this.#closed) return;
this.#buffer = this.#buffer.length === 0 ? data : Buffer.concat([this.#buffer, data]);
for (;;) {
const buffer = this.#buffer;
if (buffer.length < 2) return;
const fin = (buffer[0] & 0x80) !== 0;
const opcode = buffer[0] & 0x0f;
if ((buffer[0] & 0x70) !== 0) return this.#fail(1002, 'reserved bits');
// A client masks every frame it sends (RFC 6455, 5.1).
if ((buffer[1] & 0x80) === 0) return this.#fail(1002, 'unmasked frame');
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < 4) return;
length = buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffer.length < 10) return;
const long = buffer.readBigUInt64BE(2);
if (long > BigInt(this.#max)) return this.#fail(1009, 'message too big');
length = Number(long);
offset = 10;
}
const control = opcode >= 0x8;
if (control && (!fin || length > 125)) return this.#fail(1002, 'bad control frame');
if (!control && this.#fragmentBytes + length > this.#max) return this.#fail(1009, 'message too big');
if (buffer.length < offset + 4 + length) return;
const mask = buffer.subarray(offset, offset + 4);
const payload = Buffer.from(buffer.subarray(offset + 4, offset + 4 + length));
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
this.#buffer = buffer.subarray(offset + 4 + length);
this.#handle(opcode, fin, payload);
if (this.#closed) return;
}
}
#handle(opcode, fin, payload) {
switch (opcode) {
case 0x0:
case 0x1:
if ((opcode === 0x1) === this.#fragments.length > 0) return this.#fail(1002, 'bad continuation');
this.#fragments.push(payload);
this.#fragmentBytes += payload.length;
if (!fin) return;
{
const whole = Buffer.concat(this.#fragments);
this.#fragments = [];
this.#fragmentBytes = 0;
let text;
try {
text = utf8.decode(whole);
} catch {
return this.#fail(1007, 'not UTF-8');
}
this.onmessage(text);
}
return;
case 0x2:
return this.#fail(1003, 'text only');
case 0x8: {
// Echoed, unless it is one no endpoint may send.
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
const sendable = code >= 1000 && code <= 4999 && ![1004, 1005, 1006, 1015].includes(code);
this.close(sendable ? code : 1000);
return;
}
case 0x9:
this.#frame(0xa, payload);
return;
case 0xa:
return;
default:
return this.#fail(1002, 'unknown opcode');
}
}
}