@vibevibes/mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +51 -201
- package/bin/cli.js +11 -11
- package/bin/postinstall.js +39 -0
- package/bin/serve.js +41 -0
- package/dist/bundler.d.ts +36 -0
- package/dist/bundler.js +254 -0
- package/dist/index.d.ts +4 -9
- package/dist/index.js +687 -468
- package/dist/protocol.d.ts +85 -0
- package/dist/protocol.js +240 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +1947 -0
- package/dist/tick-engine.d.ts +81 -0
- package/dist/tick-engine.js +151 -0
- package/dist/viewer/index.html +689 -0
- package/hooks/logic.js +258 -0
- package/hooks/stop-hook.js +341 -0
- package/package.json +59 -33
package/dist/index.js
CHANGED
|
@@ -1,21 +1,112 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* vibevibes-mcp —
|
|
2
|
+
* vibevibes-mcp — MCP server for agent participation in vibevibes experiences.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The `connect` tool is the single entry point: it joins the server, writes
|
|
5
|
+
* the state file (for the stop hook to poll), and returns room info.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* npx vibevibes-mcp # defaults to http://localhost:4321
|
|
9
|
-
* npx vibevibes-mcp https://xyz.trycloudflare.com # join a shared room
|
|
10
|
-
* VIBEVIBES_SERVER_URL=https://... npx vibevibes-mcp
|
|
11
|
-
*
|
|
12
|
-
* 11 tools: connect, act, stream, spawn_room, list_rooms, list_experiences, room_config_schema, memory, screenshot, blob_set, blob_get
|
|
7
|
+
* 4 tools: connect, act, look, disconnect
|
|
13
8
|
*/
|
|
14
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
11
|
import { z } from "zod";
|
|
17
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
-
import { resolve } from "node:path";
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
|
13
|
+
import { resolve, dirname } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
/** Extract error message from unknown catch value. */
|
|
17
|
+
function toErrorMessage(err) {
|
|
18
|
+
return err instanceof Error ? err.message : String(err);
|
|
19
|
+
}
|
|
20
|
+
// ── Server auto-start ──────────────────────────────────────
|
|
21
|
+
let serverStarted = false;
|
|
22
|
+
/**
|
|
23
|
+
* Check if the server is reachable. If not, start it in a child process.
|
|
24
|
+
* The server loads the experience from the given project root.
|
|
25
|
+
*/
|
|
26
|
+
async function ensureServerRunning(projectRoot) {
|
|
27
|
+
if (serverStarted)
|
|
28
|
+
return;
|
|
29
|
+
// Check if server is already running
|
|
30
|
+
try {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
33
|
+
const res = await fetch(`${SERVER_URL}/state`, { signal: controller.signal });
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
if (res.ok) {
|
|
36
|
+
serverStarted = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Server not running — start it
|
|
42
|
+
}
|
|
43
|
+
// Find the server entry point (in mcp/dist/ or mcp/src/)
|
|
44
|
+
const serverScript = resolve(MCP_ROOT, "dist/server.js");
|
|
45
|
+
const serverTs = resolve(MCP_ROOT, "src/server.ts");
|
|
46
|
+
// Start server as a child process using a small bootstrap script
|
|
47
|
+
const bootstrapCode = `
|
|
48
|
+
import("${serverScript.replace(/\\/g, "/")}").then(m => {
|
|
49
|
+
m.startServer({ projectRoot: "${projectRoot.replace(/\\/g, "/")}" });
|
|
50
|
+
}).catch(e => {
|
|
51
|
+
// Fallback: try tsx for .ts source
|
|
52
|
+
import("${serverTs.replace(/\\/g, "/")}").then(m => {
|
|
53
|
+
m.startServer({ projectRoot: "${projectRoot.replace(/\\/g, "/")}" });
|
|
54
|
+
}).catch(e2 => {
|
|
55
|
+
console.error("Failed to start server:", e2.message);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
`;
|
|
60
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", bootstrapCode], {
|
|
61
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
62
|
+
detached: true,
|
|
63
|
+
env: { ...process.env, PORT: new URL(SERVER_URL).port || "4321" },
|
|
64
|
+
});
|
|
65
|
+
child.unref();
|
|
66
|
+
// Store child for cleanup
|
|
67
|
+
serverChild = child;
|
|
68
|
+
child.stderr?.on("data", (chunk) => {
|
|
69
|
+
const msg = chunk.toString().trim();
|
|
70
|
+
if (msg)
|
|
71
|
+
console.error(`[server] ${msg}`);
|
|
72
|
+
});
|
|
73
|
+
// Wait for server to become available (up to 15 seconds)
|
|
74
|
+
const deadline = Date.now() + 15000;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
77
|
+
try {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
80
|
+
const res = await fetch(`${SERVER_URL}/state`, { signal: controller.signal });
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
if (res.ok) {
|
|
83
|
+
serverStarted = true;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Keep waiting
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Server failed to start within 15 seconds at ${SERVER_URL}`);
|
|
92
|
+
}
|
|
93
|
+
let serverChild = null;
|
|
94
|
+
// Cleanup server on exit
|
|
95
|
+
process.on("exit", () => { if (serverChild)
|
|
96
|
+
try {
|
|
97
|
+
serverChild.kill();
|
|
98
|
+
}
|
|
99
|
+
catch { } });
|
|
100
|
+
process.on("SIGINT", () => { if (serverChild)
|
|
101
|
+
try {
|
|
102
|
+
serverChild.kill();
|
|
103
|
+
}
|
|
104
|
+
catch { } process.exit(0); });
|
|
105
|
+
process.on("SIGTERM", () => { if (serverChild)
|
|
106
|
+
try {
|
|
107
|
+
serverChild.kill();
|
|
108
|
+
}
|
|
109
|
+
catch { } process.exit(0); });
|
|
19
110
|
// Resolve server URL: CLI arg > env var > localhost default
|
|
20
111
|
const RAW_SERVER_URL = process.argv[2] ||
|
|
21
112
|
process.env.VIBEVIBES_SERVER_URL ||
|
|
@@ -25,27 +116,118 @@ const parsedUrl = new URL(RAW_SERVER_URL);
|
|
|
25
116
|
const ROOM_TOKEN = parsedUrl.searchParams.get("token");
|
|
26
117
|
// Strip the token param from the base URL so it isn't duplicated in request paths
|
|
27
118
|
parsedUrl.searchParams.delete("token");
|
|
28
|
-
|
|
29
|
-
|
|
119
|
+
let SERVER_URL = parsedUrl.toString().replace(/\/$/, ""); // remove trailing slash
|
|
120
|
+
/** All active connections in this MCP session. Multiple subagents can each connect independently. */
|
|
121
|
+
const connections = new Map(); // actorId → slot
|
|
122
|
+
/** The most recently connected identity — used as default when only one connection exists. */
|
|
30
123
|
let currentActorId = null;
|
|
31
124
|
let currentOwner = null;
|
|
32
|
-
|
|
125
|
+
/** Resolve the effective identity for a tool call. If actorId is provided, use that connection.
|
|
126
|
+
* If only one connection exists, use it. If multiple exist and no actorId given, error. */
|
|
127
|
+
function resolveIdentity(actorId) {
|
|
128
|
+
if (actorId) {
|
|
129
|
+
const slot = connections.get(actorId);
|
|
130
|
+
if (!slot)
|
|
131
|
+
throw new Error(`Unknown actorId '${actorId}'. Connected actors: ${[...connections.keys()].join(", ") || "none"}`);
|
|
132
|
+
return slot;
|
|
133
|
+
}
|
|
134
|
+
if (connections.size === 1) {
|
|
135
|
+
return connections.values().next().value;
|
|
136
|
+
}
|
|
137
|
+
if (connections.size > 1) {
|
|
138
|
+
throw new Error(`Multiple agents connected. You MUST pass actorId to identify yourself.\n` +
|
|
139
|
+
`Connected actors: ${[...connections.keys()].join(", ")}`);
|
|
140
|
+
}
|
|
141
|
+
// No connections — fall back to currentActorId for backward compat (e.g. loadIdentity)
|
|
142
|
+
if (currentActorId && currentOwner) {
|
|
143
|
+
return { actorId: currentActorId, owner: currentOwner };
|
|
144
|
+
}
|
|
145
|
+
throw new Error("Not connected. Call the connect tool first.");
|
|
146
|
+
}
|
|
147
|
+
// Derive paths from this module's location.
|
|
148
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
149
|
+
const __dirname = dirname(__filename);
|
|
150
|
+
// mcp/src/ or mcp/dist/ → MCP package root is ..
|
|
151
|
+
const MCP_ROOT = resolve(__dirname, "..");
|
|
152
|
+
// Project root = cwd (where the experience lives)
|
|
153
|
+
const PROJECT_ROOT = process.cwd();
|
|
154
|
+
const AGENTS_DIR = resolve(PROJECT_ROOT, ".claude/vibevibes-agents");
|
|
155
|
+
const SETTINGS_PATH = resolve(PROJECT_ROOT, ".claude/settings.json");
|
|
156
|
+
// ── Dynamic Hook Registration ─────────────────────────────────────
|
|
33
157
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
158
|
+
* Ensure the Stop hook is registered in .claude/settings.json.
|
|
159
|
+
* Called on connect. Idempotent — skips if already present.
|
|
160
|
+
* Uses absolute path because hook CWD may differ from project root.
|
|
36
161
|
*/
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
162
|
+
function ensureStopHook() {
|
|
163
|
+
try {
|
|
164
|
+
const hookScript = resolve(MCP_ROOT, "hooks/stop-hook.js");
|
|
165
|
+
const command = `node "${hookScript}"`;
|
|
166
|
+
let settings = {};
|
|
167
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
168
|
+
settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
169
|
+
}
|
|
170
|
+
const hooks = (settings.hooks ?? {});
|
|
171
|
+
const stopHooks = (hooks.Stop ?? []);
|
|
172
|
+
const alreadyRegistered = stopHooks.some((entry) => entry.hooks?.some((h) => h.type === "command" && h.command === command));
|
|
173
|
+
if (alreadyRegistered)
|
|
174
|
+
return;
|
|
175
|
+
stopHooks.push({
|
|
176
|
+
hooks: [{ type: "command", command, timeout: 60 }],
|
|
177
|
+
});
|
|
178
|
+
hooks.Stop = stopHooks;
|
|
179
|
+
settings.hooks = hooks;
|
|
180
|
+
// Ensure directory exists
|
|
181
|
+
const settingsDir = dirname(SETTINGS_PATH);
|
|
182
|
+
if (!existsSync(settingsDir))
|
|
183
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
184
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
40
185
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!state.active || !state.actorId) {
|
|
44
|
-
throw new Error("Agent loop not active. Use /vibevibes-join first.");
|
|
186
|
+
catch {
|
|
187
|
+
// Hook registration is best-effort
|
|
45
188
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
189
|
+
}
|
|
190
|
+
function removeStopHook() {
|
|
191
|
+
try {
|
|
192
|
+
if (!existsSync(SETTINGS_PATH))
|
|
193
|
+
return;
|
|
194
|
+
const hookScript = resolve(MCP_ROOT, "hooks/stop-hook.js");
|
|
195
|
+
const command = `node "${hookScript}"`;
|
|
196
|
+
const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
197
|
+
const hooks = (settings.hooks ?? {});
|
|
198
|
+
const stopHooks = (hooks.Stop ?? []);
|
|
199
|
+
hooks.Stop = stopHooks.filter((entry) => !entry.hooks?.some((h) => h.type === "command" && h.command === command));
|
|
200
|
+
settings.hooks = hooks;
|
|
201
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Best-effort cleanup
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function loadIdentity() {
|
|
208
|
+
if (currentOwner && currentActorId) {
|
|
209
|
+
return { actorId: currentActorId, serverUrl: SERVER_URL, owner: currentOwner };
|
|
210
|
+
}
|
|
211
|
+
throw new Error("Not connected. Call the connect tool first.");
|
|
212
|
+
}
|
|
213
|
+
// ── Name Generator ─────────────────────────────────────────
|
|
214
|
+
const ADJECTIVES = [
|
|
215
|
+
"swift", "brave", "calm", "bold", "keen",
|
|
216
|
+
"wild", "sly", "warm", "cool", "bright",
|
|
217
|
+
"quick", "sharp", "wise", "kind", "fair",
|
|
218
|
+
"proud", "free", "dark", "lost", "glad",
|
|
219
|
+
];
|
|
220
|
+
const ANIMALS = [
|
|
221
|
+
"fox", "wolf", "bear", "hawk", "lynx",
|
|
222
|
+
"deer", "owl", "crow", "hare", "pike",
|
|
223
|
+
"dove", "wren", "newt", "moth", "orca",
|
|
224
|
+
"lion", "toad", "crab", "swan", "mole",
|
|
225
|
+
];
|
|
226
|
+
function generateName() {
|
|
227
|
+
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
228
|
+
const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
|
|
229
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
230
|
+
return `${adj}-${animal}-${suffix}`;
|
|
49
231
|
}
|
|
50
232
|
// ── Helpers ────────────────────────────────────────────────
|
|
51
233
|
async function fetchJSON(path, opts) {
|
|
@@ -67,6 +249,17 @@ async function fetchJSON(path, opts) {
|
|
|
67
249
|
headers,
|
|
68
250
|
});
|
|
69
251
|
const text = await res.text();
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
// Try to parse error JSON, fall back to status text
|
|
254
|
+
let message = `HTTP ${res.status}: ${res.statusText}`;
|
|
255
|
+
try {
|
|
256
|
+
const j = JSON.parse(text);
|
|
257
|
+
if (j.error)
|
|
258
|
+
message = `HTTP ${res.status}: ${j.error}`;
|
|
259
|
+
}
|
|
260
|
+
catch { }
|
|
261
|
+
throw new Error(message);
|
|
262
|
+
}
|
|
70
263
|
try {
|
|
71
264
|
return JSON.parse(text);
|
|
72
265
|
}
|
|
@@ -75,7 +268,7 @@ async function fetchJSON(path, opts) {
|
|
|
75
268
|
}
|
|
76
269
|
}
|
|
77
270
|
catch (err) {
|
|
78
|
-
if (err.name === "AbortError") {
|
|
271
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
79
272
|
throw new Error(`Request timed out after ${timeoutMs}ms: ${path}`);
|
|
80
273
|
}
|
|
81
274
|
throw err;
|
|
@@ -89,9 +282,10 @@ function formatToolList(tools) {
|
|
|
89
282
|
return "No tools available.";
|
|
90
283
|
return tools
|
|
91
284
|
.map((t) => {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
285
|
+
const props = t.input_schema?.properties;
|
|
286
|
+
const schema = props && typeof props === "object"
|
|
287
|
+
? Object.entries(props)
|
|
288
|
+
.map(([k, v]) => `${k}: ${typeof v.type === "string" ? v.type : "any"}`)
|
|
95
289
|
.join(", ")
|
|
96
290
|
: "{}";
|
|
97
291
|
return ` ${t.name} (${t.risk || "low"}) — ${t.description}\n input: { ${schema} }`;
|
|
@@ -115,77 +309,235 @@ const server = new McpServer({
|
|
|
115
309
|
version: "0.4.0",
|
|
116
310
|
});
|
|
117
311
|
// ── Tool: connect ──────────────────────────────────────────
|
|
118
|
-
server.tool("connect", `Connect to the running experience.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
312
|
+
server.tool("connect", `Connect to the running experience.
|
|
313
|
+
|
|
314
|
+
Joins the server as an AI participant, creates a session file for the stop hook,
|
|
315
|
+
and returns available tools, current state, participants, and the browser URL.
|
|
316
|
+
|
|
317
|
+
Call this first, then use act to interact. The stop hook keeps you present.`, {
|
|
318
|
+
url: z.string().optional().describe("Server URL to connect to. Defaults to the MCP server's configured URL."),
|
|
319
|
+
role: z.string().optional().describe("Preferred participant slot role to request (e.g. 'Blue Tank', 'Red ADC'). Server assigns the first available slot if omitted."),
|
|
320
|
+
subscription: z.enum(["all", "phase"]).optional().describe("Event subscription mode. 'all' (default) wakes on every event. 'phase' wakes only on phase changes — use for orchestrator agents."),
|
|
321
|
+
agentMode: z.enum(["behavior", "manual", "hybrid"]).optional().describe("Agent operating mode. 'behavior': autonomous via tick engine (no stop hook). 'manual': all decisions via act(). 'hybrid' (default): both behaviors and act()."),
|
|
322
|
+
metadata: z.record(z.string()).optional().describe("Arbitrary metadata (model, team, tags). Flows to server and viewer. E.g. { model: 'haiku', team: 'blue' }"),
|
|
323
|
+
}, async ({ url, role: requestedRole, subscription, agentMode, metadata }) => {
|
|
125
324
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
325
|
+
if (url) {
|
|
326
|
+
SERVER_URL = url.replace(/\/$/, "");
|
|
327
|
+
}
|
|
328
|
+
// Auto-start server if not running
|
|
329
|
+
await ensureServerRunning(PROJECT_ROOT);
|
|
330
|
+
// Check if already connected (same process re-calling connect)
|
|
331
|
+
let identity = null;
|
|
332
|
+
if (currentOwner && existsSync(AGENTS_DIR)) {
|
|
333
|
+
const agentFiles = readdirSync(AGENTS_DIR).filter(f => f.endsWith(".json"));
|
|
334
|
+
for (const file of agentFiles) {
|
|
335
|
+
try {
|
|
336
|
+
const session = JSON.parse(readFileSync(resolve(AGENTS_DIR, file), "utf-8"));
|
|
337
|
+
if (session.owner !== currentOwner)
|
|
338
|
+
continue;
|
|
339
|
+
// Verify the agent still exists on the server
|
|
340
|
+
let networkError = false;
|
|
341
|
+
try {
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
344
|
+
const verifyHeaders = {};
|
|
345
|
+
if (ROOM_TOKEN)
|
|
346
|
+
verifyHeaders["Authorization"] = `Bearer ${ROOM_TOKEN}`;
|
|
347
|
+
const verifyRes = await fetch(`${SERVER_URL}/participants`, { signal: controller.signal, headers: verifyHeaders });
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
if (verifyRes.ok) {
|
|
350
|
+
const data = await verifyRes.json();
|
|
351
|
+
const alive = data.participants?.some((p) => p.actorId === session.actorId);
|
|
352
|
+
if (alive) {
|
|
353
|
+
identity = { actorId: session.actorId, owner: session.owner, id: session.owner };
|
|
354
|
+
currentActorId = session.actorId;
|
|
355
|
+
currentOwner = session.owner;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
networkError = true;
|
|
362
|
+
}
|
|
363
|
+
if (!identity && !networkError) {
|
|
364
|
+
try {
|
|
365
|
+
unlinkSync(resolve(AGENTS_DIR, file));
|
|
366
|
+
}
|
|
367
|
+
catch { }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
try {
|
|
372
|
+
unlinkSync(resolve(AGENTS_DIR, file));
|
|
373
|
+
}
|
|
374
|
+
catch { }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
let joinData = null;
|
|
379
|
+
if (!identity) {
|
|
380
|
+
const id = generateName();
|
|
381
|
+
const joinBody = { username: id, actorType: "ai", owner: id };
|
|
382
|
+
if (requestedRole)
|
|
383
|
+
joinBody.role = requestedRole;
|
|
384
|
+
if (agentMode)
|
|
385
|
+
joinBody.agentMode = agentMode;
|
|
386
|
+
if (metadata && Object.keys(metadata).length > 0)
|
|
387
|
+
joinBody.metadata = metadata;
|
|
388
|
+
joinData = await fetchJSON("/join", {
|
|
389
|
+
method: "POST",
|
|
390
|
+
body: JSON.stringify(joinBody),
|
|
391
|
+
timeoutMs: 10000,
|
|
392
|
+
});
|
|
393
|
+
if (joinData.error) {
|
|
394
|
+
throw new Error(joinData.error);
|
|
395
|
+
}
|
|
396
|
+
// Write agent file
|
|
397
|
+
if (!existsSync(AGENTS_DIR)) {
|
|
398
|
+
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
399
|
+
}
|
|
400
|
+
const sessionData = {
|
|
401
|
+
serverUrl: SERVER_URL,
|
|
402
|
+
owner: id,
|
|
403
|
+
actorId: joinData.actorId,
|
|
404
|
+
role: joinData.role || undefined,
|
|
405
|
+
};
|
|
406
|
+
if (subscription && subscription !== "all") {
|
|
407
|
+
sessionData.subscription = subscription;
|
|
408
|
+
}
|
|
409
|
+
if (agentMode) {
|
|
410
|
+
sessionData.agentMode = agentMode;
|
|
411
|
+
}
|
|
412
|
+
writeFileSync(resolve(AGENTS_DIR, `${id}.json`), JSON.stringify(sessionData, null, 2));
|
|
413
|
+
identity = { actorId: joinData.actorId, owner: id, id };
|
|
414
|
+
currentActorId = joinData.actorId;
|
|
415
|
+
currentOwner = id;
|
|
416
|
+
connections.set(joinData.actorId, {
|
|
417
|
+
actorId: joinData.actorId,
|
|
418
|
+
owner: id,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// Fetch state
|
|
422
|
+
const wroteNewFile = joinData !== null;
|
|
423
|
+
let state;
|
|
424
|
+
try {
|
|
425
|
+
state = await fetchJSON("/state", { timeoutMs: 10000 });
|
|
426
|
+
}
|
|
427
|
+
catch (stateErr) {
|
|
428
|
+
if (wroteNewFile && identity?.owner) {
|
|
429
|
+
try {
|
|
430
|
+
unlinkSync(resolve(AGENTS_DIR, `${identity.owner}.json`));
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
}
|
|
434
|
+
throw stateErr;
|
|
435
|
+
}
|
|
436
|
+
// Fetch slot definitions to detect unfilled autoSpawn AI slots
|
|
437
|
+
let unfilledSlots = [];
|
|
438
|
+
try {
|
|
439
|
+
const slotsData = await fetchJSON("/slots", { timeoutMs: 5000 });
|
|
440
|
+
if (slotsData?.slots) {
|
|
441
|
+
const currentParticipants = slotsData.participantDetails || [];
|
|
442
|
+
for (const slot of slotsData.slots) {
|
|
443
|
+
if (slot.type !== "ai" && slot.type !== "any")
|
|
444
|
+
continue;
|
|
445
|
+
if (!slot.autoSpawn)
|
|
446
|
+
continue;
|
|
447
|
+
const max = slot.maxInstances ?? 1;
|
|
448
|
+
const filled = currentParticipants.filter(p => p.role === slot.role && p.type === "ai").length;
|
|
449
|
+
if (filled < max) {
|
|
450
|
+
unfilledSlots.push({ role: slot.role, autoSpawn: slot.autoSpawn, maxInstances: max });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Slots endpoint not available — skip silently
|
|
457
|
+
}
|
|
458
|
+
const outputParts = [
|
|
130
459
|
`Connected as ${identity.actorId}`,
|
|
460
|
+
connections.size > 1
|
|
461
|
+
? `IMPORTANT: Multiple agents connected (${connections.size}). Pass actorId="${identity.actorId}" in all act/look calls.`
|
|
462
|
+
: "",
|
|
131
463
|
`Experience: ${state.experienceId || "unknown"}`,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
464
|
+
];
|
|
465
|
+
const role = joinData?.role;
|
|
466
|
+
const systemPrompt = joinData?.systemPrompt;
|
|
467
|
+
if (role)
|
|
468
|
+
outputParts.push(`Role: ${role}`);
|
|
469
|
+
outputParts.push(`Browser: ${state.browserUrl || SERVER_URL}`, `Server: ${SERVER_URL}`, ``);
|
|
470
|
+
if (systemPrompt) {
|
|
471
|
+
outputParts.push(`Your instructions:`, systemPrompt, ``);
|
|
472
|
+
}
|
|
473
|
+
outputParts.push(formatState(state.sharedState, joinData?.observation || state.observation), `Participants: ${state.participants?.join(", ")}`, ``, `Tools:`, formatToolList(joinData?.tools || state.tools || []), ``);
|
|
474
|
+
// Surface unfilled autoSpawn slots
|
|
475
|
+
if (unfilledSlots.length > 0) {
|
|
476
|
+
outputParts.push(`UNFILLED AI SLOTS (autoSpawn):`, ...unfilledSlots.map(s => ` - ${s.role} (max ${s.maxInstances ?? 1})`), ``, `These roles need AI agents. Spawn independent teammates — each gets its own MCP session and identity.`, ` Agent(subagent_type="general-purpose", model="sonnet", run_in_background=true,`, ` prompt="Call connect with role='ROLE'. Then play your role.")`, ``);
|
|
477
|
+
}
|
|
478
|
+
// Mode-specific instructions
|
|
479
|
+
if (agentMode === "behavior") {
|
|
480
|
+
outputParts.push(`BEHAVIOR MODE:`, ` You are a behavior-only agent. All actions run autonomously via the tick engine.`, ` No stop hook — you will NOT receive wake-up events.`, ` Set up behaviors now, then disconnect. The tick engine runs them automatically.`, ``);
|
|
481
|
+
}
|
|
482
|
+
else if (agentMode === "manual") {
|
|
483
|
+
outputParts.push(`MANUAL MODE:`, ` You are a manual agent. Use act() for all decisions. No behaviors.`, ` The stop hook keeps you present — use act to interact.`, ` COMMUNICATE: Use _chat.send to share strategy. Use _chat.team for team-only coordination.`, ``);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
outputParts.push(`FAST BRAIN / SLOW BRAIN:`, ` Fast brain: Register behaviors via _behavior.set for reactive, per-tick actions that run automatically.`, ` Slow brain: Use act() for strategic decisions and adapting to new situations.`, ` Set up your fast brain FIRST, then use your slow brain to observe and adapt.`, ` COMMUNICATE: Use _chat.send to share strategy. Use _chat.team for team-only coordination.`, ``, `You are now a live participant. The stop hook keeps you present — use act to interact.`);
|
|
487
|
+
}
|
|
488
|
+
// Register the stop hook so Claude Code wakes us on events
|
|
489
|
+
ensureStopHook();
|
|
490
|
+
return { content: [{ type: "text", text: outputParts.filter(Boolean).join("\n") }] };
|
|
146
491
|
}
|
|
147
492
|
catch (err) {
|
|
148
493
|
return {
|
|
149
494
|
content: [{
|
|
150
495
|
type: "text",
|
|
151
|
-
text: `Failed to connect
|
|
496
|
+
text: `Failed to connect.\n\nIs the dev server running? (npm run dev)\n\nError: ${toErrorMessage(err)}`,
|
|
152
497
|
}],
|
|
153
498
|
};
|
|
154
499
|
}
|
|
155
500
|
});
|
|
156
501
|
// ── Tool: act ──────────────────────────────────────────────
|
|
157
|
-
server.tool("act", `Execute a tool to mutate shared state. All state changes go through tools.
|
|
158
|
-
|
|
159
|
-
Supports batching: pass a "batch" array to execute multiple tool calls sequentially in one round-trip.
|
|
160
|
-
Each call sees the state left by the previous call, so order matters.
|
|
161
|
-
|
|
162
|
-
Single call:
|
|
163
|
-
act(toolName="counter.increment", input={amount: 2})
|
|
164
|
-
|
|
165
|
-
Batch call (multiple tools in one round-trip):
|
|
166
|
-
act(batch=[
|
|
167
|
-
{toolName: "counter.increment", input: {amount: 2}},
|
|
168
|
-
{toolName: "counter.increment", input: {amount: 3}},
|
|
169
|
-
{toolName: "phase.set", input: {phase: "playing"}}
|
|
170
|
-
])
|
|
171
|
-
|
|
172
|
-
Use roomId to interact with a specific room (e.g. a cross-experience spawned room).
|
|
173
|
-
Defaults to the "local" (host) room if omitted.`, {
|
|
502
|
+
server.tool("act", `Execute a tool to mutate shared state. All state changes go through tools.
|
|
503
|
+
|
|
504
|
+
Supports batching: pass a "batch" array to execute multiple tool calls sequentially in one round-trip.
|
|
505
|
+
Each call sees the state left by the previous call, so order matters.
|
|
506
|
+
|
|
507
|
+
Single call:
|
|
508
|
+
act(toolName="counter.increment", input={amount: 2})
|
|
509
|
+
|
|
510
|
+
Batch call (multiple tools in one round-trip):
|
|
511
|
+
act(batch=[
|
|
512
|
+
{toolName: "counter.increment", input: {amount: 2}},
|
|
513
|
+
{toolName: "counter.increment", input: {amount: 3}},
|
|
514
|
+
{toolName: "phase.set", input: {phase: "playing"}}
|
|
515
|
+
])`, {
|
|
174
516
|
toolName: z.string().optional().describe("Tool to call, e.g. 'counter.increment'. Required if batch is not provided."),
|
|
175
|
-
input: z.record(z.
|
|
517
|
+
input: z.record(z.unknown()).optional().describe("Tool input parameters (for single call)"),
|
|
176
518
|
batch: z.array(z.object({
|
|
177
519
|
toolName: z.string().describe("Tool to call"),
|
|
178
|
-
input: z.record(z.
|
|
520
|
+
input: z.record(z.unknown()).optional().describe("Tool input parameters"),
|
|
179
521
|
})).optional().describe("Array of tool calls to execute sequentially in one round-trip. Each call sees the state from the previous call."),
|
|
180
|
-
|
|
181
|
-
}, async ({ toolName, input, batch,
|
|
522
|
+
actorId: z.string().optional().describe("Your actorId from the connect response. Required when multiple agents are connected in the same session."),
|
|
523
|
+
}, async ({ toolName, input, batch, actorId }) => {
|
|
524
|
+
let slot;
|
|
182
525
|
try {
|
|
183
|
-
|
|
526
|
+
slot = resolveIdentity(actorId);
|
|
184
527
|
}
|
|
185
528
|
catch (err) {
|
|
186
|
-
|
|
529
|
+
try {
|
|
530
|
+
loadIdentity();
|
|
531
|
+
}
|
|
532
|
+
catch { }
|
|
533
|
+
try {
|
|
534
|
+
slot = resolveIdentity(actorId);
|
|
535
|
+
}
|
|
536
|
+
catch (err2) {
|
|
537
|
+
return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err2)}` }] };
|
|
538
|
+
}
|
|
187
539
|
}
|
|
188
|
-
const
|
|
540
|
+
const effectiveActorId = slot.actorId;
|
|
189
541
|
// Build the list of calls: either from batch or single toolName+input
|
|
190
542
|
const calls = batch && batch.length > 0
|
|
191
543
|
? batch
|
|
@@ -195,19 +547,25 @@ Defaults to the "local" (host) room if omitted.`, {
|
|
|
195
547
|
if (calls.length === 0) {
|
|
196
548
|
return { content: [{ type: "text", text: `Provide either toolName or batch.` }] };
|
|
197
549
|
}
|
|
198
|
-
|
|
550
|
+
// Validate tool names to prevent path traversal
|
|
551
|
+
const SAFE_TOOL_NAME = /^[a-zA-Z0-9_.\-]+$/;
|
|
199
552
|
for (const call of calls) {
|
|
553
|
+
if (!SAFE_TOOL_NAME.test(call.toolName)) {
|
|
554
|
+
return { content: [{ type: "text", text: `Invalid tool name: "${call.toolName}". Tool names may only contain letters, digits, dots, hyphens, and underscores.` }] };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const effectiveOwner = slot.owner || currentOwner || undefined;
|
|
558
|
+
const results = [];
|
|
559
|
+
if (calls.length === 1) {
|
|
560
|
+
const call = calls[0];
|
|
200
561
|
try {
|
|
201
|
-
const idempotencyKey = `${
|
|
202
|
-
const
|
|
203
|
-
? `/tools/${call.toolName}`
|
|
204
|
-
: `/rooms/${targetRoom}/tools/${call.toolName}`;
|
|
205
|
-
const result = await fetchJSON(toolPath, {
|
|
562
|
+
const idempotencyKey = `${effectiveActorId}-${call.toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
563
|
+
const result = await fetchJSON(`/tools/${call.toolName}`, {
|
|
206
564
|
method: "POST",
|
|
207
565
|
headers: { "X-Idempotency-Key": idempotencyKey },
|
|
208
566
|
body: JSON.stringify({
|
|
209
|
-
actorId:
|
|
210
|
-
owner:
|
|
567
|
+
actorId: effectiveActorId || "mcp-client",
|
|
568
|
+
owner: effectiveOwner,
|
|
211
569
|
input: call.input || {},
|
|
212
570
|
}),
|
|
213
571
|
timeoutMs: 30000,
|
|
@@ -216,423 +574,260 @@ Defaults to the "local" (host) room if omitted.`, {
|
|
|
216
574
|
results.push({ toolName: call.toolName, error: result.error });
|
|
217
575
|
}
|
|
218
576
|
else {
|
|
219
|
-
results.push({ toolName: call.toolName, output: result.output });
|
|
577
|
+
results.push({ toolName: call.toolName, output: result.output, observation: result.observation });
|
|
220
578
|
}
|
|
221
579
|
}
|
|
222
580
|
catch (err) {
|
|
223
|
-
results.push({ toolName: call.toolName, error: err
|
|
581
|
+
results.push({ toolName: call.toolName, error: toErrorMessage(err) });
|
|
224
582
|
}
|
|
225
583
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
584
|
+
else {
|
|
585
|
+
try {
|
|
586
|
+
const batchResult = await fetchJSON(`/tools-batch`, {
|
|
587
|
+
method: "POST",
|
|
588
|
+
body: JSON.stringify({
|
|
589
|
+
actorId: effectiveActorId || "mcp-client",
|
|
590
|
+
owner: effectiveOwner,
|
|
591
|
+
calls: calls.map(c => ({ tool: c.toolName, input: c.input || {} })),
|
|
592
|
+
}),
|
|
593
|
+
timeoutMs: 30000,
|
|
594
|
+
});
|
|
595
|
+
if (batchResult.results && Array.isArray(batchResult.results)) {
|
|
596
|
+
for (const r of batchResult.results) {
|
|
597
|
+
if (r.error) {
|
|
598
|
+
results.push({ toolName: r.tool, error: r.error });
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
results.push({ toolName: r.tool, output: r.output, observation: r.observation });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
237
604
|
}
|
|
238
|
-
else {
|
|
239
|
-
|
|
605
|
+
else if (batchResult.error) {
|
|
606
|
+
results.push({ toolName: calls[0].toolName, error: batchResult.error });
|
|
240
607
|
}
|
|
241
608
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
catch (err) {
|
|
267
|
-
return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
|
|
268
|
-
}
|
|
269
|
-
const targetRoom = roomId || "local";
|
|
270
|
-
try {
|
|
271
|
-
const streamPath = targetRoom === "local"
|
|
272
|
-
? `/streams/${name}`
|
|
273
|
-
: `/rooms/${targetRoom}/streams/${name}`;
|
|
274
|
-
const result = await fetchJSON(streamPath, {
|
|
275
|
-
method: "POST",
|
|
276
|
-
body: JSON.stringify({ actorId: currentActorId, input: input || {} }),
|
|
277
|
-
timeoutMs: 10000,
|
|
278
|
-
});
|
|
279
|
-
if (result.error) {
|
|
280
|
-
return { content: [{ type: "text", text: `Stream error: ${result.error}` }] };
|
|
609
|
+
catch (_batchErr) {
|
|
610
|
+
// Fallback: if batch endpoint fails, run calls individually
|
|
611
|
+
for (const call of calls) {
|
|
612
|
+
try {
|
|
613
|
+
const result = await fetchJSON(`/tools/${call.toolName}`, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
body: JSON.stringify({
|
|
616
|
+
actorId: effectiveActorId || "mcp-client",
|
|
617
|
+
owner: effectiveOwner,
|
|
618
|
+
input: call.input || {},
|
|
619
|
+
}),
|
|
620
|
+
timeoutMs: 30000,
|
|
621
|
+
});
|
|
622
|
+
if (result.error) {
|
|
623
|
+
results.push({ toolName: call.toolName, error: result.error });
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
results.push({ toolName: call.toolName, output: result.output, observation: result.observation });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch (e) {
|
|
630
|
+
results.push({ toolName: call.toolName, error: toErrorMessage(e) });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
281
633
|
}
|
|
282
|
-
return { content: [{ type: "text", text: `Stream ${name} sent.` }] };
|
|
283
|
-
}
|
|
284
|
-
catch (err) {
|
|
285
|
-
return { content: [{ type: "text", text: `Stream failed: ${err.message}` }] };
|
|
286
634
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
Use list_experiences to discover available experiences.
|
|
300
|
-
Use room_config_schema with an experienceId to discover config fields before spawning.
|
|
301
|
-
|
|
302
|
-
Returns the new room's ID, URL, and resolved config.`, {
|
|
303
|
-
experienceId: z.string().optional().describe("Experience to run in the new room (defaults to host experience). Use list_experiences to see available options."),
|
|
304
|
-
name: z.string().optional().describe("Room name (auto-generated if omitted)"),
|
|
305
|
-
config: z.union([z.record(z.any()), z.string()]).optional().describe("Config values object or preset name string"),
|
|
306
|
-
initialState: z.record(z.any()).optional().describe("Initial shared state for the new room"),
|
|
307
|
-
linkBack: z.boolean().optional().describe("Store parent roomId in child state"),
|
|
308
|
-
sourceRoomId: z.string().optional().describe("Parent room ID (defaults to 'local')"),
|
|
309
|
-
}, async ({ experienceId, name, config, initialState, linkBack, sourceRoomId }) => {
|
|
310
|
-
try {
|
|
311
|
-
loadIdentity();
|
|
312
|
-
}
|
|
313
|
-
catch (err) {
|
|
314
|
-
return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
|
|
315
|
-
}
|
|
316
|
-
try {
|
|
317
|
-
const result = await fetchJSON("/rooms/spawn", {
|
|
318
|
-
method: "POST",
|
|
319
|
-
body: JSON.stringify({
|
|
320
|
-
experienceId,
|
|
321
|
-
name,
|
|
322
|
-
config,
|
|
323
|
-
initialState,
|
|
324
|
-
linkBack: linkBack ?? true,
|
|
325
|
-
sourceRoomId: sourceRoomId || "local",
|
|
326
|
-
}),
|
|
327
|
-
timeoutMs: 30000, // External experiences need time to bundle
|
|
328
|
-
});
|
|
329
|
-
if (result.error) {
|
|
330
|
-
const hint = result.error.includes("not found")
|
|
331
|
-
? `\nUse list_experiences to see available experience IDs.`
|
|
332
|
-
: "";
|
|
333
|
-
return { content: [{ type: "text", text: `Spawn failed: ${result.error}${hint}` }] };
|
|
334
|
-
}
|
|
335
|
-
const output = [
|
|
336
|
-
`Room spawned successfully!`,
|
|
337
|
-
experienceId ? ` Experience: ${experienceId}` : "",
|
|
338
|
-
` Room ID: ${result.roomId}`,
|
|
339
|
-
` URL: ${result.url}`,
|
|
340
|
-
` Config: ${JSON.stringify(result.config, null, 2)}`,
|
|
341
|
-
``,
|
|
342
|
-
`The room is live. Participants can join at the URL above.`,
|
|
343
|
-
`Use act(roomId="${result.roomId}") to interact with this room.`,
|
|
344
|
-
].filter(Boolean).join("\n");
|
|
345
|
-
return { content: [{ type: "text", text: output }] };
|
|
635
|
+
// Build compact response
|
|
636
|
+
const outputParts = [];
|
|
637
|
+
let lastObservation = null;
|
|
638
|
+
for (const r of results) {
|
|
639
|
+
if (r.error) {
|
|
640
|
+
outputParts.push(`${r.toolName} → ERROR: ${r.error}`);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
outputParts.push(`${r.toolName} → ${JSON.stringify(r.output)}`);
|
|
644
|
+
if (r.observation)
|
|
645
|
+
lastObservation = r.observation;
|
|
646
|
+
}
|
|
346
647
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
content: [{
|
|
350
|
-
type: "text",
|
|
351
|
-
text: `Failed to spawn room: ${err.message}`,
|
|
352
|
-
}],
|
|
353
|
-
};
|
|
648
|
+
if (lastObservation) {
|
|
649
|
+
outputParts.push(`Observation: ${JSON.stringify(lastObservation)}`);
|
|
354
650
|
}
|
|
651
|
+
return { content: [{ type: "text", text: outputParts.join("\n") }] };
|
|
355
652
|
});
|
|
356
|
-
// ── Tool:
|
|
357
|
-
server.tool("
|
|
358
|
-
|
|
359
|
-
Returns
|
|
360
|
-
|
|
653
|
+
// ── Tool: look ─────────────────────────────────────────────
|
|
654
|
+
server.tool("look", `Observe the current state without taking any action.
|
|
655
|
+
|
|
656
|
+
Returns the observation (or raw state), participants, and available tools.
|
|
657
|
+
Read-only — no mutations, no events created.
|
|
658
|
+
|
|
659
|
+
Use this to orient yourself before deciding whether to act.`, {
|
|
660
|
+
actorId: z.string().optional().describe("Your actorId from connect. Required when multiple agents connected."),
|
|
661
|
+
}, async ({ actorId }) => {
|
|
662
|
+
let slot;
|
|
361
663
|
try {
|
|
362
|
-
|
|
664
|
+
slot = resolveIdentity(actorId);
|
|
363
665
|
}
|
|
364
|
-
catch
|
|
365
|
-
|
|
666
|
+
catch {
|
|
667
|
+
try {
|
|
668
|
+
loadIdentity();
|
|
669
|
+
slot = resolveIdentity(actorId);
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err)}` }] };
|
|
673
|
+
}
|
|
366
674
|
}
|
|
367
675
|
try {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
: "config: (default)";
|
|
376
|
-
const parentStr = r.parentRoomId ? ` ← parent: ${r.parentRoomId}` : "";
|
|
377
|
-
const childStr = r.childRoomIds?.length > 0 ? ` → children: [${r.childRoomIds.join(", ")}]` : "";
|
|
378
|
-
return ` ${r.roomId} (${r.participants?.length || 0} participants, ${r.eventCount || 0} events) ${configStr}${parentStr}${childStr}`;
|
|
379
|
-
});
|
|
380
|
-
return {
|
|
381
|
-
content: [{
|
|
382
|
-
type: "text",
|
|
383
|
-
text: `${rooms.length} room(s):\n${lines.join("\n")}`,
|
|
384
|
-
}],
|
|
385
|
-
};
|
|
676
|
+
const actorParam = slot.actorId ? `?actorId=${encodeURIComponent(slot.actorId)}` : "";
|
|
677
|
+
const state = await fetchJSON(`/state${actorParam}`, { timeoutMs: 10000 });
|
|
678
|
+
const outputParts = [];
|
|
679
|
+
outputParts.push(`Experience: ${state.experienceId || "unknown"}`);
|
|
680
|
+
outputParts.push(formatState(state.sharedState, state.observation));
|
|
681
|
+
outputParts.push(`Participants: ${(state.participants || []).join(", ")}`);
|
|
682
|
+
return { content: [{ type: "text", text: outputParts.join("\n") }] };
|
|
386
683
|
}
|
|
387
684
|
catch (err) {
|
|
388
|
-
return {
|
|
389
|
-
content: [{
|
|
390
|
-
type: "text",
|
|
391
|
-
text: `Failed to list rooms: ${err.message}`,
|
|
392
|
-
}],
|
|
393
|
-
};
|
|
685
|
+
return { content: [{ type: "text", text: `Look failed: ${toErrorMessage(err)}` }] };
|
|
394
686
|
}
|
|
395
687
|
});
|
|
396
|
-
// ── Tool:
|
|
397
|
-
server.tool("
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
loadIdentity();
|
|
407
|
-
}
|
|
408
|
-
catch (err) {
|
|
409
|
-
return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
|
|
410
|
-
}
|
|
688
|
+
// ── Tool: disconnect ────────────────────────────────────────
|
|
689
|
+
server.tool("disconnect", `Disconnect from the current experience.
|
|
690
|
+
|
|
691
|
+
Removes the agent as a participant, deletes the session file, and cleans up.
|
|
692
|
+
After disconnecting, the stop hook will no longer fire.
|
|
693
|
+
If the experience has locked agent participation (canDisconnect: false in observe()),
|
|
694
|
+
the disconnect will be rejected unless force=true is passed.`, {
|
|
695
|
+
force: z.boolean().optional().describe("Force disconnect even if the experience has locked participation"),
|
|
696
|
+
actorId: z.string().optional().describe("Disconnect a specific agent by actorId. Defaults to most recent connection. Use 'all' to disconnect all agents."),
|
|
697
|
+
}, async ({ force, actorId: disconnectActorId }) => {
|
|
411
698
|
try {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
699
|
+
// Handle "disconnect all"
|
|
700
|
+
if (disconnectActorId === "all") {
|
|
701
|
+
const allSlots = [...connections.values()];
|
|
702
|
+
for (const s of allSlots) {
|
|
703
|
+
try {
|
|
704
|
+
await fetchJSON("/leave", { method: "POST", body: JSON.stringify({ actorId: s.actorId }), timeoutMs: 5000 });
|
|
705
|
+
}
|
|
706
|
+
catch { }
|
|
707
|
+
try {
|
|
708
|
+
unlinkSync(resolve(AGENTS_DIR, `${s.owner}.json`));
|
|
709
|
+
}
|
|
710
|
+
catch { }
|
|
711
|
+
connections.delete(s.actorId);
|
|
712
|
+
}
|
|
713
|
+
currentActorId = null;
|
|
714
|
+
currentOwner = null;
|
|
715
|
+
removeStopHook();
|
|
716
|
+
return { content: [{ type: "text", text: `Disconnected all ${allSlots.length} agent(s).` }] };
|
|
421
717
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
``,
|
|
428
|
-
`Defaults: ${JSON.stringify(schema.defaults, null, 2)}`,
|
|
429
|
-
``,
|
|
430
|
-
`Presets: ${schema.presets.length > 0 ? schema.presets.join(", ") : "(none)"}`,
|
|
431
|
-
``,
|
|
432
|
-
`Use spawn_room with config values or a preset name to create a configured room.`,
|
|
433
|
-
].filter(Boolean).join("\n");
|
|
434
|
-
return { content: [{ type: "text", text: output }] };
|
|
435
|
-
}
|
|
436
|
-
catch (err) {
|
|
437
|
-
return {
|
|
438
|
-
content: [{
|
|
439
|
-
type: "text",
|
|
440
|
-
text: `Failed to get config schema: ${err.message}`,
|
|
441
|
-
}],
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
});
|
|
445
|
-
// ── Tool: list_experiences ─────────────────────────────────
|
|
446
|
-
server.tool("list_experiences", `List available experiences that can be used to spawn rooms.
|
|
447
|
-
|
|
448
|
-
Shows the host experience and any external experiences registered in vibevibes.registry.json.
|
|
449
|
-
Use this to discover what experiences are available for cross-experience room spawning.
|
|
450
|
-
|
|
451
|
-
Each experience can be used as an experienceId in spawn_room.`, {}, async () => {
|
|
452
|
-
try {
|
|
453
|
-
loadIdentity();
|
|
454
|
-
}
|
|
455
|
-
catch (err) {
|
|
456
|
-
return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
|
|
457
|
-
}
|
|
458
|
-
try {
|
|
459
|
-
const experiences = await fetchJSON("/experiences", { timeoutMs: 10000 });
|
|
460
|
-
if (!Array.isArray(experiences) || experiences.length === 0) {
|
|
461
|
-
return { content: [{ type: "text", text: "No experiences found." }] };
|
|
462
|
-
}
|
|
463
|
-
const lines = experiences.map((exp) => {
|
|
464
|
-
const tag = exp.source === "host" ? "[HOST]" : "[REGISTRY]";
|
|
465
|
-
const loaded = exp.loaded ? "" : " (not yet loaded — will bundle on first spawn)";
|
|
466
|
-
const config = exp.hasRoomConfig ? " [configurable]" : "";
|
|
467
|
-
return ` ${tag} ${exp.id} v${exp.version || "?"}${config}${loaded}\n ${exp.title || ""} — ${exp.description || ""}`;
|
|
468
|
-
});
|
|
469
|
-
const output = [
|
|
470
|
-
`${experiences.length} experience(s) available:`,
|
|
471
|
-
...lines,
|
|
472
|
-
``,
|
|
473
|
-
`Use spawn_room(experienceId="...") to create a room running any of these.`,
|
|
474
|
-
`Use room_config_schema(experienceId="...") to inspect config options before spawning.`,
|
|
475
|
-
].join("\n");
|
|
476
|
-
return { content: [{ type: "text", text: output }] };
|
|
477
|
-
}
|
|
478
|
-
catch (err) {
|
|
479
|
-
return {
|
|
480
|
-
content: [{
|
|
481
|
-
type: "text",
|
|
482
|
-
text: `Failed to list experiences: ${err.message}`,
|
|
483
|
-
}],
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
|
-
// ── Tool: memory ───────────────────────────────────────────
|
|
488
|
-
server.tool("memory", `Persistent agent memory (per-session). Survives across tool calls.
|
|
489
|
-
|
|
490
|
-
Actions:
|
|
491
|
-
get — Retrieve current memory
|
|
492
|
-
set — Merge updates into memory`, {
|
|
493
|
-
action: z.enum(["get", "set"]).describe("What to do"),
|
|
494
|
-
updates: z.record(z.any()).optional().describe("Key-value pairs to merge (for set)"),
|
|
495
|
-
}, async ({ action, updates }) => {
|
|
496
|
-
const key = currentActorId
|
|
497
|
-
? `local:${currentActorId}`
|
|
498
|
-
: "default";
|
|
499
|
-
if (action === "get") {
|
|
500
|
-
const data = await fetchJSON(`/memory?key=${encodeURIComponent(key)}`, { timeoutMs: 5000 });
|
|
501
|
-
return {
|
|
502
|
-
content: [{
|
|
503
|
-
type: "text",
|
|
504
|
-
text: `Memory: ${JSON.stringify(data, null, 2)}`,
|
|
505
|
-
}],
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
if (action === "set") {
|
|
509
|
-
if (!updates || Object.keys(updates).length === 0) {
|
|
510
|
-
return { content: [{ type: "text", text: "No updates provided." }] };
|
|
718
|
+
// Resolve which identity to disconnect
|
|
719
|
+
let identity;
|
|
720
|
+
if (disconnectActorId && connections.has(disconnectActorId)) {
|
|
721
|
+
const s = connections.get(disconnectActorId);
|
|
722
|
+
identity = { actorId: s.actorId, serverUrl: SERVER_URL, owner: s.owner };
|
|
511
723
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
724
|
+
else {
|
|
725
|
+
try {
|
|
726
|
+
identity = loadIdentity();
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// Fallback: scan agent files on disk (handles MCP process restart)
|
|
730
|
+
const cleaned = [];
|
|
731
|
+
if (existsSync(AGENTS_DIR)) {
|
|
732
|
+
for (const file of readdirSync(AGENTS_DIR).filter(f => f.endsWith(".json"))) {
|
|
733
|
+
try {
|
|
734
|
+
const agent = JSON.parse(readFileSync(resolve(AGENTS_DIR, file), "utf-8"));
|
|
735
|
+
try {
|
|
736
|
+
const sUrl = agent.serverUrl || SERVER_URL;
|
|
737
|
+
await fetch(`${sUrl}/leave`, {
|
|
738
|
+
method: "POST",
|
|
739
|
+
headers: { "Content-Type": "application/json" },
|
|
740
|
+
body: JSON.stringify({ actorId: agent.actorId }),
|
|
741
|
+
signal: AbortSignal.timeout(3000),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
catch { }
|
|
745
|
+
unlinkSync(resolve(AGENTS_DIR, file));
|
|
746
|
+
cleaned.push(agent.owner || file);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
try {
|
|
750
|
+
unlinkSync(resolve(AGENTS_DIR, file));
|
|
751
|
+
}
|
|
752
|
+
catch { }
|
|
753
|
+
cleaned.push(file);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
removeStopHook();
|
|
758
|
+
if (cleaned.length > 0) {
|
|
759
|
+
return { content: [{ type: "text", text: `Recovered from stale state. Cleaned up ${cleaned.length} agent(s): ${cleaned.join(", ")}` }] };
|
|
760
|
+
}
|
|
761
|
+
return { content: [{ type: "text", text: `Not connected. No agent files found to clean up.` }] };
|
|
762
|
+
}
|
|
535
763
|
}
|
|
536
|
-
|
|
537
|
-
|
|
764
|
+
// Check canDisconnect via agent-context
|
|
765
|
+
if (!force) {
|
|
766
|
+
try {
|
|
767
|
+
const ownerParam = identity.owner ? `&owner=${encodeURIComponent(identity.owner)}` : "";
|
|
768
|
+
const ctxUrl = `/agent-context?timeout=0&actorId=${encodeURIComponent(identity.actorId)}${ownerParam}`;
|
|
769
|
+
const ctx = await fetchJSON(ctxUrl, { timeoutMs: 10000 });
|
|
770
|
+
if (ctx?.observation?.canDisconnect === false) {
|
|
771
|
+
return {
|
|
772
|
+
content: [{
|
|
773
|
+
type: "text",
|
|
774
|
+
text: `The experience has locked agent participation (canDisconnect: false). The experience controls when agents can leave. Use force=true to override.`,
|
|
775
|
+
}],
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
// If we can't check, allow disconnect (server may be down)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// POST /leave to remove participant from server
|
|
538
784
|
try {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
785
|
+
await fetchJSON("/leave", {
|
|
786
|
+
method: "POST",
|
|
787
|
+
body: JSON.stringify({ actorId: identity.actorId }),
|
|
788
|
+
timeoutMs: 5000,
|
|
542
789
|
});
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
};
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
// Server may already be down — continue with local cleanup
|
|
793
|
+
}
|
|
794
|
+
// Delete own agent file
|
|
795
|
+
if (identity.owner) {
|
|
796
|
+
try {
|
|
797
|
+
unlinkSync(resolve(AGENTS_DIR, `${identity.owner}.json`));
|
|
551
798
|
}
|
|
552
|
-
|
|
553
|
-
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
|
554
|
-
return {
|
|
555
|
-
content: [{
|
|
556
|
-
type: "image",
|
|
557
|
-
data: base64,
|
|
558
|
-
mimeType: "image/png",
|
|
559
|
-
}],
|
|
560
|
-
};
|
|
799
|
+
catch { }
|
|
561
800
|
}
|
|
562
|
-
|
|
563
|
-
|
|
801
|
+
// Remove from connections map
|
|
802
|
+
connections.delete(identity.actorId);
|
|
803
|
+
// Clean up if no connections remain
|
|
804
|
+
if (connections.size === 0) {
|
|
805
|
+
removeStopHook();
|
|
806
|
+
currentActorId = null;
|
|
807
|
+
currentOwner = null;
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
// Update current to the last remaining connection
|
|
811
|
+
const last = [...connections.values()].pop();
|
|
812
|
+
currentActorId = last.actorId;
|
|
813
|
+
currentOwner = last.owner;
|
|
564
814
|
}
|
|
565
|
-
}
|
|
566
|
-
catch (err) {
|
|
567
815
|
return {
|
|
568
816
|
content: [{
|
|
569
817
|
type: "text",
|
|
570
|
-
text:
|
|
818
|
+
text: connections.size === 0
|
|
819
|
+
? `Disconnected from experience. You are no longer a participant.`
|
|
820
|
+
: `Disconnected ${identity.actorId}. ${connections.size} agent(s) still connected.`,
|
|
571
821
|
}],
|
|
572
822
|
};
|
|
573
823
|
}
|
|
574
|
-
});
|
|
575
|
-
// ── Tool: blob_set ─────────────────────────────────────────
|
|
576
|
-
server.tool("blob_set", `Store a binary blob on the server. Returns the blob key.
|
|
577
|
-
|
|
578
|
-
Data is sent as base64-encoded string. Use this for pixel buffers, audio data, etc.
|
|
579
|
-
The blob is stored server-side and can be referenced in state by key.`, {
|
|
580
|
-
key: z.string().describe("Blob key/ID"),
|
|
581
|
-
data: z.string().describe("Base64-encoded binary data"),
|
|
582
|
-
roomId: z.string().optional().describe("Room ID (defaults to 'local')"),
|
|
583
|
-
}, async ({ key, data, roomId }) => {
|
|
584
|
-
try {
|
|
585
|
-
loadIdentity();
|
|
586
|
-
const binaryData = Buffer.from(data, 'base64');
|
|
587
|
-
const headers = {
|
|
588
|
-
"Content-Type": "application/octet-stream",
|
|
589
|
-
};
|
|
590
|
-
if (ROOM_TOKEN)
|
|
591
|
-
headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
|
|
592
|
-
const controller = new AbortController();
|
|
593
|
-
const timer = setTimeout(() => controller.abort(), 30000);
|
|
594
|
-
try {
|
|
595
|
-
const res = await fetch(`${SERVER_URL}/blobs/${encodeURIComponent(key)}?actorId=${encodeURIComponent(currentActorId || '')}`, { method: "POST", headers, body: binaryData, signal: controller.signal });
|
|
596
|
-
const result = await res.json();
|
|
597
|
-
if (result.error)
|
|
598
|
-
return { content: [{ type: "text", text: `Blob error: ${result.error}` }] };
|
|
599
|
-
return { content: [{ type: "text", text: `Blob stored: key=${key}, size=${result.size} bytes` }] };
|
|
600
|
-
}
|
|
601
|
-
finally {
|
|
602
|
-
clearTimeout(timer);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
824
|
catch (err) {
|
|
606
|
-
return {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
Returns base64-encoded data. Use this to read pixel buffers, audio data, etc.`, {
|
|
613
|
-
key: z.string().describe("Blob key/ID to retrieve"),
|
|
614
|
-
}, async ({ key }) => {
|
|
615
|
-
try {
|
|
616
|
-
loadIdentity();
|
|
617
|
-
const headers = {};
|
|
618
|
-
if (ROOM_TOKEN)
|
|
619
|
-
headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
|
|
620
|
-
const controller = new AbortController();
|
|
621
|
-
const timer = setTimeout(() => controller.abort(), 30000);
|
|
622
|
-
try {
|
|
623
|
-
const res = await fetch(`${SERVER_URL}/blobs/${encodeURIComponent(key)}`, { headers, signal: controller.signal });
|
|
624
|
-
if (!res.ok)
|
|
625
|
-
return { content: [{ type: "text", text: `Blob not found: ${key}` }] };
|
|
626
|
-
const buf = await res.arrayBuffer();
|
|
627
|
-
const base64 = Buffer.from(buf).toString('base64');
|
|
628
|
-
return { content: [{ type: "text", text: `Blob ${key} (${buf.byteLength} bytes):\n${base64}` }] };
|
|
629
|
-
}
|
|
630
|
-
finally {
|
|
631
|
-
clearTimeout(timer);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
catch (err) {
|
|
635
|
-
return { content: [{ type: "text", text: `Blob get failed: ${err.message}` }] };
|
|
825
|
+
return {
|
|
826
|
+
content: [{
|
|
827
|
+
type: "text",
|
|
828
|
+
text: `Disconnect failed: ${toErrorMessage(err)}`,
|
|
829
|
+
}],
|
|
830
|
+
};
|
|
636
831
|
}
|
|
637
832
|
});
|
|
638
833
|
// ── Start ──────────────────────────────────────────────────
|
|
@@ -644,3 +839,27 @@ main().catch((err) => {
|
|
|
644
839
|
console.error("MCP server failed:", err);
|
|
645
840
|
process.exit(1);
|
|
646
841
|
});
|
|
842
|
+
// ── Exit handler: best-effort cleanup on process exit ──────
|
|
843
|
+
let cleaned = false;
|
|
844
|
+
function cleanup() {
|
|
845
|
+
if (cleaned)
|
|
846
|
+
return;
|
|
847
|
+
cleaned = true;
|
|
848
|
+
if (currentActorId) {
|
|
849
|
+
try {
|
|
850
|
+
const leaveUrl = `${SERVER_URL}/leave`;
|
|
851
|
+
const script = `fetch(process.argv[1],{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({actorId:process.argv[2]})}).catch(()=>{})`;
|
|
852
|
+
spawnSync("node", ["-e", script, leaveUrl, currentActorId], { timeout: 3000, stdio: "ignore" });
|
|
853
|
+
}
|
|
854
|
+
catch { }
|
|
855
|
+
}
|
|
856
|
+
if (currentOwner) {
|
|
857
|
+
try {
|
|
858
|
+
unlinkSync(resolve(AGENTS_DIR, `${currentOwner}.json`));
|
|
859
|
+
}
|
|
860
|
+
catch { }
|
|
861
|
+
}
|
|
862
|
+
removeStopHook();
|
|
863
|
+
}
|
|
864
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
865
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|