@wakeshift/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/events.js CHANGED
@@ -11,9 +11,9 @@ import { McpError } from "@modelcontextprotocol/sdk/types.js";
11
11
  import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
12
12
  import { objectFromShape, safeParse } from "@modelcontextprotocol/sdk/server/zod-compat.js";
13
13
  import { WakeshiftError } from "./errors.js";
14
- import { guestOf } from "./guest.js";
15
- import { EXTENSION_KEY, HEADER_MISMATCH, INTERNAL_ERROR, INVALID_PARAMS, LEGACY_KEY, NAME_HEADER, RESULT_TYPE, codesFor, dialectOf, headerValue, } from "./spec.js";
16
- import { applyAddition } from "./wakeshift.js";
14
+ import { EXTENSION_KEY, HEADER_MISMATCH, INTERNAL_ERROR, INVALID_PARAMS, TOP_LEVEL_KEY, NAME_HEADER, RESULT_TYPE, codesFor, dialectOf, headerValue, } from "./spec.js";
15
+ import { toolCallOf } from "./wakeshift.js";
16
+ import { Attached, toolArgs } from "./toolcall.js";
17
17
  const nameRE = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*){0,4}$/;
18
18
  const argNameRE = /^[a-z][a-z0-9_]{0,63}$/;
19
19
  // jsonSchemaOf is a zod schema as JSON Schema, without $schema (Wakeshift
@@ -21,8 +21,42 @@ const argNameRE = /^[a-z][a-z0-9_]{0,63}$/;
21
21
  function jsonSchemaOf(s) {
22
22
  const out = toJsonSchemaCompat(s);
23
23
  delete out.$schema;
24
+ normalize(out);
24
25
  return out;
25
26
  }
27
+ // normalize puts a schema in the form all three SDKs give for the same
28
+ // definition (test/events-list.json): required in name order, whatever
29
+ // order the fields were defined in, and an integer without the bounds zod
30
+ // adds for .int() (JavaScript's safe integers), which the other SDKs don't.
31
+ function normalize(s) {
32
+ if (Array.isArray(s.required))
33
+ s.required = [...s.required].sort();
34
+ if (s.type === "integer") {
35
+ if (s.minimum === Number.MIN_SAFE_INTEGER)
36
+ delete s.minimum;
37
+ if (s.maximum === Number.MAX_SAFE_INTEGER)
38
+ delete s.maximum;
39
+ }
40
+ if (s.properties && typeof s.properties === "object") {
41
+ for (const p of Object.values(s.properties))
42
+ if (p && typeof p === "object")
43
+ normalize(p);
44
+ }
45
+ if (s.items && typeof s.items === "object" && !Array.isArray(s.items))
46
+ normalize(s.items);
47
+ for (const k of ["anyOf", "oneOf", "allOf"]) {
48
+ if (Array.isArray(s[k]))
49
+ for (const b of s[k])
50
+ if (b && typeof b === "object")
51
+ normalize(b);
52
+ }
53
+ }
54
+ // canonical is JSON with every object's keys in name order, as Wakeshift
55
+ // keeps a subscription's arguments: how the tools' results echo them.
56
+ function canonical(v) {
57
+ const sortKeys = (x) => Array.isArray(x) ? x.map(sortKeys) : x && typeof x === "object" ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, sortKeys(x[k])])) : x;
58
+ return JSON.stringify(sortKeys(v) ?? {});
59
+ }
26
60
  function isSchema(v) {
27
61
  return !!v && typeof v === "object" && ("_def" in v || "_zod" in v);
28
62
  }
@@ -82,7 +116,7 @@ export class WakeEvent {
82
116
  // ev/<name>/{a}/{b}: one segment per required scalar argument; an
83
117
  // optional one filters on the payload field of its name.
84
118
  const segs = ["ev", name];
85
- filter = filter ?? {};
119
+ filter = { ...filter }; // the caller's object stays as it was
86
120
  for (const arg of Object.keys(props).sort()) {
87
121
  if (!argNameRE.test(arg))
88
122
  throw new Error(`wakeshift: event ${name}: argument ${JSON.stringify(arg)}: lowercase letters, digits and _`);
@@ -94,6 +128,9 @@ export class WakeEvent {
94
128
  segs.push(`{${arg}}`);
95
129
  }
96
130
  else if (scalar && arg in payloadProps) {
131
+ // Both on the same field is refused: neither can quietly win.
132
+ if (opts.filter && Object.prototype.hasOwnProperty.call(opts.filter, arg))
133
+ throw new Error(`wakeshift: event ${name}: filter on ${arg} conflicts with optional argument ${arg}, which already filters that payload field (remove it from filter, or pass topic and filter)`);
97
134
  filter[arg] = `{${arg}}`;
98
135
  }
99
136
  else {
@@ -140,24 +177,65 @@ export class WakeEvent {
140
177
  d.title = this.title;
141
178
  return d;
142
179
  }
180
+ /** @internal The client the event was defined on. */
181
+ owner() {
182
+ return this.wake;
183
+ }
143
184
  /**
144
185
  * Sends one occurrence: to everyone subscribed with these arguments, or,
145
- * for a personal event, to the one subscriber `to` names. The payload is
186
+ * for a personal event, to the recipients `to` names. The payload is
146
187
  * checked against the event's schema first.
147
188
  */
148
189
  async emit(args, payload, opts = {}) {
149
190
  const a = this.parseArgs(args);
150
191
  const p = safeParse(this.payloadSchema, payload);
151
192
  if (!p.success)
152
- throw new WakeshiftError(400, "bad_payload", `payload for ${this.name}: ${issues(p.error)}`);
153
- const to = opts.to?.trim();
193
+ throw new ArgsError(`payload for ${this.name}: ${issues(p.error)}`);
194
+ const to = this.wireTo(opts.to);
154
195
  if (this.personal && !to)
155
- throw new WakeshiftError(400, "bad_arguments", `${this.name} is personal: pass { to }, the one subscriber it's for`);
196
+ throw new WakeshiftError(400, "bad_arguments", `${this.name} is personal: pass { to }, the subscribers it's for`);
156
197
  if (!this.personal && to)
157
198
  throw new WakeshiftError(400, "bad_arguments", `${this.name} isn't personal: it goes to everyone subscribed with these arguments, so it takes no to`);
158
199
  const text = this.opts.text(p.data, a);
159
200
  return this.wake.emitEvent({ name: this.name, arguments: a, payload: p.data, text, id: opts.id, to });
160
201
  }
202
+ // wireTo is what the core takes for to: one plain user as a string,
203
+ // anything else as a list of "user", { user, subscription } and
204
+ // { native: user }, each name trimmed of the whitespace around it, as every
205
+ // SDK does (test/emit-recipients.json). undefined: no recipients.
206
+ wireTo(to) {
207
+ if (to === undefined)
208
+ return undefined;
209
+ const empty = (what) => new WakeshiftError(400, "bad_arguments", `${this.name}: a recipient's ${what} is empty`);
210
+ const out = [];
211
+ for (const r of Array.isArray(to) ? to : [to]) {
212
+ if (typeof r === "string") {
213
+ if (!r.trim())
214
+ throw empty("user");
215
+ out.push(r.trim());
216
+ }
217
+ else if (r && typeof r === "object" && "native" in r && typeof r.native === "string") {
218
+ if (!r.native.trim())
219
+ throw empty("user");
220
+ out.push({ native: r.native.trim() });
221
+ }
222
+ else if (r && typeof r === "object" && "user" in r && typeof r.user === "string" && typeof r.subscription === "string") {
223
+ if (!r.user.trim())
224
+ throw empty("user");
225
+ if (!r.subscription.trim())
226
+ throw empty("subscription");
227
+ out.push({ user: r.user.trim(), subscription: r.subscription.trim() });
228
+ }
229
+ else {
230
+ throw new WakeshiftError(400, "bad_arguments", `${this.name}: a recipient is a user, { user, subscription } or { native: user }`);
231
+ }
232
+ }
233
+ if (out.length === 0)
234
+ return undefined;
235
+ if (out.length === 1 && typeof out[0] === "string")
236
+ return out[0];
237
+ return out;
238
+ }
161
239
  /**
162
240
  * A capability URL that emits this event with these arguments (a personal
163
241
  * event: to the one subscriber `to` names), for services that can only
@@ -168,6 +246,9 @@ export class WakeEvent {
168
246
  */
169
247
  async publishUrl(args, opts = {}) {
170
248
  const a = this.parseArgs(args);
249
+ if (opts.to !== undefined && typeof opts.to !== "string") {
250
+ throw new WakeshiftError(400, "bad_arguments", `${this.name}: a publish URL's to is exactly one user`);
251
+ }
171
252
  const to = opts.to?.trim();
172
253
  if (this.personal && !to)
173
254
  throw new WakeshiftError(400, "bad_arguments", `${this.name} is personal: pass { to }, the one subscriber it's for`);
@@ -179,63 +260,86 @@ export class WakeEvent {
179
260
  return this.wake.requestApp("POST", "/events/publish-urls", body);
180
261
  }
181
262
  /**
182
- * Subscribes the conversation calling your tool, the way subscribe_events
183
- * would, so a tool like "join" can set up its own wakes in one step. For
184
- * clients that subscribe through Wakeshift's wake paths; native MCP Events
185
- * clients subscribe themselves. Your tool decides who may: authorize isn't
186
- * consulted here. user is the app's ID for the person (required for a
187
- * personal event); wakeArgs are your tool's wake.args (session_key,
188
- * harness, conversation_id).
263
+ * How many conversations are subscribed to this event with these
264
+ * arguments (a personal event: those of the one user `to` names), how
265
+ * many are connected now, and how. Only reads.
189
266
  */
190
- async subscribe(extra, args, opts) {
267
+ async status(args, opts = {}) {
191
268
  const a = this.parseArgs(args);
192
- if (this.personal && !opts.user) {
193
- throw new WakeshiftError(400, "bad_arguments", `${this.name} is personal: pass user, who the subscription belongs to`);
269
+ if (opts.to !== undefined && typeof opts.to !== "string") {
270
+ throw new WakeshiftError(400, "bad_arguments", `${this.name}: status's to is exactly one user`);
194
271
  }
195
- const out = await this.wake.requestApp("POST", "/event-subscriptions", {
196
- call: this.wake.call(extra, { server: opts.server, client: opts.client, user: opts.user, args: opts.wakeArgs ?? {} }),
197
- name: this.name,
198
- arguments: a,
199
- });
200
- const addition = out.addition ?? {};
201
- return {
202
- id: out.event_subscription.id,
203
- created: out.event_subscription.created,
204
- session: out.session,
205
- agent: out.agent ?? { harness: null, via: null, claimed: null },
206
- result: (text) => applyAddition(addition, text),
207
- };
272
+ const to = opts.to?.trim();
273
+ if (this.personal && !to)
274
+ throw new WakeshiftError(400, "bad_arguments", `${this.name} is personal: pass { to }, the one user to count for`);
275
+ if (!this.personal && to)
276
+ throw new WakeshiftError(400, "bad_arguments", `${this.name} isn't personal: status takes no to`);
277
+ const body = { name: this.name, arguments: a };
278
+ if (to)
279
+ body.to = to;
280
+ const out = await this.wake.requestApp("POST", "/events/status", body);
281
+ return { subscriptions: out.subscriptions, connected: out.connected, via: { stream: 0, poll: 0, hooks: 0, webhook: 0, ...out.via } };
208
282
  }
209
283
  /**
210
- * Ends the calling conversation's subscription with these arguments, the
211
- * way unsubscribe_events would: for a tool like "unwatch". False if it
212
- * wasn't subscribed.
284
+ * Reads subscriptions to this event by ID, each with the user
285
+ * `call.subscribe` made it for, up to 1,000 at once: whether each is live
286
+ * and its conversation connected now, and how. Only reads.
213
287
  */
214
- async unsubscribe(extra, args, opts) {
215
- const a = this.parseArgs(args);
216
- const out = await this.wake.requestApp("POST", "/event-subscriptions/unsubscribe", {
217
- call: this.wake.call(extra, { server: opts.server, client: opts.client, user: opts.user, args: opts.wakeArgs ?? {} }),
218
- name: this.name,
219
- arguments: a,
288
+ async statusOf(subs) {
289
+ if (subs.length === 0)
290
+ return [];
291
+ if (subs.length > MAX_STATUS_SUBSCRIPTIONS) {
292
+ throw new WakeshiftError(400, "bad_request", `${this.name}: at most ${MAX_STATUS_SUBSCRIPTIONS} subscriptions in one statusOf`);
293
+ }
294
+ const refs = subs.map((s) => {
295
+ if (!s || typeof s.user !== "string" || !s.user.trim() || typeof s.subscription !== "string" || !s.subscription.trim()) {
296
+ throw new WakeshiftError(400, "bad_request", `${this.name}: statusOf needs each subscription's user and subscription`);
297
+ }
298
+ return { user: s.user.trim(), id: s.subscription.trim() };
299
+ });
300
+ const out = await this.wake.requestApp("POST", "/events/status", { name: this.name, subscriptions: refs });
301
+ return (out.subscriptions ?? []).map((s) => {
302
+ const r = { id: s.id, user: s.user, live: !!s.live, connected: !!s.connected };
303
+ if (s.via)
304
+ r.via = s.via;
305
+ return r;
220
306
  });
221
- return !!out.ended;
222
307
  }
223
- /** Ends one of user's subscriptions to this app's events, by its ID (from subscribe or list_subscriptions). False if there was none. */
308
+ /**
309
+ * Ends one of user's subscriptions to this app's events, by its ID (a
310
+ * Subscription's id from call.subscribe, or list_subscriptions'), outside
311
+ * a tool: a player who timed out, a member removed. False if there was none.
312
+ */
224
313
  async end(user, id) {
225
314
  const out = await this.wake.requestApp("POST", "/event-subscriptions/end", { user, id });
226
315
  return !!out.ended;
227
316
  }
228
317
  }
229
- /** A subscriber's arguments don't match the event's schema. */
230
- export class ArgsError extends Error {
318
+ /** The most subscriptions one `statusOf` reads. */
319
+ export const MAX_STATUS_SUBSCRIPTIONS = 1000;
320
+ /**
321
+ * An event's arguments or payload don't match its schema: a WakeshiftError
322
+ * with code bad_arguments (status 400), as Wakeshift answers the same
323
+ * mistake, thrown before asking it.
324
+ */
325
+ export class ArgsError extends WakeshiftError {
326
+ constructor(message) {
327
+ super(400, "bad_arguments", message);
328
+ this.name = "ArgsError";
329
+ }
231
330
  }
232
- // What a client says about itself on initialize, or a request's _meta.
233
- function clientInfoName(server, meta) {
331
+ /** The tools attach adds, unless AttachOptions.tools names fewer. */
332
+ export const BUILTIN_TOOLS = ["subscribe_events", "unsubscribe_events", "list_subscriptions"];
333
+ // What a client says about itself on initialize, or a request's _meta;
334
+ // else the name identify gave (identified).
335
+ function clientInfoName(server, meta, identified) {
234
336
  const fromInit = server.server.getClientVersion()?.name;
235
337
  if (fromInit)
236
338
  return fromInit;
237
339
  const info = meta?.["io.modelcontextprotocol/clientInfo"];
238
- return typeof info?.name === "string" ? info.name : "";
340
+ if (typeof info?.name === "string" && info.name)
341
+ return info.name;
342
+ return identified ?? "";
239
343
  }
240
344
  function header(extra, name) {
241
345
  const h = extra?.requestInfo?.headers;
@@ -245,9 +349,10 @@ function header(extra, name) {
245
349
  return (Array.isArray(v) ? v[0] : v) ?? "";
246
350
  }
247
351
  /** A client that speaks MCP Events: it declared the extension, or it's one known to (ChatGPT). */
352
+ /** @internal */
248
353
  export function speaksEvents(capabilities, clientName) {
249
354
  const ext = capabilities?.extensions?.[EXTENSION_KEY];
250
- return !!ext || !!capabilities?.[LEGACY_KEY] || clientName.toLowerCase() === "openai-mcp";
355
+ return !!ext || clientName.toLowerCase() === "openai-mcp";
251
356
  }
252
357
  // The fixed wording of the tools attach adds (tested: bench S1–S9 run
253
358
  // through them), and of the refusals of a native subscribe. The Go SDK
@@ -300,12 +405,29 @@ const MAX_LISTED_EVENTS = 20;
300
405
  export function attachEvents(wake, server, events, opts = {}) {
301
406
  if (events.length === 0)
302
407
  throw new Error("wakeshift: attach needs at least one event (wake.defineEvent) defined first");
303
- const identify = opts.identify ?? ((extra) => ({ user: guestOf(extra)?.user }));
304
- const userOf = (extra) => identify(extra)?.user;
408
+ for (const t of opts.tools ?? []) {
409
+ if (!BUILTIN_TOOLS.includes(t))
410
+ throw new Error(`wakeshift: attach's tools names "${t}": its own tools are ${BUILTIN_TOOLS.join(", ")}`);
411
+ }
412
+ const tools = new Set(opts.tools ?? BUILTIN_TOOLS);
413
+ const identityOf = (extra) => opts.identify?.(extra) ?? undefined;
414
+ // Who's calling: the user, and the client's name (the request's, else identify's).
415
+ const callerOf = (extra) => {
416
+ const id = extra ? identityOf(extra) : undefined;
417
+ return { user: id?.user, client: clientInfoName(server, extra?._meta, id?.client) };
418
+ };
419
+ const attached = new Attached({
420
+ wake,
421
+ server,
422
+ identify: (extra) => identityOf(extra),
423
+ clientOf: (meta, identified) => clientInfoName(server, meta, identified),
424
+ events: opts.events ?? {},
425
+ warnOnce: (key, message) => wake.warnOnce(key, message),
426
+ });
305
427
  const byName = new Map(events.map((e) => [e.name, e]));
306
428
  const low = server.server;
307
429
  try {
308
- low.registerCapabilities({ [LEGACY_KEY]: { listChanged: false }, extensions: { [EXTENSION_KEY]: { listChanged: false } } });
430
+ low.registerCapabilities({ [TOP_LEVEL_KEY]: { listChanged: false }, extensions: { [EXTENSION_KEY]: { listChanged: false } } });
309
431
  }
310
432
  catch (err) {
311
433
  throw new Error("wakeshift: call attach before connecting the server to a transport", { cause: err });
@@ -322,7 +444,8 @@ export function attachEvents(wake, server, events, opts = {}) {
322
444
  const native = async (req, extra, unsubscribe) => {
323
445
  const p = (req.params ?? {});
324
446
  const meta = (p._meta ?? {});
325
- const client = clientInfoName(server, meta);
447
+ const id = identityOf(extra);
448
+ const client = clientInfoName(server, meta, id?.client);
326
449
  const ua = header(extra, "user-agent");
327
450
  const dialect = dialectOf(client, ua);
328
451
  const codes = codesFor(dialect);
@@ -341,9 +464,9 @@ export function attachEvents(wake, server, events, opts = {}) {
341
464
  args = ev.parseArgs(p.arguments ?? {});
342
465
  }
343
466
  catch (err) {
344
- throw new McpError(INVALID_PARAMS, err.message);
467
+ throw new McpError(INVALID_PARAMS, err instanceof WakeshiftError ? err.detail : err.message);
345
468
  }
346
- const user = userOf(extra);
469
+ const user = id?.user;
347
470
  if (!user)
348
471
  throw new McpError(codes.forbidden, fill("native_no_user"));
349
472
  const call = { user, client, meta, ...(ua ? { user_agent: ua } : {}) };
@@ -388,17 +511,19 @@ export function attachEvents(wake, server, events, opts = {}) {
388
511
  const listed = events
389
512
  .slice(0, MAX_LISTED_EVENTS)
390
513
  .map((e) => {
391
- const props = Object.keys(e.manifest.arguments.properties ?? {});
514
+ const props = Object.keys(e.manifest.arguments.properties ?? {}).sort();
392
515
  return fill("event_line", { name: e.name, arguments: props.join(", "), description: e.description });
393
516
  })
394
517
  .join("\n");
395
518
  const more = events.length > MAX_LISTED_EVENTS ? fill("more", { n: events.length - MAX_LISTED_EVENTS }) : "";
396
519
  const argsInput = z.record(z.string(), z.any()).optional().describe(EVENT_TOOL_TEXT.arguments_argument);
397
- const result = (text, addition) => applyAddition(addition ?? {}, text);
520
+ const result = (text, addition) => withAddition(text, addition ?? {});
398
521
  const fail = (text) => ({ isError: true, content: [{ type: "text", text }] });
399
- const subscribeTool = server.registerTool("subscribe_events", {
522
+ // register adds one of the tools, if the app wants it (attach's tools).
523
+ const register = (name, config, cb) => (tools.has(name) ? server.registerTool(name, config, cb) : undefined);
524
+ const subscribeTool = register("subscribe_events", {
400
525
  description: `${EVENT_TOOL_TEXT.subscribe_description}${EVENT_TOOL_TEXT.events_heading}${listed}${more}`,
401
- inputSchema: { event: z.enum(names).describe(EVENT_TOOL_TEXT.event_argument), arguments: argsInput, ...wake.args },
526
+ inputSchema: { event: z.enum(names).describe(EVENT_TOOL_TEXT.event_argument), arguments: argsInput, ...toolArgs },
402
527
  }, async (input, extra) => {
403
528
  const { event, arguments: raw, ...rest } = input;
404
529
  const ev = byName.get(event);
@@ -409,21 +534,21 @@ export function attachEvents(wake, server, events, opts = {}) {
409
534
  args = ev.parseArgs(raw ?? {});
410
535
  }
411
536
  catch (err) {
412
- return fail(err.message);
537
+ return fail(err instanceof WakeshiftError ? err.detail : err.message);
413
538
  }
414
- const user = userOf(extra);
539
+ const { user, client } = callerOf(extra);
415
540
  if (ev.personal && !user)
416
541
  return fail(fill("personal_no_user", { event: ev.name }));
417
- if (!(await ev.allows({ user, client: clientInfoName(server, extra?._meta), native: false }, args))) {
542
+ if (!(await ev.allows({ user, client, native: false }, args))) {
418
543
  return fail(fill("refused", { event: ev.name }));
419
544
  }
420
545
  try {
421
546
  const out = await wake.requestApp("POST", "/event-subscriptions", {
422
- call: wake.call(extra, { server, user, args: rest }),
547
+ call: toolCallOf(extra, client, user, rest, "subscribe_events"),
423
548
  name: ev.name,
424
549
  arguments: args,
425
550
  });
426
- const what = { event: ev.name, arguments: JSON.stringify(args) };
551
+ const what = { event: ev.name, arguments: canonical(args) };
427
552
  const lead = fill(out.event_subscription.created ? "subscribed" : "renewed", what);
428
553
  return result(lead, out.addition);
429
554
  }
@@ -431,17 +556,17 @@ export function attachEvents(wake, server, events, opts = {}) {
431
556
  return fail(fill("subscribe_failed", { detail: detailOf(err) }));
432
557
  }
433
558
  });
434
- server.registerTool("unsubscribe_events", {
559
+ register("unsubscribe_events", {
435
560
  description: EVENT_TOOL_TEXT.unsubscribe_description,
436
561
  inputSchema: {
437
562
  event: z.enum(names).optional().describe(EVENT_TOOL_TEXT.event_argument),
438
563
  arguments: argsInput,
439
564
  id: z.string().optional().describe(EVENT_TOOL_TEXT.id_argument),
440
- ...wake.args,
565
+ ...toolArgs,
441
566
  },
442
567
  }, async (input, extra) => {
443
568
  const { event, arguments: raw, id, ...rest } = input;
444
- const user = userOf(extra);
569
+ const { user, client } = callerOf(extra);
445
570
  try {
446
571
  if (id) {
447
572
  if (!user)
@@ -454,18 +579,21 @@ export function attachEvents(wake, server, events, opts = {}) {
454
579
  return fail(fill("need"));
455
580
  const args = ev.parseArgs(raw ?? {});
456
581
  const out = await wake.requestApp("POST", "/event-subscriptions/unsubscribe", {
457
- call: wake.call(extra, { server, user, args: rest }),
582
+ call: toolCallOf(extra, client, user, rest, "unsubscribe_events"),
458
583
  name: ev.name,
459
584
  arguments: args,
460
585
  });
461
- return result(fill(out.ended ? "unsubscribed" : "not_subscribed", { event: ev.name, arguments: JSON.stringify(args) }));
586
+ return result(fill(out.ended ? "unsubscribed" : "not_subscribed", { event: ev.name, arguments: canonical(args) }));
462
587
  }
463
588
  catch (err) {
464
- return fail(err instanceof ArgsError ? err.message : fill("unsubscribe_failed", { detail: detailOf(err) }));
589
+ return fail(err instanceof ArgsError ? err.detail : fill("unsubscribe_failed", { detail: detailOf(err) }));
465
590
  }
466
591
  });
467
- server.registerTool("list_subscriptions", { description: EVENT_TOOL_TEXT.list_description, inputSchema: { ...wake.args } }, async (_input, extra) => {
468
- const user = userOf(extra);
592
+ // list_subscriptions only reads: marked so (readOnlyHint), clients that
593
+ // approve read-only tools without asking (Codex, by default) don't ask; and
594
+ // idempotent, as in every SDK (test/events-list.json).
595
+ register("list_subscriptions", { description: EVENT_TOOL_TEXT.list_description, inputSchema: { ...toolArgs }, annotations: { readOnlyHint: true, idempotentHint: true } }, async (_input, extra) => {
596
+ const user = identityOf(extra)?.user;
469
597
  if (!user)
470
598
  return fail(fill("list_no_user"));
471
599
  try {
@@ -489,27 +617,40 @@ export function attachEvents(wake, server, events, opts = {}) {
489
617
  low.oninitialized = () => {
490
618
  // Set both ways: a server reconnected to another client decides again.
491
619
  if (speaksEvents(low.getClientCapabilities(), low.getClientVersion()?.name ?? ""))
492
- subscribeTool.disable();
620
+ subscribeTool?.disable();
493
621
  else
494
- subscribeTool.enable();
622
+ subscribeTool?.enable();
495
623
  prev?.();
496
624
  };
625
+ return attached;
626
+ }
627
+ // withAddition is one of attach's own tools' results: its text, then
628
+ // Wakeshift's, with Wakeshift's _meta.
629
+ function withAddition(base, a) {
630
+ const text = (base.trim() + "\n\n" + (a.text ?? "")).trim();
631
+ const out = { content: text ? [{ type: "text", text }] : [] };
632
+ if (a.meta && Object.keys(a.meta).length > 0)
633
+ out._meta = { ...a.meta };
634
+ return out;
497
635
  }
498
636
  // rpcErrorOf is the JSON-RPC error for a failed native call, in the
499
637
  // client's dialect's codes. A conversation that isn't the caller's is
500
638
  // Forbidden, whether it exists or not.
501
- function rpcErrorOf(err, codes) {
639
+ /** @internal */
640
+ export function rpcErrorOf(err, codes) {
502
641
  if (err instanceof McpError)
503
642
  return err;
504
- if (!(err instanceof WakeshiftError))
643
+ if (!(err instanceof WakeshiftError) || err.code === "unreachable")
505
644
  return new McpError(INTERNAL_ERROR, "Wakeshift couldn't be reached");
506
645
  switch (err.code) {
507
646
  case "callback_failed":
508
- return new McpError(codes.callbackEndpoint, err.detail, { reason: err.reason });
647
+ return new McpError(codes.callbackEndpoint, err.detail, { reason: err.reason ?? "" });
509
648
  case "unknown_event":
510
649
  return new McpError(codes.notFound, err.detail, { kind: "event" });
511
650
  case "unsupported":
512
651
  return new McpError(codes.unsupported, err.detail, { feature: "deliveryMode" });
652
+ case "subscription_limit":
653
+ return new McpError(codes.resourceExhausted, err.detail, { kind: "subscriptions" });
513
654
  case "bad_request":
514
655
  case "bad_arguments":
515
656
  return new McpError(INVALID_PARAMS, err.detail);