@vibevibes/mcp 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 vibevibes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # @vibevibes/mcp
2
+
3
+ MCP server that connects AI agents to running vibevibes experiences. Agents join rooms, react with tools, and persist memory — as live participants, not request-response endpoints.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Local dev server (default: localhost:4321)
9
+ npx vibevibes-mcp
10
+
11
+ # Remote shared room
12
+ npx vibevibes-mcp https://xyz.trycloudflare.com
13
+
14
+ # With room token
15
+ npx vibevibes-mcp https://xyz.trycloudflare.com?token=abc123
16
+
17
+ # Via environment variable
18
+ VIBEVIBES_SERVER_URL=https://xyz.trycloudflare.com npx vibevibes-mcp
19
+ ```
20
+
21
+ When using `@vibevibes/create-experience`, the MCP server is auto-registered via `.mcp.json` — no manual setup needed.
22
+
23
+ ## The Agent Loop
24
+
25
+ Agents are persistent participants in a shared room. The stop hook handles perception — it polls the server for new events and feeds them back as prompts, keeping the agent alive without needing to call `watch`.
26
+
27
+ ```
28
+ connect → act → (stop hook fires with new events) → act → (stop hook fires) → act → ...
29
+ ```
30
+
31
+ The stop hook automatically delivers events from other participants, available tools per room, and participant lists.
32
+
33
+ ## Tools
34
+
35
+ ### `connect`
36
+
37
+ Join the active room. Returns available tools, current state, participants, and the browser URL.
38
+
39
+ ```
40
+ connect()
41
+ → { tools, sharedState, participants, browserUrl, config }
42
+ ```
43
+
44
+ Call this first. `act` will auto-connect if you haven't.
45
+
46
+ ### `act`
47
+
48
+ Execute a tool to mutate shared state.
49
+
50
+ ```
51
+ act(toolName, input?, roomId?)
52
+ → { output, sharedState }
53
+ ```
54
+
55
+ | Parameter | Type | Default | Description |
56
+ |-----------|------|---------|-------------|
57
+ | `toolName` | string | — | Tool to call, e.g. `"counter.increment"` |
58
+ | `input` | object | `{}` | Tool input parameters |
59
+ | `roomId` | string | `"local"` | Target room (for multi-room experiences) |
60
+
61
+ ### `stream`
62
+
63
+ Send high-frequency state updates (cursors, brushes, sliders) via the stream channel. Bypasses the full tool pipeline.
64
+
65
+ ```
66
+ stream(name, input?, roomId?)
67
+ ```
68
+
69
+ ### `spawn_room`
70
+
71
+ Create a new room running any registered experience.
72
+
73
+ ```
74
+ spawn_room(experienceId?, name?, config?, initialState?, linkBack?, sourceRoomId?)
75
+ → { roomId, url, config }
76
+ ```
77
+
78
+ | Parameter | Type | Default | Description |
79
+ |-----------|------|---------|-------------|
80
+ | `experienceId` | string | host | Experience to run in the new room |
81
+ | `name` | string | auto | Room name |
82
+ | `config` | object or string | — | Config values or preset name (e.g. `"boss-fight"`) |
83
+ | `initialState` | object | — | Initial shared state |
84
+ | `linkBack` | boolean | true | Store parent roomId in child state |
85
+ | `sourceRoomId` | string | `"local"` | Parent room ID |
86
+
87
+ ### `list_rooms`
88
+
89
+ List all active rooms with participant counts and configurations.
90
+
91
+ ```
92
+ list_rooms()
93
+ → [{ roomId, participants, events, config, parentRoomId, childRoomIds }]
94
+ ```
95
+
96
+ ### `list_experiences`
97
+
98
+ Show available experiences — the host experience and any registered in `vibevibes.registry.json`.
99
+
100
+ ```
101
+ list_experiences()
102
+ → [{ id, version, title, description, source, loaded, hasRoomConfig }]
103
+ ```
104
+
105
+ ### `room_config_schema`
106
+
107
+ Get the configuration schema for an experience before spawning a room.
108
+
109
+ ```
110
+ room_config_schema(experienceId?)
111
+ → { hasConfig, schema, defaults, presets, description }
112
+ ```
113
+
114
+ ### `memory`
115
+
116
+ Persistent agent memory. Survives across tool calls within a session.
117
+
118
+ ```
119
+ memory(action: "get")
120
+ → { ...currentMemory }
121
+
122
+ memory(action: "set", updates: { key: value })
123
+ → { ...updatedMemory }
124
+ ```
125
+
126
+ ### `screenshot`
127
+
128
+ Capture a PNG of the browser canvas.
129
+
130
+ ```
131
+ screenshot(timeout?)
132
+ → PNG image (base64)
133
+ ```
134
+
135
+ Requires the browser viewer to be open. Useful for agents with vision capabilities.
136
+
137
+ ### `blob_set` / `blob_get`
138
+
139
+ Store and retrieve binary blobs (pixel buffers, audio data, etc.).
140
+
141
+ ```
142
+ blob_set(key, data) # data is base64-encoded
143
+ blob_get(key) → base64 data
144
+ ```
145
+
146
+ ## Configuration
147
+
148
+ The server URL is resolved in this order:
149
+
150
+ 1. CLI argument: `npx vibevibes-mcp https://...`
151
+ 2. Environment variable: `VIBEVIBES_SERVER_URL`
152
+ 3. Default: `http://localhost:4321`
153
+
154
+ Room tokens are extracted from the URL query parameter (`?token=abc123`) and sent as `Authorization: Bearer` headers.
155
+
156
+ ## Reliability
157
+
158
+ - **Auto-reconnect** with exponential backoff on connection failures
159
+ - **Health checks** to detect server restarts and re-join automatically
160
+ - **Idempotency keys** on tool calls to prevent duplicate execution on retry
161
+ - **Timeout handling** with configurable durations per tool
162
+
163
+ ## Example: Agent in an Experience
164
+
165
+ ```
166
+ 1. connect()
167
+ → See tools: [game.move, game.chat], state: {board: [...], turn: "white"}
168
+
169
+ 2. (stop hook delivers: human moved game.move({from: "e2", to: "e4"}))
170
+
171
+ 3. act(toolName="game.move", input={from: "e7", to: "e5"})
172
+ → Board updated, turn switches
173
+
174
+ 4. (stop hook delivers: human moved again...)
175
+
176
+ 5. act(...)
177
+ → ...forever
178
+ ```
179
+
180
+ ## Example: Cross-Experience Composition
181
+
182
+ ```
183
+ 1. connect()
184
+ → Joined overworld room
185
+
186
+ 2. list_experiences()
187
+ → ["fallout-wasteland", "fallout-combat", "fallout-dialogue"]
188
+
189
+ 3. room_config_schema(experienceId="fallout-combat")
190
+ → { presets: ["radroach-nest", "raider-ambush"], schema: {...} }
191
+
192
+ 4. spawn_room(experienceId="fallout-combat", config="raider-ambush")
193
+ → { roomId: "room-abc", url: "http://localhost:4321/room/room-abc" }
194
+
195
+ 5. act(toolName="combat.attack", input={target: 0}, roomId="room-abc")
196
+ → Combat in child room
197
+ ```
198
+
199
+ ## License
200
+
201
+ MIT
package/dist/index.d.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  * vibevibes-mcp — standalone MCP server for joining vibevibes experiences.
3
3
  *
4
4
  * Works with both local dev servers and remote shared tunnels.
5
+ * Identity is read from the state file written by setup.js — MCP never calls /join.
5
6
  *
6
7
  * Usage:
7
8
  * npx vibevibes-mcp # defaults to http://localhost:4321
8
9
  * npx vibevibes-mcp https://xyz.trycloudflare.com # join a shared room
9
10
  * VIBEVIBES_SERVER_URL=https://... npx vibevibes-mcp
10
11
  *
11
- * 5 tools: connect, watch, act, memory, screenshot
12
+ * 11 tools: connect, act, stream, spawn_room, list_rooms, list_experiences, room_config_schema, memory, screenshot, blob_set, blob_get
12
13
  */
13
14
  export {};
package/dist/index.js CHANGED
@@ -2,17 +2,20 @@
2
2
  * vibevibes-mcp — standalone MCP server for joining vibevibes experiences.
3
3
  *
4
4
  * Works with both local dev servers and remote shared tunnels.
5
+ * Identity is read from the state file written by setup.js — MCP never calls /join.
5
6
  *
6
7
  * Usage:
7
8
  * npx vibevibes-mcp # defaults to http://localhost:4321
8
9
  * npx vibevibes-mcp https://xyz.trycloudflare.com # join a shared room
9
10
  * VIBEVIBES_SERVER_URL=https://... npx vibevibes-mcp
10
11
  *
11
- * 5 tools: connect, watch, act, memory, screenshot
12
+ * 11 tools: connect, act, stream, spawn_room, list_rooms, list_experiences, room_config_schema, memory, screenshot, blob_set, blob_get
12
13
  */
13
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
16
  import { z } from "zod";
17
+ import { readFileSync, existsSync } from "node:fs";
18
+ import { resolve } from "node:path";
16
19
  // Resolve server URL: CLI arg > env var > localhost default
17
20
  const RAW_SERVER_URL = process.argv[2] ||
18
21
  process.env.VIBEVIBES_SERVER_URL ||
@@ -25,28 +28,60 @@ parsedUrl.searchParams.delete("token");
25
28
  const SERVER_URL = parsedUrl.toString().replace(/\/$/, ""); // remove trailing slash
26
29
  // ── State ──────────────────────────────────────────────────
27
30
  let currentActorId = null;
28
- let lastEventTs = 0;
29
- let connected = false;
31
+ let currentOwner = null;
32
+ const STATE_FILE = resolve(process.cwd(), ".claude/vibevibes-agent.local.json");
33
+ /**
34
+ * Load identity from the state file written by setup.js.
35
+ * Setup.js is the sole identity authority — MCP never calls /join.
36
+ */
37
+ function loadIdentity() {
38
+ if (!existsSync(STATE_FILE)) {
39
+ throw new Error("Not in agent mode. Use /vibevibes-join first.");
40
+ }
41
+ const raw = readFileSync(STATE_FILE, "utf-8");
42
+ const state = JSON.parse(raw);
43
+ if (!state.active || !state.actorId) {
44
+ throw new Error("Agent loop not active. Use /vibevibes-join first.");
45
+ }
46
+ currentActorId = state.actorId;
47
+ currentOwner = state.owner || state.actorId.split("-")[0];
48
+ return { actorId: state.actorId, serverUrl: state.serverUrl, owner: currentOwner };
49
+ }
30
50
  // ── Helpers ────────────────────────────────────────────────
31
51
  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();
52
+ const timeoutMs = opts?.timeoutMs ?? 30000;
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
45
55
  try {
46
- return JSON.parse(text);
56
+ const headers = {
57
+ "Content-Type": "application/json",
58
+ ...(opts?.headers || {}),
59
+ };
60
+ // Attach room token as Authorization header when available
61
+ if (ROOM_TOKEN) {
62
+ headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
63
+ }
64
+ const res = await fetch(`${SERVER_URL}${path}`, {
65
+ ...opts,
66
+ signal: controller.signal,
67
+ headers,
68
+ });
69
+ const text = await res.text();
70
+ try {
71
+ return JSON.parse(text);
72
+ }
73
+ catch {
74
+ throw new Error(`Server returned non-JSON: ${text.slice(0, 200)}`);
75
+ }
76
+ }
77
+ catch (err) {
78
+ if (err.name === "AbortError") {
79
+ throw new Error(`Request timed out after ${timeoutMs}ms: ${path}`);
80
+ }
81
+ throw err;
47
82
  }
48
- catch {
49
- throw new Error(`Server returned non-JSON: ${text.slice(0, 200)}`);
83
+ finally {
84
+ clearTimeout(timer);
50
85
  }
51
86
  }
52
87
  function formatToolList(tools) {
@@ -63,47 +98,49 @@ function formatToolList(tools) {
63
98
  })
64
99
  .join("\n");
65
100
  }
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();
101
+ /**
102
+ * Format state for agent consumption.
103
+ * If the experience defines observe(), show the curated observation.
104
+ * Otherwise fall back to raw state.
105
+ */
106
+ function formatState(state, observation) {
107
+ if (observation) {
108
+ return `Observation: ${JSON.stringify(observation, null, 2)}`;
109
+ }
110
+ return `State: ${JSON.stringify(state, null, 2)}`;
82
111
  }
83
112
  // ── MCP Server ─────────────────────────────────────────────
84
113
  const server = new McpServer({
85
114
  name: "vibevibes",
86
- version: "0.1.0",
115
+ version: "0.4.0",
87
116
  });
88
117
  // ── Tool: connect ──────────────────────────────────────────
89
118
  server.tool("connect", `Connect to the running experience.
90
119
 
91
120
  Returns: available tools, current state, participants, and the browser URL.
92
121
 
93
- Call this first before using watch or act.`, {}, async () => {
122
+ Identity is read from the state file written by /vibevibes-join. MCP never calls /join — setup.js owns identity.
123
+
124
+ Call this first, then use act to interact. The stop hook keeps you present.`, {}, async () => {
94
125
  try {
95
- const join = await joinRoom();
126
+ const identity = loadIdentity();
127
+ // GET /state to fetch tools, state, participants — no POST /join
128
+ const state = await fetchJSON("/state", { timeoutMs: 10000 });
96
129
  const output = [
97
- `Connected as ${currentActorId}`,
98
- `Experience: ${join.experienceId}`,
99
- `Browser: ${join.browserUrl}`,
130
+ `Connected as ${identity.actorId}`,
131
+ `Experience: ${state.experienceId || "unknown"}`,
132
+ `Browser: ${state.browserUrl || SERVER_URL}`,
100
133
  `Server: ${SERVER_URL}`,
101
134
  ``,
102
- `State: ${JSON.stringify(join.sharedState, null, 2)}`,
103
- `Participants: ${join.participants?.join(", ")}`,
135
+ formatState(state.sharedState, state.observation),
136
+ `Participants: ${state.participants?.join(", ")}`,
137
+ `Room Config: ${JSON.stringify(state.config || {}, null, 2)}`,
138
+ ...(state.hasRoomConfig ? [`(This experience supports configurable rooms — use spawn_room to create configured sub-rooms)`] : []),
104
139
  ``,
105
140
  `Tools:`,
106
- formatToolList(join.tools),
141
+ formatToolList(state.tools),
142
+ ``,
143
+ `You are now a live participant. The stop hook keeps you present — use act to interact.`,
107
144
  ].join("\n");
108
145
  return { content: [{ type: "text", text: output }] };
109
146
  }
@@ -111,123 +148,341 @@ Call this first before using watch or act.`, {}, async () => {
111
148
  return {
112
149
  content: [{
113
150
  type: "text",
114
- text: `Failed to connect to ${SERVER_URL}.\n\nIs the dev server running? (npm run dev)\n\nError: ${err.message}`,
151
+ text: `Failed to connect to ${SERVER_URL}.\n\nIs the dev server running? (npm run dev)\nHave you run /vibevibes-join?\n\nError: ${err.message}`,
115
152
  }],
116
153
  };
117
154
  }
118
155
  });
119
- // ── Tool: watch ────────────────────────────────────────────
120
- server.tool("watch", `Wait for activity in the experience. Blocks until events arrive or timeout.
156
+ // ── 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.
121
161
 
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.
162
+ Single call:
163
+ act(toolName="counter.increment", input={amount: 2})
125
164
 
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 }) => {
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.`, {
174
+ toolName: z.string().optional().describe("Tool to call, e.g. 'counter.increment'. Required if batch is not provided."),
175
+ input: z.record(z.any()).optional().describe("Tool input parameters (for single call)"),
176
+ batch: z.array(z.object({
177
+ toolName: z.string().describe("Tool to call"),
178
+ input: z.record(z.any()).optional().describe("Tool input parameters"),
179
+ })).optional().describe("Array of tool calls to execute sequentially in one round-trip. Each call sees the state from the previous call."),
180
+ roomId: z.string().optional().describe("Target room ID (defaults to 'local')"),
181
+ }, async ({ toolName, input, batch, roomId }) => {
132
182
  try {
133
- await ensureConnected();
183
+ loadIdentity();
134
184
  }
135
185
  catch (err) {
136
186
  return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
137
187
  }
138
- const t = Math.min(timeout || 30000, 55000);
139
- // Check if predicate already matches
140
- if (predicate) {
188
+ const targetRoom = roomId || "local";
189
+ // Build the list of calls: either from batch or single toolName+input
190
+ const calls = batch && batch.length > 0
191
+ ? batch
192
+ : toolName
193
+ ? [{ toolName, input }]
194
+ : [];
195
+ if (calls.length === 0) {
196
+ return { content: [{ type: "text", text: `Provide either toolName or batch.` }] };
197
+ }
198
+ const results = [];
199
+ for (const call of calls) {
141
200
  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
- };
201
+ const idempotencyKey = `${currentActorId}-${call.toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
202
+ const toolPath = targetRoom === "local"
203
+ ? `/tools/${call.toolName}`
204
+ : `/rooms/${targetRoom}/tools/${call.toolName}`;
205
+ const result = await fetchJSON(toolPath, {
206
+ method: "POST",
207
+ headers: { "X-Idempotency-Key": idempotencyKey },
208
+ body: JSON.stringify({
209
+ actorId: currentActorId || "mcp-client",
210
+ owner: currentOwner || undefined,
211
+ input: call.input || {},
212
+ }),
213
+ timeoutMs: 30000,
214
+ });
215
+ if (result.error) {
216
+ results.push({ toolName: call.toolName, error: result.error });
217
+ }
218
+ else {
219
+ results.push({ toolName: call.toolName, output: result.output });
155
220
  }
156
221
  }
157
- catch {
158
- // Predicate eval failed, continue to long-poll
222
+ catch (err) {
223
+ results.push({ toolName: call.toolName, error: err.message });
159
224
  }
160
225
  }
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));
226
+ // Fetch final state after all calls
227
+ try {
228
+ const statePath = targetRoom === "local" ? "/state" : `/rooms/${targetRoom}`;
229
+ const state = await fetchJSON(statePath, { timeoutMs: 5000 });
230
+ const stateObj = state.sharedState || state;
231
+ const outputParts = [];
232
+ if (targetRoom !== "local")
233
+ outputParts.push(`[Room: ${targetRoom}]`);
234
+ for (const r of results) {
235
+ if (r.error) {
236
+ outputParts.push(`${r.toolName} → ERROR: ${r.error}`);
237
+ }
238
+ else {
239
+ outputParts.push(`${r.toolName} → ${JSON.stringify(r.output)}`);
240
+ }
241
+ }
242
+ outputParts.push(formatState(stateObj, state.observation));
243
+ return { content: [{ type: "text", text: outputParts.join("\n") }] };
166
244
  }
167
- if (filterActors?.length) {
168
- events = events.filter((e) => filterActors.includes(e.actorId));
245
+ catch (err) {
246
+ // Still return tool results even if state fetch fails
247
+ const outputParts = results.map(r => r.error ? `${r.toolName} → ERROR: ${r.error}` : `${r.toolName} → ${JSON.stringify(r.output)}`);
248
+ outputParts.push(`(State fetch failed: ${err.message})`);
249
+ return { content: [{ type: "text", text: outputParts.join("\n") }] };
169
250
  }
170
- if (events.length > 0) {
171
- lastEventTs = Math.max(...events.map((e) => e.ts));
251
+ });
252
+ // ── Tool: stream ───────────────────────────────────────────
253
+ server.tool("stream", `Send a continuous state update via stream channel.
254
+
255
+ Streams are high-frequency state mutations (brush strokes, sliders, cursors) that bypass the full tool pipeline.
256
+ They validate input, merge into state, and broadcast — but don't create event log entries.
257
+
258
+ Use this instead of act for high-frequency updates.`, {
259
+ name: z.string().describe("Stream name, e.g. 'brush.stroke'"),
260
+ input: z.record(z.any()).optional().describe("Stream input parameters"),
261
+ roomId: z.string().optional().describe("Target room ID (defaults to 'local')"),
262
+ }, async ({ name, input, roomId }) => {
263
+ try {
264
+ loadIdentity();
172
265
  }
173
- let predicateMatched = false;
174
- if (predicate) {
175
- try {
176
- const fn = new Function("state", "actorId", `return ${predicate}`);
177
- predicateMatched = !!fn(data.sharedState, currentActorId);
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}` }] };
178
281
  }
179
- catch {
180
- // ignore
282
+ return { content: [{ type: "text", text: `Stream ${name} sent.` }] };
283
+ }
284
+ catch (err) {
285
+ return { content: [{ type: "text", text: `Stream failed: ${err.message}` }] };
286
+ }
287
+ });
288
+ // ── Tool: spawn_room ──────────────────────────────────────
289
+ server.tool("spawn_room", `Spawn a new configured room (sub-room).
290
+
291
+ Each room is an instance of the experience with its own config.
292
+ Think of it as: experience = game engine, config = level parameters.
293
+
294
+ You can pass:
295
+ - experienceId: which experience to run in the new room (defaults to current experience)
296
+ - config: an object of config values (validated against the experience's schema)
297
+ - config: a preset name string (e.g. "boss-fight") to use predefined configs
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}` }] };
181
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 }] };
346
+ }
347
+ catch (err) {
348
+ return {
349
+ content: [{
350
+ type: "text",
351
+ text: `Failed to spawn room: ${err.message}`,
352
+ }],
353
+ };
182
354
  }
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)}`);
355
+ });
356
+ // ── Tool: list_rooms ──────────────────────────────────────
357
+ server.tool("list_rooms", `List all active rooms and their configs.
358
+
359
+ Returns room IDs, participant counts, configs, and parent/child relationships.
360
+ Use this to discover what rooms exist and how they're configured.`, {}, async () => {
361
+ try {
362
+ loadIdentity();
363
+ }
364
+ catch (err) {
365
+ return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
366
+ }
367
+ try {
368
+ const rooms = await fetchJSON("/rooms", { timeoutMs: 5000 });
369
+ if (!Array.isArray(rooms) || rooms.length === 0) {
370
+ return { content: [{ type: "text", text: "No rooms found." }] };
188
371
  }
372
+ const lines = rooms.map((r) => {
373
+ const configStr = Object.keys(r.config || {}).length > 0
374
+ ? `config: ${JSON.stringify(r.config)}`
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
+ };
386
+ }
387
+ catch (err) {
388
+ return {
389
+ content: [{
390
+ type: "text",
391
+ text: `Failed to list rooms: ${err.message}`,
392
+ }],
393
+ };
394
+ }
395
+ });
396
+ // ── Tool: room_config_schema ──────────────────────────────
397
+ server.tool("room_config_schema", `Get the room configuration schema for an experience.
398
+
399
+ Shows what config values a room accepts, their types, defaults, and available presets.
400
+ Use this before spawn_room to understand what config options are available.
401
+
402
+ Pass experienceId to inspect an external experience's config (loads it on demand).`, {
403
+ experienceId: z.string().optional().describe("Experience ID to inspect (defaults to host experience)"),
404
+ }, async ({ experienceId }) => {
405
+ try {
406
+ loadIdentity();
189
407
  }
190
- else {
191
- parts.push("No new events (timeout).");
408
+ catch (err) {
409
+ return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
410
+ }
411
+ try {
412
+ const queryParam = experienceId ? `?experienceId=${encodeURIComponent(experienceId)}` : "";
413
+ const schema = await fetchJSON(`/rooms/config-schema${queryParam}`, { timeoutMs: 15000 });
414
+ if (!schema.hasConfig) {
415
+ return {
416
+ content: [{
417
+ type: "text",
418
+ text: `${experienceId ? `Experience "${experienceId}"` : "This experience"} does not define a room config schema. Rooms are spawned with default settings.`,
419
+ }],
420
+ };
421
+ }
422
+ const output = [
423
+ `Room Config Schema${experienceId ? ` (${experienceId})` : ""}:`,
424
+ schema.description ? ` ${schema.description}` : "",
425
+ ``,
426
+ `Schema: ${JSON.stringify(schema.schema, null, 2)}`,
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 }] };
192
435
  }
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}`);
436
+ catch (err) {
437
+ return {
438
+ content: [{
439
+ type: "text",
440
+ text: `Failed to get config schema: ${err.message}`,
441
+ }],
442
+ };
197
443
  }
198
- return { content: [{ type: "text", text: parts.join("\n") }] };
199
444
  });
200
- // ── Tool: act ──────────────────────────────────────────────
201
- server.tool("act", `Execute a tool to mutate shared state. All state changes go through tools.
445
+ // ── Tool: list_experiences ─────────────────────────────────
446
+ server.tool("list_experiences", `List available experiences that can be used to spawn rooms.
202
447
 
203
- Example: act(toolName="counter.increment", input={amount: 2})
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.
204
450
 
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 }) => {
451
+ Each experience can be used as an experienceId in spawn_room.`, {}, async () => {
209
452
  try {
210
- await ensureConnected();
453
+ loadIdentity();
211
454
  }
212
455
  catch (err) {
213
456
  return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
214
457
  }
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 }] };
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
+ }
231
486
  });
232
487
  // ── Tool: memory ───────────────────────────────────────────
233
488
  server.tool("memory", `Persistent agent memory (per-session). Survives across tool calls.
@@ -242,7 +497,7 @@ Actions:
242
497
  ? `local:${currentActorId}`
243
498
  : "default";
244
499
  if (action === "get") {
245
- const data = await fetchJSON(`/memory?key=${encodeURIComponent(key)}`);
500
+ const data = await fetchJSON(`/memory?key=${encodeURIComponent(key)}`, { timeoutMs: 5000 });
246
501
  return {
247
502
  content: [{
248
503
  type: "text",
@@ -257,6 +512,7 @@ Actions:
257
512
  await fetchJSON("/memory", {
258
513
  method: "POST",
259
514
  body: JSON.stringify({ key, updates }),
515
+ timeoutMs: 5000,
260
516
  });
261
517
  return { content: [{ type: "text", text: `Memory updated: ${JSON.stringify(updates)}` }] };
262
518
  }
@@ -277,27 +533,35 @@ Use this to see what the user sees — inspect paintings, check layouts, read re
277
533
  if (ROOM_TOKEN) {
278
534
  screenshotHeaders["Authorization"] = `Bearer ${ROOM_TOKEN}`;
279
535
  }
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 }));
536
+ const controller = new AbortController();
537
+ const timer = setTimeout(() => controller.abort(), t + 5000);
538
+ try {
539
+ const res = await fetch(`${SERVER_URL}/screenshot?timeout=${t}`, {
540
+ headers: screenshotHeaders,
541
+ signal: controller.signal,
542
+ });
543
+ if (!res.ok) {
544
+ const err = await res.json().catch(() => ({ error: res.statusText }));
545
+ return {
546
+ content: [{
547
+ type: "text",
548
+ text: `Screenshot failed: ${err.error || "Unknown error"}`,
549
+ }],
550
+ };
551
+ }
552
+ const arrayBuffer = await res.arrayBuffer();
553
+ const base64 = Buffer.from(arrayBuffer).toString("base64");
285
554
  return {
286
555
  content: [{
287
- type: "text",
288
- text: `Screenshot failed: ${err.error || "Unknown error"}`,
556
+ type: "image",
557
+ data: base64,
558
+ mimeType: "image/png",
289
559
  }],
290
560
  };
291
561
  }
292
- const arrayBuffer = await res.arrayBuffer();
293
- const base64 = Buffer.from(arrayBuffer).toString("base64");
294
- return {
295
- content: [{
296
- type: "image",
297
- data: base64,
298
- mimeType: "image/png",
299
- }],
300
- };
562
+ finally {
563
+ clearTimeout(timer);
564
+ }
301
565
  }
302
566
  catch (err) {
303
567
  return {
@@ -308,6 +572,69 @@ Use this to see what the user sees — inspect paintings, check layouts, read re
308
572
  };
309
573
  }
310
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
+ catch (err) {
606
+ return { content: [{ type: "text", text: `Blob set failed: ${err.message}` }] };
607
+ }
608
+ });
609
+ // ── Tool: blob_get ─────────────────────────────────────────
610
+ server.tool("blob_get", `Retrieve a binary blob from the server by key.
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}` }] };
636
+ }
637
+ });
311
638
  // ── Start ──────────────────────────────────────────────────
312
639
  async function main() {
313
640
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibevibes/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for joining vibevibes experiences — local or remote",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",