@homie-rocks/studio 0.51.3 → 0.51.4

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/CHANGELOG.md CHANGED
@@ -17,6 +17,22 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.51.4] - 2026-10-14
21
+
22
+ **Plugin 0.51.4** · Studio events through experimental MCP Events.
23
+
24
+ Your studio’s declared events reach supporting AI clients through experimental MCP polling, SSE streams and signed webhooks.
25
+
26
+ ### Added
27
+
28
+ - Every declared studio feed speaks the Triggers & Events draft: role-filtered `events/list`, cursor/replay polling, SSE push with heartbeat and resume, and authenticated webhook subscribe/refresh/unsubscribe with negotiated TTLs and client-supplied Standard Webhooks secrets.
29
+ - Extension and configured webhooks share the durable delivery engine. Owners can see retries and errors; rotation dual-signs for five minutes. Existing resource subscriptions and cursor tools keep working.
30
+ - Servers, app and office guides explain subscription setup and Cloudflare's own Workers Issues events through the person's existing Cloudflare connection.
31
+
32
+ ### Upgrade notes
33
+
34
+ - Experimental wire format tracks the working-group draft as inspected on 2026-10-14. Declare exact trusted HTTPS callback URLs in each feed's `receivers` array for extension webhooks. Workers fetch cannot pin DNS with original TLS SNI, so arbitrary callbacks are refused; the guide documents this draft limitation. Poll and push need no callback setup.
35
+
20
36
  ## [0.51.3] - 2026-10-14
21
37
 
22
38
  **Plugin 0.51.3**
@@ -0,0 +1,68 @@
1
+ # Experimental MCP Events
2
+
3
+ Studio MCP implements the [Triggers & Events working group's design sketch](https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/13e0a59749cde947374aaa8e3a65f0b424468d5a/docs/design-sketch-proposal.md) as inspected on 2026-10-14. This is an experimental draft, not an adopted MCP standard. Method names, envelopes, schemas, metadata keys and provisional error codes live in `worker/mcp-events-wire.mjs` so draft changes have one wire-format boundary. The studio's existing `resources/subscribe`, event resources and cursor-read tool continue to work.
4
+
5
+ Declare feeds in `studio.json` under `mcp.events`. Each map key is the extension's event `name`. For example:
6
+
7
+ ```json
8
+ {
9
+ "mcp": {
10
+ "events": {
11
+ "bookings": {
12
+ "event": "record.changed",
13
+ "app": "cafe",
14
+ "collection": "bookings",
15
+ "audience": { "app": "cafe", "role": "staff" },
16
+ "receivers": ["https://your-ai-receiver.example/hooks/studio"]
17
+ }
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ `receivers` is an owner-controlled list of exact HTTPS callback URLs, without credentials or fragments. Include only endpoints whose operators have agreed to receive events. A feed's existing configured `webhook.url` is also accepted. Rebuild and deploy the declaration through the studio's usual authorized workflow. Poll and push need no receiver declaration. `maxSubscriptions`, when explicitly declared on a feed, limits its active extension webhooks; otherwise there is no subscription quota. Event arguments are currently `{}`: the declared feed already supplies the app, collection and audience filter.
24
+
25
+ ## An owner's AI connects and subscribes
26
+
27
+ 1. Use an MCP Events-capable client and connect to `<studio-site>/mcp/auth`. Approve with the person's existing studio account in the browser. Staff approve their own account. Do not request an office key. Initialization advertises `capabilities.events.listChanged: true`.
28
+ 2. Call `events/list`. It returns only feeds the current account can read, with `delivery: ["poll", "push", "webhook"]`, input and payload schemas. Re-list when the general MCP GET channel receives `notifications/events/list_changed`.
29
+ 3. Choose a delivery mode. These are protocol requests made by the client/SDK, not model-visible tool calls:
30
+ - **Poll:** call `events/poll` with `{ "name": "bookings", "arguments": {}, "cursor": null }`. The first call returns no history and a cursor at now. Persist each returned cursor and pass it on the next poll. Drain `hasMore: true` immediately; otherwise wait `nextPollMs`. Optional `maxEvents` caps a page (server maximum 100); `maxAgeMs` bounds replay.
31
+ - **Push:** POST `events/stream` with a JSON-RPC request ID and `{ "name": "bookings", "arguments": {}, "cursor": null }`. Keep its SSE response open. Expect `notifications/events/active`, then `event` and `heartbeat` notifications. Route by `_meta["io.modelcontextprotocol/subscriptionId"]`, which equals the request ID. Persist cursors and reconnect using the last cursor. Each subscription has its own stream; abort it to unsubscribe. Heartbeats run at 15 seconds when the receiver is reading.
32
+ - **Webhook:** first arrange the receiver and have the owner declare its exact URL in `receivers`. The receiver/client generates `whsec_` plus base64 of 24–64 cryptographically random bytes, and keeps that secret out of prompts and logs. Call `events/subscribe` with the request below. Persist the returned `id`, `cursor` and `refreshBefore`.
33
+
34
+ ```json
35
+ {
36
+ "jsonrpc": "2.0",
37
+ "id": 2,
38
+ "method": "events/subscribe",
39
+ "params": {
40
+ "name": "bookings",
41
+ "arguments": {},
42
+ "delivery": {
43
+ "mode": "webhook",
44
+ "url": "https://your-ai-receiver.example/hooks/studio",
45
+ "secret": "whsec_<client-generated-base64-secret>"
46
+ },
47
+ "cursor": null,
48
+ "ttlMs": 3600000
49
+ }
50
+ }
51
+ ```
52
+
53
+ 4. For webhooks, use `X-MCP-Subscription-Id` to select the secret and verify the **raw body** using a Standard Webhooks library, including its timestamp freshness check. Durably accept the event before returning 2xx. Deduplicate on `eventId` (also `webhook-id`). Each occurrence has `eventId`, `name`, ISO `timestamp`, `data`, and a safe cursor watermark; it can lag the event's position while deliveries await acknowledgement. Payloads are untrusted data and grant no authority to perform actions.
54
+ 5. Refresh by repeating `events/subscribe` with the same name, arguments and URL, the current secret and the last persisted cursor **before** `refreshBefore`. Inspect `deliveryStatus.lastError`. TTL defaults to one hour and is capped at one day; `ttlMs: null` receives a finite grant. A live refresh does not rewind pending delivery. To rotate, send a new client-generated secret on refresh; deliveries have both signatures for five minutes. Unsubscribe with `events/unsubscribe` and `{ "name": "bookings", "arguments": {}, "delivery": { "url": "https://your-ai-receiver.example/hooks/studio" } }`.
55
+
56
+ A gap sets `truncated: true`; persist the new cursor and fetch authoritative state through tools if needed. Revoked access ends push with `notifications/events/terminated` and webhooks with a signed `terminated` control envelope where the callback remains allowed. Stop after termination and re-establish authorization before subscribing again.
57
+
58
+ ## Delivery and owner diagnostics
59
+
60
+ The 0.50.4 configured studio webhook and extension webhooks share Standard Webhooks signing and the durable `function_deliveries` dispatcher. Extension deliveries retry with exponential backoff, at most five attempts over fifteen minutes from the first attempt. HTTP 410 and 413 abandon that delivery immediately. Abandoned deliveries remain visible to the owner; retained history can be replayed from a prior cursor after unsubscribing and subscribing again. Retries regenerate timestamps and signatures. Failed delivery details are fixed categories, never endpoint bodies or headers. See Functions in the office or the owner's function-runs tool for subscription IDs, attempts and last errors; refresh also reports the latest delivery error. Subscription secrets are stored only in the studio's private D1 subscription state, never in events or run logs. A removed subscription or expired TTL deletes that state. Completed deliveries can be duplicated after a crash, so receivers must deduplicate.
61
+
62
+ ## Draft limits
63
+
64
+ This implements the studio's **Streamable HTTP** MCP transport; the local toolkit's stdio MCP is a different server. The draft's endpoint-consent mechanism is the explicit server-configured allowlist, not a challenge handshake. Arbitrary client-supplied callback URLs are refused with `CallbackEndpointError / challenge_failed`. Workers `fetch` does not provide DNS address pinning with original Host/TLS SNI, so this implementation cannot follow that draft MUST literally. Exact owner-approved URLs, rechecked at every attempt, and disabled redirects constrain the destination; this is not a claim of draft-complete DNS-rebinding protection. Only approve trusted receiver operators. No no-expiry grants or asymmetric server signatures are offered. Durable queue positions survive Worker restarts, extending the sketch's ephemeral in-flight bookkeeping while preserving client-owned replay cursors. A webhook's conservative safe watermark may lag the event being delivered by one event.
65
+
66
+ ## Production failures from Cloudflare
67
+
68
+ An owner's AI can also subscribe through the person's **existing Cloudflare connection** to Cloudflare's own API MCP at `https://mcp.cloudflare.com/mcp`: discover `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, then use `events/subscribe` with that descriptor's arguments scoped to the studio's Worker and the client's webhook. Enable Workers Issues on that Worker and grant **Notifications Write** and **Workers Observability Write**. Cloudflare posts signed events for new or recurring production issues, so reliability problems reach the AI without polling. Homie hosts nothing for this connection. See [Cloudflare's 2026-10-13 announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -288,3 +288,27 @@ Upgrade compares copied app presets with recorded toolkit versions. Unchanged
288
288
  preset files and default values take the new version; custom scene files, names and
289
289
  edited labels remain the studio’s. An app wall uses its join label rather than game
290
290
  instructions. Rebuild after upgrade and inspect both the wall and giving page.
291
+
292
+ ## Experimental MCP Events
293
+
294
+ For an owner's AI that should hear a studio's declared events, read
295
+ `node_modules/@homie-rocks/studio/docs/mcp-events.md`. Connect the client to
296
+ `<site>/mcp/auth` through the person's existing studio approval, discover feeds
297
+ with `events/list`, and use `events/poll`, an SSE `events/stream`, or
298
+ `events/subscribe` with a client-generated `whsec_` secret and an exact
299
+ owner-declared `mcp.events.<feed>.receivers` URL. Refresh before `refreshBefore`,
300
+ persist cursors, verify Standard Webhooks signatures, deduplicate event IDs, and
301
+ use `events/unsubscribe` to stop a webhook. The feeds keep their current roles;
302
+ resources/subscribe and the cursor tool still work. This follows the experimental
303
+ Triggers & Events working-group draft, with the Workers DNS-pinning limitation
304
+ and receiver allowlist explained in that guide. Inspect Functions in the office
305
+ and refresh's `deliveryStatus` for failed deliveries; never put secrets in logs.
306
+
307
+ For production failures, the owner's AI can also use the person's existing
308
+ Cloudflare connection to `https://mcp.cloudflare.com/mcp`, discover
309
+ `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, and
310
+ subscribe with `events/subscribe` scoped to the studio's Worker. Enable Workers
311
+ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
312
+ posts signed events to the client's webhook so new or recurring reliability
313
+ problems reach the AI without polling; Homie hosts nothing for this. See
314
+ [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -156,3 +156,27 @@ in and continues there. A plain link-preview GET spends nothing; the loaded page
156
156
  submits the same-origin sign-in form. Keep the link private.
157
157
  Local office and role commands reuse the running dev server’s D1 configuration even
158
158
  after deploy records the production database id.
159
+
160
+ ## Experimental MCP Events
161
+
162
+ For an owner's AI that should hear a studio's declared events, read
163
+ `node_modules/@homie-rocks/studio/docs/mcp-events.md`. Connect the client to
164
+ `<site>/mcp/auth` through the person's existing studio approval, discover feeds
165
+ with `events/list`, and use `events/poll`, an SSE `events/stream`, or
166
+ `events/subscribe` with a client-generated `whsec_` secret and an exact
167
+ owner-declared `mcp.events.<feed>.receivers` URL. Refresh before `refreshBefore`,
168
+ persist cursors, verify Standard Webhooks signatures, deduplicate event IDs, and
169
+ use `events/unsubscribe` to stop a webhook. The feeds keep their current roles;
170
+ resources/subscribe and the cursor tool still work. This follows the experimental
171
+ Triggers & Events working-group draft, with the Workers DNS-pinning limitation
172
+ and receiver allowlist explained in that guide. Inspect Functions in the office
173
+ and refresh's `deliveryStatus` for failed deliveries; never put secrets in logs.
174
+
175
+ For production failures, the owner's AI can also use the person's existing
176
+ Cloudflare connection to `https://mcp.cloudflare.com/mcp`, discover
177
+ `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, and
178
+ subscribe with `events/subscribe` scoped to the studio's Worker. Enable Workers
179
+ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
180
+ posts signed events to the client's webhook so new or recurring reliability
181
+ problems reach the AI without polling; Homie hosts nothing for this. See
182
+ [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -157,3 +157,27 @@ For local function and tool proof, run `dev` and use its localhost URL. The priv
157
157
  dev credential permits headless owner tool calls; production still uses OAuth.
158
158
  App need sessions are scoped to the declared role and fresh records. See the app
159
159
  and functions guides for tablet/kiosk proof and atomic function storage.
160
+
161
+ ## Experimental MCP Events
162
+
163
+ For an owner's AI that should hear a studio's declared events, read
164
+ `node_modules/@homie-rocks/studio/docs/mcp-events.md`. Connect the client to
165
+ `<site>/mcp/auth` through the person's existing studio approval, discover feeds
166
+ with `events/list`, and use `events/poll`, an SSE `events/stream`, or
167
+ `events/subscribe` with a client-generated `whsec_` secret and an exact
168
+ owner-declared `mcp.events.<feed>.receivers` URL. Refresh before `refreshBefore`,
169
+ persist cursors, verify Standard Webhooks signatures, deduplicate event IDs, and
170
+ use `events/unsubscribe` to stop a webhook. The feeds keep their current roles;
171
+ resources/subscribe and the cursor tool still work. This follows the experimental
172
+ Triggers & Events working-group draft, with the Workers DNS-pinning limitation
173
+ and receiver allowlist explained in that guide. Inspect Functions in the office
174
+ and refresh's `deliveryStatus` for failed deliveries; never put secrets in logs.
175
+
176
+ For production failures, the owner's AI can also use the person's existing
177
+ Cloudflare connection to `https://mcp.cloudflare.com/mcp`, discover
178
+ `cloudflare.alert.workers_observability_real_time_issue` with `events/list`, and
179
+ subscribe with `events/subscribe` scoped to the studio's Worker. Enable Workers
180
+ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
181
+ posts signed events to the client's webhook so new or recurring reliability
182
+ problems reach the AI without polling; Homie hosts nothing for this. See
183
+ [Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
@@ -1,3 +1,4 @@
1
+ import {callbackUrl} from '../worker/mcp-events-wire.mjs';
1
2
  import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
2
3
  import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
3
4
  import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
@@ -12,6 +13,8 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
12
13
  if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
13
14
  for(const [name,feed] of Object.entries(feeds)) {
14
15
  if(feed?.webhook)validateWebhook(name,feed.webhook);
16
+ if(feed?.maxSubscriptions!==undefined&&(!Number.isSafeInteger(feed.maxSubscriptions)||feed.maxSubscriptions<1))throw new Error('maxSubscriptions must be a positive integer');
17
+ if(feed?.receivers!==undefined){if(!Array.isArray(feed.receivers))throw new Error('Event receivers must be an array of exact HTTPS callback URLs');for(const url of feed.receivers)callbackUrl(url);}
15
18
  if(!/^[a-z][a-z0-9-]*$/.test(name)||!feed||typeof feed.event!=='string'||!feed.event.trim())throw new Error('Event feed needs a name and event type');
16
19
  if(feed.event==='record.changed'&&(!/^[a-z][a-z0-9-]*$/.test(feed.app??'')||!/^[a-z][a-z0-9-]*$/.test(feed.collection??'')))throw new Error('Record event feed needs app and collection');
17
20
  if(feed.audience!==undefined&&feed.audience!=='owner'&&!(feed.audience&&typeof feed.audience==='object'&&feed.audience.app===feed.app&&typeof feed.audience.role==='string'))throw new Error('Event feed audience must be owner or an app role');
@@ -51,5 +54,9 @@ export function functionDeploymentSQL(root) {
51
54
  const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
52
55
  for(const [name,feed] of Object.entries(readStudio(root).mcp?.events??{})){definitions.push({name:'@events/'+name,event:feed.event});if(feed.webhook)definitions.push({name:webhookName(name,feed.webhook),event:feed.event});}
53
56
  const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
54
- return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),`DELETE FROM function_subscriptions WHERE name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
57
+ const feedNames=Object.keys(readStudio(root).mcp?.events??{}).map(literal).join(',')||"''";
58
+ // Declaration removal also removes private subscription secrets. Runtime studios
59
+ // without event feeds can retain their original no-MCP-storage fast path.
60
+ const cleanup=[`DELETE FROM function_deliveries WHERE function IN (SELECT '@mcp-webhook/' || json_extract(value,'$.id') FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames}));`,`DELETE FROM function_subscriptions WHERE name IN (SELECT '@mcp-webhook/' || json_extract(value,'$.id') FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames}));`,`DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames});`];
61
+ return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),...cleanup,`DELETE FROM function_subscriptions WHERE name NOT LIKE '@mcp-webhook/%' AND name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
55
62
  }
@@ -3666,6 +3666,48 @@
3666
3666
  }
3667
3667
  }
3668
3668
  }
3669
+ },
3670
+ "0.51.4": {
3671
+ "files": {
3672
+ "CLAUDE.md": "ddf2b2a0376202f6",
3673
+ "README.md": "684b3cca8001673c",
3674
+ "HANDOFF.md": "29337e2ace95b2e7",
3675
+ "games/README.md": "5ad32fe80c61f117",
3676
+ "music/README.md": "a3dc042dadd39df8",
3677
+ "videos/README.md": "16babc3437d5545b",
3678
+ "posts/README.md": "37841aae476bd540",
3679
+ "site/README.md": "1dbdf22c03a1e505",
3680
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3681
+ },
3682
+ "sections": {
3683
+ "AGENTS.md": {
3684
+ "(intro)": "394cbbe934e80722",
3685
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3686
+ "## Layout": "f3a280b8c96cd068",
3687
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3688
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3689
+ "## The site": "0cb8dd9a6f8a0096",
3690
+ "## Making games": "bb715272fd1a615b",
3691
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3692
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3693
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3694
+ "## Servers and AI seats": "1ccfd549d2369a60",
3695
+ "## Selling things (the shop)": "43a3556be93d1235",
3696
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3697
+ "## Rules": "b2234d80f6cbe71d",
3698
+ "## Telling Homie": "80b46fdd9e578db8",
3699
+ "## Beta": "9d02638207a26e73"
3700
+ }
3701
+ },
3702
+ "named": {
3703
+ "Homie Arcade": {
3704
+ "sections": {
3705
+ "AGENTS.md": {
3706
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3707
+ }
3708
+ }
3709
+ }
3710
+ }
3669
3711
  }
3670
3712
  }
3671
3713
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.51.3",
3
+ "version": "0.51.4",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.51.3","sha256":"ac8867ee4a8f906ed64a106d28d60921552d1caad2d99dd70467ae42e70aea75"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.4","sha256":"6286b3013c1ad438111eaed4338ccbffa2f85bf2a765f9f007155a809c414f71"});
@@ -1,3 +1,6 @@
1
+ import {McpEventStreams} from './mcp-event-streams.mjs';
2
+ import {listEvents} from './mcp-events.mjs';
3
+ import {notification,sseFrame} from './mcp-events-wire.mjs';
1
4
  import {allowed,currentCaller} from './mcp-tools.mjs';
2
5
  import {settingsOf,launchOf} from './office.mjs';
3
6
 
@@ -44,9 +47,10 @@ export function eventHub(env,connection) {return env.LOBBY.get(env.LOBBY.idFromN
44
47
  export async function pushEvents(env,cat,origin) {
45
48
  if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
46
49
  // Page subscriptions, without a studio-wide subscription or delivery cap.
50
+ await env.DB.prepare("DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-stream:%' AND expires<?1").bind(Date.now()).run();
47
51
  let after='';const seen=new Set();
48
52
  for(;;){
49
- const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'event-sub:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
53
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key LIKE 'event-sub:%' OR key LIKE 'mcp-event-stream:%' OR key LIKE 'tools-stream:%') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
50
54
  for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
51
55
  const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
52
56
  if(!response.ok)throw new Error('Event stream wake failed');
@@ -58,11 +62,13 @@ export async function pushEvents(env,cat,origin) {
58
62
  /** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
59
63
  const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
60
64
  export class EventStreams {
61
- constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
65
+ constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();this.extensions=new McpEventStreams(env,ctx);}
62
66
  close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
63
67
  background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
64
68
  async fetch(request) {
65
69
  const input=await request.json();
70
+ if(input.op==='extension-stream')return this.extensions.open(input);
71
+ this.extensions.wake(input);
66
72
  if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
67
73
  if(input.op==='stream') {
68
74
  // A reconnect replaces the previous GET, including a half-open socket.
@@ -78,6 +84,7 @@ export class EventStreams {
78
84
  const caller=await streamCaller(this.env,connection).catch(()=>null);
79
85
  if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
80
86
  stream.toolSignature=await this.toolSignature(input.tools??[],caller);
87
+ stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
81
88
  await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
82
89
  // Replay and later wakes share one ordered pump. Backpressure must not
83
90
  // hold up the writer's request or a function handler.
@@ -93,6 +100,8 @@ export class EventStreams {
93
100
  let caller;
94
101
  try{caller=await streamCaller(this.env,input.connection);}
95
102
  catch{this.close(stream);return;}
103
+ const eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
104
+ if(stream.eventSignature!==eventSignature){stream.eventSignature=eventSignature;stream.controller.enqueue(sseFrame(notification('list_changed',{})));}
96
105
  if(input.tools){
97
106
  const signature=await this.toolSignature(input.tools,caller);
98
107
  if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
@@ -130,7 +139,7 @@ export class EventStreams {
130
139
  export async function pushToolList(env,cat,tools,origin) {
131
140
  if(!env.LOBBY)return;
132
141
  const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
133
- const signature=JSON.stringify(publicShape);
142
+ const signature=JSON.stringify([publicShape,eventFeeds(env)]);
134
143
  const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
135
144
  if(previous?.value===signature)return;
136
145
  await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
@@ -10,7 +10,7 @@ export async function readableWebhook(env,cat,feed) {
10
10
  if((feed.audience??'owner')==='owner')return true;
11
11
  return feed.audience?.app===app.id && app.roles?.[feed.audience.role]?.can?.includes('read:'+feed.collection)===true && launchOf(app,await settingsOf(env,{fresh:true}),env)==='public';
12
12
  }
13
- class WebhookFailure extends Error {
13
+ export class WebhookFailure extends Error {
14
14
  constructor(message){super(message);this.name='WebhookFailure';}
15
15
  }
16
16
  export function webhookFailure(error){return error instanceof WebhookFailure?error.message:'Webhook delivery failed';}
@@ -21,23 +21,28 @@ export async function postWebhook(env,name,event,{fetchFn=globalThis.fetch.bind(
21
21
  validateWebhook(name,hook);
22
22
  const secret=env[hook.secret];
23
23
  if(typeof secret!=='string'||!/^whsec_[A-Za-z0-9+/]{43}=$/.test(secret))throw new WebhookFailure('Webhook signing secret unavailable');
24
- const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
25
- // Encode the event ID to avoid ambiguous dot-delimited signatures and invalid headers.
26
24
  const id='evt_'+btoa(String.fromCharCode(...new TextEncoder().encode(event.id))).replaceAll('+','-').replaceAll('/','_').replaceAll('=','');
25
+ return signedWebhook(hook.url,{id,body:{id:event.id,type:event.type,at:event.at,data:event.data},secrets:[secret]},{fetchFn,now,timeoutMs});
26
+ }
27
+ /** Shared transport for configured studio hooks and MCP Events subscriptions. */
28
+ export async function signedWebhook(url,{id,body,secrets,headers={}},{fetchFn=globalThis.fetch.bind(globalThis),now=Date.now,timeoutMs=10000}={}) {
27
29
  const timestamp=String(Math.floor(now()/1000));
28
- const body=JSON.stringify({id:event.id,type:event.type,at:event.at,data:event.data});
29
- const signature=btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${id}.${timestamp}.${body}`)))));
30
- const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),timeoutMs);
30
+ const encoded=JSON.stringify(body),signatures=[];
31
+ for(const secret of secrets){
32
+ const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
33
+ signatures.push('v1,'+btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${id}.${timestamp}.${encoded}`))))));
34
+ }
35
+ const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeoutMs);
31
36
  try {
32
- const response=await fetchFn(hook.url,{method:'POST',redirect:'manual',signal:controller.signal,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':'v1,'+signature},body});
33
- // Never read or log receiver content: it may echo credentials or private records.
37
+ const response=await fetchFn(url,{method:'POST',redirect:'manual',signal:controller.signal,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':signatures.join(' '),...headers},body:encoded});
34
38
  await response.body?.cancel();
35
- if(!response.ok)throw new WebhookFailure('Webhook HTTP '+response.status);
39
+ if(!response.ok){const error=new WebhookFailure('Webhook HTTP '+response.status);error.status=response.status;throw error;}
36
40
  }catch(error){
37
41
  if(error instanceof WebhookFailure)throw error;
38
42
  throw new WebhookFailure(controller.signal.aborted?'Webhook timed out':'Webhook network failure');
39
43
  }finally{clearTimeout(timer);}
40
44
  }
45
+
41
46
  export function webhookSubscriptions(env,cat,options={}) {
42
47
  return Object.entries(eventFeeds(env)).filter(([,feed])=>feed.webhook).map(([name,feed])=>({
43
48
  name:webhookName(name,feed.webhook),event:feed.event,webhook:true,
@@ -1,3 +1,4 @@
1
+ import {eventHookSubscriptions} from './mcp-events.mjs';
1
2
  import {webhookSubscriptions,webhookFailure,webhookBackoff} from './event-webhooks.mjs';
2
3
  import {parseSchedule,scheduleStatus} from './function-schedule.mjs';
3
4
  import {redactText} from '../lib/redact.mjs';
@@ -15,7 +16,7 @@ export async function syncFunctions(env, definitions = null, now = Date.now()) {
15
16
  // Deploy owns the production registry; an old in-flight Worker must not overwrite it.
16
17
  if(await env.DB.prepare("SELECT 1 FROM meta WHERE key='function-registry-deployed'").first())return definitions;
17
18
  const statements=definitions.map(fn=>env.DB.prepare(`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,(SELECT COALESCE(MAX(seq),0) FROM studio_events),?3) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type`).bind(fn.name,fn.event,now));
18
- statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
19
+ statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT LIKE '@mcp-webhook/%' AND name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
19
20
  await env.DB.batch(statements);
20
21
  return definitions;
21
22
  }
@@ -33,7 +34,7 @@ export async function pruneEvents(env) {
33
34
  const cursor=Number((await env.DB.prepare("SELECT value FROM meta WHERE key='function-prune-cursor'").first())?.value??0);
34
35
  const rows=(await env.DB.prepare('SELECT seq,id FROM studio_events WHERE seq>?1 ORDER BY seq LIMIT 100').bind(cursor).all()).results;
35
36
  const statements=rows.flatMap(({id})=>[
36
- env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
37
+ env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%' AND function NOT LIKE '@mcp-webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
37
38
  env.DB.prepare(`DELETE FROM studio_events WHERE id=?1 AND NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.type=studio_events.type AND s.cursor<studio_events.seq) AND NOT EXISTS (SELECT 1 FROM function_deliveries d WHERE d.event=studio_events.id)`).bind(id),
38
39
  ]);
39
40
  statements.push(env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-prune-cursor',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(String(rows.at(-1)?.seq??0)));
@@ -41,10 +42,11 @@ export async function pruneEvents(env) {
41
42
  }
42
43
  /** A lease is for retry after a crashed Worker, not a deadline imposed on studio code. */
43
44
  export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000, webhookOptions = {}} = {}) {
45
+ if(!env.DB)return;
44
46
  definitions = await syncFunctions(env,definitions,now());
45
- if(!definitions.length)return;
47
+ if(!definitions.length&&!env.STUDIO_EVENT_FEEDS)return;
46
48
  if(typeof cat==='function')cat=await cat();
47
- definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions));
49
+ definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions),env.STUDIO_EVENT_FEEDS?await eventHookSubscriptions(env,cat,{...webhookOptions,now}):[]);
48
50
  // An AI notification must not wait behind a slow function handler.
49
51
  const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
50
52
  const notifications=notify();
@@ -72,18 +74,21 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
72
74
  env.DB.prepare("INSERT INTO function_runs(id,function,event,trigger,started,outcome,attempt) SELECT ?1,?2,?3,?4,?5,'running',attempts FROM function_deliveries WHERE event=?3 AND function=?2 AND lease=?1").bind(lease,fn.name,row.event,row.event.startsWith(`schedule:${fn.name}:`)?`schedule: ${JSON.parse(row.data).cron}`:row.type,started),
73
75
  ]);
74
76
  if (!claimed.meta.changes) continue;
75
- let failure=null;
77
+ let failure=null,permanent=false;
76
78
  try {
77
79
  const stored=JSON.parse(row.data);
78
80
  const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
79
81
  const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:isolated?{...stored,app:isolated[1]}:stored,attempt:row.attempts+1});
80
82
  const testScope=isolated?isolated[2]:null;
81
83
  await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
82
- } catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
84
+ } catch(error) {permanent=error.permanent===true;failure=fn.webhook?webhookFailure(error):functionError(error,env);}
83
85
  const finished=now();
86
+ const firstAttempt=fn.mcpEvent?(await env.DB.prepare('SELECT MIN(started) at FROM function_runs WHERE event=?1 AND function=?2').bind(row.event,fn.name).first()).at:finished;
87
+ const abandoned=fn.mcpEvent&&failure!==null&&(permanent||row.attempts+1>=5||finished-firstAttempt>=900000);
84
88
  await env.DB.batch([
85
89
  failure===null
86
90
  ? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished)
91
+ : abandoned ? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=?5 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished,failure)
87
92
  : env.DB.prepare(`UPDATE function_deliveries SET state='failed',error=?5,due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished+(fn.webhook?webhookBackoff(row.attempts+1):leaseMs),fn.webhook?failure:'Function failed; inspect its code and retry'),
88
93
  env.DB.prepare("UPDATE function_runs SET outcome=?2,finished=?3,error=?4 WHERE id=?1").bind(lease,failure===null?'complete':'failed',finished,failure),
89
94
  ]);
@@ -0,0 +1,58 @@
1
+ import {eventSource,eventCaller,checkEvent,eventStart,pollEvents} from './mcp-events.mjs';
2
+ import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseFrame,streamResult,cursorOf} from './mcp-events-wire.mjs';
3
+
4
+ /** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
5
+ export class McpEventStreams {
6
+ constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
7
+ close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(()=>{}));}
8
+ async write(stream,value,authorize){
9
+ if(stream.closed)return false;
10
+ if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
11
+ if(stream.closed)return false;
12
+ if(authorize)await authorize();
13
+ try{stream.controller.enqueue(sseFrame(value));return true;}catch{this.close(stream);return false;}
14
+ }
15
+ wake(input){for(const stream of this.streams){if(input.cat)stream.cat=input.cat;this.pump(stream);}}
16
+ pump(stream){if(stream.busy||stream.closed)return;stream.busy=true;this.ctx.waitUntil(this.deliver(stream).catch(()=>this.close(stream)).finally(()=>{stream.busy=false;}));}
17
+ async open(input){
18
+ try{
19
+ const caller=await eventCaller(this.env,input.connection);const feed=await checkEvent(this.env,input.cat,caller,input.params);
20
+ const start=await eventStart(this.env,input.params);
21
+ const registryKey='mcp-event-stream:'+input.connection+':'+crypto.randomUUID();
22
+ await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Date.now()+60000).run();
23
+ const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
24
+ const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
25
+ this.streams.add(stream);
26
+ // Heartbeats also recheck authorization, catch missed wakes and renew discovery.
27
+ stream.timer=setInterval(()=>this.pump(stream),15000);
28
+ this.pump(stream);
29
+ return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
30
+ }catch(error){return Response.json({jsonrpc:'2.0',id:input.id,error:eventError(error)});}
31
+ }
32
+ async deliver(stream){
33
+ try{
34
+ let more=true;
35
+ while(more&&!stream.closed){
36
+ const caller=await eventCaller(this.env,stream.connection);
37
+ const feed=await checkEvent(this.env,stream.cat,caller,stream.params);
38
+ if(stream.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});
39
+ const start=await eventStart(this.env,{name:stream.params.name,cursor:stream.cursor});
40
+ if(start.truncated){stream.cursor=cursorOf(stream.params.name,start.after);await this.write(stream,notification('active',{cursor:stream.cursor,truncated:true},stream.id));}
41
+ const page=await pollEvents(this.env,stream.cat,caller,{...stream.params,maxAgeMs:undefined,cursor:stream.cursor});
42
+ for(const event of page.events){
43
+ await checkEvent(this.env,stream.cat,await eventCaller(this.env,stream.connection),stream.params);
44
+ if(!await this.write(stream,notification('event',event,stream.id),async()=>checkEvent(this.env,stream.cat,await eventCaller(this.env,stream.connection),stream.params)))return;
45
+ stream.cursor=event.cursor;
46
+ }
47
+ stream.cursor=page.cursor;more=page.hasMore;
48
+ }
49
+ await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Date.now()+60000).run();
50
+ await this.write(stream,notification('heartbeat',{cursor:stream.cursor},stream.id));
51
+ }catch(error){
52
+ const details=eventError(error);
53
+ if(terminalEventError(details.code)){
54
+ await this.write(stream,notification('terminated',{error:details},stream.id));await this.write(stream,streamResult(stream.id));this.close(stream);
55
+ }else await this.write(stream,notification('error',{error:details},stream.id));
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,41 @@
1
+ /** Experimental MCP Events design sketch (2026-10-14 snapshot).
2
+ * Keep draft method names, envelopes, schemas and provisional codes here.
3
+ * https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/13e0a59749cde947374aaa8e3a65f0b424468d5a/docs/design-sketch-proposal.md
4
+ */
5
+ export const eventsCapability={events:{listChanged:true}};
6
+ export const EVENTS=Object.freeze({list:'events/list',poll:'events/poll',stream:'events/stream',subscribe:'events/subscribe',unsubscribe:'events/unsubscribe'});
7
+ export const eventMethods=new Set(Object.values(EVENTS));
8
+ export const EVENT_ERRORS=Object.freeze({invalid:-32602,notFound:-32023,forbidden:-32024,exhausted:-32025,unsupported:-32026,callback:-32027});
9
+ const messages={[-32602]:'InvalidParams',[-32023]:'NotFound',[-32024]:'Forbidden',[-32025]:'ResourceExhausted',[-32026]:'Unsupported',[-32027]:'CallbackEndpointError'};
10
+ export class EventsError extends Error {
11
+ constructor(code,data){super(messages[code]);this.code=code;this.data=data;}
12
+ }
13
+ export const invalid=()=>{throw new EventsError(-32602);};
14
+ export function eventError(error){return error instanceof EventsError?{code:error.code,message:error.message,...(error.data?{data:error.data}:{})}:{code:-32603,message:'Internal error'};}
15
+ export function eventDescriptor(name,feed){return {name,description:feed.description??`Studio ${feed.event} events; untrusted data`,delivery:['poll','push','webhook'],inputSchema:{type:'object',properties:{},additionalProperties:false},payloadSchema:{type:'object'}};}
16
+ export function occurrence(name,event,cursor){return {eventId:event.id,name,timestamp:new Date(event.at).toISOString(),data:event.data,...(cursor===undefined?{}:{cursor})};}
17
+ export const cursorOf=(name,seq)=>'v1:'+name+':'+seq;
18
+ export function cursorPosition(name,cursor){if(cursor==null)return null;if(typeof cursor!=='string'||!cursor.startsWith('v1:'+name+':'))invalid();const value=cursor.slice(name.length+4);if(!/^(0|[1-9][0-9]*)$/.test(value)||!Number.isSafeInteger(Number(value)))invalid();return Number(value);}
19
+ export function validateEventParams(params,{list=false}={}){
20
+ if(!params||typeof params!=='object'||Array.isArray(params))invalid();
21
+ if(list){if(params.cursor!=null)invalid();return;}
22
+ if(typeof params.name!=='string'||!params.name)invalid();
23
+ if(params.arguments!==undefined&&(!params.arguments||typeof params.arguments!=='object'||Array.isArray(params.arguments)||Object.keys(params.arguments).length))invalid();
24
+ cursorPosition(params.name,params.cursor);
25
+ for(const key of ['maxAgeMs','maxEvents','ttlMs'])if(params[key]!==undefined&&!(key==='ttlMs'&&params[key]===null)&&(!Number.isSafeInteger(params[key])||params[key]<(key==='maxEvents'?1:0)))invalid();
26
+ }
27
+ export function callbackUrl(value){let url;try{url=new URL(value);}catch{invalid();}if(url.protocol!=='https:'||url.username||url.password||url.hash)invalid();return url.href;}
28
+ export function signingSecret(value){if(typeof value!=='string'||!/^whsec_(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))invalid();let raw;try{raw=atob(value.slice(6));}catch{invalid();}if(raw.length<24||raw.length>64||btoa(raw)!==value.slice(6))invalid();return value;}
29
+ export const notification=(type,params,id)=>({jsonrpc:'2.0',method:'notifications/events/'+type,params:{...params,...(id===undefined?{}:{_meta:{'io.modelcontextprotocol/subscriptionId':id}})}});
30
+ export const sseFrame=value=>new TextEncoder().encode('event: message\ndata: '+JSON.stringify(value)+'\n\n');
31
+ export const streamResult=id=>({jsonrpc:'2.0',id,result:{_meta:{}}});
32
+ export const control=(type,fields)=>({type,...fields});
33
+ export const subscriptionResult=(sub,cursor,truncated,deliveryStatus)=>({id:sub.id,refreshBefore:new Date(sub.expires).toISOString(),cursor,truncated,...(deliveryStatus?{deliveryStatus}:{})});
34
+ export const subscriptionHeaders=id=>({'X-MCP-Subscription-Id':id});
35
+ /** Internal pages keep stable names; draft field changes stay at the transport boundary. */
36
+ export function eventsResult(method,value){
37
+ if(method===EVENTS.list)return {events:value.events};
38
+ if(method===EVENTS.poll)return {events:value.events.map(({cursor,...event})=>event),cursor:value.cursor,truncated:value.truncated,hasMore:value.hasMore,nextPollMs:value.nextPollMs};
39
+ return value;
40
+ }
41
+ export const terminalEventError=code=>[EVENT_ERRORS.notFound,EVENT_ERRORS.forbidden,EVENT_ERRORS.unsupported].includes(code);
@@ -0,0 +1,132 @@
1
+ import {rateLimit,callsPerMinute} from './mcp-store.mjs';
2
+ import {eventFeeds,readableFeed,matchesFeed,eventHub} from './event-resources.mjs';
3
+ import {currentCaller} from './mcp-tools.mjs';
4
+ import {signedWebhook,WebhookFailure} from './event-webhooks.mjs';
5
+ import {EVENTS,EVENT_ERRORS,eventsResult,EventsError,invalid,eventError,eventDescriptor,occurrence,cursorOf,cursorPosition,validateEventParams,callbackUrl,signingSecret,subscriptionResult,subscriptionHeaders,control} from './mcp-events-wire.mjs';
6
+
7
+ const prefix='mcp-event-hook:';
8
+ export const eventSource=feed=>JSON.stringify([feed.event,feed.app??null,feed.collection??null]);
9
+ export const hookFunction=id=>'@mcp-webhook/'+id;
10
+ const head=async env=>(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
11
+ export async function eventCaller(env,connection){
12
+ if(connection==='local-test-owner'&&env.HOMIE_LOCAL_TEST_SECRET)return {id:'local-test-owner',owner:true,connection};
13
+ try{return await currentCaller(env,{connection});}catch{throw new EventsError(EVENT_ERRORS.forbidden);}
14
+ }
15
+ export async function checkEvent(env,cat,caller,params){
16
+ validateEventParams(params);
17
+ const feeds=eventFeeds(env),feed=Object.hasOwn(feeds,params.name)?feeds[params.name]:null;
18
+ if(!feed)throw new EventsError(EVENT_ERRORS.notFound,{kind:'event'});
19
+ if(!await readableFeed(env,cat,caller,params.name))throw new EventsError(EVENT_ERRORS.forbidden);
20
+ return feed;
21
+ }
22
+ export async function listEvents(env,cat,caller,params={}){
23
+ validateEventParams(params,{list:true});const events=[];
24
+ for(const [name,feed] of Object.entries(eventFeeds(env)))if(await readableFeed(env,cat,caller,name))events.push(eventDescriptor(name,feed));
25
+ return {events};
26
+ }
27
+ export async function eventStart(env,params,now=Date.now()){
28
+ const through=await head(env);let after=cursorPosition(params.name,params.cursor),truncated=false;
29
+ if(after===null)return {after:through,through,truncated};
30
+ if(after>through){after=through;truncated=true;}
31
+ const first=(await env.DB.prepare('SELECT MIN(seq) seq FROM studio_events').first()).seq;
32
+ if(first!==null&&after<first-1){after=first-1;truncated=true;}
33
+ if(params.maxAgeMs!==undefined){const floor=(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events WHERE at<?1 AND seq<=?2').bind(now-params.maxAgeMs,through).first()).seq;if(floor>after){after=floor;truncated=true;}}
34
+ return {after,through,truncated};
35
+ }
36
+ export async function pollEvents(env,cat,caller,params,now=Date.now()){
37
+ const feed=await checkEvent(env,cat,caller,params),start=await eventStart(env,params,now);
38
+ const max=Math.min(params.maxEvents??100,100);
39
+ // Filter before limiting: unrelated collections must not create a false hasMore
40
+ // or force the client through pages containing no matching occurrences.
41
+ const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
42
+ WHERE type=?1 AND seq>?2 AND seq<=?3
43
+ AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
44
+ ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?feed.app:null,feed.collection??null,max+1).all()).results;
45
+ const hasMore=rows.length>max,page=rows.slice(0,max);
46
+ const events=page.map(row=>occurrence(params.name,{...row,data:JSON.parse(row.data)},cursorOf(params.name,row.seq)));
47
+ const after=hasMore?page.at(-1).seq:start.through;
48
+ return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
49
+ }
50
+ async function subId(caller,params,url){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify([caller.id,url,params.name,{}])));return 'sub_'+Array.from(new Uint8Array(bytes),b=>b.toString(16).padStart(2,'0')).join('');}
51
+ async function stored(env,id){const row=await env.DB.prepare('SELECT value FROM mcp_kv WHERE key=?1').bind(prefix+id).first();return row?JSON.parse(row.value):null;}
52
+ export async function removeEventHook(env,id){await env.DB.batch([env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(prefix+id),env.DB.prepare('DELETE FROM function_subscriptions WHERE name=?1').bind(hookFunction(id)),env.DB.prepare('DELETE FROM function_deliveries WHERE function=?1').bind(hookFunction(id))]);}
53
+ export function deliveryCategory(error){if(error.message==='challenge_failed')return 'challenge_failed';if(error.message==='Webhook timed out')return 'timeout';if(error.status>=500)return 'http_5xx';if(error.status)return 'http_4xx';return 'connection_refused';}
54
+ // Worker fetch cannot pin an independently resolved address with original TLS SNI.
55
+ // Restrict callbacks to exact owner-declared URLs, rechecked on every attempt.
56
+ // This also supplies the draft's server-configured endpoint-consent allowlist.
57
+ export function permittedCallback(env,name,url){
58
+ const feed=eventFeeds(env)[name];
59
+ return [...(feed?.receivers??[]),...(feed?.webhook?[feed.webhook.url]:[])].some(value=>callbackUrl(value)===url);
60
+ }
61
+ async function send(env,sub,body,id,options={}){
62
+ if(!permittedCallback(env,sub.name,sub.url))throw new WebhookFailure('Callback no longer allowed');
63
+ const secrets=[sub.secret,...(sub.previous&&sub.rotateUntil>(options.now??Date.now)()?[sub.previous]:[])];
64
+ return signedWebhook(sub.url,{id,body,secrets,headers:subscriptionHeaders(sub.id)},options);
65
+ }
66
+ async function watermark(env,sub){
67
+ const registry=await env.DB.prepare('SELECT cursor FROM function_subscriptions WHERE name=?1').bind(hookFunction(sub.id)).first();
68
+ const pending=await env.DB.prepare("SELECT MIN(e.seq) seq FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state!='complete'").bind(hookFunction(sub.id)).first();
69
+ return cursorOf(sub.name,Math.max(sub.start,Math.min(registry?.cursor??sub.start,pending.seq===null?Number.MAX_SAFE_INTEGER:pending.seq-1)));
70
+ }
71
+ export async function subscribeEvents(env,cat,caller,params,{now=Date.now}={}){
72
+ if(!caller.id)throw new EventsError(EVENT_ERRORS.forbidden);
73
+ const feed=await checkEvent(env,cat,caller,params);
74
+ if(!params.delivery||typeof params.delivery!=='object')invalid();
75
+ if(params.delivery.mode!==undefined&&params.delivery.mode!=='webhook')throw new EventsError(EVENT_ERRORS.unsupported,{feature:'deliveryMode',value:params.delivery.mode});
76
+ const url=callbackUrl(params.delivery.url),secret=signingSecret(params.delivery.secret);
77
+ if(!permittedCallback(env,params.name,url))throw new EventsError(EVENT_ERRORS.callback,{reason:'challenge_failed'});
78
+ const id=await subId(caller,params,url),previous=await stored(env,id),live=previous&&previous.expires>now();
79
+ if(live&&previous.source!==eventSource(feed)){await removeEventHook(env,id);throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
80
+ if(previous&&!live)await removeEventHook(env,id);
81
+ if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%'").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
82
+ const start=await eventStart(env,params,now());
83
+ const sub={id,name:params.name,source:eventSource(feed),url,secret,principal:caller.id,connection:caller.connection??'local-test-owner',start:live?previous.start:start.after,expires:now()+Math.min(params.ttlMs??3600000,86400000)};
84
+ if(live){if(previous.secret!==secret){sub.previous=previous.secret;sub.rotateUntil=now()+300000;}else if(previous.rotateUntil>now()){sub.previous=previous.previous;sub.rotateUntil=previous.rotateUntil;}}
85
+ await env.DB.batch([
86
+ env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(prefix+id,JSON.stringify(sub)),
87
+ env.DB.prepare('INSERT OR IGNORE INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,?3,?4)').bind(hookFunction(id),feed.event,sub.start,now()),
88
+ ]);
89
+ const latest=await env.DB.prepare("SELECT error,completed FROM function_deliveries WHERE function=?1 ORDER BY CASE WHEN state!='complete' AND error IS NOT NULL THEN 0 ELSE 1 END, rowid DESC LIMIT 1").bind(hookFunction(id)).first();
90
+ return subscriptionResult(sub,await watermark(env,sub),live?false:start.truncated,{active:true,lastError:latest?.error??null,lastDeliveryAt:latest?.completed?new Date(latest.completed).toISOString():null});
91
+ }
92
+ export async function unsubscribeEvents(env,caller,params){
93
+ if(!caller.id)throw new EventsError(EVENT_ERRORS.forbidden);validateEventParams(params);
94
+ const id=await subId(caller,params,callbackUrl(params.delivery?.url)),sub=await stored(env,id);
95
+ if(!sub||sub.expires<=Date.now())throw new EventsError(EVENT_ERRORS.notFound,{kind:'subscription'});
96
+ await removeEventHook(env,id);return {};
97
+ }
98
+ /** Dynamic hooks become ordinary durable function deliveries: one queue and retry engine. */
99
+ export async function eventHookSubscriptions(env,cat,options={}){
100
+ const now=options.now??Date.now,definitions=[];let after='';
101
+ for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
102
+ for(const row of rows){const sub=JSON.parse(row.value);if(sub.expires<=now()){await removeEventHook(env,sub.id);continue;}
103
+ let feed;
104
+ try{feed=await checkEvent(env,cat,await eventCaller(env,sub.connection),{name:sub.name});if(!permittedCallback(env,sub.name,sub.url))throw new EventsError(EVENT_ERRORS.forbidden);if(sub.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
105
+ catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,sub.id);continue;}
106
+ definitions.push({name:hookFunction(sub.id),event:feed.event,webhook:true,mcpEvent:true,matches:event=>matchesFeed(feed,event),async handler(event){
107
+ const fresh=await stored(env,sub.id);if(!fresh||fresh.expires<=now())return;
108
+ try{await checkEvent(env,cat,await eventCaller(env,fresh.connection),{name:fresh.name});}
109
+ catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,fresh.id);return;}
110
+ try{await send(env,fresh,occurrence(fresh.name,event,await watermark(env,fresh)),event.id,options);}
111
+ catch(error){const failure=new WebhookFailure(deliveryCategory(error));failure.permanent=error.status===410||error.status===413;throw failure;}
112
+ }});
113
+ }
114
+ if(rows.length<100)break;after=rows.at(-1).key;
115
+ }return definitions;
116
+ }
117
+ export async function eventsRequest(request,env,ctx,cat,caller,body){
118
+ const params=body.params??{};
119
+ try{
120
+ if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))throw new EventsError(EVENT_ERRORS.exhausted,{limit:'requests'});
121
+ if(body.jsonrpc!=='2.0'||!['string','number'].includes(typeof body.id))invalid();
122
+ if(body.method===EVENTS.stream){
123
+ await checkEvent(env,cat,caller,params);
124
+ if(!env.LOBBY)throw new EventsError(EVENT_ERRORS.unsupported,{feature:'deliveryMode',value:'push'});
125
+ const connection=caller.connection??'local-test-owner';
126
+ return eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'extension-stream',connection,cat,id:body.id,params})});
127
+ }
128
+ const result=body.method===EVENTS.list?await listEvents(env,cat,caller,params):body.method===EVENTS.poll?await pollEvents(env,cat,caller,params):body.method===EVENTS.subscribe?await subscribeEvents(env,cat,caller,params):await unsubscribeEvents(env,caller,params);
129
+ if(body.method===EVENTS.subscribe){const work=import('./functions.mjs').then(m=>m.runFunctions(env,cat,new URL(request.url).origin));if(ctx?.waitUntil)ctx.waitUntil(work);else await work;}
130
+ return Response.json({jsonrpc:'2.0',id:body.id,result:eventsResult(body.method,result)},{headers:{'cache-control':'no-store'}});
131
+ }catch(error){return Response.json({jsonrpc:'2.0',id:body.id??null,error:eventError(error)},{headers:{'cache-control':'no-store'}});}
132
+ }
package/worker/mcp.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
2
+ import {eventsRequest} from './mcp-events.mjs';
1
3
  function freshErrorMessage(error,env,caller) {
2
4
  let message=String(error?.message??error);
3
5
  for(const [key,value] of Object.entries(env))if(typeof value==='string'&&/SECRET|KEY|TOKEN|PASSWORD/.test(key)&&value.length)message=message.replaceAll(value,'[redacted]');
@@ -50,6 +52,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
50
52
  }
51
53
  if(request.method==='POST') {
52
54
  const body=await request.clone().json().catch(()=>null);
55
+ if(eventMethods.has(body?.method))return eventsRequest(request,env,ctx,cat,caller,body);
53
56
  if(body?.method==='tools/call') {
54
57
  const tool=tools.find(t=>t.name===body.params?.name);
55
58
  const schema=tool?.price?{...tool.inputSchema,properties:{...tool.inputSchema.properties,_payment:paymentInputSchema}}:tool?.inputSchema;
@@ -71,7 +74,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
71
74
  };
72
75
  const server=async()=>{
73
76
  const capabilities=await selling.machineCapabilities(env);
74
- const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{tools:{listChanged:true},...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true}}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
77
+ const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{tools:{listChanged:true},...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true},...eventsCapability}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
75
78
  const dispatch=new Map();
76
79
  for(const name of Object.keys(eventFeeds(env))) {
77
80
  if(!await readableFeed(env,cat,caller,name))continue;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.51.3';
2
+ export const STUDIO_VERSION_TAG = '0.51.4';