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
@@ -35,8 +35,9 @@ jobs:
|
||||
env:
|
||||
GM_TEST_WORKERS: 2
|
||||
|
||||
# The API's own rules (#180, #179, #231, #266, #427). Not part of check.sh: the host the editor is
|
||||
# built on has no PHP, the image does. rights.test.php runs the API itself under `php -S`.
|
||||
# The API's own rules (#180, #179, #231, #266, #427, #429). Not part of check.sh: the host the
|
||||
# editor is built on has no PHP, the image does. rights.test.php runs the API itself under
|
||||
# `php -S`.
|
||||
- name: PHP tests
|
||||
run: |
|
||||
php tests/php/accounts.test.php
|
||||
@@ -46,6 +47,7 @@ jobs:
|
||||
php tests/php/videos.test.php
|
||||
php tests/php/limits.test.php
|
||||
php tests/php/demos.test.php
|
||||
php tests/php/agents.test.php
|
||||
php tests/php/rights.test.php
|
||||
|
||||
suite:
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
/data/e2e-admin-password.txt
|
||||
/data/sessions/
|
||||
/data/sign-ins/
|
||||
/data/agent-tokens/
|
||||
/data/agent-tickets/
|
||||
/data/mcp-relay.key
|
||||
/data/snapshots/
|
||||
/data/releases/
|
||||
/data/demos/
|
||||
|
||||
+132
-5
@@ -15,7 +15,14 @@ declare(strict_types=1);
|
||||
* other logins, keeps the one asking)
|
||||
*
|
||||
* Five wrong passwords from one address within 15 minutes, and it gets 429 with `Retry-After` for
|
||||
* the next 15 (see LoginThrottle, #427).
|
||||
* the next 15 (see LoginThrottle, #427). Wrong agent tokens count alike.
|
||||
*
|
||||
* The MCP relay (#429, tools/mcp/) asks two routes of its own, with the key both sides read from
|
||||
* `data/mcp-relay.key` in `X-Relay-Key`; without it they answer 404 like a route that is not there:
|
||||
*
|
||||
* POST ?r=agent/verify {token,address} → {user, role} | 401 | 429
|
||||
* (`address` is the agent's, which the relay saw; the brake counts it)
|
||||
* POST ?r=agent/redeem {ticket} → {user, role} | 401
|
||||
*
|
||||
* A request that changes something (anything but GET) is refused with 403 when the browser says it
|
||||
* comes from another site, `Sec-Fetch-Site` being `cross-site` or `same-site`: the session cookie is
|
||||
@@ -39,6 +46,15 @@ declare(strict_types=1);
|
||||
* (renaming or deleting signs the account out everywhere; a new
|
||||
* password ends its other sessions)
|
||||
*
|
||||
* Agent tokens (#429) open the relay for an agent of the account and nothing else; a ticket gets a
|
||||
* tab onto it (see AgentTokens). Neither is for a guest:
|
||||
*
|
||||
* GET ?r=agent-tokens → [{id,name,createdAt,expiresAt,usedAt}], oldest first
|
||||
* POST ?r=agent-tokens {name,days?} → 201 {id,name,createdAt,expiresAt,usedAt,token} | 409 | 422
|
||||
* (the one answer the token is in; `days` 1–365, 90 without)
|
||||
* DELETE ?r=agent-tokens/{id} → 204 | 404
|
||||
* POST ?r=agent/ticket → {ticket, expiresIn}
|
||||
*
|
||||
* GET ?r=projects → [{id,name,revision,updatedAt}]
|
||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||||
@@ -124,6 +140,7 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/AccountRules.php';
|
||||
require __DIR__ . '/lib/AgentTokens.php';
|
||||
require __DIR__ . '/lib/AtomicFile.php';
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/DemoFiles.php';
|
||||
@@ -239,6 +256,7 @@ function routeAuth(array $parts, string $method, Auth $auth): never
|
||||
countFailure($throttle);
|
||||
Response::error('Wrong password', 401);
|
||||
}
|
||||
agentTokens()->endAllOf($user);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -259,18 +277,18 @@ function clientAddress(): string
|
||||
return (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
}
|
||||
|
||||
function countFailure(LoginThrottle $throttle): void
|
||||
function countFailure(LoginThrottle $throttle, ?string $address = null): void
|
||||
{
|
||||
$throttle->fail(clientAddress());
|
||||
$throttle->fail($address ?? clientAddress());
|
||||
// Now and then, so the folder holds the addresses of the last quarter of an hour and no more.
|
||||
if (random_int(1, 20) === 1) {
|
||||
$throttle->prune();
|
||||
}
|
||||
}
|
||||
|
||||
function refuseWhileThrottled(LoginThrottle $throttle): void
|
||||
function refuseWhileThrottled(LoginThrottle $throttle, ?string $address = null): void
|
||||
{
|
||||
$wait = $throttle->waitFor(clientAddress());
|
||||
$wait = $throttle->waitFor($address ?? clientAddress());
|
||||
if ($wait === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -521,6 +539,107 @@ function snapshotName(array $body): string
|
||||
return $name;
|
||||
}
|
||||
|
||||
function agentTokens(): AgentTokens
|
||||
{
|
||||
return new AgentTokens(DATA_DIR . '/agent-tokens', DATA_DIR . '/agent-tickets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request is the MCP relay's (#429): it carries the key that lies in `data/` for both
|
||||
* sides. Nobody else can read that folder, nginx serves none of it.
|
||||
*/
|
||||
function fromRelay(): bool
|
||||
{
|
||||
$path = DATA_DIR . '/mcp-relay.key';
|
||||
$key = is_file($path) ? trim((string) file_get_contents($path)) : '';
|
||||
$given = (string) ($_SERVER['HTTP_X_RELAY_KEY'] ?? '');
|
||||
return strlen($key) >= 32 && hash_equals($key, $given);
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay asks who is behind an agent's token or a tab's ticket. A wrong token is a wrong password
|
||||
* to the brake, counted against the agent's address, not the relay's.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
*/
|
||||
function routeRelay(array $parts, string $method, Auth $auth): never
|
||||
{
|
||||
if (!fromRelay() || count($parts) !== 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
if ($method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$body = Response::body();
|
||||
$tokens = agentTokens();
|
||||
if ($parts[1] === 'verify') {
|
||||
$throttle = new LoginThrottle(DATA_DIR . '/sign-ins');
|
||||
$address = (string) ($body['address'] ?? '');
|
||||
refuseWhileThrottled($throttle, $address);
|
||||
$user = $tokens->verify((string) ($body['token'] ?? ''));
|
||||
if ($user === null) {
|
||||
countFailure($throttle, $address);
|
||||
Response::error('Unknown or expired agent token', 401);
|
||||
}
|
||||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||||
}
|
||||
if ($parts[1] === 'redeem') {
|
||||
$user = $tokens->redeemTicket((string) ($body['ticket'] ?? ''));
|
||||
if ($user === null) {
|
||||
Response::error('Unknown or used ticket', 401);
|
||||
}
|
||||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||||
}
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeAgentTokens(array $parts, string $method, string $user): never
|
||||
{
|
||||
$tokens = agentTokens();
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($tokens->listOf($user));
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$body = Response::body();
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
$days = $body['days'] ?? AgentTokens::DEFAULT_DAYS;
|
||||
if (!AgentTokens::isValidName($name)) {
|
||||
Response::error('A token name is 1–60 characters', 422);
|
||||
}
|
||||
if (!is_int($days) || $days < 1 || $days > AgentTokens::MAX_DAYS) {
|
||||
Response::error('A token lasts 1–' . AgentTokens::MAX_DAYS . ' days', 422);
|
||||
}
|
||||
try {
|
||||
Response::json($tokens->create($user, $name, $days), 201);
|
||||
} catch (TooManyAgentTokensException $e) {
|
||||
Response::error($e->getMessage(), 409);
|
||||
}
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
if ($method !== 'DELETE') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
if (!$tokens->revoke($user, $parts[1])) {
|
||||
Response::error('Agent token not found', 404);
|
||||
}
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeAgent(array $parts, string $method, string $user): never
|
||||
{
|
||||
if (($parts[1] ?? '') !== 'ticket' || count($parts) !== 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
if ($method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
Response::json(['ticket' => agentTokens()->issueTicket($user), 'expiresIn' => AgentTokens::TICKET_SECONDS]);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
||||
{
|
||||
@@ -604,6 +723,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
|
||||
if ($password !== null) {
|
||||
$auth->resetPassword($target, $password);
|
||||
agentTokens()->endAllOf($target);
|
||||
}
|
||||
if ($newRole !== null && $newRole !== $role) {
|
||||
$users->setRole($target, $newRole);
|
||||
@@ -613,6 +733,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
$users->rename($name, $newName);
|
||||
$projects->transfer($name, $newName);
|
||||
$auth->endSessionsOf($name);
|
||||
agentTokens()->endAllOf($name);
|
||||
$name = $newName;
|
||||
}
|
||||
Response::json($shown($name));
|
||||
@@ -629,6 +750,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
}
|
||||
$users->remove($target);
|
||||
$auth->endSessionsOf($target);
|
||||
agentTokens()->endAllOf($target);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -872,6 +994,9 @@ try {
|
||||
if ($parts[0] === 'demos') {
|
||||
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true);
|
||||
}
|
||||
if ($parts[0] === 'agent' && in_array($parts[1] ?? '', ['verify', 'redeem'], true)) {
|
||||
routeRelay($parts, $method, $auth);
|
||||
}
|
||||
// A guest's editor runs the plugins a demo switches on (#428); they are the server's own code,
|
||||
// put there by its administrator, and only to be read.
|
||||
if ($parts[0] === 'plugins' && $method === 'GET') {
|
||||
@@ -947,6 +1072,8 @@ try {
|
||||
),
|
||||
'pixels' => routePixels($parts, $method),
|
||||
'plugins' => routePlugins($parts, $method),
|
||||
'agent-tokens' => routeAgentTokens($parts, $method, $user),
|
||||
'agent' => routeAgent($parts, $method, $user),
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The keys of the MCP relay (#429): agent tokens, which an account makes for an agent such as
|
||||
* Claude Code, and tickets, with which an editor tab gets onto the relay.
|
||||
*
|
||||
* A token is shown once, when it is made, and kept as `{dir}/{sha256(token)}.json` like a session:
|
||||
* the file on disk cannot be replayed as the token. It opens the relay and nothing else, the API
|
||||
* takes no token. It ends at its expiry, at its owner's word, and with every login of the account
|
||||
* when the password changes or the account is renamed or deleted.
|
||||
*
|
||||
* A ticket is what a tab gets from the API with its session cookie, and hands to the relay on its
|
||||
* WebSocket: the cookie belongs to the API's folder and never reaches the relay. It lasts a minute
|
||||
* and is taken once.
|
||||
*/
|
||||
final class AgentTokens
|
||||
{
|
||||
public const PREFIX = 'fwa_';
|
||||
public const DEFAULT_DAYS = 90;
|
||||
public const MAX_DAYS = 365;
|
||||
public const MAX_PER_ACCOUNT = 20;
|
||||
public const TICKET_SECONDS = 60;
|
||||
|
||||
/** How often a use is written down: every request of an agent would cost a write otherwise. */
|
||||
private const USE_RESOLUTION_SECONDS = 60;
|
||||
|
||||
/** @param (Closure():int)|null $clock the tests' way to move time on */
|
||||
public function __construct(
|
||||
private readonly string $dir,
|
||||
private readonly string $ticketDir,
|
||||
private readonly ?Closure $clock = null,
|
||||
) {}
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
{
|
||||
return trim($name) !== '' && mb_strlen($name) <= 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a token for `$user`. The answer is the only place the token itself ever appears.
|
||||
*
|
||||
* @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:null,token:string}
|
||||
*/
|
||||
public function create(string $user, string $name, int $days): array
|
||||
{
|
||||
if (count($this->listOf($user)) >= self::MAX_PER_ACCOUNT) {
|
||||
throw new TooManyAgentTokensException('An account has at most ' . self::MAX_PER_ACCOUNT . ' agent tokens');
|
||||
}
|
||||
$token = self::PREFIX . bin2hex(random_bytes(32));
|
||||
$now = $this->now();
|
||||
$record = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'username' => $user,
|
||||
'name' => trim($name),
|
||||
'createdAt' => $now,
|
||||
'expiresAt' => $now + max(1, min(self::MAX_DAYS, $days)) * 86400,
|
||||
'usedAt' => null,
|
||||
];
|
||||
$this->write($this->dir . '/' . hash('sha256', $token) . '.json', $record);
|
||||
return self::shown($record) + ['token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens of `$user` that have not expired, oldest first, without the tokens themselves.
|
||||
*
|
||||
* @return list<array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}>
|
||||
*/
|
||||
public function listOf(string $user): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ($this->records() as $record) {
|
||||
if (strcasecmp($record['username'], $user) === 0) {
|
||||
$found[] = self::shown($record);
|
||||
}
|
||||
}
|
||||
usort($found, static fn(array $a, array $b): int => $a['createdAt'] <=> $b['createdAt']);
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Ends one token of `$user`; false when they have none by that id. */
|
||||
public function revoke(string $user, string $id): bool
|
||||
{
|
||||
foreach ($this->records() as $path => $record) {
|
||||
if ($record['id'] === $id && strcasecmp($record['username'], $user) === 0) {
|
||||
return @unlink($path);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ends every token of `$user`, and answers how many there were. */
|
||||
public function endAllOf(string $user): int
|
||||
{
|
||||
$ended = 0;
|
||||
foreach ($this->records() as $path => $record) {
|
||||
if (strcasecmp($record['username'], $user) === 0 && @unlink($path)) {
|
||||
$ended++;
|
||||
}
|
||||
}
|
||||
return $ended;
|
||||
}
|
||||
|
||||
/** The account a token belongs to, or null when it is unknown, malformed or expired. */
|
||||
public function verify(string $token): ?string
|
||||
{
|
||||
if (!preg_match('/^' . self::PREFIX . '[0-9a-f]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->dir . '/' . hash('sha256', $token) . '.json';
|
||||
$record = self::read($path);
|
||||
if ($record === null) {
|
||||
return null;
|
||||
}
|
||||
$now = $this->now();
|
||||
if ($record['expiresAt'] <= $now) {
|
||||
@unlink($path);
|
||||
return null;
|
||||
}
|
||||
if ($record['usedAt'] === null || $now - $record['usedAt'] >= self::USE_RESOLUTION_SECONDS) {
|
||||
$record['usedAt'] = $now;
|
||||
$this->write($path, $record);
|
||||
}
|
||||
return $record['username'];
|
||||
}
|
||||
|
||||
/** A ticket for `$user`'s tab to get onto the relay with. */
|
||||
public function issueTicket(string $user): string
|
||||
{
|
||||
$this->sweepTickets();
|
||||
$ticket = bin2hex(random_bytes(32));
|
||||
$this->write($this->ticketDir . '/' . hash('sha256', $ticket) . '.json', [
|
||||
'username' => $user,
|
||||
'createdAt' => $this->now(),
|
||||
]);
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/** The account a ticket was issued to, once: a second redemption finds nothing. */
|
||||
public function redeemTicket(string $ticket): ?string
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{64}$/', $ticket)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->ticketDir . '/' . hash('sha256', $ticket) . '.json';
|
||||
// Moved aside before it is read: of two redemptions at once, one gets the file.
|
||||
$taken = $path . '.taken-' . bin2hex(random_bytes(4));
|
||||
if (!@rename($path, $taken)) {
|
||||
return null;
|
||||
}
|
||||
$raw = file_get_contents($taken);
|
||||
@unlink($taken);
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['createdAt'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return $this->now() - $data['createdAt'] <= self::TICKET_SECONDS ? $data['username'] : null;
|
||||
}
|
||||
|
||||
/** Throws out the tickets nobody redeemed. */
|
||||
private function sweepTickets(): void
|
||||
{
|
||||
foreach (glob($this->ticketDir . '/*.json*') ?: [] as $path) {
|
||||
if ($this->now() - (int) @filemtime($path) > self::TICKET_SECONDS) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored token by its path; expired ones are dropped on the way.
|
||||
*
|
||||
* @return array<string,array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}>
|
||||
*/
|
||||
private function records(): array
|
||||
{
|
||||
$records = [];
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
|
||||
$record = self::read($path);
|
||||
if ($record === null) {
|
||||
continue;
|
||||
}
|
||||
if ($record['expiresAt'] <= $this->now()) {
|
||||
@unlink($path);
|
||||
continue;
|
||||
}
|
||||
$records[$path] = $record;
|
||||
}
|
||||
return $records;
|
||||
}
|
||||
|
||||
/** @return array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}|null */
|
||||
private static function read(string $path): ?array
|
||||
{
|
||||
$raw = is_file($path) ? file_get_contents($path) : false;
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
if (
|
||||
!is_array($data)
|
||||
|| !is_string($data['id'] ?? null)
|
||||
|| !is_string($data['username'] ?? null)
|
||||
|| !is_string($data['name'] ?? null)
|
||||
|| !is_int($data['createdAt'] ?? null)
|
||||
|| !is_int($data['expiresAt'] ?? null)
|
||||
|| !(is_int($data['usedAt'] ?? null) || ($data['usedAt'] ?? null) === null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int} */
|
||||
private static function shown(array $record): array
|
||||
{
|
||||
return [
|
||||
'id' => $record['id'],
|
||||
'name' => $record['name'],
|
||||
'createdAt' => $record['createdAt'],
|
||||
'expiresAt' => $record['expiresAt'],
|
||||
'usedAt' => $record['usedAt'],
|
||||
];
|
||||
}
|
||||
|
||||
private function write(string $path, array $data): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException("Cannot create {$dir}");
|
||||
}
|
||||
AtomicFile::write($path, (string) json_encode($data, JSON_UNESCAPED_SLASHES), AtomicFile::HIDDEN, 'agent token');
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
return $this->clock === null ? time() : ($this->clock)();
|
||||
}
|
||||
}
|
||||
|
||||
final class TooManyAgentTokensException extends RuntimeException {}
|
||||
@@ -14,6 +14,14 @@ export function sitePath(pagePath: string | undefined): string {
|
||||
/** The API's one entry point. */
|
||||
export const API_ENDPOINT = `${SITE_PATH}api/index.php`;
|
||||
|
||||
/** The MCP relay (#429): agents post to it, the editor's tab opens its WebSocket there. */
|
||||
export const MCP_PATH = `${SITE_PATH}mcp`;
|
||||
|
||||
/** A WebSocket address on the page's own host: `wss:` on an `https:` page. */
|
||||
export function socketUrl(path: string, page: { protocol: string; host: string }): string {
|
||||
return `${page.protocol === 'https:' ? 'wss:' : 'ws:'}//${page.host}${path}`;
|
||||
}
|
||||
|
||||
/** Where the uploaded files of one kind are served, e.g. `dataPath('textures')`. */
|
||||
export function dataPath(kind: string): string {
|
||||
return `${SITE_PATH}data/${kind}`;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { apiRequest, jsonBody } from './http';
|
||||
|
||||
/** An agent token as listed (#429): everything but the token itself, which is shown once. */
|
||||
export interface AgentToken {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Unix seconds, like everything the API stamps. */
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
usedAt: number | null;
|
||||
}
|
||||
|
||||
export interface MadeAgentToken extends AgentToken {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** The account's agent tokens, and the tickets its tab gets onto the MCP relay with. */
|
||||
export interface AgentStore {
|
||||
list(): Promise<AgentToken[]>;
|
||||
create(name: string, days: number): Promise<MadeAgentToken>;
|
||||
revoke(id: string): Promise<void>;
|
||||
ticket(): Promise<string>;
|
||||
}
|
||||
|
||||
/** The API's refusal as its bare sentence (a name too long, too many tokens). */
|
||||
function refusal(status: number, data: unknown): Error | undefined {
|
||||
const message = (data as { error?: unknown } | null)?.error;
|
||||
return status >= 400 && status < 500 && typeof message === 'string' ? new Error(message) : undefined;
|
||||
}
|
||||
|
||||
export class HttpAgentStore implements AgentStore {
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
list(): Promise<AgentToken[]> {
|
||||
return apiRequest<AgentToken[]>(this.endpoint, 'GET', 'agent-tokens', {}, refusal);
|
||||
}
|
||||
|
||||
create(name: string, days: number): Promise<MadeAgentToken> {
|
||||
return apiRequest<MadeAgentToken>(
|
||||
this.endpoint,
|
||||
'POST',
|
||||
'agent-tokens',
|
||||
jsonBody({ name, days }),
|
||||
refusal,
|
||||
);
|
||||
}
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
await apiRequest<void>(this.endpoint, 'DELETE', `agent-tokens/${id}`, {}, refusal);
|
||||
}
|
||||
|
||||
async ticket(): Promise<string> {
|
||||
return (await apiRequest<{ ticket: string }>(this.endpoint, 'POST', 'agent/ticket', {}, refusal)).ticket;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
||||
import { HttpAgentStore } from '../core/store/AgentStore';
|
||||
import { type DemoStore, type DemoSummary, HttpDemoStore } from '../core/store/DemoStore';
|
||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||
import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore';
|
||||
@@ -153,6 +154,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
role,
|
||||
users: new HttpUsersStore(API_ENDPOINT),
|
||||
demos: admin ? demos : undefined,
|
||||
agents: new HttpAgentStore(API_ENDPOINT),
|
||||
logOut,
|
||||
// Straight through to the store: the server checks the old password, and the session
|
||||
// this tab holds is the one it keeps – so there is nothing to do here afterwards.
|
||||
|
||||
+185
-1
@@ -3,7 +3,9 @@ import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||||
import { assetTitle } from '../core/music/embedded';
|
||||
import { mixOf, videoBusesOf } from '../core/music/mixer';
|
||||
import type { AgentStore, MadeAgentToken } from '../core/store/AgentStore';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
import { MCP_PATH, socketUrl } from '../core/site';
|
||||
import type { DemoStore } from '../core/store/DemoStore';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { pluginsOf } from '../core/store/projectPlugins';
|
||||
@@ -19,10 +21,12 @@ import {
|
||||
clamp,
|
||||
HStack,
|
||||
isRendering,
|
||||
Label,
|
||||
lastRenderTime,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
Panel,
|
||||
Popup,
|
||||
renderCount,
|
||||
requestRender,
|
||||
Splitter,
|
||||
@@ -112,6 +116,11 @@ import { RoutingEditor } from './routing/RoutingEditor';
|
||||
import { CompressorEditor } from './soundfx/CompressorEditor';
|
||||
import { EqEditor } from './soundfx/EqEditor';
|
||||
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
|
||||
import { AgentLink } from './agent/AgentLink';
|
||||
import { runTool } from './agent/agentTools';
|
||||
import { AgentTokensDialog } from './agent/AgentTokensDialog';
|
||||
import { type AgentView, agentView, viewSummary } from './agent/agentView';
|
||||
import { screenshot } from './agent/screenshot';
|
||||
|
||||
export type CentreTab =
|
||||
| 'scene'
|
||||
@@ -164,6 +173,8 @@ export interface EditorAccount {
|
||||
users: UsersStore;
|
||||
/** An administrator's: making the demo copy of the open project (#428). */
|
||||
demos?: DemoStore;
|
||||
/** The agent tokens and the relay's tickets (#429); a guest has none. */
|
||||
agents?: AgentStore;
|
||||
logOut: () => void;
|
||||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||||
changePassword: (current: string, next: string) => Promise<void>;
|
||||
@@ -178,6 +189,15 @@ type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | NodeTab
|
||||
|
||||
const FIXED_TABS = ['scene', 'game'] as const;
|
||||
|
||||
/** `sessionStorage` key: the agent was on in this browser tab (#429). */
|
||||
const AGENT_KEY = 'feuerwerk.agent';
|
||||
|
||||
/** A dialog's bold first line, or a menu's first entry: what tells one popup from another. */
|
||||
function popupTitle(popup: Popup): string {
|
||||
const first = popup.content.children.find((child) => child instanceof Label);
|
||||
return first instanceof Label ? first.text : '';
|
||||
}
|
||||
|
||||
const PANE_ORDER = [
|
||||
'scene',
|
||||
'game',
|
||||
@@ -361,6 +381,9 @@ export class Editor implements PlayHost {
|
||||
private readonly passwordDialog = dialogSlot<PasswordDialog>();
|
||||
private readonly generalDialog = dialogSlot<GeneralSettingsDialog>();
|
||||
private readonly recordingDialog = dialogSlot<EditorRecordingDialog>();
|
||||
private readonly agentTokensDialog = dialogSlot<AgentTokensDialog>();
|
||||
/** The tab's end of the MCP relay (#429); null for a guest. */
|
||||
readonly agent: AgentLink | null;
|
||||
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
||||
private fpsFrames = 0;
|
||||
private fpsElapsed = 0;
|
||||
@@ -495,9 +518,11 @@ export class Editor implements PlayHost {
|
||||
togglePause: () => this.play.togglePause(),
|
||||
step: () => this.play.step(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
stopAgent: () => this.switchAgent(false),
|
||||
},
|
||||
account.guest === true,
|
||||
);
|
||||
this.agent = account.agents ? this.makeAgentLink(app, account.agents) : null;
|
||||
root.add(this.toolbar);
|
||||
|
||||
this.workspace = new HStack();
|
||||
@@ -894,16 +919,172 @@ export class Editor implements PlayHost {
|
||||
onSelect: () => {
|
||||
const dialog = this.passwordDialog.open(
|
||||
new PasswordDialog(account.changePassword, () =>
|
||||
this.state.setNotice('Password changed – your other sessions were signed out'),
|
||||
this.state.setNotice('Password changed – your other sessions and agent tokens were ended'),
|
||||
),
|
||||
);
|
||||
dialog.focusFirstField();
|
||||
},
|
||||
},
|
||||
...this.agentEntries(account),
|
||||
{ separator: true },
|
||||
{ label: 'Logout', onSelect: () => account.logOut() },
|
||||
]).openBelow(anchor);
|
||||
}
|
||||
|
||||
/** The agent's switch and its tokens, in the account menu (#429). */
|
||||
private agentEntries(account: EditorAccount): MenuEntry[] {
|
||||
const link = this.agent;
|
||||
const agents = account.agents;
|
||||
if (!link || !agents) return [];
|
||||
return [
|
||||
{ separator: true },
|
||||
{ label: 'Agent', checked: () => link.on, onSelect: () => this.switchAgent(!link.on) },
|
||||
{ label: 'Agent tokens…', onSelect: () => void this.openAgentTokens(agents) },
|
||||
];
|
||||
}
|
||||
|
||||
private makeAgentLink(app: Application, agents: AgentStore): AgentLink {
|
||||
const link = new AgentLink(socketUrl(MCP_PATH, location), {
|
||||
ticket: () => agents.ticket(),
|
||||
view: () => {
|
||||
const view = this.agentView();
|
||||
return { view, summary: viewSummary(view) };
|
||||
},
|
||||
summary: () => viewSummary(this.agentView(false)),
|
||||
run: (tool, args) =>
|
||||
runTool(tool, args, {
|
||||
view: () => this.agentView(),
|
||||
screenshot: (maxWidth) => screenshot(app, maxWidth),
|
||||
}),
|
||||
replaced: () => {
|
||||
sessionStorage.removeItem(AGENT_KEY);
|
||||
this.state.setNotice('Agent switched off: another tab of your account has it now');
|
||||
},
|
||||
});
|
||||
link.onChange = () => this.syncAgentBadge();
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the agent on or off. Kept for this browser tab alone, so a reload – a deployment –
|
||||
* comes back the way it was, and another tab does not take over by itself.
|
||||
*/
|
||||
switchAgent(on: boolean): void {
|
||||
if (!this.agent) return;
|
||||
if (on) {
|
||||
sessionStorage.setItem(AGENT_KEY, '1');
|
||||
this.agent.start();
|
||||
} else {
|
||||
sessionStorage.removeItem(AGENT_KEY);
|
||||
this.agent.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private syncAgentBadge(): void {
|
||||
const link = this.agent;
|
||||
if (!link?.on) {
|
||||
this.toolbar.showAgent(null);
|
||||
return;
|
||||
}
|
||||
const off = ' Click to switch it off.';
|
||||
if (link.status === 'on') {
|
||||
this.toolbar.showAgent(
|
||||
link.agents > 0 ? `Agent · ${link.agents}` : 'Agent on',
|
||||
`An agent may work in this tab (${link.agents} connected).${off}`,
|
||||
);
|
||||
} else if (link.status === 'retrying') {
|
||||
this.toolbar.showAgent('Agent offline', `The agent relay is out of reach; trying again.${off}`);
|
||||
} else {
|
||||
this.toolbar.showAgent('Agent…', `Connecting to the agent relay.${off}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Agent tokens…": the list, and the token just made when there is one (shown once). */
|
||||
private async openAgentTokens(agents: AgentStore, made: MadeAgentToken | null = null): Promise<void> {
|
||||
const reopen = (next: MadeAgentToken | null = null) => void this.openAgentTokens(agents, next);
|
||||
const attempt = async (work: () => Promise<void>) => {
|
||||
try {
|
||||
await work();
|
||||
} catch (error) {
|
||||
this.state.setNotice(
|
||||
`Agent tokens: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
reopen();
|
||||
}
|
||||
};
|
||||
await attempt(async () => {
|
||||
const tokens = await agents.list();
|
||||
const dialog = this.agentTokensDialog.open(
|
||||
new AgentTokensDialog(tokens, made, `${location.origin}${MCP_PATH}`, {
|
||||
onCreate: (name, days) =>
|
||||
void attempt(async () => {
|
||||
const token = await agents.create(name, days);
|
||||
this.state.setNotice(`Agent token "${token.name}" made – copy it now`);
|
||||
reopen(token);
|
||||
}),
|
||||
onRevoke: (token) =>
|
||||
void attempt(async () => {
|
||||
await agents.revoke(token.id);
|
||||
this.state.setNotice(`Agent token "${token.name}" revoked`);
|
||||
reopen();
|
||||
}),
|
||||
onCopy: (text, what) =>
|
||||
void navigator.clipboard.writeText(text).then(
|
||||
() => this.state.setNotice(`${what} copied`),
|
||||
() => this.state.setNotice(`${what} could not be copied – select it and press Ctrl+C`, 'error'),
|
||||
),
|
||||
}),
|
||||
);
|
||||
if (!made) dialog.focusName();
|
||||
});
|
||||
}
|
||||
|
||||
/** The agent's badge in the toolbar, null while hidden (test hook). */
|
||||
agentBadge(): string | null {
|
||||
return this.toolbar.agentBadge();
|
||||
}
|
||||
|
||||
/** The open "Agent tokens" dialog, for the check scripts. */
|
||||
agentTokens(): AgentTokensDialog | null {
|
||||
return this.agentTokensDialog.current();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user sees, for the agent (#429). `withCamera` is off for the line the link compares
|
||||
* twice a second: the camera is not news.
|
||||
*/
|
||||
agentView(withCamera = true): AgentView {
|
||||
const { state } = this;
|
||||
const view = agentView({
|
||||
project: state.project,
|
||||
mode: state.mode,
|
||||
paused: state.paused,
|
||||
saveStatus: state.saveStatus,
|
||||
tab: { id: this.tab, label: this.tabLabel(this.tab) },
|
||||
document: this.documents.active,
|
||||
windows: [...this.floats].map(([kind, float]) => ({ kind, document: float.doc })),
|
||||
selection: state.selection,
|
||||
popups: ui()
|
||||
.overlay.children.filter((child): child is Popup => child instanceof Popup && child.visible)
|
||||
.map((popup) => ({ kind: popup instanceof Menu ? 'menu' : 'dialog', title: popupTitle(popup) })),
|
||||
});
|
||||
const camera = withCamera ? this.camera() : undefined;
|
||||
return camera ? { ...view, camera } : view;
|
||||
}
|
||||
|
||||
private tabLabel(tab: TabId): string {
|
||||
return tab.startsWith('plugin:') ? tab.slice('plugin:'.length) : TAB_LABELS[tab as CentreTab];
|
||||
}
|
||||
|
||||
/** The camera of the tab showing, where it has one: the scene, a container, a graph. */
|
||||
private camera(): Record<string, number> | undefined {
|
||||
if (this.tab === 'scene') return this.viewport.camera();
|
||||
const editor = (this.assetEditors as Partial<Record<TabId, AssetTabEditor<Asset>>>)[this.tab];
|
||||
const canvas = editor?.canvas as { camera?: () => Record<string, number> } | undefined;
|
||||
return typeof canvas?.camera === 'function' ? canvas.camera() : undefined;
|
||||
}
|
||||
|
||||
/** The open "Change password" dialog, for the check scripts. */
|
||||
accountDialog(): PasswordDialog | null {
|
||||
return this.passwordDialog.current();
|
||||
@@ -1373,6 +1554,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
async boot(): Promise<void> {
|
||||
await this.projects.boot();
|
||||
if (sessionStorage.getItem(AGENT_KEY) === '1') this.agent?.start();
|
||||
if (this.guest) {
|
||||
this.state.setNotice(
|
||||
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
|
||||
@@ -1395,6 +1577,8 @@ export class Editor implements PlayHost {
|
||||
this.autosave.stop();
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
this.editorRecorder.stop();
|
||||
// Signed out, the next account in this tab starts without the agent.
|
||||
this.switchAgent(false);
|
||||
this.editorRun.shutDown();
|
||||
await this.plugins?.stopAll();
|
||||
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { failure, type ToolResult } from './agentTools';
|
||||
|
||||
/** Where the link stands: off, on its way, on the relay, or waiting to try again. */
|
||||
export type AgentStatus = 'off' | 'connecting' | 'on' | 'retrying';
|
||||
|
||||
/** What the link needs of the editor. */
|
||||
export interface AgentLinkHost {
|
||||
/** A one-minute ticket from the API, got with this tab's session. */
|
||||
ticket(): Promise<string>;
|
||||
/** What the user sees, and its one line (see `viewSummary`). */
|
||||
view(): { view: object; summary: string };
|
||||
/** The one line alone, cheap enough to ask twice a second. */
|
||||
summary(): string;
|
||||
run(tool: string, args: Record<string, unknown>): Promise<ToolResult>;
|
||||
/** Another tab of the account switched the agent on; this one is off now. */
|
||||
replaced(): void;
|
||||
}
|
||||
|
||||
/** How often the view is looked at for a change the agent should hear about. */
|
||||
const VIEW_POLL_MS = 500;
|
||||
const MAX_RETRY_MS = 30_000;
|
||||
/** The relay's close codes (tools/mcp/relay.mjs). */
|
||||
const REPLACED = 4000;
|
||||
|
||||
/**
|
||||
* The editor's end of the MCP relay (#429): a WebSocket that the agent's tool calls come in on.
|
||||
* It is on only while the user has switched it on, and tries again after a lost connection – a
|
||||
* deployment restarts nothing here, but a network hiccup or a relay restart would end it.
|
||||
*/
|
||||
export class AgentLink {
|
||||
status: AgentStatus = 'off';
|
||||
/** Agents on the relay for this account, as the relay last said. */
|
||||
agents = 0;
|
||||
onChange: () => void = () => {};
|
||||
private socket: WebSocket | null = null;
|
||||
private wanted = false;
|
||||
private failures = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastSummary = '';
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly host: AgentLinkHost,
|
||||
) {}
|
||||
|
||||
get on(): boolean {
|
||||
return this.wanted;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.wanted) return;
|
||||
this.wanted = true;
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.wanted = false;
|
||||
this.halt();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
socket?.close(1000, 'switched off');
|
||||
this.agents = 0;
|
||||
this.set('off');
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
this.set('connecting');
|
||||
let ticket: string;
|
||||
try {
|
||||
ticket = await this.host.ticket();
|
||||
} catch (error) {
|
||||
console.warn('[agent] No ticket for the relay:', error);
|
||||
this.later();
|
||||
return;
|
||||
}
|
||||
if (!this.wanted) return;
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', ticket }));
|
||||
socket.onmessage = (event) => {
|
||||
if (this.socket === socket) void this.receive(socket, JSON.parse(String(event.data)) as RelayMessage);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (this.socket !== socket) return;
|
||||
this.socket = null;
|
||||
this.halt();
|
||||
this.agents = 0;
|
||||
if (event.code === REPLACED) {
|
||||
this.wanted = false;
|
||||
this.set('off');
|
||||
this.host.replaced();
|
||||
return;
|
||||
}
|
||||
if (this.wanted) this.later();
|
||||
};
|
||||
}
|
||||
|
||||
private async receive(socket: WebSocket, message: RelayMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
this.failures = 0;
|
||||
this.set('on');
|
||||
this.lastSummary = '';
|
||||
this.look(socket);
|
||||
this.pollTimer = setInterval(() => this.look(socket), VIEW_POLL_MS);
|
||||
return;
|
||||
case 'agents':
|
||||
this.agents = message.count ?? 0;
|
||||
this.onChange();
|
||||
return;
|
||||
case 'call': {
|
||||
let result: ToolResult;
|
||||
try {
|
||||
result = await this.host.run(message.tool ?? '', message.args ?? {});
|
||||
} catch (error) {
|
||||
result = failure(`The editor failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (socket.readyState === WebSocket.OPEN)
|
||||
socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the view along when its line changed since the last time. */
|
||||
private look(socket: WebSocket): void {
|
||||
const summary = this.host.summary();
|
||||
if (summary === this.lastSummary || socket.readyState !== WebSocket.OPEN) return;
|
||||
this.lastSummary = summary;
|
||||
const { view } = this.host.view();
|
||||
socket.send(JSON.stringify({ type: 'view', view, summary }));
|
||||
}
|
||||
|
||||
private later(): void {
|
||||
this.set('retrying');
|
||||
const wait = Math.min(MAX_RETRY_MS, 1000 * 2 ** this.failures++);
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
if (this.wanted) void this.connect();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private halt(): void {
|
||||
if (this.pollTimer !== null) clearInterval(this.pollTimer);
|
||||
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
|
||||
this.pollTimer = null;
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
private set(status: AgentStatus): void {
|
||||
this.status = status;
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
interface RelayMessage {
|
||||
type: string;
|
||||
id?: number;
|
||||
tool?: string;
|
||||
args?: Record<string, unknown>;
|
||||
count?: number;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AgentToken, MadeAgentToken } from '../../core/store/AgentStore';
|
||||
import { Button, HStack, Label, Popup, Spacer, TextField, Theme, ui } from '../../ui';
|
||||
|
||||
const WIDTH = 520;
|
||||
const LIFETIMES = [
|
||||
{ days: 30, label: '30 days' },
|
||||
{ days: 90, label: '90 days' },
|
||||
{ days: 365, label: '1 year' },
|
||||
] as const;
|
||||
|
||||
/** `2026-10-17`: the day is enough for a token that lasts months. */
|
||||
function day(seconds: number): string {
|
||||
return new Date(seconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** What one token is: when it was made, when it ends and when an agent last used it. */
|
||||
export function tokenLine(token: AgentToken): string {
|
||||
const used = token.usedAt === null ? 'never used' : `last used ${day(token.usedAt)}`;
|
||||
return `made ${day(token.createdAt)} · expires ${day(token.expiresAt)} · ${used}`;
|
||||
}
|
||||
|
||||
/** The line that sets the editor up in Claude Code, with the token in it. */
|
||||
export function claudeCommand(endpoint: string, token: string): string {
|
||||
return `claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Agent tokens…" in the account menu (#429): the account's tokens with Revoke, and a new one made
|
||||
* from a name and a lifetime. A token made is shown here once, with the line that sets it up in
|
||||
* Claude Code; the server keeps only its hash. Every action closes the dialog and hands the choice
|
||||
* on, and the caller opens it again on the new state, like the Release dialog.
|
||||
*/
|
||||
export class AgentTokensDialog extends Popup {
|
||||
private readonly nameField: TextField;
|
||||
private days: number = LIFETIMES[1].days;
|
||||
private readonly made: MadeAgentToken | null;
|
||||
private readonly command: string | null;
|
||||
|
||||
constructor(
|
||||
private readonly tokens: readonly AgentToken[],
|
||||
made: MadeAgentToken | null,
|
||||
endpoint: string,
|
||||
actions: {
|
||||
onCreate(name: string, days: number): void;
|
||||
onRevoke(token: AgentToken): void;
|
||||
onCopy(text: string, what: string): void;
|
||||
},
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = WIDTH;
|
||||
this.made = made;
|
||||
this.command = made && claudeCommand(endpoint, made.token);
|
||||
this.content.add(new Label('Agent tokens', { style: 'bodyBold' }));
|
||||
this.content.add(
|
||||
new Label(
|
||||
'A token lets an agent such as Claude Code work in this editor while "Agent" is switched on in ' +
|
||||
'the account menu. It opens nothing else.',
|
||||
{ style: 'muted', wrap: true },
|
||||
),
|
||||
);
|
||||
|
||||
if (made && this.command) {
|
||||
this.content.add(
|
||||
new Label(`"${made.name}" – copy it now, it is not shown again:`, { style: 'warning' }),
|
||||
);
|
||||
this.content.add(this.copyRow(made.token, 'Copy token', () => actions.onCopy(made.token, 'Token')));
|
||||
this.content.add(new Label('Set it up in Claude Code:', { style: 'muted' }));
|
||||
const command = this.command;
|
||||
this.content.add(this.copyRow(command, 'Copy command', () => actions.onCopy(command, 'Command')));
|
||||
}
|
||||
|
||||
if (tokens.length === 0) this.content.add(new Label('No tokens yet.', { style: 'muted' }));
|
||||
for (const token of tokens) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(new Label(token.name));
|
||||
row.add(new Label(tokenLine(token), { style: 'small' }), 1);
|
||||
row.add(
|
||||
new Button('Revoke', { variant: 'danger', onClick: this.closeThen(() => actions.onRevoke(token)) }),
|
||||
);
|
||||
this.content.add(row);
|
||||
}
|
||||
|
||||
const create = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
this.nameField = new TextField('', { placeholder: 'Name, e.g. Claude Code', width: 200 });
|
||||
create.add(this.nameField, 1);
|
||||
const lifetimes = LIFETIMES.map(({ days, label }) => {
|
||||
const button = new Button(label, { toggle: true });
|
||||
button.setToggled(days === this.days);
|
||||
return { days, button };
|
||||
});
|
||||
for (const { days, button } of lifetimes) {
|
||||
button.onClick = () => {
|
||||
this.days = days;
|
||||
for (const other of lifetimes) other.button.setToggled(other.days === days);
|
||||
};
|
||||
create.add(button);
|
||||
}
|
||||
const make = () => {
|
||||
const name = this.nameField.value.trim();
|
||||
if (name !== '') this.closeThen(() => actions.onCreate(name, this.days))();
|
||||
};
|
||||
this.nameField.onSubmit = make;
|
||||
create.add(new Button('New token', { variant: 'primary', onClick: make }));
|
||||
this.content.add(create);
|
||||
|
||||
const end = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
end.add(new Spacer(), 1);
|
||||
end.add(new Button('Close', { onClick: this.closeThen() }));
|
||||
this.content.add(end);
|
||||
}
|
||||
|
||||
focusName(): void {
|
||||
ui().focus.focus(this.nameField);
|
||||
}
|
||||
|
||||
/** What the dialog shows, for the check scripts. */
|
||||
info(): { tokens: string[]; made: string | null; command: string | null } {
|
||||
return {
|
||||
tokens: this.tokens.map((token) => token.name),
|
||||
made: this.made?.token ?? null,
|
||||
command: this.command,
|
||||
};
|
||||
}
|
||||
|
||||
private copyRow(text: string, caption: string, onCopy: () => void): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.add(new TextField(text, { readOnly: true, width: WIDTH - 130 }), 1);
|
||||
row.add(new Button(caption, { onClick: onCopy }));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AgentView } from './agentView';
|
||||
|
||||
/** What a tool answers, as the relay hands it on to the agent (MCP's `CallToolResult`). */
|
||||
export interface ToolResult {
|
||||
content: ({ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string })[];
|
||||
isError?: boolean;
|
||||
structuredContent?: object;
|
||||
}
|
||||
|
||||
/** What the tools need of the editor. */
|
||||
export interface ToolHost {
|
||||
view(): AgentView;
|
||||
/** The editor as drawn, as a base64 PNG at most `maxWidth` pixels wide. */
|
||||
screenshot(maxWidth: number): Promise<{ data: string; width: number; height: number }>;
|
||||
}
|
||||
|
||||
const DEFAULT_SCREENSHOT_WIDTH = 1280;
|
||||
|
||||
export function failure(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }], isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of the tools of `tools.json` (#429). The relay has checked the name against the same
|
||||
* list; the arguments are checked here, where their meaning is known.
|
||||
*/
|
||||
export async function runTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
host: ToolHost,
|
||||
): Promise<ToolResult> {
|
||||
switch (name) {
|
||||
case 'get_view': {
|
||||
const view = host.view();
|
||||
return { content: [{ type: 'text', text: JSON.stringify(view) }], structuredContent: view };
|
||||
}
|
||||
case 'screenshot': {
|
||||
const maxWidth = args.maxWidth ?? DEFAULT_SCREENSHOT_WIDTH;
|
||||
if (typeof maxWidth !== 'number' || !Number.isInteger(maxWidth) || maxWidth < 320 || maxWidth > 3840) {
|
||||
return failure('maxWidth has to be a whole number from 320 to 3840');
|
||||
}
|
||||
const shot = await host.screenshot(maxWidth);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'image', data: shot.data, mimeType: 'image/png' },
|
||||
{ type: 'text', text: `The editor as the user sees it, ${shot.width}x${shot.height} pixels.` },
|
||||
],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return failure(`This editor has no tool ${name}; reload it to get the newest.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
findAsset,
|
||||
findGraphAsset,
|
||||
findObjectHolder,
|
||||
type Project,
|
||||
ROUTING_ID,
|
||||
} from '../../core/model/Project';
|
||||
import type { EditorMode, SaveStatus, Selection } from '../state';
|
||||
|
||||
/** What the editor tells about itself for `get_view` (#429); `agentView` makes the answer of it. */
|
||||
export interface ViewFacts {
|
||||
project: Project;
|
||||
mode: EditorMode;
|
||||
paused: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
/** The tab showing, by its id, and its caption. */
|
||||
tab: { id: string; label: string };
|
||||
/** The document of that tab (`asset:<id>`, `values:<key>`, …), null for Scene, Game or a plugin's tab. */
|
||||
document: string | null;
|
||||
/** Tabs in windows of their own (#371). */
|
||||
windows: { kind: string; document: string }[];
|
||||
selection: Selection;
|
||||
/** Menus and dialogs open over the editor, topmost last. */
|
||||
popups: { kind: 'menu' | 'dialog'; title: string }[];
|
||||
}
|
||||
|
||||
export interface AssetRef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection with names: an asset is `asset`, everything else lies `in` an asset. Objects and
|
||||
* nodes are listed with their names and types, other kinds keep the ids of the selection as they are.
|
||||
*/
|
||||
export interface SelectionView {
|
||||
kind: string;
|
||||
asset?: AssetRef;
|
||||
in?: AssetRef;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentView {
|
||||
project: { id: string; name: string };
|
||||
tab: { id: string; label: string };
|
||||
document: { id: string; asset?: AssetRef } | null;
|
||||
windows: { kind: string; document: string }[];
|
||||
selection: SelectionView | null;
|
||||
play: 'editing' | 'playing' | 'paused';
|
||||
saved: SaveStatus;
|
||||
popups: { kind: 'menu' | 'dialog'; title: string }[];
|
||||
/** The camera of the tab showing, where it has one. */
|
||||
camera?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function agentView(facts: ViewFacts): AgentView {
|
||||
const { project } = facts;
|
||||
return {
|
||||
project: { id: project.id, name: project.name },
|
||||
tab: facts.tab,
|
||||
document: documentView(project, facts.document),
|
||||
windows: facts.windows,
|
||||
selection: selectionView(project, facts.selection),
|
||||
play: facts.mode === 'edit' ? 'editing' : facts.paused ? 'paused' : 'playing',
|
||||
saved: facts.saveStatus,
|
||||
popups: facts.popups,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of what the user sees: the agent is told it when it changes. The camera is left out, or
|
||||
* every scroll of the wheel would be news.
|
||||
*/
|
||||
export function viewSummary(view: AgentView): string {
|
||||
const parts = [`project "${view.project.name}"`, `tab ${view.tab.label}`];
|
||||
const asset = view.document?.asset;
|
||||
if (asset) parts[1] += ` showing ${asset.type} "${asset.name}"`;
|
||||
else if (view.document) parts[1] += ` showing ${view.document.id}`;
|
||||
parts.push(`selection: ${selectionText(view.selection)}`);
|
||||
parts.push(view.play);
|
||||
const top = view.popups.at(-1);
|
||||
if (top) parts.push(top.kind === 'menu' ? 'a menu is open' : `dialog "${top.title}" is open`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function selectionText(selection: SelectionView | null): string {
|
||||
if (!selection) return 'nothing';
|
||||
if (selection.asset) return `${selection.asset.type} "${selection.asset.name}"`;
|
||||
const where = selection.in ? ` in "${selection.in.name}"` : '';
|
||||
if (selection.kind === 'object') {
|
||||
const objects = selection.objects as { name: string }[];
|
||||
return `${countOf(objects.length, 'object')} (${objects.map((o) => `"${o.name}"`).join(', ')})${where}`;
|
||||
}
|
||||
if (selection.kind === 'node') {
|
||||
const nodes = selection.nodes as { type: string }[];
|
||||
return `${countOf(nodes.length, 'node')} (${nodes.map((n) => n.type).join(', ')})${where}`;
|
||||
}
|
||||
return `${selection.kind}${where}`;
|
||||
}
|
||||
|
||||
function countOf(n: number, noun: string): string {
|
||||
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function assetRef(project: Project, id: string): AssetRef {
|
||||
if (id === ROUTING_ID) return { id, name: 'Master routing', type: 'routing' };
|
||||
const asset = findAsset(project, id);
|
||||
return { id, name: asset?.name ?? '', type: asset?.type ?? 'missing' };
|
||||
}
|
||||
|
||||
function documentView(project: Project, document: string | null): AgentView['document'] {
|
||||
if (document === null) return null;
|
||||
return document.startsWith('asset:')
|
||||
? { id: document, asset: assetRef(project, document.slice('asset:'.length)) }
|
||||
: { id: document };
|
||||
}
|
||||
|
||||
function selectionView(project: Project, selection: Selection): SelectionView | null {
|
||||
if (!selection) return null;
|
||||
switch (selection.kind) {
|
||||
case 'asset':
|
||||
return { kind: 'asset', asset: assetRef(project, selection.assetId) };
|
||||
case 'object': {
|
||||
const holder = findObjectHolder(project, selection.holderId);
|
||||
return {
|
||||
kind: 'object',
|
||||
in: assetRef(project, selection.holderId),
|
||||
primary: selection.objectId,
|
||||
objects: selection.objectIds.map((id) => {
|
||||
const object = holder?.objects.find((candidate) => candidate.id === id);
|
||||
const name = object ? object.name || (findAsset(project, object.assetId)?.name ?? '') : '';
|
||||
return { id, name, asset: object?.assetId ?? '' };
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'node': {
|
||||
const graph = findGraphAsset(project, selection.assetId)?.graph;
|
||||
return {
|
||||
kind: 'node',
|
||||
in: assetRef(project, selection.assetId),
|
||||
primary: selection.nodeId,
|
||||
nodes: selection.nodeIds.map((id) => ({
|
||||
id,
|
||||
type: graph?.nodes.find((node) => node.id === id)?.type ?? '',
|
||||
})),
|
||||
edges: [...selection.edgeIds],
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const { kind, assetId, ...rest } = selection;
|
||||
return { kind, in: assetRef(project, assetId), ...rest };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
|
||||
/**
|
||||
* The editor as drawn, for the agent's `screenshot` (#429): a frame is drawn right before the copy,
|
||||
* because a WebGL canvas holds its picture only until the browser has shown it.
|
||||
*/
|
||||
export async function screenshot(
|
||||
app: Application,
|
||||
maxWidth: number,
|
||||
): Promise<{ data: string; width: number; height: number }> {
|
||||
app.render();
|
||||
const source = app.canvas;
|
||||
const scale = Math.min(1, maxWidth / source.width);
|
||||
const width = Math.max(1, Math.round(source.width * scale));
|
||||
const height = Math.max(1, Math.round(source.height * scale));
|
||||
const picture = new OffscreenCanvas(width, height);
|
||||
picture.getContext('2d')!.drawImage(source, 0, 0, width, height);
|
||||
const blob = await picture.convertToBlob({ type: 'image/png' });
|
||||
return { data: await base64Of(blob), width, height };
|
||||
}
|
||||
|
||||
function base64Of(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
resolve(url.slice(url.indexOf(',') + 1));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Cannot read the picture'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"name": "get_view",
|
||||
"title": "What the user sees",
|
||||
"description": "What the user is looking at in the editor right now: the project, the open tab and document, the selection (asset, scene objects, graph nodes, keys, ...), the camera of the view showing, whether the game is playing or paused, and an open dialog. Call it first, and again whenever you were told the view changed.",
|
||||
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "screenshot",
|
||||
"title": "Screenshot of the editor",
|
||||
"description": "The whole editor as the user sees it, as a PNG: toolbar, panels, the open tab, menus and dialogs included. Scaled down to maxWidth pixels (default 1280) if it is wider.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maxWidth": {
|
||||
"type": "integer",
|
||||
"minimum": 320,
|
||||
"maximum": 3840,
|
||||
"description": "Widest the picture may be, in pixels."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
}
|
||||
]
|
||||
@@ -120,6 +120,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
planned: () => [...music.planned],
|
||||
latency: () => soundLibrary.latency(),
|
||||
},
|
||||
/** The agent's link (#429): where it stands, the badge in the toolbar, and what `get_view` answers. */
|
||||
agent: () => ({
|
||||
on: editor.agent?.on ?? false,
|
||||
status: editor.agent?.status ?? 'off',
|
||||
agents: editor.agent?.agents ?? 0,
|
||||
badge: editor.agentBadge(),
|
||||
view: editor.agentView(),
|
||||
}),
|
||||
switchAgent: (on: boolean) => editor.switchAgent(on),
|
||||
/** The "Agent tokens" dialog as shown; null while it is closed. */
|
||||
agentTokens: () => editor.agentTokens()?.info() ?? null,
|
||||
world: () => editor.worldSnapshot(),
|
||||
playInfo: () => editor.playInfo(),
|
||||
/** Which scene Play would start, and under which of the two rules (#183: set in the settings). */
|
||||
|
||||
@@ -262,6 +262,11 @@ export class GraphCanvas extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/** Pan in screen pixels and zoom, as they are this frame – for the agent's `get_view` (#429). */
|
||||
camera(): { panX: number; panY: number; zoom: number } {
|
||||
return { panX: this.pan.x, panY: this.pan.y, zoom: this.zoom };
|
||||
}
|
||||
|
||||
saveView(): GraphViewState {
|
||||
this.tween.land();
|
||||
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ToolbarActions {
|
||||
step(): void;
|
||||
/** The account menu at the far right: who is signed in, changing the password, signing out. */
|
||||
openAccountMenu(anchor: Widget): void;
|
||||
/** Switches the agent off (#429): the badge beside the account menu is that switch. */
|
||||
stopAgent(): void;
|
||||
}
|
||||
|
||||
const SAVE_LABELS: Record<SaveStatus, string> = {
|
||||
@@ -63,6 +65,9 @@ export class Toolbar extends HStack {
|
||||
private readonly status: Label;
|
||||
private readonly notice: Label;
|
||||
private readonly fps: Label;
|
||||
/** Beside the account menu while an agent may work in this tab (#429); a click switches it off. */
|
||||
private readonly agentButton: Button;
|
||||
private agentTip = '';
|
||||
/** The picture each run button currently wears; `refresh` swaps them with the mode. */
|
||||
private playIcon: IconName = 'play';
|
||||
private pauseIcon: IconName = 'pause';
|
||||
@@ -120,6 +125,13 @@ export class Toolbar extends HStack {
|
||||
this.add(this.fps);
|
||||
// Far right, where the account lives. A menu rather than a Logout button (#182): it holds
|
||||
// everything about the account without asking the row for more width, which it has none of.
|
||||
this.agentButton = new Button('Agent', {
|
||||
variant: 'primary',
|
||||
onClick: () => actions.stopAgent(),
|
||||
tooltip: () => this.agentTip,
|
||||
});
|
||||
this.agentButton.visible = false;
|
||||
this.add(this.agentButton);
|
||||
const account = new Dropdown('Account', { onClick: () => actions.openAccountMenu(account) });
|
||||
this.add(account);
|
||||
|
||||
@@ -177,6 +189,21 @@ export class Toolbar extends HStack {
|
||||
return { x: at.x, y: this.menuButton.getGlobalPosition().y, w: RECORDING_ROOM, h: this.menuButton.h };
|
||||
}
|
||||
|
||||
/** Shows the agent's badge with `text`, or hides it for null. */
|
||||
showAgent(text: string | null, tooltip = ''): void {
|
||||
this.agentTip = tooltip;
|
||||
const visible = text !== null;
|
||||
if (visible) this.agentButton.setText(text);
|
||||
if (this.agentButton.visible === visible && !visible) return;
|
||||
this.agentButton.visible = visible;
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
/** The agent's badge as shown, null while hidden (test hook). */
|
||||
agentBadge(): string | null {
|
||||
return this.agentButton.visible ? this.agentButton.text : null;
|
||||
}
|
||||
|
||||
/** Frame rate readout; `null` while no measurement is available yet. */
|
||||
setFps(fps: number | null): void {
|
||||
this.fps.setText(fps === null ? '– fps' : `${fps} fps`);
|
||||
|
||||
@@ -102,6 +102,11 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
|
||||
this.screenOrigin = this.getGlobalPosition();
|
||||
}
|
||||
|
||||
/** World point at the centre and zoom, as they are this frame – for the agent's `get_view` (#429). */
|
||||
camera(): { x: number; y: number; zoom: number } {
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
/** Where the camera stands, to come back to (#171). */
|
||||
saveView(): unknown {
|
||||
this.tween.land();
|
||||
|
||||
+18
-1
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { API_ENDPOINT, dataPath, moveOldStoredKeys, sitePath } from '../../src/core/site';
|
||||
import {
|
||||
API_ENDPOINT,
|
||||
dataPath,
|
||||
MCP_PATH,
|
||||
moveOldStoredKeys,
|
||||
sitePath,
|
||||
socketUrl,
|
||||
} from '../../src/core/site';
|
||||
|
||||
function memoryStore(initial: Record<string, string>) {
|
||||
const items = new Map(Object.entries(initial));
|
||||
@@ -21,6 +28,16 @@ describe('site', () => {
|
||||
expect(dataPath('textures')).toBe('/feuerwerk/data/textures');
|
||||
});
|
||||
|
||||
it('opens the MCP relay on the page’s own host, encrypted on an encrypted page (#429)', () => {
|
||||
expect(MCP_PATH).toBe('/feuerwerk/mcp');
|
||||
expect(socketUrl('/mcp', { protocol: 'https:', host: 'feuerwerk.nockal.com' })).toBe(
|
||||
'wss://feuerwerk.nockal.com/mcp',
|
||||
);
|
||||
expect(socketUrl('/feuerwerk/mcp', { protocol: 'http:', host: '127.0.0.1:8081' })).toBe(
|
||||
'ws://127.0.0.1:8081/feuerwerk/mcp',
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the path from the folder of the open page', () => {
|
||||
expect(sitePath('/feuerwerk/')).toBe('/feuerwerk/');
|
||||
expect(sitePath('/feuerwerk/index.html')).toBe('/feuerwerk/');
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { agentView, type ViewFacts, viewSummary } from '../../src/editor/agent/agentView';
|
||||
import { runTool } from '../../src/editor/agent/agentTools';
|
||||
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
|
||||
import TOOLS from '../../src/editor/agent/tools.json';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
/** What the agent learns of the editor (#429): `get_view`, its one line, and the tools around it. */
|
||||
|
||||
function facts(fixture: ReturnType<typeof makeSpriteScene>, extra: Partial<ViewFacts> = {}): ViewFacts {
|
||||
return {
|
||||
project: fixture.project,
|
||||
mode: 'edit',
|
||||
paused: false,
|
||||
saveStatus: 'saved',
|
||||
tab: { id: 'scene', label: 'Scene' },
|
||||
document: null,
|
||||
windows: [],
|
||||
selection: null,
|
||||
popups: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('get_view', () => {
|
||||
it('names the selected objects, with the asset’s name where an object has none', () => {
|
||||
const fixture = makeSpriteScene('Crate');
|
||||
const a = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||||
const b = place(fixture, fixture.spriteId, { x: 10, y: 0 });
|
||||
fixture.object(b).name = 'Lid';
|
||||
const view = agentView(
|
||||
facts(fixture, {
|
||||
selection: { kind: 'object', holderId: fixture.sceneId, objectId: b, objectIds: [a, b] },
|
||||
}),
|
||||
);
|
||||
expect(view.selection).toEqual({
|
||||
kind: 'object',
|
||||
in: { id: fixture.sceneId, name: 'Level', type: 'scene' },
|
||||
primary: b,
|
||||
objects: [
|
||||
{ id: a, name: 'Crate', asset: fixture.spriteId },
|
||||
{ id: b, name: 'Lid', asset: fixture.spriteId },
|
||||
],
|
||||
});
|
||||
expect(viewSummary(view)).toBe(
|
||||
'project "P"; tab Scene; selection: 2 objects ("Crate", "Lid") in "Level"; editing',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the selected nodes by type, and the document a tab shows', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture, 'Jump');
|
||||
const node = graphEditor(fixture, behavior).addNode('OnStart');
|
||||
const view = agentView(
|
||||
facts(fixture, {
|
||||
tab: { id: 'graph', label: 'Behavior' },
|
||||
document: `asset:${behavior.id}`,
|
||||
selection: {
|
||||
kind: 'node',
|
||||
assetId: behavior.id,
|
||||
nodeId: node.id,
|
||||
nodeIds: [node.id],
|
||||
annotationIds: [],
|
||||
edgeIds: [],
|
||||
},
|
||||
mode: 'play',
|
||||
paused: true,
|
||||
}),
|
||||
);
|
||||
expect(view.document).toEqual({
|
||||
id: `asset:${behavior.id}`,
|
||||
asset: { id: behavior.id, name: 'Jump', type: 'behavior' },
|
||||
});
|
||||
expect(view.selection).toMatchObject({
|
||||
kind: 'node',
|
||||
nodes: [{ id: node.id, type: 'OnStart' }],
|
||||
edges: [],
|
||||
});
|
||||
expect(view.play).toBe('paused');
|
||||
expect(viewSummary(view)).toBe(
|
||||
'project "P"; tab Behavior showing behavior "Jump"; selection: 1 node (OnStart) in "Jump"; paused',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the ids of the other kinds, says what an asset is, and the dialog on top', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = new CreateAssetCommand('texture', 'Tiles');
|
||||
fixture.stack.execute(texture);
|
||||
const frame = agentView(
|
||||
facts(fixture, { selection: { kind: 'frame', assetId: texture.asset.id, frameId: 'f1' } }),
|
||||
);
|
||||
expect(frame.selection).toEqual({
|
||||
kind: 'frame',
|
||||
in: { id: texture.asset.id, name: 'Tiles', type: 'texture' },
|
||||
frameId: 'f1',
|
||||
});
|
||||
const asset = agentView(
|
||||
facts(fixture, {
|
||||
selection: { kind: 'asset', assetId: fixture.spriteId },
|
||||
popups: [
|
||||
{ kind: 'menu', title: 'New project' },
|
||||
{ kind: 'dialog', title: 'Agent tokens' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(viewSummary(asset)).toBe(
|
||||
'project "P"; tab Scene; selection: sprite "Box"; editing; dialog "Agent tokens" is open',
|
||||
);
|
||||
});
|
||||
|
||||
it('has a line that does not move with the camera', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const view = agentView(facts(fixture));
|
||||
expect(viewSummary({ ...view, camera: { x: 1, y: 2, zoom: 3 } })).toBe(viewSummary(view));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tools', () => {
|
||||
const host = {
|
||||
view: () => agentView(facts(makeSpriteScene())),
|
||||
screenshot: (maxWidth: number) =>
|
||||
Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }),
|
||||
};
|
||||
|
||||
it('are the ones the relay lists, each with a schema', () => {
|
||||
expect(TOOLS.map((tool) => tool.name)).toEqual(['get_view', 'screenshot']);
|
||||
for (const tool of TOOLS) expect(tool.inputSchema.type).toBe('object');
|
||||
});
|
||||
|
||||
it('get_view answers the view as text and as structured content', async () => {
|
||||
const result = await runTool('get_view', {}, host);
|
||||
expect(result.structuredContent).toMatchObject({ tab: { id: 'scene' }, play: 'editing' });
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: JSON.stringify(result.structuredContent) });
|
||||
});
|
||||
|
||||
it('screenshot answers a PNG at 1280 pixels unless told otherwise, and refuses a width out of range', async () => {
|
||||
const shot = await runTool('screenshot', {}, host);
|
||||
expect(shot.content[0]).toEqual({ type: 'image', data: 'iVBOR', mimeType: 'image/png' });
|
||||
expect(shot.content[1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'The editor as the user sees it, 1280x900 pixels.',
|
||||
});
|
||||
expect((await runTool('screenshot', { maxWidth: 100 }, host)).isError).toBe(true);
|
||||
expect((await runTool('screenshot', { maxWidth: 'big' }, host)).isError).toBe(true);
|
||||
});
|
||||
|
||||
it('an unknown tool says to reload', async () => {
|
||||
const answer = (await runTool('run_command', {}, host)).content[0];
|
||||
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent tokens', () => {
|
||||
it('are listed by day made, day of expiry and last use', () => {
|
||||
const day = Date.UTC(2026, 9, 17) / 1000;
|
||||
expect(tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 90 * 86400, usedAt: null })).toBe(
|
||||
'made 2026-10-17 · expires 2027-01-15 · never used',
|
||||
);
|
||||
expect(
|
||||
tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 86400, usedAt: day + 3600 }),
|
||||
).toBe('made 2026-10-17 · expires 2026-10-18 · last used 2026-10-17');
|
||||
});
|
||||
|
||||
it('are set up in Claude Code with one line', () => {
|
||||
expect(claudeCommand('https://feuerwerk.nockal.com/mcp', 'fwa_00')).toBe(
|
||||
'claude mcp add --transport http feuerwerk https://feuerwerk.nockal.com/mcp --header "Authorization: Bearer fwa_00"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { type ApiAnswer, CLOSE, type Relay, startRelay, VIEW_URI } from '../../tools/mcp/relay.mjs';
|
||||
|
||||
/**
|
||||
* The MCP relay (#429) between a fake API, agents speaking MCP over HTTP, and fake editor tabs on
|
||||
* WebSockets: no browser, no PHP.
|
||||
*/
|
||||
|
||||
const ORIGIN = 'https://editor.test';
|
||||
const TOOLS = [
|
||||
{ name: 'get_view', description: 'view', inputSchema: { type: 'object' } },
|
||||
{ name: 'screenshot', description: 'picture', inputSchema: { type: 'object' } },
|
||||
];
|
||||
const TOKENS: Record<string, string> = { fwa_alice: 'alice', fwa_bob: 'bob', fwa_alice2: 'Alice' };
|
||||
|
||||
let relay!: Relay;
|
||||
let running = false;
|
||||
let tickets: Map<string, string>;
|
||||
let addresses: string[];
|
||||
let throttled: boolean;
|
||||
const open: WebSocket[] = [];
|
||||
|
||||
async function start(extra: Partial<Parameters<typeof startRelay>[0]> = {}): Promise<void> {
|
||||
if (running) await relay.close();
|
||||
relay = await startRelay({
|
||||
api: {
|
||||
verifyToken: (token: string, address: string): Promise<ApiAnswer> => {
|
||||
addresses.push(address);
|
||||
if (throttled) return Promise.resolve({ status: 429, retryAfter: '600', error: 'Too many' });
|
||||
const user = TOKENS[token];
|
||||
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
|
||||
},
|
||||
redeemTicket: (ticket: string): Promise<ApiAnswer> => {
|
||||
const user = tickets.get(ticket);
|
||||
tickets.delete(ticket);
|
||||
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
|
||||
},
|
||||
},
|
||||
tools: () => Promise.resolve(TOOLS),
|
||||
origins: [ORIGIN],
|
||||
...extra,
|
||||
});
|
||||
running = true;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tickets = new Map();
|
||||
addresses = [];
|
||||
throttled = false;
|
||||
await start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of open.splice(0)) socket.close();
|
||||
await relay.close();
|
||||
running = false;
|
||||
});
|
||||
|
||||
const url = () => `http://127.0.0.1:${relay.port}/mcp`;
|
||||
|
||||
interface Content {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
interface ToolResult {
|
||||
content: Content[];
|
||||
isError?: boolean;
|
||||
structuredContent?: object;
|
||||
}
|
||||
interface Reply {
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
/** What a fake tab hears from the relay. */
|
||||
interface TabMessage {
|
||||
type: string;
|
||||
user?: string;
|
||||
count?: number;
|
||||
id?: number;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
const toolResult = (body: Reply | null) => body?.result as unknown as ToolResult;
|
||||
const textOf = (body: Reply | null, at = 0) => toolResult(body).content[at]?.text;
|
||||
const viewText = (body: Reply | null) =>
|
||||
(body?.result as unknown as { contents: { text: string }[] }).contents[0]?.text ?? '';
|
||||
|
||||
async function rpc(
|
||||
token: string | null,
|
||||
method: string,
|
||||
params: object = {},
|
||||
session?: string,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const res = await fetch(url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(session ? { 'Mcp-Session-Id': session } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
});
|
||||
const text = await res.text();
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body: (text ? JSON.parse(text) : null) as Reply | null,
|
||||
};
|
||||
}
|
||||
|
||||
async function initialize(token: string, protocolVersion = '2025-06-18'): Promise<string> {
|
||||
const answer = await rpc(token, 'initialize', {
|
||||
protocolVersion,
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test' },
|
||||
});
|
||||
expect(answer.status).toBe(200);
|
||||
return answer.headers.get('mcp-session-id') ?? '';
|
||||
}
|
||||
|
||||
interface FakeTab {
|
||||
socket: WebSocket;
|
||||
received: TabMessage[];
|
||||
closed: Promise<number>;
|
||||
/** Answers every call with what `answer` makes of it; null leaves it unanswered. */
|
||||
answer: (call: TabMessage) => object | null;
|
||||
next(type: string): Promise<TabMessage>;
|
||||
}
|
||||
|
||||
function connectTab(
|
||||
user: string,
|
||||
origin: string | null = ORIGIN,
|
||||
ticket = `ticket-${Math.random()}`,
|
||||
): FakeTab {
|
||||
tickets.set(ticket, user);
|
||||
const socket = new WebSocket(url().replace('http', 'ws'), {
|
||||
headers: origin ? { Origin: origin } : {},
|
||||
} as unknown as string[]);
|
||||
open.push(socket);
|
||||
const tab: FakeTab = {
|
||||
socket,
|
||||
received: [],
|
||||
// Node's client reports a refused handshake as an error alone, without the close a browser adds.
|
||||
closed: new Promise((resolve) => {
|
||||
socket.addEventListener('close', (event) => resolve(event.code));
|
||||
socket.addEventListener('error', () => resolve(1006));
|
||||
}),
|
||||
answer: (call) => ({ content: [{ type: 'text', text: `${user} did ${call.tool ?? ''}` }] }),
|
||||
next: (type) =>
|
||||
new Promise((resolve) => {
|
||||
const seen = tab.received.find((message) => message.type === type);
|
||||
if (seen) {
|
||||
tab.received.splice(tab.received.indexOf(seen), 1);
|
||||
resolve(seen);
|
||||
return;
|
||||
}
|
||||
const listener = (event: MessageEvent) => {
|
||||
const message = JSON.parse(String(event.data)) as TabMessage;
|
||||
if (message.type !== type) return;
|
||||
tab.received.splice(tab.received.indexOf(message), 1);
|
||||
socket.removeEventListener('message', listener);
|
||||
resolve(message);
|
||||
};
|
||||
socket.addEventListener('message', listener);
|
||||
}),
|
||||
};
|
||||
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'hello', ticket })));
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(String(event.data)) as TabMessage;
|
||||
if (message.type === 'call') {
|
||||
const result = tab.answer(message);
|
||||
if (result) socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
|
||||
return;
|
||||
}
|
||||
tab.received.push(message);
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function onTab(user: string): Promise<FakeTab> {
|
||||
const tab = connectTab(user);
|
||||
expect((await tab.next('welcome')).user).toBe(user);
|
||||
return tab;
|
||||
}
|
||||
|
||||
const call = (token: string, session: string, name: string, args: object = {}) =>
|
||||
rpc(token, 'tools/call', { name, arguments: args }, session);
|
||||
|
||||
describe('agents', () => {
|
||||
it('need a token the API knows, and hear 429 while the API brakes them', async () => {
|
||||
const none = await rpc(null, 'initialize');
|
||||
const wrong = await rpc('fwa_nobody', 'initialize');
|
||||
throttled = true;
|
||||
const braked = await rpc('fwa_alice', 'initialize');
|
||||
expect([none.status, wrong.status, braked.status]).toEqual([401, 401, 429]);
|
||||
expect(none.headers.get('www-authenticate')).toMatch(/^Bearer/);
|
||||
expect(braked.headers.get('retry-after')).toBe('600');
|
||||
});
|
||||
|
||||
it('pass their own address to the brake, or nginx’s word for it only when told to trust it', async () => {
|
||||
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
|
||||
await start({ trustProxy: true });
|
||||
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
|
||||
expect(addresses).toHaveLength(2);
|
||||
expect(addresses[0]).not.toBe('203.0.113.9');
|
||||
expect(addresses[1]).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('start a session in the protocol version they ask for, or the newest', async () => {
|
||||
const asked = await rpc('fwa_alice', 'initialize', { protocolVersion: '2025-03-26' });
|
||||
const unknown = await rpc('fwa_alice', 'initialize', { protocolVersion: '1999-01-01' });
|
||||
expect(asked.body?.result).toMatchObject({
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: { tools: {}, resources: { subscribe: true } },
|
||||
serverInfo: { name: 'feuerwerk' },
|
||||
});
|
||||
expect(asked.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(unknown.body?.result?.protocolVersion).toBe('2025-11-25');
|
||||
});
|
||||
|
||||
it('get 202 for a notification and the tool list on asking', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const res = await fetch(url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer fwa_alice',
|
||||
'Mcp-Session-Id': session,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
expect((await rpc('fwa_alice', 'tools/list', {}, session)).body?.result).toEqual({ tools: TOOLS });
|
||||
});
|
||||
|
||||
it('cannot use a session of another account, nor one that is not there', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
expect((await rpc('fwa_bob', 'tools/list', {}, session)).status).toBe(404);
|
||||
expect((await rpc('fwa_alice', 'tools/list', {}, 'no-such-session')).status).toBe(404);
|
||||
expect((await rpc('fwa_alice2', 'tools/list', {}, session)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('are refused from a page of another site, not from the editor’s own', async () => {
|
||||
const foreign = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: 'https://evil.test' });
|
||||
const own = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: ORIGIN });
|
||||
expect([foreign.status, own.status]).toEqual([403, 200]);
|
||||
});
|
||||
|
||||
it('hear about unknown tools and methods in JSON-RPC terms', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
expect((await call('fwa_alice', session, 'rm_rf')).body?.error?.code).toBe(-32602);
|
||||
expect((await rpc('fwa_alice', 'prompts/list', {}, session)).body?.error?.code).toBe(-32601);
|
||||
});
|
||||
|
||||
it('end a session with DELETE', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const res = await fetch(url(), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session },
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect((await rpc('fwa_alice', 'tools/list', {}, session)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('send no body larger than the limit', async () => {
|
||||
await start({ maxBodyBytes: 1000 });
|
||||
const res = await fetch(url(), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer fwa_alice', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { pad: 'x'.repeat(2000) },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs', () => {
|
||||
it('get a call through to the tab of the same account and its answer back', async () => {
|
||||
await onTab('alice');
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'get_view');
|
||||
expect(answer.body?.result).toEqual({
|
||||
content: [{ type: 'text', text: 'alice did get_view' }],
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('without a tab, a call says so and how to fix it', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'get_view');
|
||||
expect(toolResult(answer.body).isError).toBe(true);
|
||||
expect(textOf(answer.body)).toMatch(/No Feuerwerk editor is connected.*"Agent"/);
|
||||
});
|
||||
|
||||
it('never reach the tab of another account', async () => {
|
||||
const bobs = await onTab('bob');
|
||||
const calls: string[] = [];
|
||||
bobs.answer = (message) => {
|
||||
calls.push(message.tool ?? '');
|
||||
return { content: [{ type: 'text', text: 'bob' }] };
|
||||
};
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'get_view');
|
||||
expect(toolResult(answer.body).isError).toBe(true);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('are refused from another origin or none, before a ticket is asked for', async () => {
|
||||
const foreign = connectTab('alice', 'https://evil.test');
|
||||
const none = connectTab('alice', null);
|
||||
expect(await foreign.closed).toBe(1006);
|
||||
expect(await none.closed).toBe(1006);
|
||||
expect(tickets.size).toBe(2);
|
||||
});
|
||||
|
||||
it('need a ticket the API takes, once', async () => {
|
||||
const tab = connectTab('alice', ORIGIN, 'shared');
|
||||
await tab.next('welcome');
|
||||
tickets.delete('shared');
|
||||
const again = connectTab('alice', ORIGIN, 'other');
|
||||
tickets.delete('other');
|
||||
expect(await again.closed).toBe(CLOSE.refused);
|
||||
expect(relay.stats().tabs).toEqual(['alice']);
|
||||
});
|
||||
|
||||
it('that say no hello in time are let go', async () => {
|
||||
await start({ helloTimeoutMs: 100 });
|
||||
const socket = new WebSocket(url().replace('http', 'ws'), {
|
||||
headers: { Origin: ORIGIN },
|
||||
} as unknown as string[]);
|
||||
open.push(socket);
|
||||
const code = await new Promise((resolve) =>
|
||||
socket.addEventListener('close', (event) => resolve(event.code)),
|
||||
);
|
||||
expect(code).toBe(CLOSE.helloTimeout);
|
||||
});
|
||||
|
||||
it('one per account: the next takes over, and a call open on the first ends', async () => {
|
||||
const first = await onTab('alice');
|
||||
first.answer = () => null;
|
||||
const session = await initialize('fwa_alice');
|
||||
const pending = call('fwa_alice', session, 'get_view');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const second = await onTab('Alice');
|
||||
expect((await first.next('replaced')).type).toBe('replaced');
|
||||
expect(await first.closed).toBe(CLOSE.replaced);
|
||||
expect(textOf((await pending).body)).toBe('Another editor tab took over');
|
||||
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('Alice did get_view');
|
||||
second.socket.close();
|
||||
});
|
||||
|
||||
it('that do not answer in time end the call with an error', async () => {
|
||||
await start({ callTimeoutMs: 100 });
|
||||
const tab = await onTab('alice');
|
||||
tab.answer = () => null;
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'screenshot');
|
||||
expect(answer.body?.result).toEqual({
|
||||
content: [{ type: 'text', text: 'The editor did not answer within 0 s' }],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const data = Buffer.alloc(300_000, 7).toString('base64');
|
||||
tab.answer = () => ({
|
||||
content: [
|
||||
{ type: 'image', data, mimeType: 'image/png' },
|
||||
{ type: 'script', text: 'x' },
|
||||
],
|
||||
structuredContent: { width: 10 },
|
||||
});
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'screenshot');
|
||||
expect(toolResult(answer.body).content).toEqual([{ type: 'image', data, mimeType: 'image/png' }]);
|
||||
expect(toolResult(answer.body).structuredContent).toEqual({ width: 10 });
|
||||
});
|
||||
|
||||
it('learn how many agents their account has on, and an agent gone quiet counts out', async () => {
|
||||
await start({ agentRecentMs: 150, pingMs: 50 });
|
||||
await initialize('fwa_alice');
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const tab = await onTab('alice');
|
||||
expect((await tab.next('agents')).count).toBe(0);
|
||||
await initialize('fwa_alice');
|
||||
expect((await tab.next('agents')).count).toBe(1);
|
||||
expect((await tab.next('agents')).count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the view', () => {
|
||||
it('is read as a resource, and a subscribed agent hears when it changes', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const session = await initialize('fwa_alice');
|
||||
expect((await rpc('fwa_alice', 'resources/list', {}, session)).body?.result).toMatchObject({
|
||||
resources: [{ uri: VIEW_URI }],
|
||||
});
|
||||
expect((await rpc('fwa_alice', 'resources/subscribe', { uri: VIEW_URI }, session)).body?.result).toEqual(
|
||||
{},
|
||||
);
|
||||
|
||||
const stream = await fetch(url(), {
|
||||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||||
});
|
||||
expect(stream.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = stream.body!.getReader();
|
||||
const heard = (async () => {
|
||||
let text = '';
|
||||
while (!text.includes('notifications/resources/updated')) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += new TextDecoder().decode(value);
|
||||
}
|
||||
return text;
|
||||
})();
|
||||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
|
||||
expect(await heard).toContain(`"params":{"uri":"${VIEW_URI}"}`);
|
||||
await reader.cancel();
|
||||
|
||||
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
|
||||
expect(JSON.parse(viewText(read.body))).toEqual({ tab: 'graph' });
|
||||
});
|
||||
|
||||
it('read without a tab says it is not connected', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
|
||||
expect(JSON.parse(viewText(read.body))).toMatchObject({ connected: false });
|
||||
});
|
||||
|
||||
it('changed since the agent last looked is told with the next answer, once', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const session = await initialize('fwa_alice');
|
||||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'scene' }, summary: 'tab scene' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await call('fwa_alice', session, 'get_view');
|
||||
const same = await call('fwa_alice', session, 'screenshot');
|
||||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const changed = await call('fwa_alice', session, 'screenshot');
|
||||
const after = await call('fwa_alice', session, 'screenshot');
|
||||
expect(toolResult(same.body).content).toHaveLength(1);
|
||||
expect(textOf(changed.body, 1)).toBe("The user's view has changed since you last looked: tab graph");
|
||||
expect(toolResult(after.body).content).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in front of a plain server', () => {
|
||||
let backend: Server;
|
||||
afterEach(() => new Promise<void>((resolve) => backend.close(() => resolve())));
|
||||
|
||||
it('passes everything off its own path on', async () => {
|
||||
backend = createServer((req, res) =>
|
||||
res.end(`backend ${req.method} ${req.url} ${req.headers.range ?? ''}`),
|
||||
);
|
||||
await new Promise<void>((resolve) => backend.listen(0, '127.0.0.1', () => resolve()));
|
||||
const port = (backend.address() as { port: number }).port;
|
||||
await start({ backend: `http://127.0.0.1:${port}` });
|
||||
const page = await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/data/x.mp4?y=1`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
});
|
||||
expect(await page.text()).toBe('backend GET /feuerwerk/data/x.mp4?y=1 bytes=0-9');
|
||||
expect((await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/mcp`, { method: 'POST' })).status).toBe(
|
||||
401,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// A command-line script: under a web server it does nothing (#427).
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent tokens and tickets of the MCP relay (#429), on a scratch directory with a clock of its own.
|
||||
* Plain PHP like limits.test.php; CI runs it with `php tests/php/agents.test.php`.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../../api/lib/AtomicFile.php';
|
||||
require __DIR__ . '/../../api/lib/AgentTokens.php';
|
||||
|
||||
$failed = 0;
|
||||
$passed = 0;
|
||||
|
||||
function check(string $name, bool $ok, mixed $got = null): void
|
||||
{
|
||||
global $failed, $passed;
|
||||
if ($ok) {
|
||||
$passed++;
|
||||
echo "PASS {$name}\n";
|
||||
return;
|
||||
}
|
||||
$failed++;
|
||||
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||||
}
|
||||
|
||||
$dir = sys_get_temp_dir() . '/feuerwerk-php-agents-' . bin2hex(random_bytes(4));
|
||||
mkdir($dir);
|
||||
register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($dir)));
|
||||
|
||||
$now = 1_000_000;
|
||||
$tokens = new AgentTokens("{$dir}/agent-tokens", "{$dir}/agent-tickets", static function () use (&$now): int {
|
||||
return $now;
|
||||
});
|
||||
|
||||
// --- Tokens ----------------------------------------------------------------------------------------
|
||||
|
||||
$made = $tokens->create('alice', ' Claude Code ', 30);
|
||||
check(
|
||||
'a token is made with its name trimmed, an expiry in days, and the token itself in the answer',
|
||||
preg_match('/^fwa_[0-9a-f]{64}$/', $made['token']) === 1
|
||||
&& $made['name'] === 'Claude Code'
|
||||
&& $made['expiresAt'] === $now + 30 * 86400
|
||||
&& $made['usedAt'] === null,
|
||||
$made,
|
||||
);
|
||||
$files = glob("{$dir}/agent-tokens/*.json");
|
||||
$stored = implode('', array_map('file_get_contents', $files));
|
||||
check(
|
||||
'on disk it is one file under the hash of the token, 0640, and the token is nowhere in it',
|
||||
count($files) === 1
|
||||
&& basename($files[0]) === hash('sha256', $made['token']) . '.json'
|
||||
&& (fileperms($files[0]) & 0777) === 0640
|
||||
&& !str_contains($stored, substr($made['token'], 4)),
|
||||
[$files, $stored],
|
||||
);
|
||||
$listed = $tokens->listOf('Alice');
|
||||
check(
|
||||
'its owner lists it, under any spelling of the name, without the token',
|
||||
count($listed) === 1 && $listed[0]['id'] === $made['id'] && !isset($listed[0]['token']) && !isset($listed[0]['username']),
|
||||
$listed,
|
||||
);
|
||||
check('another account lists nothing', $tokens->listOf('bob') === []);
|
||||
|
||||
check('the token names its account', $tokens->verify($made['token']) === 'alice');
|
||||
check('and its use is written down', $tokens->listOf('alice')[0]['usedAt'] === $now);
|
||||
$now += 30;
|
||||
$tokens->verify($made['token']);
|
||||
check('but not again within the minute', $tokens->listOf('alice')[0]['usedAt'] === $now - 30);
|
||||
$now += 60;
|
||||
$tokens->verify($made['token']);
|
||||
check('after it, again', $tokens->listOf('alice')[0]['usedAt'] === $now);
|
||||
|
||||
$wrong = 'fwa_' . str_repeat('0', 64);
|
||||
check('an unknown token names nobody', $tokens->verify($wrong) === null);
|
||||
check('nor does one of the wrong shape', $tokens->verify(substr($made['token'], 4)) === null && $tokens->verify($made['token'] . '0') === null);
|
||||
check('nor a path', $tokens->verify('fwa_../../users') === null);
|
||||
|
||||
check('another account cannot revoke it', $tokens->revoke('bob', $made['id']) === false && $tokens->verify($made['token']) === 'alice');
|
||||
check('its owner can', $tokens->revoke('alice', $made['id']) === true);
|
||||
check('and then it names nobody', $tokens->verify($made['token']) === null && $tokens->listOf('alice') === []);
|
||||
check('revoking it twice finds nothing', $tokens->revoke('alice', $made['id']) === false);
|
||||
|
||||
$short = $tokens->create('alice', 'one day', 1);
|
||||
$now += 86400 - 1;
|
||||
check('a token works until the second before it expires', $tokens->verify($short['token']) === 'alice');
|
||||
$now += 1;
|
||||
check('then it names nobody', $tokens->verify($short['token']) === null);
|
||||
check('and is gone from the list and the disk', $tokens->listOf('alice') === [] && glob("{$dir}/agent-tokens/*.json") === []);
|
||||
|
||||
$long = $tokens->create('alice', 'long', 10_000);
|
||||
check('a token lasts at most a year', $long['expiresAt'] === $now + AgentTokens::MAX_DAYS * 86400, $long);
|
||||
|
||||
$tokens->create('bob', 'bob', 5);
|
||||
check('ending an account’s tokens ends its own alone', $tokens->endAllOf('ALICE') === 1 && count($tokens->listOf('bob')) === 1);
|
||||
|
||||
for ($i = 0; $i < AgentTokens::MAX_PER_ACCOUNT; $i++) {
|
||||
$tokens->create('carol', "t{$i}", 5);
|
||||
}
|
||||
$refused = null;
|
||||
try {
|
||||
$tokens->create('carol', 'one too many', 5);
|
||||
} catch (TooManyAgentTokensException $e) {
|
||||
$refused = $e->getMessage();
|
||||
}
|
||||
check('an account has at most ' . AgentTokens::MAX_PER_ACCOUNT . ' tokens', $refused !== null && count($tokens->listOf('carol')) === AgentTokens::MAX_PER_ACCOUNT, $refused);
|
||||
|
||||
check('a name is 1–60 characters', AgentTokens::isValidName('x') && !AgentTokens::isValidName(' ') && !AgentTokens::isValidName(str_repeat('x', 61)));
|
||||
|
||||
// --- Tickets ---------------------------------------------------------------------------------------
|
||||
|
||||
$ticket = $tokens->issueTicket('alice');
|
||||
$ticketFiles = glob("{$dir}/agent-tickets/*.json");
|
||||
check(
|
||||
'a ticket is kept under its hash, 0640',
|
||||
preg_match('/^[0-9a-f]{64}$/', $ticket) === 1
|
||||
&& count($ticketFiles) === 1
|
||||
&& basename($ticketFiles[0]) === hash('sha256', $ticket) . '.json'
|
||||
&& (fileperms($ticketFiles[0]) & 0777) === 0640,
|
||||
$ticketFiles,
|
||||
);
|
||||
check('redeemed, it names its account', $tokens->redeemTicket($ticket) === 'alice');
|
||||
check('a second time, nobody', $tokens->redeemTicket($ticket) === null);
|
||||
check('and nothing of it is left', glob("{$dir}/agent-tickets/*") === []);
|
||||
|
||||
$late = $tokens->issueTicket('alice');
|
||||
$now += AgentTokens::TICKET_SECONDS + 1;
|
||||
check('a ticket older than a minute names nobody', $tokens->redeemTicket($late) === null);
|
||||
check('an agent token is no ticket', $tokens->redeemTicket($long['token']) === null);
|
||||
|
||||
$stale = $tokens->issueTicket('bob');
|
||||
$now += AgentTokens::TICKET_SECONDS + 1;
|
||||
touch("{$dir}/agent-tickets/" . hash('sha256', $stale) . '.json', $now - AgentTokens::TICKET_SECONDS - 1);
|
||||
$tokens->issueTicket('bob');
|
||||
check('issuing a ticket sweeps the ones nobody redeemed', count(glob("{$dir}/agent-tickets/*.json")) === 1);
|
||||
|
||||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||||
exit($failed === 0 ? 0 : 1);
|
||||
@@ -157,6 +157,8 @@ check(
|
||||
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token'], $demo['token']) && $offered === 204,
|
||||
[$saved['status'], $texture, $sound, $snapshot, $release, $demo, $offered],
|
||||
);
|
||||
// An agent token of alice's (#429), which only the relay's routes take.
|
||||
$agent = call('POST', 'agent-tokens', $alice, ['name' => 'Claude Code', 'days' => 7])['body'];
|
||||
$folder = explode('/', $texture)[0];
|
||||
$p = $project['id'];
|
||||
$s = $snapshot['id'];
|
||||
@@ -219,6 +221,14 @@ $matrix = [
|
||||
['POST', 'pixels', 'not a cel', 'POST pixels', [401, 422, 422, 422]],
|
||||
['GET', 'plugins', null, 'GET plugins', [200, 200, 200, 200]],
|
||||
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [404, 404, 404, 404]],
|
||||
|
||||
['GET', 'agent-tokens', null, 'GET agent-tokens', [401, 200, 200, 200]],
|
||||
['POST', 'agent-tokens', ['name' => ''], 'POST agent-tokens', [401, 422, 422, 422]],
|
||||
['DELETE', "agent-tokens/{$agent['id']}", null, 'DELETE agent-tokens/{}', [401, null, 404, 404]],
|
||||
['POST', 'agent/ticket', null, 'POST agent/ticket', [401, 200, 200, 200]],
|
||||
// Without the relay's key they are not there, for anybody.
|
||||
['POST', 'agent/verify', ['token' => $agent['token'], 'address' => '192.0.2.1'], 'POST agent/verify', [404, 404, 404, 404]],
|
||||
['POST', 'agent/redeem', ['ticket' => str_repeat('0', 64)], 'POST agent/redeem', [404, 404, 404, 404]],
|
||||
];
|
||||
|
||||
$actors = ['signed out' => null, 'alice' => $alice, 'bob' => $bob, 'an administrator' => $admin];
|
||||
@@ -285,6 +295,60 @@ check('a demo copy goes out under its token, not the project id', ($opened['proj
|
||||
check('an unknown demo is not found', call('GET', 'demos/' . str_repeat('0', 32))['status'] === 404);
|
||||
check('a demo cannot be changed through its open route', call('POST', "demos/{$demo['token']}", $admin)['status'] === 404);
|
||||
|
||||
// --- The MCP relay's routes (#429) ----------------------------------------------------------------
|
||||
|
||||
$relayKey = bin2hex(random_bytes(32));
|
||||
file_put_contents("{$data}/mcp-relay.key", $relayKey . "\n");
|
||||
$relay = ['X-Relay-Key' => $relayKey];
|
||||
$verify = static fn(string $token, string $address, array $headers): array =>
|
||||
call('POST', 'agent/verify', null, ['token' => $token, 'address' => $address], $headers);
|
||||
|
||||
$known = $verify($agent['token'], '192.0.2.1', $relay);
|
||||
check('with the key, the relay learns whose token it is', $known['status'] === 200 && $known['body'] === ['user' => 'alice', 'role' => 'user'], $known);
|
||||
check('with a wrong key the route is not there', $verify($agent['token'], '192.0.2.1', ['X-Relay-Key' => str_repeat('a', 64)])['status'] === 404);
|
||||
check('a wrong token is 401', $verify('fwa_' . str_repeat('0', 64), '192.0.2.1', $relay)['status'] === 401);
|
||||
check(
|
||||
'an agent token opens no route of the API',
|
||||
call('GET', 'projects', null, null, ['Authorization' => "Bearer {$agent['token']}"])['status'] === 401,
|
||||
);
|
||||
|
||||
$ticket = call('POST', 'agent/ticket', $alice)['body'];
|
||||
check('a tab gets a ticket for a minute', preg_match('/^[0-9a-f]{64}$/', (string) ($ticket['ticket'] ?? '')) === 1 && $ticket['expiresIn'] === 60, $ticket);
|
||||
check('asked without the key, it is not taken', call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']])['status'] === 404);
|
||||
$redeemed = call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']], $relay);
|
||||
check('the relay redeems it for its account', $redeemed['status'] === 200 && $redeemed['body']['user'] === 'alice', $redeemed);
|
||||
check('once', call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']], $relay)['status'] === 401);
|
||||
|
||||
$guesses = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$guesses[] = $verify('fwa_' . str_repeat((string) $i, 64), '198.51.100.9', $relay)['status'];
|
||||
}
|
||||
$braked = $verify($agent['token'], '198.51.100.9', $relay);
|
||||
check(
|
||||
'five wrong tokens from one address, and even the right one waits there, with Retry-After',
|
||||
$guesses === [401, 401, 401, 401, 401] && $braked['status'] === 429 && preg_grep('/^Retry-After: \d+$/i', $braked['headers']) !== [],
|
||||
[$guesses, $braked['status']],
|
||||
);
|
||||
check('another address is not braked', $verify($agent['token'], '192.0.2.1', $relay)['status'] === 200);
|
||||
|
||||
$bobsToken = call('POST', 'agent-tokens', $bob, ['name' => 'bob'])['body'];
|
||||
check('another account cannot revoke alice’s token', call('DELETE', "agent-tokens/{$agent['id']}", $bob)['status'] === 404);
|
||||
check(
|
||||
'an account lists its own tokens, never a token itself',
|
||||
array_column(call('GET', 'agent-tokens', $bob)['body'], 'id') === [$bobsToken['id']]
|
||||
&& !array_key_exists('token', call('GET', 'agent-tokens', $bob)['body'][0]),
|
||||
);
|
||||
check('alice revokes hers', call('DELETE', "agent-tokens/{$agent['id']}", $alice)['status'] === 204);
|
||||
check('and the relay no longer takes it', $verify($agent['token'], '192.0.2.1', $relay)['status'] === 401);
|
||||
check('a token lasts at most a year', call('POST', 'agent-tokens', $alice, ['name' => 'x', 'days' => 366])['status'] === 422);
|
||||
|
||||
$second = call('POST', 'agent-tokens', $alice, ['name' => 'again'])['body'];
|
||||
call('POST', 'auth/password', $alice, ['currentPassword' => 'password-alice', 'newPassword' => 'password-alice-2']);
|
||||
check('a new password ends the account’s tokens', $verify($second['token'], '192.0.2.1', $relay)['status'] === 401);
|
||||
call('PATCH', 'users/bob', $admin, ['password' => 'password-bob-2']);
|
||||
check('so does one an administrator sets', $verify($bobsToken['token'], '192.0.2.1', $relay)['status'] === 401);
|
||||
$bob = signIn('bob', 'password-bob-2');
|
||||
|
||||
// --- Paths, depth and unknown routes --------------------------------------------------------------
|
||||
|
||||
check('an unknown route is 401 signed out', call('GET', 'nothing')['status'] === 401);
|
||||
@@ -339,7 +403,7 @@ for ($i = 0; $i < 5; $i++) {
|
||||
$wrong[] = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'guess'])['status'];
|
||||
}
|
||||
check('five wrong passwords are five 401', $wrong === [401, 401, 401, 401, 401], $wrong);
|
||||
$locked = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'password-bob']);
|
||||
$locked = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'password-bob-2']);
|
||||
$retry = preg_grep('/^Retry-After: \d+$/i', $locked['headers']);
|
||||
check('then even the right one waits, with 429 and Retry-After', $locked['status'] === 429 && $retry !== [], $locked);
|
||||
check('the session of somebody already signed in goes on', call('GET', 'projects', $alice)['status'] === 200);
|
||||
|
||||
+31
-2
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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