The agent got stuck where the editor has it easy: it could not make a
Sample play without a sound asset, it built instruments node by node
although the Preset menu has them ready, and it could not tell what
names an asset before reworking or deleting it.
- list_files is a new tool. It lists the pictures, fonts, sounds or
videos the server holds, with size, video state and the asset that
already wears a file. Bakes and stream copies of songs are left out.
- add_file_asset makes an asset of such a file, as the picker does.
It reuses the create rules of the import pipeline, so the four
import kinds are exported now. It refuses a file already in the
project (naming that asset), a picture without a size, a video not
yet converted and a file the server does not hold.
- run_commands fetches the file lists its steps need before it runs,
so the commands themselves stay synchronous.
- apply_preset sets one of the 16 instrument presets or 7 sfxr
presets, like the Preset menu. Presets take every note, so the
C4 to B4 range of a new Notes node is no trap there.
- get_asset gains usedBy: every asset naming the asset or one of its
frames or clips, with the object, node, item or key that does, plus
start scene, master FX and master routing of the project.
- usesOf in src/core/model/usages.ts walks the project data instead of
a list of fields, so no new field can be missed. The node every
asset has in the master routing does not count as a use.
- The relay tells the agent about all three ways.
Found on the way: suite 740 for 2e4c0a0 (#430) was red in e2e 68h
only, a sound clip 61 ms early against 50 allowed. #430 did not touch
sound timing, and e2e 68 on the same tree was green on atx (-4 ms).
Taken as a timing wobble under load until the next suite says more.
Tests:
- agent-files (new, 4 tests) with the other agent and relay tests:
130/130.
- 8 counter checks, all compiling, all red.
- e2e 71 on atx: 26/26, new 71g9 lists an uploaded sound, makes an
asset of it and fills an instrument from a preset.
- Gate on atx: CHECK EXIT 0, 326 files, 5021 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
654 lines
25 KiB
JavaScript
654 lines
25 KiB
JavaScript
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.',
|
||
'Read the project with list_assets, get_asset and get_graph. Change it with run_commands: its steps',
|
||
'are commands from catalog topic "commands" (by area; ask with query for their arguments), run as',
|
||
'one undo step, and a step can name what an earlier one made ("as": "x", then "$x"). Nodes for a',
|
||
'graph come from catalog topic "nodes". A Variables node reads or writes a variable once add_port',
|
||
'names it. A behavior acts on the object it is placed under: place it with parent set to that object.',
|
||
'Give new nodes no x and y: the editor arranges them when the call ends, next to what they link to',
|
||
'and into its group; the nodes already there stay, or move right to make room. Keep a graph',
|
||
'readable: a value needed far away comes from a second Get or Owner node next to its user, not',
|
||
'over a long link. Check with lint_graph, the crossings too: when there are more than the links',
|
||
'need, arrange_graph orders the whole graph for the fewest. Put nodes that belong together into a',
|
||
'group with add_group, and say what they do with add_comment; a comment a node lies on',
|
||
'(coveredNotes) goes back above its nodes with fit_group. Music plays nothing without an',
|
||
'instrument, and an instrument sounds only the notes its Notes nodes take in, C4 to B4 in a new',
|
||
'one: get_asset lists under silent what of a pattern, song or composition is not heard.',
|
||
'apply_preset gives an instrument that sounds every note in one step. Pictures, fonts, sounds',
|
||
'and videos are files: list_files lists those the server holds, add_file_asset makes an asset',
|
||
'of one. Before deleting or reworking an asset, get_asset says under usedBy what names it.',
|
||
'The editor follows your edits: after run_commands the user sees the graph, scene or asset it worked',
|
||
'on, with what it touched selected. Use open and select to show anything else.',
|
||
'Test what you built yourself: play, then play_input sends keys and mouse clicks into the game,',
|
||
'and get_runtime reads where the objects are, their variables and the console. A game needs a',
|
||
'camera in its scene to show anything; get_view says when it has none.',
|
||
'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,
|
||
waitTimeoutMs = 120_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(),
|
||
calledAt: 0,
|
||
lastSummary: null,
|
||
};
|
||
sessions.set(session.id, session);
|
||
log(`agent session ${session.id.slice(0, 8)} 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) {
|
||
session.calledAt = Date.now();
|
||
tellAgents(session.key);
|
||
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' });
|
||
}
|
||
// A client may drop the stream while its token is being checked: its close is over by the time
|
||
// the stream would be kept, and a stream kept then would count an agent that is gone, for good.
|
||
let dropped = false;
|
||
res.once('close', () => (dropped = true));
|
||
const user = await agent(req, res);
|
||
if (user === null || dropped) 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);
|
||
log(`stream open on ${session.id.slice(0, 8)}`);
|
||
tellAgents(session.key);
|
||
res.on('close', () => {
|
||
session.streams.delete(res);
|
||
log(`stream closed on ${session.id.slice(0, 8)}`);
|
||
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;
|
||
log(`agent session ${session.id.slice(0, 8)} ended by the agent`);
|
||
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 called a tool lately. A client that went away without a DELETE leaves its
|
||
* session behind, and so does a revoked token until it is used again. Claude Code opens two
|
||
* spare sessions next to the one it keeps whenever it connects: they never call a tool.
|
||
*/
|
||
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.calledAt < agentRecentMs,
|
||
).length;
|
||
if (count === tab.agents) return;
|
||
tab.agents = count;
|
||
const seen = sessionsOf(key).map(
|
||
(session) =>
|
||
`${session.id.slice(0, 8)} streams ${session.streams.size} idle ${Math.round((now - session.seenAt) / 1000)}s`,
|
||
);
|
||
log(`agents for ${tab.user}: ${count} [${seen.join('; ')}]`);
|
||
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, waitTimeoutMs);
|
||
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, waitTimeoutMs) {
|
||
this.user = user;
|
||
this.conn = conn;
|
||
this.timeoutMs = timeoutMs;
|
||
this.waitTimeoutMs = waitTimeoutMs;
|
||
}
|
||
|
||
/** @returns {Promise<{content: object[], isError?: boolean, structuredContent?: object}>} */
|
||
call(tool, args) {
|
||
const id = this.#next++;
|
||
return new Promise((resolve) => {
|
||
this.#pending.set(id, { resolve, timer: this.#timer(id, this.timeoutMs) });
|
||
this.conn.send(JSON.stringify({ type: 'call', id, tool, args }));
|
||
});
|
||
}
|
||
|
||
#timer(id, ms) {
|
||
return setTimeout(() => {
|
||
const waiting = this.#pending.get(id);
|
||
this.#pending.delete(id);
|
||
waiting?.resolve(failure(`The editor did not answer within ${Math.round(ms / 1000)} s`));
|
||
}, ms);
|
||
}
|
||
|
||
receive(message) {
|
||
if (message?.type === 'wait') {
|
||
// The tab is asking the user something: the call gets the longer time, once.
|
||
const waiting = this.#pending.get(message.id);
|
||
if (!waiting || waiting.extended) return;
|
||
clearTimeout(waiting.timer);
|
||
waiting.timer = this.#timer(message.id, this.waitTimeoutMs);
|
||
waiting.extended = true;
|
||
} else 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, calledAt: number,
|
||
* lastSummary: string | null}} Session
|
||
*/
|