Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

<p align="center">
Expand Down
120 changes: 113 additions & 7 deletions avatar/electron/agent-bus.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
Expand All @@ -142,6 +143,39 @@ 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;

/**
* 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]
Expand All @@ -158,6 +192,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",
Expand All @@ -167,6 +203,7 @@ function createAgentBusServer({
getState,
applyAction,
getToken,
version = "0.0.0",
}) {
if (typeof resolveCommand !== "function") {
throw new Error("createAgentBusServer requires a resolveCommand function.");
Expand All @@ -193,6 +230,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.
Expand Down Expand Up @@ -229,14 +268,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 = isMcpRequest(request);

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") {
Expand Down Expand Up @@ -326,7 +426,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 (isMcpRequest(request)) {
sendJsonRpcError(response, 500, JSON_RPC_SERVER_ERROR, message);
return;
}
sendJson(response, 500, failure("internal-error", message));
});
});

Expand Down Expand Up @@ -425,6 +530,7 @@ function createAgentBusServer({
module.exports = {
COMMAND_PATH,
DEFAULT_AGENT_BUS_PORT,
MCP_PATH,
SOCKET_PATH,
STATE_PATH,
createAgentBusServer,
Expand Down
1 change: 1 addition & 0 deletions avatar/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading