@theprototype/agent 1.51.0 → 1.53.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/README.md CHANGED
@@ -26,6 +26,10 @@ claude mcp add theprototype -- npx -y @theprototype/agent mcp
26
26
 
27
27
  and tell Claude *"join https://theprototype.app/#F0FD3"* (it calls `connect`).
28
28
 
29
+ **A Claude Code that is already running** joins with no restart: `npx -y @theprototype/agent serve --port 8765`, then
30
+ `claude mcp add --transport http theprototype http://127.0.0.1:8765/mcp`. It answers on this machine only, and lists the
31
+ full tool catalog before a join (`--no-static-tools` = off); scene tools answer *not connected — join first* until `connect`.
32
+
29
33
  **Claude Desktop** and other MCP hosts: the server is `npx -y @theprototype/agent mcp <link>`
30
34
  over stdio. For Claude Desktop, in `claude_desktop_config.json`:
31
35
 
package/catalog.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "hostTools": 21,
7
7
  "dropped": []
8
8
  },
9
- "instructions": "You are building in the person's own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.\nThis may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words (\"Added three trees by the fire\"). Never read out uuids, lists of numbers or JSON.\nIf a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.\nCall status first: it says whether you may edit (a viewer can only look).\nCall list_scene before editing so you use real uuids; batch many objects into one call.\nMotion and interaction are flow nodes (create_flow_nodes); call describe_node_types before wiring edges.\nUse screenshot to check a result when it matters, not after every step.\nWhen asked what you can do here, call what_can_you_do and say its \"say\" line.",
9
+ "instructions": "You are building in the person's own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.\nThis may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words (\"Added three trees by the fire\"). Never read out uuids, lists of numbers or JSON.\nIf a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.\nCall status first: it says whether you may edit (a viewer can only look).\nYou act as this person in their own tab: status \"you\" is them (their peer id, name and role); the other users are everyone else.\nCall list_scene before editing so you use real uuids; batch many objects into one call.\nFor \"in front of me\", \"here\" or \"this\", call where_are_users and build at the spots it gives, facing the person.\nMotion and interaction are flow nodes (create_flow_nodes); call describe_node_types before wiring edges.\nTake a screenshot after each change to check your work.\nWhen asked what you can do here, call what_can_you_do and say its \"say\" line.",
10
10
  "tools": [
11
11
  {
12
12
  "name": "list_scene",
13
13
  "title": "Read the scene",
14
- "description": "List every object currently in the scene with its uuid, name, type, parent, transform, material, color and (for a primitive) its shape — geometry {type, sizes such as the thickness of a wall}. Call this before editing so you use real uuids. On a big scene narrow it: brief (uuids + names only), name (a glob), type, near {position, radius}. A big flow graph shows its first page and says how to read the rest: pass {flow: \"scene\" | object uuid, page} to read one page of one graph.",
14
+ "description": "List every object currently in the scene with its uuid, name, type, parent, transform, material, color and (for a primitive) its shape — geometry {type, sizes such as the thickness of a wall}. Call this before editing so you use real uuids. On a big scene narrow it: brief (uuids + names only), name (a glob), type, near {position, radius}. A big flow graph shows its first page and says how to read the rest: pass {flow: \"scene\" | object uuid, page} to read one page of one graph.\nIf a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.\nCall list_scene before editing so you use real uuids; batch many objects into one call.",
15
15
  "inputSchema": {
16
16
  "type": "object",
17
17
  "properties": {
@@ -66,7 +66,7 @@
66
66
  {
67
67
  "name": "create_objects",
68
68
  "title": "Add objects",
69
- "description": "Create one or more primitives, lights or empty groups in a single call. Ground is the y=0 plane; place objects so they rest ON it (a box of height h sits at y=h/2). Returns the new uuids.",
69
+ "description": "Create one or more primitives, lights or empty groups in a single call. Ground is the y=0 plane; place objects so they rest ON it (a box of height h sits at y=h/2). Returns the new uuids.\nThis may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words (\"Added three trees by the fire\"). Never read out uuids, lists of numbers or JSON.\nCall list_scene before editing so you use real uuids; batch many objects into one call.\nFor \"in front of me\", \"here\" or \"this\", call where_are_users and build at the spots it gives, facing the person.\nTake a screenshot after each change to check your work.",
70
70
  "inputSchema": {
71
71
  "type": "object",
72
72
  "properties": {
@@ -236,7 +236,7 @@
236
236
  {
237
237
  "name": "update_objects",
238
238
  "title": "Change objects",
239
- "description": "Modify existing objects by uuid: move/rotate/scale, recolor, change material, rename, show/hide, or reparent. Batch many in one call.",
239
+ "description": "Modify existing objects by uuid: move/rotate/scale, recolor, change material, rename, show/hide, or reparent. Batch many in one call.\nCall list_scene before editing so you use real uuids; batch many objects into one call.\nTake a screenshot after each change to check your work.",
240
240
  "inputSchema": {
241
241
  "type": "object",
242
242
  "properties": {
@@ -360,7 +360,7 @@
360
360
  {
361
361
  "name": "delete_objects",
362
362
  "title": "Delete objects",
363
- "description": "Delete objects by uuid.",
363
+ "description": "Delete objects by uuid.\nTake a screenshot after each change to check your work.",
364
364
  "inputSchema": {
365
365
  "type": "object",
366
366
  "properties": {
@@ -385,7 +385,7 @@
385
385
  {
386
386
  "name": "group_objects",
387
387
  "title": "Group objects",
388
- "description": "Create a new group and move the given member objects into it. Returns the group uuid.",
388
+ "description": "Create a new group and move the given member objects into it. Returns the group uuid.\nTake a screenshot after each change to check your work.",
389
389
  "inputSchema": {
390
390
  "type": "object",
391
391
  "properties": {
@@ -414,7 +414,7 @@
414
414
  {
415
415
  "name": "clear_scene",
416
416
  "title": "Clear the scene",
417
- "description": "Delete ALL objects in the scene. Requires confirm:true. Use only when the user clearly asks to start over.",
417
+ "description": "Delete ALL objects in the scene. Requires confirm:true. Use only when the user clearly asks to start over.\nTake a screenshot after each change to check your work.",
418
418
  "inputSchema": {
419
419
  "type": "object",
420
420
  "properties": {
@@ -436,7 +436,7 @@
436
436
  {
437
437
  "name": "animate_parts",
438
438
  "title": "Animate parts",
439
- "description": "Animate a model's NAMED PARTS (child nodes from list_scene: a rotor, a door, a lid) with one clip — e.g. spin a rotor: tracks [{part: 'rotor', keys: [{t: 0, rot: [0, 0, 0]}, {t: 1, rot: [0, 360, 0]}]}], loop true. Keys are offsets from each part's rest pose (mode 'relative', the default; 'absolute' = local values). rot is Euler degrees [x, y, z]; pos metres [x, y, z]; scale a factor. The clip is saved as a .tpanim file, plays on every peer, and is one undo step.",
439
+ "description": "Animate a model's NAMED PARTS (child nodes from list_scene: a rotor, a door, a lid) with one clip — e.g. spin a rotor: tracks [{part: 'rotor', keys: [{t: 0, rot: [0, 0, 0]}, {t: 1, rot: [0, 360, 0]}]}], loop true. Keys are offsets from each part's rest pose (mode 'relative', the default; 'absolute' = local values). rot is Euler degrees [x, y, z]; pos metres [x, y, z]; scale a factor. The clip is saved as a .tpanim file, plays on every peer, and is one undo step.\nTake a screenshot after each change to check your work.",
440
440
  "inputSchema": {
441
441
  "type": "object",
442
442
  "properties": {
@@ -529,7 +529,7 @@
529
529
  {
530
530
  "name": "create_flow_nodes",
531
531
  "title": "Add behaviour (flow) nodes",
532
- "description": "Add behavior (flow) nodes to a node graph — this is how objects get MOTION and interactivity: spin, bounce, patrol a path, pulse, blink, react to clicks. graph is \"scene\" or an object uuid (ONE graph per call). KEY RULE: a behavior node placed in an OBJECT's graph with no edges automatically drives that object — most behaviors are one node, zero edges, so usually OMIT edges. Describe nodes EITHER as text (graph lines, see the system prompt) OR as nodes/edges arrays. A call with some bad nodes still creates the good ones and returns partial:true + errors[] — resend only what errors[] names.",
532
+ "description": "Add behavior (flow) nodes to a node graph — this is how objects get MOTION and interactivity: spin, bounce, patrol a path, pulse, blink, react to clicks. graph is \"scene\" or an object uuid (ONE graph per call). KEY RULE: a behavior node placed in an OBJECT's graph with no edges automatically drives that object — most behaviors are one node, zero edges, so usually OMIT edges. Describe nodes EITHER as text (graph lines, see the system prompt) OR as nodes/edges arrays. A call with some bad nodes still creates the good ones and returns partial:true + errors[] — resend only what errors[] names.\nMotion and interaction are flow nodes (create_flow_nodes); call describe_node_types before wiring edges.\nTake a screenshot after each change to check your work.",
533
533
  "inputSchema": {
534
534
  "type": "object",
535
535
  "properties": {
@@ -604,7 +604,7 @@
604
604
  {
605
605
  "name": "update_flow_nodes",
606
606
  "title": "Change or remove flow nodes",
607
- "description": "Tune or remove existing behavior nodes in one graph. Node ids and current data are in the scene summary under each object's \"flow\" (and \"sceneFlow\").",
607
+ "description": "Tune or remove existing behavior nodes in one graph. Node ids and current data are in the scene summary under each object's \"flow\" (and \"sceneFlow\").\nTake a screenshot after each change to check your work.",
608
608
  "inputSchema": {
609
609
  "type": "object",
610
610
  "properties": {
@@ -653,7 +653,7 @@
653
653
  {
654
654
  "name": "describe_node_types",
655
655
  "title": "Look up flow node types",
656
- "description": "Look up flow node types before wiring them: per type its inputs (toHandle names + socket types), its output type and named outputs (fromHandle), its params with types and defaults, and one line on what it does. No arguments = every type name by group; group = that group with one-line docs; types = full details (up to 12). Changes nothing.",
656
+ "description": "Look up flow node types before wiring them: per type its inputs (toHandle names + socket types), its output type and named outputs (fromHandle), its params with types and defaults, and one line on what it does. No arguments = every type name by group; group = that group with one-line docs; types = full details (up to 12). Changes nothing.\nMotion and interaction are flow nodes (create_flow_nodes); call describe_node_types before wiring edges.",
657
657
  "inputSchema": {
658
658
  "type": "object",
659
659
  "properties": {
@@ -679,7 +679,7 @@
679
679
  {
680
680
  "name": "create_behaviour",
681
681
  "title": "Add a code behaviour",
682
- "description": "Create a code behaviour: game logic (rules, waves, scoring, timers, win/lose) as ONE JavaScript module, run on the session authority and replicated. Prefer it over large node graphs for game rules. Linted before it is applied; problems come back with line numbers. The result names the node id and its sockets — wire them with create_flow_nodes (an On Click / On Enter into an input; an output into HUD Text, Visibility, Announce…).\nBEHAVIOURS (create_behaviour / edit_behaviour): game logic as ONE JavaScript module, run on ONE peer (the session authority); `state` replicates to everyone.\nexport default behaviour({ name, params: {k: literal | {value, min, max, step, unit}}, state: {plain JSON}, inputs: [names], outputs: [names], on: {event(payload) {...}}, ...methods });\ninputs: a trigger wired into input \"x\" runs on.x. outputs: a state field = a value output, any other name = an event fired by this.emit(\"name\").\nA behaviour does NOT move, rotate, recolour or hide objects (no such this.*): it drives effect nodes (spin, visibility, setcolor…) via its outputs. NO per-frame hook (no onTick/update): repeat with this.after(seconds, \"method\").\nthis: params, state, kit (also bare `kit`), after(seconds, \"method\", ...args) (survives the host leaving), cancel(key), rand(), randInt(a,b), pick(list), now(), find(glob) / findAll(glob) -> {uuid, name, pos, tags}, object, log() — always as this.find(…) etc. (only `kit` and the helpers are bare). Helpers: dist(a,b), clamp, lerp.\nevents: load (runs on EVERY peer when the behaviour loads — local, state read-only: register things there), start (once, on the authority); grabRequest({piece, distance, hand, refuse}) (runs on the grabbing peer, read-only: call refuse(reason)); any kit event as \"piece.event\" or roundStart, go, won, lost, paused, resumed, menu, levelSelected, scored, collected, allCollected, spawned, despawned, emptied, damaged, healed, died, revived, stuck, grabRefused. Entity payloads have entity.is(glob), entity.hasTag(t).\nkit calls: kit.round.start/win(reason)/lose(reason), kit.score.add(n), kit.spawner.spawn({kind, template: uuid, at, count, hp, mover: {speed}}), kit.health.damage(id, n), kit.mover.chase(kind, \"nearestPlayer\"), kit.rules.set({reach, jump}), kit.pickups.register({id, score, respawn, radius}) — register pickups in on.load (every peer must know them, or touching one does nothing), never in start.\nForbidden (the load is refused): Math.random, Date / Date.now, performance.now, localStorage, document/window/globalThis, fetch, eval, setTimeout/setInterval, import. Unknown this.* members, unknown events and hook-named methods nothing calls are refused too.\nEXAMPLE (passes the lint; wire a trigger into input gemTouched, output left into a HUD Text, timeUp into a Visibility or Announce):\nexport default behaviour({\n name: 'Countdown',\n params: { seconds: 30 },\n state: { left: 30, found: 0 },\n inputs: ['gemTouched'],\n outputs: ['left', 'found', 'timeUp'],\n on: {\n start() { this.state.left = this.params.seconds; this.after(1, 'tick'); },\n gemTouched() { this.state.found += 1; }\n },\n tick() {\n this.state.left -= 1;\n if (this.state.left > 0) this.after(1, 'tick');\n else this.emit('timeUp');\n }\n});",
682
+ "description": "Create a code behaviour: game logic (rules, waves, scoring, timers, win/lose) as ONE JavaScript module, run on the session authority and replicated. Prefer it over large node graphs for game rules. Linted before it is applied; problems come back with line numbers. The result names the node id and its sockets — wire them with create_flow_nodes (an On Click / On Enter into an input; an output into HUD Text, Visibility, Announce…).\nBEHAVIOURS (create_behaviour / edit_behaviour): game logic as ONE JavaScript module, run on ONE peer (the session authority); `state` replicates to everyone.\nexport default behaviour({ name, params: {k: literal | {value, min, max, step, unit}}, state: {plain JSON}, inputs: [names], outputs: [names], on: {event(payload) {...}}, ...methods });\ninputs: a trigger wired into input \"x\" runs on.x. outputs: a state field = a value output, any other name = an event fired by this.emit(\"name\").\nA behaviour does NOT move, rotate, recolour or hide objects (no such this.*): it drives effect nodes (spin, visibility, setcolor…) via its outputs. NO per-frame hook (no onTick/update): repeat with this.after(seconds, \"method\").\nthis: params, state, kit (also bare `kit`), after(seconds, \"method\", ...args) (survives the host leaving), cancel(key), rand(), randInt(a,b), pick(list), now(), find(glob) / findAll(glob) -> {uuid, name, pos, tags}, object, log() — always as this.find(…) etc. (only `kit` and the helpers are bare). Helpers: dist(a,b), clamp, lerp.\nevents: load (runs on EVERY peer when the behaviour loads — local, state read-only: register things there), start (once, on the authority); grabRequest({piece, distance, hand, refuse}) (runs on the grabbing peer, read-only: call refuse(reason)); any kit event as \"piece.event\" or roundStart, go, won, lost, paused, resumed, menu, levelSelected, scored, collected, allCollected, spawned, despawned, emptied, damaged, healed, died, revived, stuck, grabRefused. Entity payloads have entity.is(glob), entity.hasTag(t).\nkit calls: kit.round.start/win(reason)/lose(reason), kit.score.add(n), kit.spawner.spawn({kind, template: uuid, at, count, hp, mover: {speed}}), kit.health.damage(id, n), kit.mover.chase(kind, \"nearestPlayer\"), kit.rules.set({reach, jump}), kit.pickups.register({id, score, respawn, radius}) — register pickups in on.load (every peer must know them, or touching one does nothing), never in start.\nForbidden (the load is refused): Math.random, Date / Date.now, performance.now, localStorage, document/window/globalThis, fetch, eval, setTimeout/setInterval, import. Unknown this.* members, unknown events and hook-named methods nothing calls are refused too.\nEXAMPLE (passes the lint; wire a trigger into input gemTouched, output left into a HUD Text, timeUp into a Visibility or Announce):\nexport default behaviour({\n name: 'Countdown',\n params: { seconds: 30 },\n state: { left: 30, found: 0 },\n inputs: ['gemTouched'],\n outputs: ['left', 'found', 'timeUp'],\n on: {\n start() { this.state.left = this.params.seconds; this.after(1, 'tick'); },\n gemTouched() { this.state.found += 1; }\n },\n tick() {\n this.state.left -= 1;\n if (this.state.left > 0) this.after(1, 'tick');\n else this.emit('timeUp');\n }\n});\nTake a screenshot after each change to check your work.",
683
683
  "inputSchema": {
684
684
  "type": "object",
685
685
  "properties": {
@@ -711,7 +711,7 @@
711
711
  {
712
712
  "name": "edit_behaviour",
713
713
  "title": "Edit a code behaviour",
714
- "description": "Replace an existing behaviour's source (the scene summary lists them; read the current source with the name). Linted before it is applied. Same format as create_behaviour (see its description).",
714
+ "description": "Replace an existing behaviour's source (the scene summary lists them; read the current source with the name). Linted before it is applied. Same format as create_behaviour (see its description).\nTake a screenshot after each change to check your work.",
715
715
  "inputSchema": {
716
716
  "type": "object",
717
717
  "properties": {
@@ -738,7 +738,7 @@
738
738
  {
739
739
  "name": "set_physics",
740
740
  "title": "Set physics",
741
- "description": "Set physics body params on objects (batch). NOTE: objects made with create_objects are STATIC unless created with physics \"dynamic\" or a mass — use this to make existing objects dynamic, to TUNE bodies, or to pin scenery (mode \"static\"). mode \"auto\" reverts to scenery defaults.",
741
+ "description": "Set physics body params on objects (batch). NOTE: objects made with create_objects are STATIC unless created with physics \"dynamic\" or a mass — use this to make existing objects dynamic, to TUNE bodies, or to pin scenery (mode \"static\"). mode \"auto\" reverts to scenery defaults.\nTake a screenshot after each change to check your work.",
742
742
  "inputSchema": {
743
743
  "type": "object",
744
744
  "properties": {
@@ -835,7 +835,7 @@
835
835
  {
836
836
  "name": "create_joints",
837
837
  "title": "Add physics joints",
838
- "description": "Attach object pairs with physics joints: \"fixed\" welds them rigidly, \"revolute\" hinges around an axis (optional motor spins it). Move parts to their final pose WORLD-ALIGNED (no rotation) before jointing. Without anchor a hinge sits at b's origin (its centre) and a weld midway: pass anchor [x,y,z] (world) for the real pivot, e.g. the top of a pendulum rod or a door's edge. Jointed bodies that touch (a door in its frame, a wheel in its arch) also need contacts:false, or the solver pushes them apart.",
838
+ "description": "Attach object pairs with physics joints: \"fixed\" welds them rigidly, \"revolute\" hinges around an axis (optional motor spins it). Move parts to their final pose WORLD-ALIGNED (no rotation) before jointing. Without anchor a hinge sits at b's origin (its centre) and a weld midway: pass anchor [x,y,z] (world) for the real pivot, e.g. the top of a pendulum rod or a door's edge. Jointed bodies that touch (a door in its frame, a wheel in its arch) also need contacts:false, or the solver pushes them apart.\nTake a screenshot after each change to check your work.",
839
839
  "inputSchema": {
840
840
  "type": "object",
841
841
  "properties": {
@@ -921,7 +921,7 @@
921
921
  {
922
922
  "name": "control_simulation",
923
923
  "title": "Run or stop the simulation",
924
- "description": "Start, stop, pause, resume or reset the physics simulation. After building a physics assembly, start the simulation so the user sees it move.",
924
+ "description": "Start, stop, pause, resume or reset the physics simulation. After building a physics assembly, start the simulation so the user sees it move.\nTake a screenshot after each change to check your work.",
925
925
  "inputSchema": {
926
926
  "type": "object",
927
927
  "properties": {
@@ -950,7 +950,7 @@
950
950
  {
951
951
  "name": "import_model",
952
952
  "title": "Import model",
953
- "description": "Import a self-contained GLB (binary glTF 2.0, everything embedded, ≤ 25 MB) into the scene through the app's own import pipeline (auto-optimize, LOD, budget check). Named nodes are kept as a hierarchy and returned with uuids. One undo step; syncs to every peer; provenance is recorded. Use for models built outside the app (e.g. an img2threejs rebuild exported with named pivots). Materials: standard PBR only — custom shaders do not survive glTF.",
953
+ "description": "Import a self-contained GLB (binary glTF 2.0, everything embedded, ≤ 25 MB) into the scene through the app's own import pipeline (auto-optimize, LOD, budget check). Named nodes are kept as a hierarchy and returned with uuids. One undo step; syncs to every peer; provenance is recorded. Use for models built outside the app (e.g. an img2threejs rebuild exported with named pivots). Materials: standard PBR only — custom shaders do not survive glTF.\nTake a screenshot after each change to check your work.",
954
954
  "inputSchema": {
955
955
  "type": "object",
956
956
  "properties": {
@@ -1014,7 +1014,7 @@
1014
1014
  {
1015
1015
  "name": "import_file",
1016
1016
  "title": "Import file",
1017
- "description": "Bring a file made on your machine into the session: PNG / WebP images (≤ 16 MB, metadata stripped), JSON (≤ 2 MB) or a .tpanim clip. The type is read from the bytes. It lands in the Explorer (folder \"Generated\" unless `folder`), shared with every peer. Pass the generator's input as `spec` and it is saved beside the file as <name>.<generator>.json (e.g. knight.png + knight.pixling.json) so it can be regenerated. An image is also placed in the scene as a card (a plane; `place: false` to skip): `filter: \"nearest\"` keeps pixel art crisp (default when a spec is given), `frame: {w, h, col, row}` shows one frame of a sprite sheet, `facing: \"camera-y\"` turns it to face each viewer. One undo step; editors and admins only. Large files: send base64 parts with `chunk: {id, index, total}` (the bridge does this for you with `path`).",
1017
+ "description": "Bring a file made on your machine into the session: PNG / WebP images (≤ 16 MB, metadata stripped), JSON (≤ 2 MB) or a .tpanim clip. The type is read from the bytes. It lands in the Explorer (folder \"Generated\" unless `folder`), shared with every peer. Pass the generator's input as `spec` and it is saved beside the file as <name>.<generator>.json (e.g. knight.png + knight.pixling.json) so it can be regenerated. An image is also placed in the scene as a card (a plane; `place: false` to skip): `filter: \"nearest\"` keeps pixel art crisp (default when a spec is given), `frame: {w, h, col, row}` shows one frame of a sprite sheet, `facing: \"camera-y\"` turns it to face each viewer. One undo step; editors and admins only. Large files: send base64 parts with `chunk: {id, index, total}` (the bridge does this for you with `path`).\nTake a screenshot after each change to check your work.",
1018
1018
  "inputSchema": {
1019
1019
  "type": "object",
1020
1020
  "properties": {
@@ -1150,7 +1150,7 @@
1150
1150
  {
1151
1151
  "name": "install_modules",
1152
1152
  "title": "Install modules",
1153
- "description": "Install the modules the other peers in this session run and this page lacks (status().missingModules), from the module gallery. Local to this page; omit ids to install every missing one.",
1153
+ "description": "Install the modules the other peers in this session run and this page lacks (status().missingModules), from the module gallery. Local to this page; omit ids to install every missing one.\nTake a screenshot after each change to check your work.",
1154
1154
  "inputSchema": {
1155
1155
  "type": "object",
1156
1156
  "properties": {
@@ -1187,7 +1187,7 @@
1187
1187
  {
1188
1188
  "name": "accept_offer",
1189
1189
  "title": "Answer a question the app asks",
1190
- "description": "Press a button on a pending offer (see pending_offers). action = the button label; omit for the first button.",
1190
+ "description": "Press a button on a pending offer (see pending_offers). action = the button label; omit for the first button.\nTake a screenshot after each change to check your work.",
1191
1191
  "inputSchema": {
1192
1192
  "type": "object",
1193
1193
  "properties": {
@@ -1212,7 +1212,7 @@
1212
1212
  {
1213
1213
  "name": "what_can_you_do",
1214
1214
  "title": "What can you do here",
1215
- "description": "Answer \"what can you do here?\": a short spoken summary of what you can build in this tab, what you cannot do, and example requests. Changes nothing.",
1215
+ "description": "Answer \"what can you do here?\": a short spoken summary of what you can build in this tab, what you cannot do, and example requests. Changes nothing.\nYou are building in the person's own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.\nThis may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words (\"Added three trees by the fire\"). Never read out uuids, lists of numbers or JSON.\nWhen asked what you can do here, call what_can_you_do and say its \"say\" line.",
1216
1216
  "inputSchema": {
1217
1217
  "type": "object",
1218
1218
  "properties": {}
@@ -1226,7 +1226,7 @@
1226
1226
  {
1227
1227
  "name": "connect",
1228
1228
  "title": "Join a session",
1229
- "description": "Join a theprototype.app session. Pass the link the host shared (https://theprototype.app/#F0FD3, possibly with &invite=…) or the bare session id. An invite link joins at once; a plain link asks the host to Approve. The scene tools appear once the agent page is up; call status to see whether you are in.",
1229
+ "description": "Join a theprototype.app session. Pass the link the host shared (https://theprototype.app/#F0FD3, possibly with &invite=…) or the bare session id. An invite link joins at once; a plain link asks the host to Approve. The scene tools appear once the agent page is up; call status to see whether you are in.\nTake a screenshot after each change to check your work.",
1230
1230
  "inputSchema": {
1231
1231
  "type": "object",
1232
1232
  "properties": {
@@ -1250,7 +1250,7 @@
1250
1250
  {
1251
1251
  "name": "status",
1252
1252
  "title": "Session status",
1253
- "description": "Where the agent is: connection state, whether the host has let it in, its role (viewer edits stay local), the app version, and why any tool is missing.",
1253
+ "description": "Where the agent is: connection state, whether the host has let it in, its role (viewer edits stay local), the app version, and why any tool is missing.\nYou are building in the person's own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.\nThis may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words (\"Added three trees by the fire\"). Never read out uuids, lists of numbers or JSON.\nIf a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.\nCall status first: it says whether you may edit (a viewer can only look).\nYou act as this person in their own tab: status \"you\" is them (their peer id, name and role); the other users are everyone else.",
1254
1254
  "inputSchema": {
1255
1255
  "type": "object",
1256
1256
  "properties": {}
@@ -1312,6 +1312,43 @@
1312
1312
  "readOnlyHint": true,
1313
1313
  "openWorldHint": false
1314
1314
  }
1315
+ },
1316
+ {
1317
+ "name": "join_session",
1318
+ "title": "Join a session (asks the person)",
1319
+ "description": "Join a theprototype.app session from a link the person gave you (https://theprototype.app/#F0FD3, possibly with &invite=…, or the bare session id). Nothing happens until the person presses Join on a card in their tab that names the host and the session; Cancel refuses. The app may then ask them what to do with the scene they have open. Answers joining, refused or cancelled; call status afterwards to see where the tab is.\nTake a screenshot after each change to check your work.",
1320
+ "inputSchema": {
1321
+ "type": "object",
1322
+ "properties": {
1323
+ "link": {
1324
+ "type": "string",
1325
+ "description": "the session link, invite link or session id the person gave you"
1326
+ }
1327
+ },
1328
+ "required": [
1329
+ "link"
1330
+ ]
1331
+ },
1332
+ "annotations": {
1333
+ "title": "Join a session (asks the person)",
1334
+ "readOnlyHint": false,
1335
+ "destructiveHint": true,
1336
+ "openWorldHint": true
1337
+ }
1338
+ },
1339
+ {
1340
+ "name": "where_are_users",
1341
+ "title": "Where the people are",
1342
+ "description": "Where each person in the session is and which way they look (their desktop camera, or the VR headset): position, facing as a compass yaw plus forward and right vectors, the floor under them, and ready-made spots 1.5 m and 3 m in front of them at floor level. The entry with \"you\": true is the person you act as. Use it for \"in front of me\", \"around me\", \"behind me\", \"to my left\".\nYou act as this person in their own tab: status \"you\" is them (their peer id, name and role); the other users are everyone else.\nFor \"in front of me\", \"here\" or \"this\", call where_are_users and build at the spots it gives, facing the person.",
1343
+ "inputSchema": {
1344
+ "type": "object",
1345
+ "properties": {}
1346
+ },
1347
+ "annotations": {
1348
+ "title": "Where the people are",
1349
+ "readOnlyHint": true,
1350
+ "openWorldHint": false
1351
+ }
1315
1352
  }
1316
1353
  ]
1317
1354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theprototype/agent",
3
- "version": "1.51.0",
3
+ "version": "1.53.0",
4
4
  "description": "Let an AI agent (Claude Code, Claude Desktop, any MCP host) join a theprototype.app session from its link and build the 3D scene with you.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "scripts": {
42
- "test": "node test/chat.test.mjs && node test/messages.test.mjs && node test/bridge.test.mjs && node test/link.test.mjs && node test/mcp.test.mjs && node test/repl.test.mjs && node test/wire.test.mjs && node test/catalog.test.mjs",
42
+ "test": "node test/chat.test.mjs && node test/messages.test.mjs && node test/bridge.test.mjs && node test/link.test.mjs && node test/mcp.test.mjs && node test/repl.test.mjs && node test/wire.test.mjs && node test/catalog.test.mjs && node test/http.test.mjs",
43
43
  "spike": "node src/spike.js",
44
44
  "smoke": "node src/cli.js smoke",
45
45
  "repl": "node src/cli.js repl",
package/src/catalog.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // the bridge tools a tab answers itself. Nobody hand-writes a tool. The instructions (86 T3) are built
8
8
  // from the same list, so a line about a tool appears only while that tool exists.
9
9
 
10
- import { mapHostTools, bridgeTools } from './mcpTools.js';
10
+ import { mapHostTools, bridgeTools, connectorOnlyTools } from './mcpTools.js';
11
11
 
12
12
  /** Bump when the catalog's SHAPE changes (not when tools change). 2 = titles + annotations. */
13
13
  export const CATALOG_FORMAT = 2;
@@ -42,7 +42,10 @@ export const TOOL_ANNOTATIONS = /** @type {Record<string, {title: string, read?:
42
42
  clear_scene: { title: 'Clear the scene', destructive: true },
43
43
  install_modules: { title: 'Install modules', destructive: false, openWorld: true },
44
44
  accept_offer: { title: 'Answer a question the app asks', destructive: true },
45
- connect: { title: 'Join a session', destructive: false, openWorld: true }
45
+ connect: { title: 'Join a session', destructive: false, openWorld: true },
46
+ // 86-fix N2: the connector's join — the person confirms in the tab; joining may replace what the tab shows
47
+ join_session: { title: 'Join a session (asks the person)', destructive: true, openWorld: true },
48
+ where_are_users: { title: 'Where the people are', read: true }
46
49
  });
47
50
 
48
51
  /** a title from a tool name: `create_objects` → `Create objects` @param {string} name */
@@ -62,31 +65,93 @@ export function annotate(t) {
62
65
  return { name: t.name, title, description: t.description, inputSchema: t.inputSchema, annotations };
63
66
  }
64
67
 
68
+ /** every edit tool: the catalog's non-read tools @type {'EDITS'} */
69
+ const EDITS = 'EDITS';
70
+
71
+ /**
72
+ * 86-fix B4 — THE GUIDANCE, ONE TABLE. claude.ai drops a server's `instructions`, so every line that matters
73
+ * lives in the DESCRIPTION of the tools it is about (`on`); the instructions are the same lines for the
74
+ * clients that do read them. `when` = the tool that must exist for the line to appear at all (default: the
75
+ * first of `on`). `guidanceGaps` (catalog:agent --check, the unit test) fails on an instruction line no
76
+ * description carries.
77
+ * @type {{line: string, on: (string|'EDITS')[], when?: string}[]}
78
+ */
79
+ export const GUIDANCE = [
80
+ {
81
+ line: 'You are building in the person\'s own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.',
82
+ on: ['status', 'what_can_you_do']
83
+ },
84
+ {
85
+ line: 'This may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words ("Added three trees by the fire"). Never read out uuids, lists of numbers or JSON.',
86
+ on: ['status', 'what_can_you_do', 'create_objects']
87
+ },
88
+ {
89
+ line: 'If a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.',
90
+ on: ['status', 'list_scene']
91
+ },
92
+ { line: 'Call status first: it says whether you may edit (a viewer can only look).', on: ['status'] },
93
+ {
94
+ line: 'You act as this person in their own tab: status "you" is them (their peer id, name and role); the other users are everyone else.',
95
+ on: ['status', 'where_are_users'],
96
+ when: 'join_session'
97
+ },
98
+ { line: 'Call list_scene before editing so you use real uuids; batch many objects into one call.', on: ['list_scene', 'create_objects', 'update_objects'] },
99
+ {
100
+ line: 'For "in front of me", "here" or "this", call where_are_users and build at the spots it gives, facing the person.',
101
+ on: ['where_are_users', 'create_objects']
102
+ },
103
+ { line: 'For "give me a flashlight" or another prop, make the object, then attach_to_user to the person who asked.', on: ['attach_to_user'] },
104
+ {
105
+ line: 'Motion and interaction are flow nodes (create_flow_nodes); call describe_node_types before wiring edges.',
106
+ on: ['create_flow_nodes', 'describe_node_types'],
107
+ when: 'describe_node_types'
108
+ },
109
+ { line: 'Motion and interaction are flow nodes (create_flow_nodes).', on: ['create_flow_nodes'], when: '!describe_node_types' },
110
+ { line: 'Take a screenshot after each change to check your work.', on: [EDITS], when: 'screenshot' },
111
+ { line: 'When asked what you can do here, call what_can_you_do and say its "say" line.', on: ['what_can_you_do'] }
112
+ ];
113
+
114
+ /** @param {{line: string, on: string[], when?: string}} g @param {Set<string>} has */
115
+ function applies(g, has) {
116
+ const when = g.when ?? g.on.find((n) => n !== EDITS) ?? '';
117
+ return when.startsWith('!') ? has.has(g.on[0]) && !has.has(when.slice(1)) : has.has(when);
118
+ }
119
+
65
120
  /**
66
121
  * The server instructions for a voice-first client (claude.ai chat, phone and voice mode). Short,
67
- * spoken-style, and only about tools in `names`.
122
+ * spoken-style, and only about tools in `names` — the GUIDANCE lines, verbatim.
68
123
  * @param {string[]} names the tool names the client will see
69
124
  * @returns {string}
70
125
  */
71
126
  export function connectorInstructions(names) {
72
127
  const has = new Set(names);
73
- const lines = [
74
- 'You are building in the person\'s own open theprototype.app tab: a live, shared 3D scene. Every change shows for everyone in the session and can be undone.',
75
- 'This may be a VOICE conversation: answer in one or two short spoken sentences and say what changed in plain words ("Added three trees by the fire"). Never read out uuids, lists of numbers or JSON.',
76
- 'If a tool says the tab is closed or not connected, tell the person to open theprototype.app and press Connect Claude, then wait; do not retry in a loop.'
77
- ];
78
- if (has.has('status')) lines.push('Call status first: it says whether you may edit (a viewer can only look).');
79
- if (has.has('list_scene')) lines.push('Call list_scene before editing so you use real uuids; batch many objects into one call.');
80
- if (has.has('where_are_users'))
81
- lines.push('For "in front of me", "here" or "this", call where_are_users and build at the spots it gives, facing the person.');
82
- if (has.has('attach_to_user')) lines.push('For "give me a flashlight" or another prop, make the object, then attach_to_user to the person who asked.');
83
- if (has.has('create_flow_nodes'))
84
- lines.push(
85
- 'Motion and interaction are flow nodes (create_flow_nodes)' + (has.has('describe_node_types') ? '; call describe_node_types before wiring edges.' : '.')
86
- );
87
- if (has.has('screenshot')) lines.push('Use screenshot to check a result when it matters, not after every step.');
88
- if (has.has('what_can_you_do')) lines.push('When asked what you can do here, call what_can_you_do and say its "say" line.');
89
- return lines.join('\n');
128
+ return GUIDANCE.filter((g) => applies(g, has))
129
+ .map((g) => g.line)
130
+ .join('\n');
131
+ }
132
+
133
+ /**
134
+ * The GUIDANCE lines appended to each tool's description (86-fix B4: claude.ai reads descriptions, not
135
+ * instructions). A tool's own text comes first; each line once.
136
+ * @template {{name: string, description: string, annotations?: any}} T @param {T[]} tools @returns {T[]}
137
+ */
138
+ export function withGuidance(tools) {
139
+ const has = new Set(tools.map((t) => t.name));
140
+ return tools.map((t) => {
141
+ const isEdit = !(t.annotations?.readOnlyHint === true);
142
+ const lines = GUIDANCE.filter((g) => applies(g, has) && (g.on.includes(t.name) || (isEdit && g.on.includes(EDITS)))).map((g) => g.line);
143
+ const own = String(t.description ?? '').trim();
144
+ return lines.length ? { ...t, description: [own, ...lines.filter((l) => !own.includes(l))].filter(Boolean).join('\n') } : t;
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Instruction lines no tool description carries — the check `catalog:agent --check` runs (86-fix B4).
150
+ * @param {{instructions?: string, tools: {description: string}[]}} catalog @returns {string[]}
151
+ */
152
+ export function guidanceGaps(catalog) {
153
+ const lines = String(catalog.instructions ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
154
+ return lines.filter((l) => !catalog.tools.some((t) => String(t.description ?? '').includes(l)));
90
155
  }
91
156
 
92
157
  /**
@@ -99,7 +164,7 @@ export function buildCatalog(hostTools, opts = {}) {
99
164
  if (!Array.isArray(hostTools) || !hostTools.length) throw new Error('the host tool list is empty');
100
165
  const mapped = mapHostTools(hostTools);
101
166
  // the bridge tools a page answers itself; `connect` is the agent CLI's (the Worker drops it, serve keeps it)
102
- const tools = [...mapped.tools, ...bridgeTools('browser')].map(annotate);
167
+ const tools = withGuidance([...mapped.tools, ...bridgeTools('browser'), ...connectorOnlyTools()].map(annotate));
103
168
  return {
104
169
  format: CATALOG_FORMAT,
105
170
  generator: {
package/src/cli.js CHANGED
@@ -12,8 +12,8 @@ import { parseLink, redactLink } from './link.js';
12
12
  import { BrowserEngine } from './browserEngine.js';
13
13
  import { WireEngine } from './wireEngine.js';
14
14
 
15
- const BOOLEAN = new Set(['mcp', 'repl', 'smoke', 'verbose', 'compat', 'headed', 'insecure', 'no-turn', 'no-install', 'help', 'version']);
16
- const MODES = ['mcp', 'repl', 'smoke'];
15
+ const BOOLEAN = new Set(['mcp', 'serve', 'static-tools', 'no-static-tools', 'repl', 'smoke', 'verbose', 'compat', 'headed', 'insecure', 'no-turn', 'no-install', 'help', 'version']);
16
+ const MODES = ['mcp', 'serve', 'repl', 'smoke'];
17
17
 
18
18
  /** argv → flags: --flag value | --flag=value | --bool; a known boolean never eats the next word. */
19
19
  export function parseArgs(/** @type {string[]} */ argv) {
@@ -157,6 +157,7 @@ function usage() {
157
157
  'theprototype-agent — join a theprototype.app session as a peer and build the scene',
158
158
  '',
159
159
  ' theprototype-agent mcp <link> MCP server over stdio (claude mcp add theprototype -- npx -y @theprototype/agent mcp <link>)',
160
+ ' theprototype-agent serve --port <n> [link] MCP over HTTP on 127.0.0.1 (claude mcp add --transport http theprototype http://127.0.0.1:<n>/mcp)',
160
161
  ' theprototype-agent repl <link> your own OpenAI-compatible model (--api-url --api-key --model)',
161
162
  ' theprototype-agent smoke <link> scripted create/move/delete, no model',
162
163
  '',
@@ -171,6 +172,7 @@ function usage() {
171
172
  ' --peer-server h:p[:insecure] | public wire: signaling override (default: from the link)',
172
173
  ' --turn-url <url> | --no-turn wire: TURN credentials endpoint (default: from the link)',
173
174
  ' --id <id> wire: stable peer id --approval-timeout <s> (default 120)',
175
+ ' --static-tools list the full tool catalog before a join (the default with serve; --no-static-tools = off)',
174
176
  ' --verbose log to stderr'
175
177
  ].join('\n')
176
178
  );
@@ -192,10 +194,15 @@ async function main() {
192
194
  const kind = (args.engine || 'browser') === 'wire' ? 'wire' : 'browser';
193
195
  if (args.engine && !['browser', 'wire'].includes(args.engine)) throw new Error('--engine is browser or wire');
194
196
 
195
- if (mode === 'mcp') {
197
+ if (mode === 'mcp' || mode === 'serve') {
196
198
  const m = await import('./mcpServer.js');
197
- const staticTools = kind === 'wire' ? (await import('./tools.js')).TOOL_SCHEMAS : undefined;
198
- await m.runMcp(args, { kind, link, staticTools, makeEngine: (l, name) => makeEngine(args, l, name) });
199
+ const wantCatalog = mode === 'serve' ? !args['no-static-tools'] : !!args['static-tools'];
200
+ const staticTools = kind === 'wire' ? (await import('./tools.js')).TOOL_SCHEMAS : wantCatalog ? m.catalogHostTools() : undefined;
201
+ const deps = { kind, link, staticTools, makeEngine: (/** @type {string} */ l, /** @type {any} */ name) => makeEngine(args, l, name) };
202
+ if (mode === 'serve') {
203
+ const { runHttp } = await import('./httpServer.js');
204
+ await runHttp(args, deps);
205
+ } else await m.runMcp(args, deps);
199
206
  return;
200
207
  }
201
208
  if (!link) throw new Error(mode + ' needs a session link: theprototype-agent ' + mode + ' https://theprototype.app/#<id>');
@@ -0,0 +1,117 @@
1
+ // `theprototype-agent serve --port <n>` (roadmap 56 C18): the same bridge as `mcp`, as streamable HTTP MCP on
2
+ // 127.0.0.1, so a Claude Code that is already running joins with no MCP restart.
3
+ //
4
+ // It answers loopback only: the listener binds 127.0.0.1, and a request whose Host or Origin is another site
5
+ // is refused, or any web page could reach it through DNS rebinding and drive this agent's session.
6
+ import { createServer } from 'node:http';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
10
+ import { createBridgeServer, nameFromClient } from './mcpServer.js';
11
+
12
+ /** import_model / import_file carry a 25 MB file as base64 */
13
+ const MAX_BODY = 40 * 1024 * 1024;
14
+ const LOOPBACK = /^(127\.0\.0\.1|localhost)(:\d+)?$/i;
15
+
16
+ /** Is this request from this machine's own clients? @param {import('node:http').IncomingHttpHeaders} headers */
17
+ export function loopbackRequest(headers) {
18
+ if (!LOOPBACK.test(String(headers.host ?? ''))) return false;
19
+ const origin = headers.origin;
20
+ if (!origin) return true; // a CLI sends none; a browser always does across origins
21
+ try {
22
+ return LOOPBACK.test(new URL(String(origin)).host);
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** @param {import('node:http').IncomingMessage} req @returns {Promise<any>} */
29
+ function readJson(req) {
30
+ return new Promise((resolve, reject) => {
31
+ /** @type {Buffer[]} */
32
+ const parts = [];
33
+ let size = 0;
34
+ req.on('data', (c) => {
35
+ size += c.length;
36
+ if (size > MAX_BODY) {
37
+ reject(Object.assign(new Error('request too large'), { status: 413 }));
38
+ req.destroy();
39
+ } else parts.push(c);
40
+ });
41
+ req.on('end', () => {
42
+ try {
43
+ resolve(JSON.parse(Buffer.concat(parts).toString('utf8')));
44
+ } catch {
45
+ reject(Object.assign(new Error('not JSON'), { status: 400 }));
46
+ }
47
+ });
48
+ req.on('error', reject);
49
+ });
50
+ }
51
+
52
+ /**
53
+ * @param {import('node:http').ServerResponse} res @param {number} status @param {string} message
54
+ */
55
+ function refuse(res, status, message) {
56
+ res.writeHead(status, { 'content-type': 'application/json' });
57
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message }, id: null }));
58
+ }
59
+
60
+ /**
61
+ * The listener over a bridge. One client at a time: a new initialize (a restarted client) takes the bridge over,
62
+ * and the session the agent joined stays up.
63
+ * @param {ReturnType<typeof createBridgeServer>} bridge @param {number} port
64
+ * @returns {Promise<import('node:http').Server>}
65
+ */
66
+ export function listenHttp(bridge, port) {
67
+ /** @type {StreamableHTTPServerTransport | null} */
68
+ let transport = null;
69
+ const http = createServer(async (req, res) => {
70
+ try {
71
+ if (!loopbackRequest(req.headers)) return refuse(res, 403, 'loopback clients only');
72
+ if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') return refuse(res, 404, 'the endpoint is /mcp');
73
+ const body = req.method === 'POST' ? await readJson(req) : undefined;
74
+ if (body !== undefined && isInitializeRequest(body)) {
75
+ if (transport) await bridge.server.close();
76
+ transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
77
+ await bridge.server.connect(transport);
78
+ }
79
+ if (!transport) return refuse(res, 400, 'initialize first');
80
+ await transport.handleRequest(req, res, body);
81
+ } catch (e) {
82
+ if (!res.headersSent) refuse(res, /** @type {any} */ (e).status ?? 500, String(/** @type {any} */ (e).message ?? e));
83
+ }
84
+ });
85
+ return new Promise((resolve, reject) => {
86
+ http.once('error', reject);
87
+ http.listen(port, '127.0.0.1', () => resolve(http));
88
+ });
89
+ }
90
+
91
+ /** @param {Record<string, any>} args @param {any} deps */
92
+ export async function runHttp(args, deps) {
93
+ const port = Number(args.port);
94
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('serve needs --port <1-65535>');
95
+ const log = (/** @type {string} */ m) => console.error('[agent] ' + m);
96
+ let name = args.name || null;
97
+ const bridge = createBridgeServer({
98
+ kind: deps.kind,
99
+ staticTools: deps.staticTools,
100
+ createEngine: (link) => deps.makeEngine(link, name || nameFromClient(bridge.server.getClientVersion())),
101
+ log: args.verbose ? log : () => {}
102
+ });
103
+ const http = await listenHttp(bridge, port);
104
+ if (deps.link) bridge.connect(deps.link).catch((/** @type {any} */ e) => log('connect failed: ' + e.message));
105
+ let exiting = false;
106
+ const exit = async (/** @type {string} */ why) => {
107
+ if (exiting) return;
108
+ exiting = true;
109
+ log('leaving the session (' + why + ')');
110
+ http.close();
111
+ await bridge.shutdown();
112
+ process.exit(0);
113
+ };
114
+ for (const sig of /** @type {const} */ (['SIGINT', 'SIGTERM', 'SIGHUP'])) process.on(sig, () => exit(sig));
115
+ log('MCP HTTP server ready: http://127.0.0.1:' + port + '/mcp (' + deps.kind + ' engine' + (deps.link ? '' : ', call connect to join') + ')');
116
+ return { bridge, http };
117
+ }
package/src/mcpServer.js CHANGED
@@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs';
15
15
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
17
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
18
- import { bridgeTools, mapHostTools, toolSignature, textResult, resultIsError } from './mcpTools.js';
18
+ import { bridgeTools, connectorOnlyTools, mapHostTools, toolSignature, textResult, resultIsError } from './mcpTools.js';
19
19
  import { chatTools, waitMs, ImageSpool, messageContent, withImportPath, execBridged } from './chat.js';
20
20
  import { parseLink } from './link.js';
21
21
 
@@ -42,6 +42,17 @@ const WAIT_EDIT_MAX_MS = 120000;
42
42
  /** @param {number} ms */
43
43
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
44
 
45
+ /**
46
+ * 56 C19: the host tools of the generated catalog, for a client that reads tools/list once and ignores
47
+ * list_changed (claude.ai's custom connector). The live list replaces them once joined.
48
+ * @returns {any[]}
49
+ */
50
+ export function catalogHostTools() {
51
+ const catalog = JSON.parse(readFileSync(new URL('../catalog.json', import.meta.url), 'utf8'));
52
+ const skip = new Set(connectorOnlyTools().map((t) => t.name));
53
+ return catalog.tools.filter((/** @type {{name: string}} */ t) => !skip.has(t.name));
54
+ }
55
+
45
56
  /**
46
57
  * The server, engine-agnostic (tests drive it with a fake engine over an in-memory transport).
47
58
  * @param {Object} opts
@@ -208,7 +219,7 @@ export function createBridgeServer(opts) {
208
219
  return textResult(st, st.state === 'failed');
209
220
  }
210
221
  if (name === 'status' || (name === 'get_status' && opts.kind === 'wire')) return textResult(await statusInfo());
211
- if (!engine) return textResult({ error: 'not connected', note: 'call connect with the session link first' }, true);
222
+ if (!engine) return textResult({ error: 'not connected — join first', note: 'call connect with the session link first' }, true);
212
223
  if (name === 'screenshot' && reserved.includes('screenshot')) {
213
224
  if (!engineReady()) return textResult({ error: 'the agent page is not up yet (' + engine.state + ')' }, true);
214
225
  const shot = await engine.screenshot({ view: a.view, uuid: a.uuid, size: a.size });
package/src/mcpTools.js CHANGED
@@ -109,6 +109,36 @@ export function bridgeTools(engine = 'browser') {
109
109
  return tools;
110
110
  }
111
111
 
112
+ /** 86-fix N2: tools only the Claude connector's tab answers (its dispatcher), listed by the Worker's catalog.
113
+ * The agent CLI has `connect` instead (no person at the screen to ask). */
114
+ export const JOIN_SESSION_TOOL = {
115
+ name: 'join_session',
116
+ description:
117
+ 'Join a theprototype.app session from a link the person gave you (https://theprototype.app/#F0FD3, possibly with &invite=…, or the bare session id). ' +
118
+ 'Nothing happens until the person presses Join on a card in their tab that names the host and the session; Cancel refuses. ' +
119
+ 'The app may then ask them what to do with the scene they have open. Answers joining, refused or cancelled; call status afterwards to see where the tab is.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: { link: { type: 'string', description: 'the session link, invite link or session id the person gave you' } },
123
+ required: ['link']
124
+ }
125
+ };
126
+
127
+ /** 86-fix (85 S1 pulled forward): where the people are — read-only; answered by a connector tab */
128
+ export const WHERE_ARE_USERS_TOOL = {
129
+ name: 'where_are_users',
130
+ description:
131
+ 'Where each person in the session is and which way they look (their desktop camera, or the VR headset): position, facing as a compass yaw ' +
132
+ 'plus forward and right vectors, the floor under them, and ready-made spots 1.5 m and 3 m in front of them at floor level. ' +
133
+ 'The entry with "you": true is the person you act as. Use it for "in front of me", "around me", "behind me", "to my left".',
134
+ inputSchema: { type: 'object', properties: {} }
135
+ };
136
+
137
+ /** @returns {any[]} */
138
+ export function connectorOnlyTools() {
139
+ return [JOIN_SESSION_TOOL, WHERE_ARE_USERS_TOOL];
140
+ }
141
+
112
142
  /** Text content for an MCP result. @param {any} obj @param {boolean} [isError] */
113
143
  export function textResult(obj, isError) {
114
144
  const r = { content: [{ type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj) }] };