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