@vibevibes/mcp 0.1.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/dist/index.js CHANGED
@@ -1,18 +1,112 @@
1
1
  /**
2
- * vibevibes-mcp — standalone MCP server for joining vibevibes experiences.
2
+ * vibevibes-mcp — MCP server for agent participation in vibevibes experiences.
3
3
  *
4
- * Works with both local dev servers and remote shared tunnels.
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.
5
6
  *
6
- * Usage:
7
- * npx vibevibes-mcp # defaults to http://localhost:4321
8
- * npx vibevibes-mcp https://xyz.trycloudflare.com # join a shared room
9
- * VIBEVIBES_SERVER_URL=https://... npx vibevibes-mcp
10
- *
11
- * 5 tools: connect, watch, act, memory, screenshot
7
+ * 4 tools: connect, act, look, disconnect
12
8
  */
13
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
11
  import { z } from "zod";
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); });
16
110
  // Resolve server URL: CLI arg > env var > localhost default
17
111
  const RAW_SERVER_URL = process.argv[2] ||
18
112
  process.env.VIBEVIBES_SERVER_URL ||
@@ -22,31 +116,165 @@ const parsedUrl = new URL(RAW_SERVER_URL);
22
116
  const ROOM_TOKEN = parsedUrl.searchParams.get("token");
23
117
  // Strip the token param from the base URL so it isn't duplicated in request paths
24
118
  parsedUrl.searchParams.delete("token");
25
- const SERVER_URL = parsedUrl.toString().replace(/\/$/, ""); // remove trailing slash
26
- // ── State ──────────────────────────────────────────────────
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. */
27
123
  let currentActorId = null;
28
- let lastEventTs = 0;
29
- let connected = false;
124
+ let currentOwner = null;
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 ─────────────────────────────────────
157
+ /**
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.
161
+ */
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));
185
+ }
186
+ catch {
187
+ // Hook registration is best-effort
188
+ }
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}`;
231
+ }
30
232
  // ── Helpers ────────────────────────────────────────────────
31
233
  async function fetchJSON(path, opts) {
32
- const headers = {
33
- "Content-Type": "application/json",
34
- ...(opts?.headers || {}),
35
- };
36
- // Attach room token as Authorization header when available
37
- if (ROOM_TOKEN) {
38
- headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
39
- }
40
- const res = await fetch(`${SERVER_URL}${path}`, {
41
- ...opts,
42
- headers,
43
- });
44
- const text = await res.text();
234
+ const timeoutMs = opts?.timeoutMs ?? 30000;
235
+ const controller = new AbortController();
236
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
45
237
  try {
46
- return JSON.parse(text);
238
+ const headers = {
239
+ "Content-Type": "application/json",
240
+ ...(opts?.headers || {}),
241
+ };
242
+ // Attach room token as Authorization header when available
243
+ if (ROOM_TOKEN) {
244
+ headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
245
+ }
246
+ const res = await fetch(`${SERVER_URL}${path}`, {
247
+ ...opts,
248
+ signal: controller.signal,
249
+ headers,
250
+ });
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
+ }
263
+ try {
264
+ return JSON.parse(text);
265
+ }
266
+ catch {
267
+ throw new Error(`Server returned non-JSON: ${text.slice(0, 200)}`);
268
+ }
47
269
  }
48
- catch {
49
- throw new Error(`Server returned non-JSON: ${text.slice(0, 200)}`);
270
+ catch (err) {
271
+ if (err instanceof Error && err.name === "AbortError") {
272
+ throw new Error(`Request timed out after ${timeoutMs}ms: ${path}`);
273
+ }
274
+ throw err;
275
+ }
276
+ finally {
277
+ clearTimeout(timer);
50
278
  }
51
279
  }
52
280
  function formatToolList(tools) {
@@ -54,248 +282,542 @@ function formatToolList(tools) {
54
282
  return "No tools available.";
55
283
  return tools
56
284
  .map((t) => {
57
- const schema = t.input_schema?.properties
58
- ? Object.entries(t.input_schema.properties)
59
- .map(([k, v]) => `${k}: ${v.type || "any"}`)
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"}`)
60
289
  .join(", ")
61
290
  : "{}";
62
291
  return ` ${t.name} (${t.risk || "low"}) — ${t.description}\n input: { ${schema} }`;
63
292
  })
64
293
  .join("\n");
65
294
  }
66
- async function joinRoom() {
67
- const join = await fetchJSON("/join", {
68
- method: "POST",
69
- body: JSON.stringify({ username: "claude", actorType: "ai" }),
70
- });
71
- if (join.error)
72
- throw new Error(join.error);
73
- currentActorId = join.actorId;
74
- lastEventTs = Date.now();
75
- connected = true;
76
- return join;
77
- }
78
- async function ensureConnected() {
79
- if (connected)
80
- return;
81
- await joinRoom();
295
+ /**
296
+ * Format state for agent consumption.
297
+ * If the experience defines observe(), show the curated observation.
298
+ * Otherwise fall back to raw state.
299
+ */
300
+ function formatState(state, observation) {
301
+ if (observation) {
302
+ return `Observation: ${JSON.stringify(observation, null, 2)}`;
303
+ }
304
+ return `State: ${JSON.stringify(state, null, 2)}`;
82
305
  }
83
306
  // ── MCP Server ─────────────────────────────────────────────
84
307
  const server = new McpServer({
85
308
  name: "vibevibes",
86
- version: "0.1.0",
309
+ version: "0.4.0",
87
310
  });
88
311
  // ── Tool: connect ──────────────────────────────────────────
89
- server.tool("connect", `Connect to the running experience.
90
-
91
- Returns: available tools, current state, participants, and the browser URL.
92
-
93
- Call this first before using watch or act.`, {}, async () => {
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 }) => {
94
324
  try {
95
- const join = await joinRoom();
96
- const output = [
97
- `Connected as ${currentActorId}`,
98
- `Experience: ${join.experienceId}`,
99
- `Browser: ${join.browserUrl}`,
100
- `Server: ${SERVER_URL}`,
101
- ``,
102
- `State: ${JSON.stringify(join.sharedState, null, 2)}`,
103
- `Participants: ${join.participants?.join(", ")}`,
104
- ``,
105
- `Tools:`,
106
- formatToolList(join.tools),
107
- ].join("\n");
108
- return { content: [{ type: "text", text: output }] };
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 = [
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
+ : "",
463
+ `Experience: ${state.experienceId || "unknown"}`,
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") }] };
109
491
  }
110
492
  catch (err) {
111
493
  return {
112
494
  content: [{
113
495
  type: "text",
114
- text: `Failed to connect to ${SERVER_URL}.\n\nIs the dev server running? (npm run dev)\n\nError: ${err.message}`,
496
+ text: `Failed to connect.\n\nIs the dev server running? (npm run dev)\n\nError: ${toErrorMessage(err)}`,
115
497
  }],
116
498
  };
117
499
  }
118
500
  });
119
- // ── Tool: watch ────────────────────────────────────────────
120
- server.tool("watch", `Wait for activity in the experience. Blocks until events arrive or timeout.
121
-
122
- Use predicate to wait for a condition, e.g. "state.count > 5".
123
- Use filterTools to only wake for specific tools, e.g. ["pixel.place"].
124
- Use filterActors to only wake for specific actors.
125
-
126
- Auto-connects if not already connected.`, {
127
- timeout: z.number().optional().describe("Max wait ms (default 30000, max 55000)"),
128
- predicate: z.string().optional().describe('JS expression, e.g. "state.count > 5"'),
129
- filterTools: z.array(z.string()).optional().describe("Only wake for these tools"),
130
- filterActors: z.array(z.string()).optional().describe("Only wake for these actors"),
131
- }, async ({ timeout, predicate, filterTools, filterActors }) => {
501
+ // ── Tool: act ──────────────────────────────────────────────
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
+ ])`, {
516
+ toolName: z.string().optional().describe("Tool to call, e.g. 'counter.increment'. Required if batch is not provided."),
517
+ input: z.record(z.unknown()).optional().describe("Tool input parameters (for single call)"),
518
+ batch: z.array(z.object({
519
+ toolName: z.string().describe("Tool to call"),
520
+ input: z.record(z.unknown()).optional().describe("Tool input parameters"),
521
+ })).optional().describe("Array of tool calls to execute sequentially in one round-trip. Each call sees the state from the previous call."),
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;
132
525
  try {
133
- await ensureConnected();
526
+ slot = resolveIdentity(actorId);
134
527
  }
135
528
  catch (err) {
136
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
137
- }
138
- const t = Math.min(timeout || 30000, 55000);
139
- // Check if predicate already matches
140
- if (predicate) {
141
529
  try {
142
- const current = await fetchJSON("/state");
143
- const fn = new Function("state", "actorId", `return ${predicate}`);
144
- if (fn(current.sharedState, currentActorId)) {
145
- return {
146
- content: [{
147
- type: "text",
148
- text: [
149
- `Predicate already true: ${predicate}`,
150
- `State: ${JSON.stringify(current.sharedState, null, 2)}`,
151
- `Participants: ${current.participants?.join(", ")}`,
152
- ].join("\n"),
153
- }],
154
- };
155
- }
530
+ loadIdentity();
156
531
  }
157
- catch {
158
- // Predicate eval failed, continue to long-poll
532
+ catch { }
533
+ try {
534
+ slot = resolveIdentity(actorId);
535
+ }
536
+ catch (err2) {
537
+ return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err2)}` }] };
159
538
  }
160
539
  }
161
- // Long-poll for events
162
- const data = await fetchJSON(`/events?since=${lastEventTs}&timeout=${t}`);
163
- let events = data.events || [];
164
- if (filterTools?.length) {
165
- events = events.filter((e) => filterTools.includes(e.tool));
166
- }
167
- if (filterActors?.length) {
168
- events = events.filter((e) => filterActors.includes(e.actorId));
540
+ const effectiveActorId = slot.actorId;
541
+ // Build the list of calls: either from batch or single toolName+input
542
+ const calls = batch && batch.length > 0
543
+ ? batch
544
+ : toolName
545
+ ? [{ toolName, input }]
546
+ : [];
547
+ if (calls.length === 0) {
548
+ return { content: [{ type: "text", text: `Provide either toolName or batch.` }] };
169
549
  }
170
- if (events.length > 0) {
171
- lastEventTs = Math.max(...events.map((e) => e.ts));
550
+ // Validate tool names to prevent path traversal
551
+ const SAFE_TOOL_NAME = /^[a-zA-Z0-9_.\-]+$/;
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
+ }
172
556
  }
173
- let predicateMatched = false;
174
- if (predicate) {
557
+ const effectiveOwner = slot.owner || currentOwner || undefined;
558
+ const results = [];
559
+ if (calls.length === 1) {
560
+ const call = calls[0];
175
561
  try {
176
- const fn = new Function("state", "actorId", `return ${predicate}`);
177
- predicateMatched = !!fn(data.sharedState, currentActorId);
562
+ const idempotencyKey = `${effectiveActorId}-${call.toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
563
+ const result = await fetchJSON(`/tools/${call.toolName}`, {
564
+ method: "POST",
565
+ headers: { "X-Idempotency-Key": idempotencyKey },
566
+ body: JSON.stringify({
567
+ actorId: effectiveActorId || "mcp-client",
568
+ owner: effectiveOwner,
569
+ input: call.input || {},
570
+ }),
571
+ timeoutMs: 30000,
572
+ });
573
+ if (result.error) {
574
+ results.push({ toolName: call.toolName, error: result.error });
575
+ }
576
+ else {
577
+ results.push({ toolName: call.toolName, output: result.output, observation: result.observation });
578
+ }
178
579
  }
179
- catch {
180
- // ignore
580
+ catch (err) {
581
+ results.push({ toolName: call.toolName, error: toErrorMessage(err) });
181
582
  }
182
583
  }
183
- const parts = [];
184
- if (events.length > 0) {
185
- parts.push(`${events.length} event(s):`);
186
- for (const e of events) {
187
- parts.push(` [${e.actorId}] ${e.tool}(${JSON.stringify(e.input)}) → ${e.error ? `ERROR: ${e.error}` : JSON.stringify(e.output)}`);
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
+ }
604
+ }
605
+ else if (batchResult.error) {
606
+ results.push({ toolName: calls[0].toolName, error: batchResult.error });
607
+ }
608
+ }
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
+ }
188
633
  }
189
634
  }
190
- else {
191
- parts.push("No new events (timeout).");
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
+ }
192
647
  }
193
- parts.push(`State: ${JSON.stringify(data.sharedState, null, 2)}`);
194
- parts.push(`Participants: ${data.participants?.join(", ")}`);
195
- if (predicate) {
196
- parts.push(`Predicate "${predicate}": ${predicateMatched}`);
648
+ if (lastObservation) {
649
+ outputParts.push(`Observation: ${JSON.stringify(lastObservation)}`);
197
650
  }
198
- return { content: [{ type: "text", text: parts.join("\n") }] };
651
+ return { content: [{ type: "text", text: outputParts.join("\n") }] };
199
652
  });
200
- // ── Tool: act ──────────────────────────────────────────────
201
- server.tool("act", `Execute a tool to mutate shared state. All state changes go through tools.
202
-
203
- Example: act(toolName="counter.increment", input={amount: 2})
204
-
205
- Auto-connects if not already connected.`, {
206
- toolName: z.string().describe("Tool to call, e.g. 'counter.increment'"),
207
- input: z.record(z.any()).optional().describe("Tool input parameters"),
208
- }, async ({ toolName, input }) => {
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;
209
663
  try {
210
- await ensureConnected();
664
+ slot = resolveIdentity(actorId);
211
665
  }
212
- catch (err) {
213
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
214
- }
215
- const result = await fetchJSON(`/tools/${toolName}`, {
216
- method: "POST",
217
- body: JSON.stringify({
218
- actorId: currentActorId || "mcp-client",
219
- input: input || {},
220
- }),
221
- });
222
- if (result.error) {
223
- return { content: [{ type: "text", text: `Tool error: ${result.error}` }] };
224
- }
225
- const state = await fetchJSON("/state");
226
- const output = [
227
- `${toolName} → ${JSON.stringify(result.output)}`,
228
- `State: ${JSON.stringify(state.sharedState, null, 2)}`,
229
- ].join("\n");
230
- return { content: [{ type: "text", text: output }] };
231
- });
232
- // ── Tool: memory ───────────────────────────────────────────
233
- server.tool("memory", `Persistent agent memory (per-session). Survives across tool calls.
234
-
235
- Actions:
236
- get — Retrieve current memory
237
- set — Merge updates into memory`, {
238
- action: z.enum(["get", "set"]).describe("What to do"),
239
- updates: z.record(z.any()).optional().describe("Key-value pairs to merge (for set)"),
240
- }, async ({ action, updates }) => {
241
- const key = currentActorId
242
- ? `local:${currentActorId}`
243
- : "default";
244
- if (action === "get") {
245
- const data = await fetchJSON(`/memory?key=${encodeURIComponent(key)}`);
246
- return {
247
- content: [{
248
- type: "text",
249
- text: `Memory: ${JSON.stringify(data, null, 2)}`,
250
- }],
251
- };
252
- }
253
- if (action === "set") {
254
- if (!updates || Object.keys(updates).length === 0) {
255
- return { content: [{ type: "text", text: "No updates provided." }] };
666
+ catch {
667
+ try {
668
+ loadIdentity();
669
+ slot = resolveIdentity(actorId);
256
670
  }
257
- await fetchJSON("/memory", {
258
- method: "POST",
259
- body: JSON.stringify({ key, updates }),
260
- });
261
- return { content: [{ type: "text", text: `Memory updated: ${JSON.stringify(updates)}` }] };
671
+ catch (err) {
672
+ return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err)}` }] };
673
+ }
674
+ }
675
+ try {
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") }] };
683
+ }
684
+ catch (err) {
685
+ return { content: [{ type: "text", text: `Look failed: ${toErrorMessage(err)}` }] };
262
686
  }
263
- return { content: [{ type: "text", text: `Unknown action: ${action}` }] };
264
687
  });
265
- // ── Tool: screenshot ──────────────────────────────────────
266
- server.tool("screenshot", `Capture a screenshot of the experience as seen in the browser.
267
-
268
- Returns the current visual state as a PNG image.
269
- Requires the browser viewer to be open.
270
-
271
- Use this to see what the user sees inspect paintings, check layouts, read rendered text, etc.`, {
272
- timeout: z.number().optional().describe("Max wait ms (default 10000)"),
273
- }, async ({ timeout }) => {
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 }) => {
274
698
  try {
275
- const t = Math.min(timeout || 10000, 30000);
276
- const screenshotHeaders = {};
277
- if (ROOM_TOKEN) {
278
- screenshotHeaders["Authorization"] = `Bearer ${ROOM_TOKEN}`;
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).` }] };
279
717
  }
280
- const res = await fetch(`${SERVER_URL}/screenshot?timeout=${t}`, {
281
- headers: screenshotHeaders,
282
- });
283
- if (!res.ok) {
284
- const err = await res.json().catch(() => ({ error: res.statusText }));
285
- return {
286
- content: [{
287
- type: "text",
288
- text: `Screenshot failed: ${err.error || "Unknown error"}`,
289
- }],
290
- };
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 };
723
+ }
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
+ }
763
+ }
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
784
+ try {
785
+ await fetchJSON("/leave", {
786
+ method: "POST",
787
+ body: JSON.stringify({ actorId: identity.actorId }),
788
+ timeoutMs: 5000,
789
+ });
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`));
798
+ }
799
+ catch { }
800
+ }
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;
291
814
  }
292
- const arrayBuffer = await res.arrayBuffer();
293
- const base64 = Buffer.from(arrayBuffer).toString("base64");
294
815
  return {
295
816
  content: [{
296
- type: "image",
297
- data: base64,
298
- mimeType: "image/png",
817
+ type: "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.`,
299
821
  }],
300
822
  };
301
823
  }
@@ -303,7 +825,7 @@ Use this to see what the user sees — inspect paintings, check layouts, read re
303
825
  return {
304
826
  content: [{
305
827
  type: "text",
306
- text: `Screenshot failed: ${err.message}. Is the dev server running? Is the browser open?`,
828
+ text: `Disconnect failed: ${toErrorMessage(err)}`,
307
829
  }],
308
830
  };
309
831
  }
@@ -317,3 +839,27 @@ main().catch((err) => {
317
839
  console.error("MCP server failed:", err);
318
840
  process.exit(1);
319
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); });