From 74e12de71f3445a0d0eb0c58bb0490842f71c9a7 Mon Sep 17 00:00:00 2001 From: AUDO Date: Fri, 21 Aug 2026 19:25:22 +0800 Subject: [PATCH 1/6] feat(agent-bus): MCP server on the local bus The loopback bus now answers Streamable HTTP MCP at /mcp, so an agent in an editor can drive the stage without hand-rolling HTTP calls. A peer of /v1/command and /v1/socket rather than a wrapper: the tools call the same dispatch, resolve against the same catalog the window reports, and come back with the same refusals. In the Electron main process rather than a spawned sidecar, which is what keeps the registration a URL - no absolute path into an install directory the user can change, no unpacking out of the asar, no assumption that Node is installed - and what makes the endpoint exist exactly while AVATAR does. No new settings: Enable local bus and Require token govern /mcp and /v1/* together, and Copy MCP setup hands over the whole registration line, since only the app knows the port it bound and the token. The tool surface is not a mirror of the HTTP API. It is read by a model, so it is smaller - the audio source stays out as a user setting - and play_animation defaults to playing once instead of the bus default of select, with persist as the deliberate opt-in that writes to config.yaml. animation stays an open string validated against the live catalog at call time, because a custom folder changes its ids while the app runs and a client holding a stale tool list still has to work. A refused command is a tool error carrying the way out rather than a protocol error, because the model is the one who has to fix it. Stateless: one server per request, nothing pushed, nothing left behind by a client that is killed. GET and DELETE are answered 405 for clients written against revisions before 2026-07-28. The Origin refusal is unchanged and now documented as a limit. Measured before deciding: Claude Code, the official TypeScript SDK, and the Codex CLI all send no Origin header; a webview-based desktop client may, and is not supported here. Refs #61, #6 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- README.md | 1 + avatar/electron/agent-bus.cjs | 107 +- avatar/electron/main.cjs | 1 + avatar/electron/mcp-server.cjs | 275 +++++ avatar/electron/mcp-server.test.cjs | 279 +++++ avatar/package-lock.json | 1079 ++++++++++++++++- avatar/package.json | 4 +- .../src/components/panels/AgentBusPanel.jsx | 21 + docs/agents/local-bus.md | 56 +- 10 files changed, 1770 insertions(+), 56 deletions(-) create mode 100644 avatar/electron/mcp-server.cjs create mode 100644 avatar/electron/mcp-server.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 068e320..5ec197c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Asset license manifest** — [`docs/assets-manifest.yml`](docs/assets-manifest.yml) inventories bundled VRM/VRMA/environment media, derived thumbnails, installer branding, documentation screenshots, and runtime VRM libraries with paths, licenses, credit lines, and audit status. Validated in `npm test` via `avatar/scripts/validate-assets-manifest.mjs`. Linked from [Assets & credits](docs/assets-and-credits.md), README, CONTRIBUTING, and the maintainer release checklist. (#11) -- **Local agent bus** — Settings → **Agents**. An opt-in loopback server (`127.0.0.1:47903`, off by default) so scripts and agent frameworks can drive the avatar: `POST /v1/command` and a WebSocket at `/v1/socket` as peers, both dispatching the *existing* stage commands (`animation.play`, `animation.default`, `animation.stop`, `avatar.set`, `environment.set`, `audio.source`) rather than a second set of names. `GET /v1/state` lists what is on stage with **id and label**, since a custom folder derives animation ids from file paths and a caller cannot invent them — the label works as a play id, and `playableOnce` says which clips accept `"mode": "once"`. Omitting `mode` still means *select*, which persists to `config.yaml`; agents almost always want `once`, and every example says so. Validation happens in the Electron main process against a catalog the window reports, so a request is answered with the same error codes the UI produces and the accepted action is applied by the window — a `200` means accepted, not that the model finished loading. A token is minted on first enable, reused after that, and stored encrypted with the OS keychain rather than in `config.yaml` (which the renderer rewrites on every change); it travels in `Authorization: Bearer`, never a query string. Anything carrying an `Origin` header is refused on both transports, so a web page — including a local dev server — cannot drive the avatar behind your back. Fixed port with no silent fallback, because the copied `curl` example names one. The WebSocket replies to what it is sent and pushes nothing; every reply carries an `id`, so events could be added later as frames without one. MCP is deliberately not in this: an adapter can sit on top of these HTTP commands later. See [Local agent bus](docs/agents/local-bus.md). (#6) +- **Local agent bus** — Settings → **Agents**. An opt-in loopback server (`127.0.0.1:47903`, off by default) so scripts and agent frameworks can drive the avatar: `POST /v1/command` and a WebSocket at `/v1/socket` as peers, both dispatching the *existing* stage commands (`animation.play`, `animation.default`, `animation.stop`, `avatar.set`, `environment.set`, `audio.source`) rather than a second set of names. `GET /v1/state` lists what is on stage with **id and label**, since a custom folder derives animation ids from file paths and a caller cannot invent them — the label works as a play id, and `playableOnce` says which clips accept `"mode": "once"`. Omitting `mode` still means *select*, which persists to `config.yaml`; agents almost always want `once`, and every example says so. Validation happens in the Electron main process against a catalog the window reports, so a request is answered with the same error codes the UI produces and the accepted action is applied by the window — a `200` means accepted, not that the model finished loading. A token is minted on first enable, reused after that, and stored encrypted with the OS keychain rather than in `config.yaml` (which the renderer rewrites on every change); it travels in `Authorization: Bearer`, never a query string. Anything carrying an `Origin` header is refused on both transports, so a web page — including a local dev server — cannot drive the avatar behind your back. Fixed port with no silent fallback, because the copied `curl` example names one. The WebSocket replies to what it is sent and pushes nothing; every reply carries an `id`, so events could be added later as frames without one. MCP is deliberately not in this: an adapter can sit on top of these HTTP commands later — which is what `/mcp` below now does. See [Local agent bus](docs/agents/local-bus.md). (#6) +- **MCP server on the agent bus** — the same loopback server now answers Streamable HTTP MCP at `/mcp`, so an agent in an editor can drive the stage without anyone hand-rolling HTTP calls. It is a peer of `/v1/command` and `/v1/socket`, not a wrapper: the tools call the same `dispatch`, resolve against the same catalog the window reports, and come back with the same refusals. In the Electron main process rather than a spawned sidecar, which is what keeps the registration a URL — no absolute path into an install directory the user is free to change, no unpacking out of the asar, no assumption that a Node runtime is installed — and what makes the endpoint exist exactly while AVATAR does. No new settings: **Enable local bus** and **Require token** govern `/mcp` and `/v1/*` together, and **Copy MCP setup** hands over the whole registration line, since only the app knows the port it bound and the token. The tool surface is *not* a mirror of the HTTP API — it is read by a model, so it is smaller (`list_stage`, `get_status`, `play_animation`, `stop_animation`, `set_avatar`, `set_environment`; the audio source stays out as a user setting), and `play_animation` defaults to playing **once** instead of the bus default of *select*, with `persist: true` as the deliberate opt-in that writes to `config.yaml`. `animation` stays an open string validated against the live catalog at call time, because a custom folder changes its ids while the app runs and a client holding a stale tool list still has to work. A refused command is a tool error carrying the way out — an unknown id says to call `list_stage` — rather than a protocol error, because the model is the one who has to fix it. Stateless: one server per request, nothing pushed, nothing left behind by a client that is killed; `GET` and `DELETE` are answered `405` for clients written against revisions before `2026-07-28`. The `Origin` refusal is unchanged and now documented as a limit — command-line clients send none, a webview-based desktop client may, and is not supported here. See [Local agent bus](docs/agents/local-bus.md). (#61) - **Reactive glass-bar live dot** — when an audio source is selected, the 8px indicator shows waiting (amber), live quiet→loud (mint→green from analyser level), or error (coral), instead of a binary always-green pulse. Amplitude updates via a level ref on the dot node so the bar does not take an extra React state path for loudness. (#42) ### Changed diff --git a/README.md b/README.md index d997984..6baa53d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ It renders `.vrm` models, plays `.vrma` motion, and drives mouth shapes from liv - **Environments** — built-ins, Custom folder from Settings → Directories, color fade, or none ([environments](docs/environments.md)) - **Persistent settings** — `config.yaml` across launches ([user settings](docs/user-settings.md)) - **VRMA animations** — Default greeting + loop, bundled clips, or your own `.vrma` folder from Settings → Directories; **Motion Deck** one-shot hotkeys ([VRMA](docs/animations/vrma.md) · [user settings](docs/user-settings.md#motion-deck-motiondeck)) +- **Local agent bus & MCP** — opt-in loopback server on `127.0.0.1` so scripts and editor agents can play animations, swap avatars and set the environment; the same endpoint is a Streamable HTTP MCP server ([local agent bus](docs/agents/local-bus.md)) - **Browser / localhost** — for contributors ([install](docs/getting-started/installation.md))

diff --git a/avatar/electron/agent-bus.cjs b/avatar/electron/agent-bus.cjs index 9409dfe..83dded0 100644 --- a/avatar/electron/agent-bus.cjs +++ b/avatar/electron/agent-bus.cjs @@ -3,6 +3,7 @@ const crypto = require("node:crypto"); const http = require("node:http"); const { WebSocketServer } = require("ws"); +const { MAX_MCP_BODY_BYTES, MCP_PATH, createMcpHandler } = require("./mcp-server.cjs"); /** * The local agent bus (Refs #6): a loopback-only command intake so agents and @@ -90,13 +91,13 @@ function bearerToken(request) { return match ? match[1] : null; } -function readBody(request) { +function readBody(request, limit = MAX_BODY_BYTES) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; request.on("data", (chunk) => { size += chunk.length; - if (size > MAX_BODY_BYTES) { + if (size > limit) { // Stop reading but leave the socket alone: destroying it here would // race the 413 and the caller would see a reset instead of a reason. request.pause(); @@ -116,12 +117,12 @@ function readBody(request) { * did, and a 413 nobody can read is just a connection reset. * @param {import('node:http').IncomingMessage} request */ -function drainAndDiscard(request) { +function drainAndDiscard(request, limit = MAX_BODY_BYTES) { let seen = 0; request.on("data", (chunk) => { seen += chunk.length; // A caller determined to keep talking is hung up on eventually. - if (seen > MAX_BODY_BYTES * 8) request.destroy(); + if (seen > limit * 8) request.destroy(); }); request.resume(); } @@ -142,6 +143,26 @@ function failure(code, error) { return { ok: false, code, error }; } +/** + * The bus's `{ ok: false, code, error }` means nothing to an MCP client, so + * everything refused on `/mcp` — including the shared transport checks — is + * answered in the shape that transport speaks. + * @param {import('node:http').ServerResponse} response + * @param {number} status + * @param {number} code JSON-RPC error code. + * @param {string} message + */ +function sendJsonRpcError(response, status, code, message) { + // No `id`: these are refused before anything is parsed, so there is no + // request id to echo. The spec allows an error response without one. + sendJson(response, status, { jsonrpc: "2.0", error: { code, message } }); +} + +/** JSON-RPC codes used for the transport-level refusals above. */ +const JSON_RPC_INVALID_REQUEST = -32600; +const JSON_RPC_PARSE_ERROR = -32700; +const JSON_RPC_SERVER_ERROR = -32000; + /** * @param {Object} options * @param {string} [options.host] @@ -158,6 +179,8 @@ function failure(code, error) { * the model has finished loading. * @param {() => string | null} options.getToken Null when the user has turned * `requireToken` off. + * @param {string} [options.version] Reported to MCP clients as the server + * version; the app's own. */ function createAgentBusServer({ host = "127.0.0.1", @@ -167,6 +190,7 @@ function createAgentBusServer({ getState, applyAction, getToken, + version = "0.0.0", }) { if (typeof resolveCommand !== "function") { throw new Error("createAgentBusServer requires a resolveCommand function."); @@ -193,6 +217,8 @@ function createAgentBusServer({ return result; } + const mcp = createMcpHandler({ dispatch, getState, version }); + /** * Transport-level checks, before anything is parsed. Returns null when the * request may proceed. @@ -229,14 +255,75 @@ function createAgentBusServer({ return null; } + /** + * `POST /mcp`. The MCP surface is a peer of the two routes below, sharing + * their transport checks and their `dispatch` — see mcp-server.cjs. + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + */ + async function handleMcp(request, response) { + if (request.method !== "POST") { + // Protocol revision 2026-07-28 dropped the GET stream and DELETE + // teardown, and this server is stateless besides. Clients written against + // an older revision still try both, and a 405 is what tells them to stop + // rather than to retry. + response.setHeader("allow", "POST"); + sendJsonRpcError( + response, + 405, + JSON_RPC_INVALID_REQUEST, + "This MCP endpoint is stateless: use POST.", + ); + return; + } + + const declared = Number(request.headers["content-length"]); + if (Number.isFinite(declared) && declared > MAX_MCP_BODY_BYTES) { + sendJsonRpcError(response, 413, JSON_RPC_INVALID_REQUEST, "Request body is too large."); + drainAndDiscard(request, MAX_MCP_BODY_BYTES); + return; + } + + let raw; + try { + raw = await readBody(request, MAX_MCP_BODY_BYTES); + } catch (error) { + if (error && error.tooLarge) { + sendJsonRpcError(response, 413, JSON_RPC_INVALID_REQUEST, "Request body is too large."); + drainAndDiscard(request, MAX_MCP_BODY_BYTES); + } + return; + } + + let frame; + try { + frame = JSON.parse(raw); + } catch { + sendJsonRpcError(response, 400, JSON_RPC_PARSE_ERROR, "Request body must be JSON."); + return; + } + + await mcp(request, response, frame); + } + async function handleRequest(request, response) { + const url = new URL(request.url ?? "/", "http://localhost"); + const isMcp = url.pathname === MCP_PATH; + const rejection = refuse(request); if (rejection) { - sendJson(response, rejection.status, rejection.body); + if (isMcp) { + sendJsonRpcError(response, rejection.status, JSON_RPC_INVALID_REQUEST, rejection.body.error); + } else { + sendJson(response, rejection.status, rejection.body); + } return; } - const url = new URL(request.url ?? "/", "http://localhost"); + if (isMcp) { + await handleMcp(request, response); + return; + } if (url.pathname === STATE_PATH) { if (request.method !== "GET") { @@ -326,7 +413,12 @@ function createAgentBusServer({ response.destroy(); return; } - sendJson(response, 500, failure("internal-error", "The avatar window could not be reached.")); + const message = "The avatar window could not be reached."; + if ((request.url ?? "").startsWith(MCP_PATH)) { + sendJsonRpcError(response, 500, JSON_RPC_SERVER_ERROR, message); + return; + } + sendJson(response, 500, failure("internal-error", message)); }); }); @@ -425,6 +517,7 @@ function createAgentBusServer({ module.exports = { COMMAND_PATH, DEFAULT_AGENT_BUS_PORT, + MCP_PATH, SOCKET_PATH, STATE_PATH, createAgentBusServer, diff --git a/avatar/electron/main.cjs b/avatar/electron/main.cjs index 9d08630..6299153 100644 --- a/avatar/electron/main.cjs +++ b/avatar/electron/main.cjs @@ -370,6 +370,7 @@ async function startAgentBus() { try { const server = createAgentBusServer({ port: agentBusSettings.port, + version: app.getVersion(), resolveCommand: await stageResolver(), getContext: () => stageSnapshot?.context ?? null, // The window reports the catalogs; the version and the runtime are diff --git a/avatar/electron/mcp-server.cjs b/avatar/electron/mcp-server.cjs new file mode 100644 index 0000000..c523478 --- /dev/null +++ b/avatar/electron/mcp-server.cjs @@ -0,0 +1,275 @@ +"use strict"; + +const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); +const { + StreamableHTTPServerTransport, +} = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); +const { z } = require("zod"); + +/** + * An MCP adapter over the stage command layer (Refs #6, #61). + * + * A peer of `/v1/command` and `/v1/socket`, not a wrapper around either: the + * tools below call the same `dispatch` those routes do, so one set of stage + * names and one set of error codes serve every caller. Nothing here talks HTTP + * to the bus it is part of. + * + * The surface is deliberately *not* a mirror of the HTTP API. It is read by a + * model, so it is smaller, it is named for what the model is trying to do, and + * its defaults are the ones an agent almost always wants — see `persist` below. + * + * Every dependency is injected, as in agent-bus.cjs, so `node --test` can run + * this with no Electron and no window. + */ + +const MCP_PATH = "/mcp"; + +/** + * MCP frames carry a `_meta` block with client info and capabilities, so the + * bus's stage-command ceiling is too tight here. Still bounded: this buffers in + * the main process. + */ +const MAX_MCP_BODY_BYTES = 256 * 1024; + +const SERVER_INSTRUCTIONS = [ + "AVATAR shows a VRM character on the user's desktop.", + "Use play_animation when the user asks for a visible reaction, or when a gesture clearly fits what they asked for.", + "Call list_stage before naming an animation, avatar, or environment you have not seen in this session: a custom library derives its ids from file paths and cannot be guessed.", + "list_stage and get_status are read-only. Everything else changes what is on the user's screen, so do not call them speculatively.", + "A successful call means the request was accepted, not that the model has finished loading.", +].join(" "); + +/** Stage failures a model can act on, plus the two this layer adds. */ +const RECOVERY_HINT = Object.freeze({ + "unknown-animation": "Call list_stage for the animations this library actually has.", + "unknown-avatar": "Call list_stage for the avatar ids this library actually has.", + "unknown-environment": "Call list_stage for the environment ids this library actually has.", + "not-playable-once": "Play it with persist: true, or pick a single clip instead.", + "not-ready": "AVATAR is still starting up. Try again shortly.", +}); + +/** + * @param {unknown} value + * @returns {{ content: { type: 'text', text: string }[], structuredContent?: Record, isError?: boolean }} + */ +function ok(value) { + return { + content: [{ type: "text", text: JSON.stringify(value, null, 2) }], + structuredContent: value && typeof value === "object" ? value : { value }, + }; +} + +/** + * A refused command is a tool error, not a protocol error: the model asked for + * something reasonable and needs to be told what to do instead. + * @param {{ code?: string, error?: string }} result + */ +function refused(result) { + const hint = RECOVERY_HINT[result?.code ?? ""]; + const text = [result?.error ?? "The command was refused.", hint].filter(Boolean).join(" "); + return { + content: [{ type: "text", text }], + structuredContent: { ok: false, code: result?.code ?? "bad-payload", error: text }, + isError: true, + }; +} + +/** + * @param {(command: string, payload: unknown) => { ok: boolean, action?: unknown, code?: string, error?: string }} dispatch + * @param {string} command + * @param {unknown} payload + */ +function run(dispatch, command, payload) { + let result; + try { + result = dispatch(command, payload); + } catch { + return refused({ code: "internal-error", error: "The avatar window could not be reached." }); + } + if (!result?.ok) return refused(result ?? {}); + // The accepted action comes back so the model can see what its request became + // — play_animation matches labels as well as ids, so the two can differ. + return ok({ ok: true, action: result.action }); +} + +/** + * @param {Object} options + * @param {(command: string, payload: unknown) => any} options.dispatch The bus's + * own dispatch: resolves against the window's catalog and applies the action. + * @param {() => unknown} options.getState Body of `GET /v1/state`, or null while + * the window has not reported one. + * @param {string} options.version + */ +function createMcpServer({ dispatch, getState, version }) { + const server = new McpServer( + { name: "avatar", version }, + { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }, + ); + + server.registerTool( + "list_stage", + { + title: "List what is on stage", + description: + "The animations, avatars, environments and audio sources this AVATAR install actually has, plus what is currently selected. Read-only. Ask for this before naming anything you have not seen: a custom library derives its ids from file paths, and `playableOnce` says which clips can be played without changing the selection.", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => { + const state = getState(); + if (!state) return refused({ code: "not-ready" }); + return ok(state); + }, + ); + + server.registerTool( + "get_status", + { + title: "Get AVATAR status", + description: + "Whether AVATAR is ready to take commands, and what is on stage right now. Read-only, and answers even while the window is still starting up.", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => { + const state = getState(); + if (!state) { + return ok({ + ready: false, + detail: "AVATAR is running but its window has not reported a catalog yet.", + }); + } + return ok({ ready: true, runtime: state.runtime ?? null, current: state.current ?? null }); + }, + ); + + server.registerTool( + "play_animation", + { + title: "Play an animation", + description: + "Play one of the avatar's animations. `animation` takes an id or a label from list_stage. By default the clip plays once and the avatar returns to whatever was selected before, which is almost always what you want. Set persist to true only when the user asks to *change* the avatar's animation rather than react with one.", + inputSchema: { + // Deliberately an open string, not an enum: a custom animations folder + // derives ids from file paths, so the set changes while the app runs + // and a client holding a stale tool list still has to work. The live + // catalog is what validates this, at call time. + animation: z + .string() + .min(1) + .describe("An animation id or label, as listed by list_stage."), + persist: z + .boolean() + .optional() + .describe( + "Leave unset to play once and return. True makes it the avatar's selected animation and writes it to the user's config.", + ), + }, + }, + async ({ animation, persist }) => + run(dispatch, "animation.play", { id: animation, mode: persist ? "select" : "once" }), + ); + + server.registerTool( + "stop_animation", + { + title: "Stop the current one-shot", + description: + "End a one-shot animation early and return to the selected one. Stopping when nothing is playing is not an error.", + inputSchema: {}, + }, + async () => run(dispatch, "animation.stop", null), + ); + + server.registerTool( + "set_avatar", + { + title: "Switch avatar", + description: + "Switch to a different avatar model. Takes an id from list_stage — ids only, labels are not accepted here.", + inputSchema: { + avatar: z.string().min(1).describe("An avatar id, as listed by list_stage."), + }, + }, + async ({ avatar }) => run(dispatch, "avatar.set", { id: avatar }), + ); + + server.registerTool( + "set_environment", + { + title: "Set the environment", + description: + "Change what is behind the avatar: a named environment, a flat colour, or nothing at all.", + inputSchema: { + type: z + .enum(["env", "color", "none"]) + .describe("`env` for a listed environment, `color` for a flat colour, `none` to clear."), + id: z + .string() + .min(1) + .optional() + .describe("Required for type `env`: an environment id from list_stage."), + color: z + .string() + .regex(/^#[0-9a-f]{6}$/i) + .optional() + .describe("Required for type `color`: a hex colour such as #e9e1fa."), + }, + }, + async ({ type, id, color }) => { + if (type === "env") return run(dispatch, "environment.set", { type: "env", id }); + if (type === "color") return run(dispatch, "environment.set", { type: "color", value: color }); + return run(dispatch, "environment.set", { type: "none" }); + }, + ); + + return server; +} + +/** + * A handler for `POST /mcp`, stateless: one server and one transport per + * request, both closed when the response ends. + * + * Sessions are what the SDK offers instead, and they are what this does not + * want. Every tool here is a single round trip and nothing is pushed, so a + * session would only add state that a client which is killed — or that + * reconnects without saying goodbye — leaves behind on an endpoint reachable by + * anything on this machine. Protocol revision 2026-07-28 removes sessions for + * much the same reason. + * + * @param {Object} options + * @param {(command: string, payload: unknown) => any} options.dispatch + * @param {() => unknown} options.getState + * @param {string} [options.version] + */ +function createMcpHandler({ dispatch, getState, version = "0.0.0" }) { + if (typeof dispatch !== "function") { + throw new Error("createMcpHandler requires a dispatch function."); + } + + /** + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + * @param {unknown} parsedBody Already read by the bus, which enforces the size cap. + */ + return async function handleMcpRequest(request, response, parsedBody) { + const server = createMcpServer({ dispatch, getState, version }); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + + response.on("close", () => { + void transport.close(); + void server.close(); + }); + + await server.connect(transport); + await transport.handleRequest(request, response, parsedBody); + }; +} + +module.exports = { + MAX_MCP_BODY_BYTES, + MCP_PATH, + SERVER_INSTRUCTIONS, + createMcpHandler, + createMcpServer, +}; diff --git a/avatar/electron/mcp-server.test.cjs b/avatar/electron/mcp-server.test.cjs new file mode 100644 index 0000000..24136e4 --- /dev/null +++ b/avatar/electron/mcp-server.test.cjs @@ -0,0 +1,279 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const http = require("node:http"); +const test = require("node:test"); +const { Client } = require("@modelcontextprotocol/sdk/client/index.js"); +const { + StreamableHTTPClientTransport, +} = require("@modelcontextprotocol/sdk/client/streamableHttp.js"); +const { MCP_PATH, createAgentBusServer } = require("./agent-bus.cjs"); + +// Same resolver the bus itself uses, loaded the way main.cjs loads it. +const resolverReady = import("../src/lib/stageCommands.js"); + +const CONTEXT = { + animationCatalog: [ + { id: "vrma-03", label: "Peace Sign", source: "vrma", vrmaUrl: "/peace.vrma" }, + { id: "default", label: "Default", source: "sequence", sequence: ["vrma-03"] }, + ], + avatarIds: ["avatar1", "avatar2"], + environmentIds: ["env-forest"], + audioSourceIds: ["system"], +}; + +const STATE = { + runtime: { version: "9.9.9", mode: "desktop" }, + animations: [{ id: "vrma-03", label: "Peace Sign", playableOnce: true }], + avatars: [{ id: "avatar1" }, { id: "avatar2" }], + environments: [{ id: "env-forest", label: "Forest" }], + audioSources: [{ id: "system", label: "Device output" }], + current: { animationId: "default", avatarId: "avatar1" }, +}; + +/** + * @param {import('node:test').TestContext} context + * @param {{ state?: unknown, token?: string | null }} [options] + */ +async function startServer(context, { state = STATE, token = null } = {}) { + const { resolveStageCommand } = await resolverReady; + const applied = []; + const server = createAgentBusServer({ + port: 0, + version: "9.9.9", + resolveCommand: resolveStageCommand, + getContext: () => CONTEXT, + getState: () => state, + applyAction: (action) => applied.push(action), + getToken: () => token, + }); + const address = await server.listen(); + context.after(() => server.close()); + return { port: address.port, applied }; +} + +/** + * @param {import('node:test').TestContext} context + * @param {number} port + * @param {string | null} [token] + */ +async function connect(context, port, token = null) { + const client = new Client({ name: "test", version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${port}${MCP_PATH}`), + token ? { requestInit: { headers: { authorization: `Bearer ${token}` } } } : undefined, + ); + await client.connect(transport); + context.after(() => client.close()); + return client; +} + +/** The tool result's JSON payload, whichever half of the result carries it. */ +function payload(result) { + if (result.structuredContent) return result.structuredContent; + return JSON.parse(result.content[0].text); +} + +/** + * @param {number} port + * @param {{ method?: string, headers?: Record, body?: string }} [options] + */ +function raw(port, { method = "POST", headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const outgoing = { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers }; + if (body !== undefined) outgoing["content-length"] = String(Buffer.byteLength(body)); + const request = http.request( + { host: "127.0.0.1", port, path: MCP_PATH, method, headers: outgoing }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => + resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString("utf8") }), + ); + }, + ); + request.on("error", reject); + if (body !== undefined) request.write(body); + request.end(); + }); +} + +test("advertises the agent-facing tool surface", async (t) => { + const { port } = await startServer(t); + const client = await connect(t, port); + + const names = (await client.listTools()).tools.map((tool) => tool.name).sort(); + assert.deepEqual(names, [ + "get_status", + "list_stage", + "play_animation", + "set_avatar", + "set_environment", + "stop_animation", + ]); +}); + +test("does not expose the audio source, which is the user's setting", async (t) => { + const { port } = await startServer(t); + const client = await connect(t, port); + + const names = (await client.listTools()).tools.map((tool) => tool.name); + assert.equal( + names.some((name) => name.includes("audio")), + false, + ); +}); + +test("play_animation plays once by default rather than changing the selection", async (t) => { + const { port, applied } = await startServer(t); + const client = await connect(t, port); + + const result = await client.callTool({ + name: "play_animation", + arguments: { animation: "vrma-03" }, + }); + + assert.equal(result.isError ?? false, false); + assert.deepEqual(applied, [{ kind: "animation.play", animationId: "vrma-03", mode: "once" }]); +}); + +test("play_animation persists the selection only when asked", async (t) => { + const { port, applied } = await startServer(t); + const client = await connect(t, port); + + await client.callTool({ + name: "play_animation", + arguments: { animation: "vrma-03", persist: true }, + }); + + assert.deepEqual(applied, [{ kind: "animation.play", animationId: "vrma-03", mode: "select" }]); +}); + +test("play_animation accepts a label and reports the id it became", async (t) => { + const { port, applied } = await startServer(t); + const client = await connect(t, port); + + const result = await client.callTool({ + name: "play_animation", + arguments: { animation: "Peace Sign" }, + }); + + assert.equal(applied[0].animationId, "vrma-03"); + assert.equal(payload(result).action.animationId, "vrma-03"); +}); + +test("a refused command is a tool error carrying a way out", async (t) => { + const { port, applied } = await startServer(t); + const client = await connect(t, port); + + const result = await client.callTool({ + name: "play_animation", + arguments: { animation: "Peace Sing" }, + }); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /No animation matches/); + assert.match(result.content[0].text, /list_stage/); + assert.deepEqual(applied, []); +}); + +test("a looping sequence cannot be played once, and says what to do instead", async (t) => { + const { port } = await startServer(t); + const client = await connect(t, port); + + const result = await client.callTool({ + name: "play_animation", + arguments: { animation: "default" }, + }); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /persist: true/); +}); + +test("set_environment takes each of the three shapes", async (t) => { + const { port, applied } = await startServer(t); + const client = await connect(t, port); + + await client.callTool({ name: "set_environment", arguments: { type: "env", id: "env-forest" } }); + await client.callTool({ name: "set_environment", arguments: { type: "color", color: "#e9e1fa" } }); + await client.callTool({ name: "set_environment", arguments: { type: "none" } }); + + assert.deepEqual( + applied.map((action) => action.selection), + [{ type: "env", id: "env-forest" }, { type: "color", value: "#e9e1fa" }, { type: "none" }], + ); +}); + +test("set_avatar refuses an id this install does not have", async (t) => { + const { port, applied } = await startServer(t); + const client = await connect(t, port); + + const result = await client.callTool({ name: "set_avatar", arguments: { avatar: "avatar9" } }); + + assert.equal(result.isError, true); + assert.deepEqual(applied, []); +}); + +test("list_stage hands over the same catalog /v1/state does", async (t) => { + const { port } = await startServer(t); + const client = await connect(t, port); + + const result = await client.callTool({ name: "list_stage", arguments: {} }); + + assert.deepEqual(payload(result), STATE); +}); + +test("before the window reports, list_stage refuses and get_status still answers", async (t) => { + const { port } = await startServer(t, { state: null }); + const client = await connect(t, port); + + const listed = await client.callTool({ name: "list_stage", arguments: {} }); + assert.equal(listed.isError, true); + assert.match(listed.content[0].text, /still starting up/); + + const status = await client.callTool({ name: "get_status", arguments: {} }); + assert.equal(status.isError ?? false, false); + assert.equal(payload(status).ready, false); +}); + +test("the token gate is the bus's, not a second one", async (t) => { + const { port } = await startServer(t, { token: "secret-token" }); + + await assert.rejects(() => connect(t, port)); + + const client = await connect(t, port, "secret-token"); + assert.equal((await client.listTools()).tools.length, 6); +}); + +test("a stateless endpoint turns away the GET and DELETE of older revisions", async (t) => { + const { port } = await startServer(t); + + for (const method of ["GET", "DELETE"]) { + const response = await raw(port, { method }); + assert.equal(response.status, 405, method); + assert.equal(JSON.parse(response.body).error.message, "This MCP endpoint is stateless: use POST."); + } +}); + +test("a web page is refused here too, in the shape MCP speaks", async (t) => { + const { port } = await startServer(t); + + const response = await raw(port, { + headers: { origin: "http://localhost:5173" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + assert.equal(response.status, 403); + const body = JSON.parse(response.body); + assert.equal(body.jsonrpc, "2.0"); + assert.match(body.error.message, /web page/); +}); + +test("malformed JSON is a parse error, not a stage error", async (t) => { + const { port } = await startServer(t); + + const response = await raw(port, { body: "{ not json" }); + + assert.equal(response.status, 400); + assert.equal(JSON.parse(response.body).error.code, -32700); +}); diff --git a/avatar/package-lock.json b/avatar/package-lock.json index 6968142..e779f94 100644 --- a/avatar/package-lock.json +++ b/avatar/package-lock.json @@ -9,6 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@pixiv/three-vrm": "^3.4.2", "@pixiv/three-vrm-animation": "^3.4.2", "@react-three/drei": "^10.5.2", @@ -18,7 +19,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "three": "^0.178.0", - "ws": "^8.21.3" + "ws": "^8.21.3", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.30.1", @@ -1379,6 +1381,18 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1671,6 +1685,68 @@ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", "license": "Apache-2.0" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@monogrid/gainmap-js": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz", @@ -2643,6 +2719,73 @@ "dev": true, "license": "ISC" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2723,6 +2866,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -3226,6 +3408,59 @@ "bluebird": "^3.5.5" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3444,6 +3679,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacache": { "version": "16.1.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", @@ -3561,7 +3805,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3571,6 +3814,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3961,6 +4220,28 @@ "dev": true, "license": "ISC" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3968,6 +4249,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -3975,6 +4274,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -4080,10 +4396,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4211,6 +4526,15 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-gpu": { "version": "5.0.70", "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", @@ -4394,7 +4718,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4412,6 +4735,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4634,6 +4963,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -4676,7 +5014,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4686,7 +5023,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4696,7 +5032,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4781,6 +5116,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4972,6 +5313,36 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -4979,32 +5350,119 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" }, - "bin": { - "extract-zip": "cli.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { - "node": ">= 10.17.0" + "node": ">= 16" }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, "engines": [ "node >=0.6.0" ], @@ -5015,7 +5473,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -5032,6 +5489,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -5109,6 +5582,27 @@ "node": ">=10" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5215,6 +5709,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -5277,7 +5789,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5328,7 +5839,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5353,7 +5863,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5488,7 +5997,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5558,7 +6066,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5594,7 +6101,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5609,6 +6115,15 @@ "integrity": "sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==", "license": "Apache-2.0" }, + "node_modules/hono": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5649,6 +6164,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5838,19 +6373,26 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -6025,6 +6567,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6081,6 +6632,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6406,12 +6963,36 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/meshline": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", @@ -6634,7 +7215,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6833,6 +7413,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -6844,11 +7445,22 @@ "node": ">= 0.4" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6990,6 +7602,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7053,6 +7674,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pe-library": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-2.0.1.tgz", @@ -7094,6 +7725,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", @@ -7248,6 +7888,19 @@ "lie": "^3.0.2" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -7279,6 +7932,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -7292,6 +7961,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -7589,6 +8302,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7624,7 +8359,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -7671,6 +8405,57 @@ "license": "MIT", "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -7688,6 +8473,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -7695,6 +8499,12 @@ "dev": true, "license": "ISC" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7729,6 +8539,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -7907,6 +8789,15 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8201,6 +9092,15 @@ "tmp": "^0.2.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -8322,6 +9222,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -8379,6 +9335,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -8452,6 +9417,15 @@ "node": ">= 4" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -8660,7 +9634,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -8803,6 +9776,24 @@ "node": ">= 10" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zustand": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz", diff --git a/avatar/package.json b/avatar/package.json index 0c53914..dc5710d 100644 --- a/avatar/package.json +++ b/avatar/package.json @@ -21,6 +21,7 @@ "preview": "vite preview" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@pixiv/three-vrm": "^3.4.2", "@pixiv/three-vrm-animation": "^3.4.2", "@react-three/drei": "^10.5.2", @@ -30,7 +31,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "three": "^0.178.0", - "ws": "^8.21.3" + "ws": "^8.21.3", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.30.1", diff --git a/avatar/src/components/panels/AgentBusPanel.jsx b/avatar/src/components/panels/AgentBusPanel.jsx index 7f070b8..cb9a28d 100644 --- a/avatar/src/components/panels/AgentBusPanel.jsx +++ b/avatar/src/components/panels/AgentBusPanel.jsx @@ -40,6 +40,16 @@ export function AgentBusPanel({ settings, status, onChange, onRotateToken }) { ` -d '{"command":"animation.play","payload":{"id":"Peace Sign","mode":"once"}}'`, ].join('\n'); + // The whole registration line, not just the endpoint: only the app knows the + // port it actually bound and the token, and an MCP client config is where + // both have to end up (Refs #61). + const mcpSetup = [ + `claude mcp add --transport http avatar http://127.0.0.1:${settings.port}/mcp`, + ...(settings.requireToken + ? [` --header "Authorization: Bearer ${token ?? ''}"`] + : []), + ].join(''); + async function copy(what, text) { try { await navigator.clipboard.writeText(text); @@ -143,6 +153,17 @@ export function AgentBusPanel({ settings, status, onChange, onRotateToken }) { {copied === 'curl' ? 'Copied' : 'Copy example curl'} + +

+ +
+ +

+ The bus is also an MCP server at /mcp, so an agent can drive the avatar from + your editor. Register it once; it answers only while AVATAR is running. +

); } diff --git a/docs/agents/local-bus.md b/docs/agents/local-bus.md index 88432c8..c560faf 100644 --- a/docs/agents/local-bus.md +++ b/docs/agents/local-bus.md @@ -22,6 +22,7 @@ Settings → **Agents** → *Enable local bus*. | **Require token** | On by default. A token is generated the first time you enable the bus and reused after that | | **Copy token** / **Regenerate** | Regenerating invalidates the old one at once | | **Copy example curl** | The command below, with your port and token filled in | +| **Copy MCP setup** | The MCP registration line, same two values filled in — see [MCP](#mcp) | The token is **not** in `config.yaml` — it is encrypted with the OS keychain beside the VRoid Hub credentials. On a machine with no keychain available the panel says so, and the token is kept in @@ -118,11 +119,62 @@ The body is the same shape the app uses internally: | `401` | `unauthorized` | Missing or wrong token | | `403` | `forbidden-host`, `forbidden-origin` | Not loopback, or sent by a web page | | `404` | `unknown-animation`, `unknown-avatar`, `unknown-environment`, `unknown-audio-source`, `not-found` | The id — or the route — does not exist | -| `405` / `413` / `415` | | Wrong method, body over 16 KB, or not `application/json` | +| `405` / `413` / `415` | | Wrong method, body over 16 KB (256 KB on `/mcp`, whose frames carry client metadata), or not `application/json` | | `409` | `not-playable-once` | That clip cannot be a one-shot; play it with `"select"` | | `500` | `internal-error` | The window could not be reached — it was closing as the request arrived | | `503` | `not-ready` | The window is still starting up, or reloading | +## MCP + +`POST http://127.0.0.1:47903/mcp` — a Streamable HTTP **MCP server**, so an agent in your editor +can drive the avatar without anyone hand-rolling HTTP calls. A peer of the routes above, not a +wrapper around them: same dispatch, same catalog, same refusals. + +Register it once, with the port and token this install actually uses — **Settings → Agents → +Copy MCP setup** fills both in: + +```bash +claude mcp add --transport http avatar http://127.0.0.1:47903/mcp --header "Authorization: Bearer " +``` + +The endpoint exists exactly while the bus is running. There is no separate MCP switch: **Enable +local bus** and **Require token** govern this route and the ones above together. + +| Tool | Input | | +| :--- | :--- | :--- | +| `list_stage` | — | Everything `/v1/state` returns. Read-only | +| `get_status` | — | Ready or not, and what is on stage. Answers while the window is still starting | +| `play_animation` | `animation`, `persist?` | `animation` takes an id **or a label** | +| `stop_animation` | — | Ends a one-shot early | +| `set_avatar` | `avatar` | Ids only | +| `set_environment` | `type`, `id?`, `color?` | The three shapes `environment.set` takes | + +> **`persist` is the `mode` question, asked the other way round.** This surface is read by a +> model, so its default is the one an agent almost always wants: `play_animation` plays **once** +> and leaves the selection alone. `persist: true` is the deliberate opt-in that changes the +> selection and writes it to `config.yaml`. + +`audio.source` is not exposed. It is the user's lip-sync setting, and one fewer tool is one fewer +thing for a model to reach for by mistake. + +A refused command comes back as a **tool error carrying the way out** — an unknown id says to call +`list_stage`, a looping sequence says to use `persist` — rather than as a protocol error, because +the model is the one who has to fix it. + +**Stateless.** One server per request, no sessions, nothing pushed. `GET` and `DELETE` on `/mcp` +are answered `405`: clients written against protocol revisions before `2026-07-28` try both, and a +`405` tells them to stop rather than to retry. + +### What breaks a registration + +- **Changing the port**, or **Regenerate**: the line you registered still names the old one. Copy + it again and re-register. +- **No OS keychain**: the token changes every launch, so a registration made with one will not + survive a restart. +- **Clients that send an `Origin` header** are refused, like every other route here — see + Security. Command-line clients do not send one; a webview-based desktop client may, and is not + supported in this version. + ## Security - Binds `127.0.0.1` only. **Remote binding is out of scope** — there is no setting for it, and @@ -137,8 +189,6 @@ The body is the same shape the app uses internally: ## Notes -- **MCP** is not part of this. An MCP server can sit on top of these HTTP commands; the bus stays - the one control path underneath. - Window control (move, scale, close) and loading a VRoid Hub character are not commands and are not planned to be. From 8b27b6809878205695af67cfa95cf4ca2aa03c65 Mon Sep 17 00:00:00 2001 From: AUDO Date: Fri, 21 Aug 2026 19:31:15 +0800 Subject: [PATCH 2/6] fix(agent-bus): decide a request is MCP's in one place The 500 fallback matched `request.url` with startsWith, while routing matched `url.pathname` exactly, so a throw while handling /mcpfoo would have answered in JSON-RPC shape for a request that never reached the MCP route. Both now ask the same predicate. Refs #61 Co-Authored-By: Claude Opus 5 --- avatar/electron/agent-bus.cjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/avatar/electron/agent-bus.cjs b/avatar/electron/agent-bus.cjs index 83dded0..93d69fa 100644 --- a/avatar/electron/agent-bus.cjs +++ b/avatar/electron/agent-bus.cjs @@ -163,6 +163,19 @@ const JSON_RPC_INVALID_REQUEST = -32600; const JSON_RPC_PARSE_ERROR = -32700; const JSON_RPC_SERVER_ERROR = -32000; +/** + * The one place that decides a request is MCP's, so the error shape a failure + * comes back in cannot disagree with the route that handled it. + * @param {import('node:http').IncomingMessage} request + */ +function isMcpRequest(request) { + try { + return new URL(request.url ?? "/", "http://localhost").pathname === MCP_PATH; + } catch { + return false; + } +} + /** * @param {Object} options * @param {string} [options.host] @@ -308,7 +321,7 @@ function createAgentBusServer({ async function handleRequest(request, response) { const url = new URL(request.url ?? "/", "http://localhost"); - const isMcp = url.pathname === MCP_PATH; + const isMcp = isMcpRequest(request); const rejection = refuse(request); if (rejection) { @@ -414,7 +427,7 @@ function createAgentBusServer({ return; } const message = "The avatar window could not be reached."; - if ((request.url ?? "").startsWith(MCP_PATH)) { + if (isMcpRequest(request)) { sendJsonRpcError(response, 500, JSON_RPC_SERVER_ERROR, message); return; } From 3f882666e3c03dbdb71de8d0a2b5ace108f1c92f Mon Sep 17 00:00:00 2001 From: AUDO Date: Fri, 21 Aug 2026 19:38:50 +0800 Subject: [PATCH 3/6] refactor(agent-bus): show the MCP endpoint, not one client's command Copy MCP setup handed over a `claude mcp add` line, which only helps people using that client. Every MCP client spells registration differently, so the panel now shows the endpoint itself with Copy MCP URL, beside the Copy token that was already there and already hidden when Require token is off - the two values every client needs, and nothing that presumes which one you use. Refs #61 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../src/components/panels/AgentBusPanel.jsx | 36 ++++++++++--------- avatar/src/styles/app.css | 4 +-- docs/agents/local-bus.md | 16 +++++---- 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec197c..009a231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Asset license manifest** — [`docs/assets-manifest.yml`](docs/assets-manifest.yml) inventories bundled VRM/VRMA/environment media, derived thumbnails, installer branding, documentation screenshots, and runtime VRM libraries with paths, licenses, credit lines, and audit status. Validated in `npm test` via `avatar/scripts/validate-assets-manifest.mjs`. Linked from [Assets & credits](docs/assets-and-credits.md), README, CONTRIBUTING, and the maintainer release checklist. (#11) - **Local agent bus** — Settings → **Agents**. An opt-in loopback server (`127.0.0.1:47903`, off by default) so scripts and agent frameworks can drive the avatar: `POST /v1/command` and a WebSocket at `/v1/socket` as peers, both dispatching the *existing* stage commands (`animation.play`, `animation.default`, `animation.stop`, `avatar.set`, `environment.set`, `audio.source`) rather than a second set of names. `GET /v1/state` lists what is on stage with **id and label**, since a custom folder derives animation ids from file paths and a caller cannot invent them — the label works as a play id, and `playableOnce` says which clips accept `"mode": "once"`. Omitting `mode` still means *select*, which persists to `config.yaml`; agents almost always want `once`, and every example says so. Validation happens in the Electron main process against a catalog the window reports, so a request is answered with the same error codes the UI produces and the accepted action is applied by the window — a `200` means accepted, not that the model finished loading. A token is minted on first enable, reused after that, and stored encrypted with the OS keychain rather than in `config.yaml` (which the renderer rewrites on every change); it travels in `Authorization: Bearer`, never a query string. Anything carrying an `Origin` header is refused on both transports, so a web page — including a local dev server — cannot drive the avatar behind your back. Fixed port with no silent fallback, because the copied `curl` example names one. The WebSocket replies to what it is sent and pushes nothing; every reply carries an `id`, so events could be added later as frames without one. MCP is deliberately not in this: an adapter can sit on top of these HTTP commands later — which is what `/mcp` below now does. See [Local agent bus](docs/agents/local-bus.md). (#6) -- **MCP server on the agent bus** — the same loopback server now answers Streamable HTTP MCP at `/mcp`, so an agent in an editor can drive the stage without anyone hand-rolling HTTP calls. It is a peer of `/v1/command` and `/v1/socket`, not a wrapper: the tools call the same `dispatch`, resolve against the same catalog the window reports, and come back with the same refusals. In the Electron main process rather than a spawned sidecar, which is what keeps the registration a URL — no absolute path into an install directory the user is free to change, no unpacking out of the asar, no assumption that a Node runtime is installed — and what makes the endpoint exist exactly while AVATAR does. No new settings: **Enable local bus** and **Require token** govern `/mcp` and `/v1/*` together, and **Copy MCP setup** hands over the whole registration line, since only the app knows the port it bound and the token. The tool surface is *not* a mirror of the HTTP API — it is read by a model, so it is smaller (`list_stage`, `get_status`, `play_animation`, `stop_animation`, `set_avatar`, `set_environment`; the audio source stays out as a user setting), and `play_animation` defaults to playing **once** instead of the bus default of *select*, with `persist: true` as the deliberate opt-in that writes to `config.yaml`. `animation` stays an open string validated against the live catalog at call time, because a custom folder changes its ids while the app runs and a client holding a stale tool list still has to work. A refused command is a tool error carrying the way out — an unknown id says to call `list_stage` — rather than a protocol error, because the model is the one who has to fix it. Stateless: one server per request, nothing pushed, nothing left behind by a client that is killed; `GET` and `DELETE` are answered `405` for clients written against revisions before `2026-07-28`. The `Origin` refusal is unchanged and now documented as a limit — command-line clients send none, a webview-based desktop client may, and is not supported here. See [Local agent bus](docs/agents/local-bus.md). (#61) +- **MCP server on the agent bus** — the same loopback server now answers Streamable HTTP MCP at `/mcp`, so an agent in an editor can drive the stage without anyone hand-rolling HTTP calls. It is a peer of `/v1/command` and `/v1/socket`, not a wrapper: the tools call the same `dispatch`, resolve against the same catalog the window reports, and come back with the same refusals. In the Electron main process rather than a spawned sidecar, which is what keeps the registration a URL — no absolute path into an install directory the user is free to change, no unpacking out of the asar, no assumption that a Node runtime is installed — and what makes the endpoint exist exactly while AVATAR does. No new settings: **Enable local bus** and **Require token** govern `/mcp` and `/v1/*` together, and the panel shows the endpoint with **Copy MCP URL** beside the existing **Copy token**, since every client spells its own registration differently and those two values are the ones they all need. The tool surface is *not* a mirror of the HTTP API — it is read by a model, so it is smaller (`list_stage`, `get_status`, `play_animation`, `stop_animation`, `set_avatar`, `set_environment`; the audio source stays out as a user setting), and `play_animation` defaults to playing **once** instead of the bus default of *select*, with `persist: true` as the deliberate opt-in that writes to `config.yaml`. `animation` stays an open string validated against the live catalog at call time, because a custom folder changes its ids while the app runs and a client holding a stale tool list still has to work. A refused command is a tool error carrying the way out — an unknown id says to call `list_stage` — rather than a protocol error, because the model is the one who has to fix it. Stateless: one server per request, nothing pushed, nothing left behind by a client that is killed; `GET` and `DELETE` are answered `405` for clients written against revisions before `2026-07-28`. The `Origin` refusal is unchanged and now documented as a limit — command-line clients send none, a webview-based desktop client may, and is not supported here. See [Local agent bus](docs/agents/local-bus.md). (#61) - **Reactive glass-bar live dot** — when an audio source is selected, the 8px indicator shows waiting (amber), live quiet→loud (mint→green from analyser level), or error (coral), instead of a binary always-green pulse. Amplitude updates via a level ref on the dot node so the bar does not take an extra React state path for loudness. (#42) ### Changed diff --git a/avatar/src/components/panels/AgentBusPanel.jsx b/avatar/src/components/panels/AgentBusPanel.jsx index cb9a28d..bbeb6fe 100644 --- a/avatar/src/components/panels/AgentBusPanel.jsx +++ b/avatar/src/components/panels/AgentBusPanel.jsx @@ -40,15 +40,11 @@ export function AgentBusPanel({ settings, status, onChange, onRotateToken }) { ` -d '{"command":"animation.play","payload":{"id":"Peace Sign","mode":"once"}}'`, ].join('\n'); - // The whole registration line, not just the endpoint: only the app knows the - // port it actually bound and the token, and an MCP client config is where - // both have to end up (Refs #61). - const mcpSetup = [ - `claude mcp add --transport http avatar http://127.0.0.1:${settings.port}/mcp`, - ...(settings.requireToken - ? [` --header "Authorization: Bearer ${token ?? ''}"`] - : []), - ].join(''); + // The endpoint, not a registration command for one particular client: every + // MCP client spells that differently, and the two values they all need — this + // URL and, when it is on, the token above — are the two this panel owns + // (Refs #61). + const mcpUrl = `http://127.0.0.1:${settings.port}/mcp`; async function copy(what, text) { try { @@ -115,7 +111,7 @@ export function AgentBusPanel({ settings, status, onChange, onRotateToken }) { {settings.requireToken ? ( <> {token ? ( -

{token}

+

{token}

) : (

Generated when you enable the bus.

)} @@ -154,16 +150,22 @@ export function AgentBusPanel({ settings, status, onChange, onRotateToken }) { -
- -
+ MCP server

- The bus is also an MCP server at /mcp, so an agent can drive the avatar from - your editor. Register it once; it answers only while AVATAR is running. + The bus is also an MCP server, so an agent can drive the avatar from your editor. Register + this URL with your client + {settings.requireToken ? ' and give it the token above as a Bearer header' : ''}. It answers + only while AVATAR is running.

+ +

{mcpUrl}

+ +
+ +
); } diff --git a/avatar/src/styles/app.css b/avatar/src/styles/app.css index 2a56816..70c7ce7 100644 --- a/avatar/src/styles/app.css +++ b/avatar/src/styles/app.css @@ -723,7 +723,7 @@ color: rgba(63, 52, 88, 0.45); } -.glass-drawer .agent-bus-panel__token { +.glass-drawer .agent-bus-panel__value { background: rgba(255, 255, 255, 0.62); border-color: rgba(161, 138, 214, 0.45); } @@ -999,7 +999,7 @@ /* The token is a value to be read and copied, so it gets the Port field's box rather than the dimmed caption treatment the mono class carries. */ -.agent-bus-panel__token { +.agent-bus-panel__value { margin: 0; padding: 6px 8px; border: 1px solid var(--accent); diff --git a/docs/agents/local-bus.md b/docs/agents/local-bus.md index c560faf..35b065f 100644 --- a/docs/agents/local-bus.md +++ b/docs/agents/local-bus.md @@ -22,7 +22,7 @@ Settings → **Agents** → *Enable local bus*. | **Require token** | On by default. A token is generated the first time you enable the bus and reused after that | | **Copy token** / **Regenerate** | Regenerating invalidates the old one at once | | **Copy example curl** | The command below, with your port and token filled in | -| **Copy MCP setup** | The MCP registration line, same two values filled in — see [MCP](#mcp) | +| **MCP server** / **Copy MCP URL** | The MCP endpoint for this install — see [MCP](#mcp) | The token is **not** in `config.yaml` — it is encrypted with the OS keychain beside the VRoid Hub credentials. On a machine with no keychain available the panel says so, and the token is kept in @@ -130,11 +130,15 @@ The body is the same shape the app uses internally: can drive the avatar without anyone hand-rolling HTTP calls. A peer of the routes above, not a wrapper around them: same dispatch, same catalog, same refusals. -Register it once, with the port and token this install actually uses — **Settings → Agents → -Copy MCP setup** fills both in: +Register the endpoint with your client. Every client spells that differently, so the panel hands +over the two values they all need rather than one client's command line: **Settings → Agents → +Copy MCP URL**, and **Copy token** above it while **Require token** is on. The token travels in an +`Authorization: Bearer` header, the same as everywhere else here. ```bash -claude mcp add --transport http avatar http://127.0.0.1:47903/mcp --header "Authorization: Bearer " +# Claude Code, for example +claude mcp add --transport http avatar http://127.0.0.1:47903/mcp \ + --header "Authorization: Bearer " ``` The endpoint exists exactly while the bus is running. There is no separate MCP switch: **Enable @@ -167,8 +171,8 @@ are answered `405`: clients written against protocol revisions before `2026-07-2 ### What breaks a registration -- **Changing the port**, or **Regenerate**: the line you registered still names the old one. Copy - it again and re-register. +- **Changing the port**, or **Regenerate**: what you registered still names the old value. Copy it + again and re-register. - **No OS keychain**: the token changes every launch, so a registration made with one will not survive a restart. - **Clients that send an `Origin` header** are refused, like every other route here — see From 094c4f05f039f0b643a40e9e062f4864975f5a9a Mon Sep 17 00:00:00 2001 From: AUDO Date: Fri, 21 Aug 2026 19:42:57 +0800 Subject: [PATCH 4/6] refactor(agent-bus): one block for the token and both endpoints The MCP section had its own heading and had to point at "the token above", which is the layout admitting the split was wrong: one server, one switch, one token, and two ways to reach it. The endpoint now sits with the token, and the two copy actions share a row. Stacked rather than side by side - "Copy example curl" wraps inside a half-width button in a drawer this narrow, and the label is worth more than the row. Refs #61 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../src/components/panels/AgentBusPanel.jsx | 21 ++++++++----------- docs/agents/local-bus.md | 8 +++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 009a231..aafd0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Asset license manifest** — [`docs/assets-manifest.yml`](docs/assets-manifest.yml) inventories bundled VRM/VRMA/environment media, derived thumbnails, installer branding, documentation screenshots, and runtime VRM libraries with paths, licenses, credit lines, and audit status. Validated in `npm test` via `avatar/scripts/validate-assets-manifest.mjs`. Linked from [Assets & credits](docs/assets-and-credits.md), README, CONTRIBUTING, and the maintainer release checklist. (#11) - **Local agent bus** — Settings → **Agents**. An opt-in loopback server (`127.0.0.1:47903`, off by default) so scripts and agent frameworks can drive the avatar: `POST /v1/command` and a WebSocket at `/v1/socket` as peers, both dispatching the *existing* stage commands (`animation.play`, `animation.default`, `animation.stop`, `avatar.set`, `environment.set`, `audio.source`) rather than a second set of names. `GET /v1/state` lists what is on stage with **id and label**, since a custom folder derives animation ids from file paths and a caller cannot invent them — the label works as a play id, and `playableOnce` says which clips accept `"mode": "once"`. Omitting `mode` still means *select*, which persists to `config.yaml`; agents almost always want `once`, and every example says so. Validation happens in the Electron main process against a catalog the window reports, so a request is answered with the same error codes the UI produces and the accepted action is applied by the window — a `200` means accepted, not that the model finished loading. A token is minted on first enable, reused after that, and stored encrypted with the OS keychain rather than in `config.yaml` (which the renderer rewrites on every change); it travels in `Authorization: Bearer`, never a query string. Anything carrying an `Origin` header is refused on both transports, so a web page — including a local dev server — cannot drive the avatar behind your back. Fixed port with no silent fallback, because the copied `curl` example names one. The WebSocket replies to what it is sent and pushes nothing; every reply carries an `id`, so events could be added later as frames without one. MCP is deliberately not in this: an adapter can sit on top of these HTTP commands later — which is what `/mcp` below now does. See [Local agent bus](docs/agents/local-bus.md). (#6) -- **MCP server on the agent bus** — the same loopback server now answers Streamable HTTP MCP at `/mcp`, so an agent in an editor can drive the stage without anyone hand-rolling HTTP calls. It is a peer of `/v1/command` and `/v1/socket`, not a wrapper: the tools call the same `dispatch`, resolve against the same catalog the window reports, and come back with the same refusals. In the Electron main process rather than a spawned sidecar, which is what keeps the registration a URL — no absolute path into an install directory the user is free to change, no unpacking out of the asar, no assumption that a Node runtime is installed — and what makes the endpoint exist exactly while AVATAR does. No new settings: **Enable local bus** and **Require token** govern `/mcp` and `/v1/*` together, and the panel shows the endpoint with **Copy MCP URL** beside the existing **Copy token**, since every client spells its own registration differently and those two values are the ones they all need. The tool surface is *not* a mirror of the HTTP API — it is read by a model, so it is smaller (`list_stage`, `get_status`, `play_animation`, `stop_animation`, `set_avatar`, `set_environment`; the audio source stays out as a user setting), and `play_animation` defaults to playing **once** instead of the bus default of *select*, with `persist: true` as the deliberate opt-in that writes to `config.yaml`. `animation` stays an open string validated against the live catalog at call time, because a custom folder changes its ids while the app runs and a client holding a stale tool list still has to work. A refused command is a tool error carrying the way out — an unknown id says to call `list_stage` — rather than a protocol error, because the model is the one who has to fix it. Stateless: one server per request, nothing pushed, nothing left behind by a client that is killed; `GET` and `DELETE` are answered `405` for clients written against revisions before `2026-07-28`. The `Origin` refusal is unchanged and now documented as a limit — command-line clients send none, a webview-based desktop client may, and is not supported here. See [Local agent bus](docs/agents/local-bus.md). (#61) +- **MCP server on the agent bus** — the same loopback server now answers Streamable HTTP MCP at `/mcp`, so an agent in an editor can drive the stage without anyone hand-rolling HTTP calls. It is a peer of `/v1/command` and `/v1/socket`, not a wrapper: the tools call the same `dispatch`, resolve against the same catalog the window reports, and come back with the same refusals. In the Electron main process rather than a spawned sidecar, which is what keeps the registration a URL — no absolute path into an install directory the user is free to change, no unpacking out of the asar, no assumption that a Node runtime is installed — and what makes the endpoint exist exactly while AVATAR does. No new settings: **Enable local bus** and **Require token** govern `/mcp` and `/v1/*` together, and the endpoint sits in the same block as the token rather than in a section of its own, because a client needs both and they are one server. Every MCP client spells registration differently, so the panel hands over the two values — **Copy MCP URL**, **Copy token** — rather than one client's command line. The tool surface is *not* a mirror of the HTTP API — it is read by a model, so it is smaller (`list_stage`, `get_status`, `play_animation`, `stop_animation`, `set_avatar`, `set_environment`; the audio source stays out as a user setting), and `play_animation` defaults to playing **once** instead of the bus default of *select*, with `persist: true` as the deliberate opt-in that writes to `config.yaml`. `animation` stays an open string validated against the live catalog at call time, because a custom folder changes its ids while the app runs and a client holding a stale tool list still has to work. A refused command is a tool error carrying the way out — an unknown id says to call `list_stage` — rather than a protocol error, because the model is the one who has to fix it. Stateless: one server per request, nothing pushed, nothing left behind by a client that is killed; `GET` and `DELETE` are answered `405` for clients written against revisions before `2026-07-28`. The `Origin` refusal is unchanged and now documented as a limit — command-line clients send none, a webview-based desktop client may, and is not supported here. See [Local agent bus](docs/agents/local-bus.md). (#61) - **Reactive glass-bar live dot** — when an audio source is selected, the 8px indicator shows waiting (amber), live quiet→loud (mint→green from analyser level), or error (coral), instead of a binary always-green pulse. Amplitude updates via a level ref on the dot node so the bar does not take an extra React state path for loudness. (#42) ### Changed diff --git a/avatar/src/components/panels/AgentBusPanel.jsx b/avatar/src/components/panels/AgentBusPanel.jsx index bbeb6fe..52125f8 100644 --- a/avatar/src/components/panels/AgentBusPanel.jsx +++ b/avatar/src/components/panels/AgentBusPanel.jsx @@ -144,27 +144,24 @@ export function AgentBusPanel({ settings, status, onChange, onRotateToken }) {

)} -
- -
- - MCP server -

- The bus is also an MCP server, so an agent can drive the avatar from your editor. Register - this URL with your client - {settings.requireToken ? ' and give it the token above as a Bearer header' : ''}. It answers - only while AVATAR is running. + The MCP endpoint, for an agent in your editor. Same server as the curl below + {settings.requireToken ? ', behind the same token' : ''} — it answers only while AVATAR is + running.

{mcpUrl}

+ {/* Stacked rather than side by side: "Copy example curl" wraps inside a + half-width button in a drawer this narrow, and the label is worth more + than the row. */}
+
); diff --git a/docs/agents/local-bus.md b/docs/agents/local-bus.md index 35b065f..bff266f 100644 --- a/docs/agents/local-bus.md +++ b/docs/agents/local-bus.md @@ -22,7 +22,7 @@ Settings → **Agents** → *Enable local bus*. | **Require token** | On by default. A token is generated the first time you enable the bus and reused after that | | **Copy token** / **Regenerate** | Regenerating invalidates the old one at once | | **Copy example curl** | The command below, with your port and token filled in | -| **MCP server** / **Copy MCP URL** | The MCP endpoint for this install — see [MCP](#mcp) | +| **Copy MCP URL** | The MCP endpoint for this install — see [MCP](#mcp) | The token is **not** in `config.yaml` — it is encrypted with the OS keychain beside the VRoid Hub credentials. On a machine with no keychain available the panel says so, and the token is kept in @@ -131,9 +131,9 @@ can drive the avatar without anyone hand-rolling HTTP calls. A peer of the route wrapper around them: same dispatch, same catalog, same refusals. Register the endpoint with your client. Every client spells that differently, so the panel hands -over the two values they all need rather than one client's command line: **Settings → Agents → -Copy MCP URL**, and **Copy token** above it while **Require token** is on. The token travels in an -`Authorization: Bearer` header, the same as everywhere else here. +over the two values they all need rather than one client's command line: **Copy MCP URL** and +**Copy token**, in the one block, because they are one server — same switch, same token, same +port. The token travels in an `Authorization: Bearer` header, as it does everywhere else here. ```bash # Claude Code, for example From 22fbf7894d4c0393e4a4f037c9abeab26eb431eb Mon Sep 17 00:00:00 2001 From: AUDO Date: Fri, 21 Aug 2026 20:08:22 +0800 Subject: [PATCH 5/6] docs: name MCP in the documentation index row Refs #61 Co-Authored-By: Claude Opus 5 --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 6662f07..a50287a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,7 +24,7 @@ Electron desktop companion first; browser localhost for development. | [Audio sources](voice/audio-sources.md) | Desktop loopback, window, mic, file — full Voice panel reference | | [Lip sync](voice/lip-sync.md) | Amplitude visemes, status-aware live dot, troubleshooting | | [VRoid Hub connection](vroid-hub.md) | Opt-in OAuth (Settings), pick characters in Appearance, session-only Hub VRMs | -| [Local agent bus](agents/local-bus.md) | Opt-in loopback API so scripts and agents can play animations and swap avatars | +| [Local agent bus](agents/local-bus.md) | Opt-in loopback API — and MCP server — so scripts and agents can play animations and swap avatars | ## Project From 82a5c3443beca59b06b76510283b22017ded0d86 Mon Sep 17 00:00:00 2001 From: AUDO Date: Fri, 21 Aug 2026 20:13:58 +0800 Subject: [PATCH 6/6] build: pin the MCP SDK to an exact version The SDK decides how /mcp behaves on the wire, so a caret range would let a minor release change the endpoint's observable behaviour without anyone choosing it - after it had been tested against real clients. Upgrading should be a deliberate edit. zod keeps its range; it only shapes tool inputs. Refs #61 Co-Authored-By: Claude Opus 5 --- avatar/package-lock.json | 2 +- avatar/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/avatar/package-lock.json b/avatar/package-lock.json index e779f94..29de209 100644 --- a/avatar/package-lock.json +++ b/avatar/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "1.30.0", "@pixiv/three-vrm": "^3.4.2", "@pixiv/three-vrm-animation": "^3.4.2", "@react-three/drei": "^10.5.2", diff --git a/avatar/package.json b/avatar/package.json index dc5710d..62ba813 100644 --- a/avatar/package.json +++ b/avatar/package.json @@ -21,7 +21,7 @@ "preview": "vite preview" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "1.30.0", "@pixiv/three-vrm": "^3.4.2", "@pixiv/three-vrm-animation": "^3.4.2", "@react-three/drei": "^10.5.2",