@@ -121,6 +121,27 @@ progress, then the handler's result) and `task_cancel`. To stop a cancelled task
121121` await task.isCancelled() ` between steps. Cancelling is cooperative, so running code only stops
122122where it checks.
123123
124+ ** Check what the arguments point at.** The model fills in tool arguments, so a ` workspaceId ` in
125+ them is whatever it was told. Give the task an ` authorize ` , which runs before the task is stored or
126+ queued, and use ` task.principal ` (the user who started the task) inside the handler. Never take a
127+ user id from the arguments:
128+
129+ ``` ts
130+ tasks .define (
131+ " export_workspace" ,
132+ {
133+ description: " Exports a workspace to CSV." ,
134+ inputSchema: z .object ({ workspaceId: z .string () }),
135+ // May this user export this workspace? `false` refuses the call.
136+ authorize : ({ workspaceId }, { principal }) => canExport (principal , workspaceId ),
137+ },
138+ async ({ workspaceId }, task ) => {
139+ const csv = await exportWorkspace (workspaceId , { as: task .principal });
140+ return { content: [{ type: " text" , text: csv }] };
141+ },
142+ );
143+ ```
144+
124145<details >
125146<summary ><b >What the model sees</b ></summary >
126147
@@ -166,9 +187,13 @@ export const tasks = createTaskLayer({
166187
167188tasks .define (
168189 " migrate_workspace" ,
169- { description: " Copies a workspace to new storage." , inputSchema: z .object ({ workspaceId: z .string () }) },
190+ {
191+ description: " Copies a workspace to new storage." ,
192+ inputSchema: z .object ({ workspaceId: z .string () }),
193+ authorize : ({ workspaceId }, { principal }) => isOwner (principal , workspaceId ),
194+ },
170195 async ({ workspaceId }, task ) => {
171- const batches = await task .run (" plan" , () => listBatches (workspaceId ));
196+ const batches = await task .run (" plan" , () => listBatches (workspaceId , task . principal ));
172197 for (const [i, batch] of batches .entries ()) {
173198 if (await task .isCancelled ()) return {};
174199 await task .update (` Copying batch ${i + 1 }/${batches .length } ` );
@@ -278,8 +303,10 @@ the event id stays the same across retries so the host can drop duplicates.
278303<summary ><b >Matching and <code >authorize</code ></b ></summary >
279304
280305Every ` input ` field must also be a ` payload ` field: the payload is the one place an event's values
281- come from, and the same values are used to route it and to authorize it. A subscription matches
282- when each argument it gave equals the payload's value. Emitting
306+ come from, and the same values are used to route it and to authorize it. The payload's values for
307+ the input fields are parsed with the ` input ` schema, so its transforms apply on both sides and
308+ ` authorize ` gets the types it declares. A subscription matches when each argument it gave equals
309+ the payload's value. Emitting
283310` { documentId: "doc_123", text } ` reaches subscribers of ` { documentId: "doc_123" } ` and of ` {} ` .
284311
285312` authorize(args, caller) ` runs twice:
@@ -294,8 +321,8 @@ when each argument it gave equals the payload's value. Emitting
294321
295322` () => true ` lets every authenticated subscriber hear every matching event.
296323
297- Pass ` { eventId } ` as the second argument to ` emit ` to deduplicate: emitting the same id twice
298- delivers once .
324+ Pass ` { eventId } ` as the second argument to ` emit ` to give an event a stable id. It is sent as
325+ ` webhook-id ` , so a host drops a second emit with the same id as a duplicate .
299326
300327</details >
301328
@@ -318,6 +345,11 @@ delivers once.
318345 random bytes. A refresh with the same secret skips the challenge. If you rotate the key, stored
319346 subscriptions stop receiving events until the host refreshes them.
320347- ** Lifetime.** A subscription lasts 7 days by default and 30 days at most.
348+ - ** How many.** A subscriber holds at most 8 live subscriptions across all events (set
349+ ` maxSubscriptions ` ; ` Infinity ` turns it off). Past that, a new subscribe is refused before the
350+ callback is challenged, with reason ` subscription_limit ` ; refreshing an existing one still works.
351+ Each subscription is a webhook per matching emit, so this bounds what one user can make your
352+ server send.
321353- ** Host answers.** ` 410 ` deletes the subscription, ` 413 ` and redirects drop the event, and any
322354 other error is retried.
323355- ** Not implemented:** the draft's poll and stream delivery modes (` events/subscribe ` refuses
@@ -341,7 +373,9 @@ subscribe yet. The spec draft is
341373<details >
342374<summary ><b >Who can see what</b ></summary >
343375
344- ** Tasks.** The server sets the owner from ` principal ` , and no tool argument can set it.
376+ ** Tasks.** The server sets the owner from ` principal ` , and no tool argument can set it. Whether a
377+ caller may start a task with given arguments is your ` authorize ` ; the handler gets the owner as
378+ ` task.principal ` .
345379` task_status ` and ` task_cancel ` only accept UUID task ids, and compare the stored owner with the
346380caller. For anyone else, the task looks exactly like an unknown id, so they can't even tell it
347381exists. Task ids are random UUIDs. No tool lists tasks, and the execute route only accepts signed
@@ -375,8 +409,9 @@ Treat the Redis credentials like any other production secret.
375409- A Redis client built with ` automaticDeserialization: false ` is not supported.
376410
377411** ` mcp-events:sub:<id> ` ** : the subscription (` event ` , ` args ` , ` url ` , ` encryptedSecret ` ,
378- ` subscriber ` , ` createdAt ` , ` expiresAt ` ), expiring with it. ** ` mcp-events:idx:<event> ` ** : a sorted
379- set of the event's subscription ids, scored by expiry.
412+ ` subscriber ` , ` createdAt ` , ` expiresAt ` ), expiring with it. ** ` mcp-events:idx:<event> ` ** and
413+ ** ` mcp-events:by:<subscriber> ` ** : sorted sets of the event's and the subscriber's subscription ids,
414+ scored by expiry. The second one enforces the limit.
380415
381416** Not in Redis.** A task message in QStash carries only ` { taskId } ` . An event message carries the
382417full payload, which stays in QStash (and in its DLQ, if every retry fails) until it is delivered.
@@ -389,13 +424,13 @@ The toolkit never stores the caller's token, the request, the plaintext webhook
389424<summary ><b >All options</b ></summary >
390425
391426** ` createTaskLayer ` ** : ` store ` , ` dispatcher ` and ` principal ` are required. Optional:
392- ` defaults.ttlMs ` (1 day) and ` defaults.pollIntervalMs ` (2s).
427+ ` defaults.ttlMs ` (1 day) and ` defaults.pollIntervalMs ` (2s), in positive whole milliseconds .
393428
394429** ` tasks.define(name, config, handler) ` ** : ` description ` and ` inputSchema ` are required. Optional:
395- ` title ` , ` completedMessage ` .
430+ ` title ` , ` completedMessage ` , ` authorize(args, { principal, auth, request }) ` .
396431
397432** ` createEventLayer ` ** : ` store ` , ` delivery ` and ` principal ` are required, and so is ` secretKey `
398- unless ` MCP_EVENTS_SECRET_KEY ` is set. Optional: ` allowInsecureCallbacks ` .
433+ unless ` MCP_EVENTS_SECRET_KEY ` is set. Optional: ` maxSubscriptions ` (8), ` allowInsecureCallbacks ` .
399434
400435** ` events.define(name, config) ` ** : ` description ` , ` payload ` and ` authorize ` are required.
401436Optional: ` title ` , ` input ` .
@@ -436,15 +471,17 @@ interface TaskStore {
436471}
437472
438473interface TaskDispatcher <TContext = unknown > {
439- dispatch(task : Task ): Promise <void >; // idempotent per task id
474+ dispatch(task : Task ): Promise <void >; // called once per task
440475 cancel(taskId : string ): Promise <void >;
441476 createExecuteHandler(endpoints : TaskEndpoints <TContext >): (request : Request ) => Promise <Response >;
442477}
443478
444479interface SubscriptionStore {
445- put(subscription : Subscription ): Promise <void >;
480+ // false, storing nothing, when a new one would put its subscriber over `limit` (atomically)
481+ put(subscription : Subscription , options : { limit: number }): Promise <boolean >;
446482 get(id : string ): Promise <Subscription | null >;
447- delete(subscription : { id: string ; event: string }): Promise <void >;
483+ count(subscriber : string ): Promise <number >; // live subscriptions, across all events
484+ delete(subscription : { id: string ; event: string ; subscriber: string }): Promise <void >;
448485 find(event : string ): Promise <Subscription []>; // every live subscription to the event
449486}
450487
0 commit comments