@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 +16 -0
- package/docs/mcp-events.md +68 -0
- package/guides/app/SKILL.md +24 -0
- package/guides/office/SKILL.md +24 -0
- package/guides/servers/SKILL.md +24 -0
- package/lib/functions-build.mjs +8 -1
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +12 -3
- package/worker/event-webhooks.mjs +14 -9
- package/worker/functions.mjs +11 -6
- package/worker/mcp-event-streams.mjs +58 -0
- package/worker/mcp-events-wire.mjs +41 -0
- package/worker/mcp-events.mjs +132 -0
- package/worker/mcp.mjs +4 -1
- package/worker/version.mjs +1 -1
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/).
|
package/guides/app/SKILL.md
CHANGED
|
@@ -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/).
|
package/guides/office/SKILL.md
CHANGED
|
@@ -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/).
|
package/guides/servers/SKILL.md
CHANGED
|
@@ -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/).
|
package/lib/functions-build.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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",
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
29
|
-
const
|
|
30
|
-
|
|
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(
|
|
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)
|
|
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,
|
package/worker/functions.mjs
CHANGED
|
@@ -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'&¶ms[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&¶ms.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;
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.4';
|