slotlock 0.5.2 → 0.5.3

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/agent-server.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { AsyncResource } from 'node:async_hooks';
1
2
  import { createHash, randomBytes } from 'node:crypto';
2
3
  import { z } from 'zod';
3
4
  import { SLOTLOCK_MCP_APPS_EXTENSION, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, clientSupportsSlotlockMcpApp, } from './mcp-app.js';
4
5
  import { MCP_META_PROTOCOL_VERSION, MCP_META_SERVER_INFO, MCP_META_SUBSCRIPTION_ID, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, canonicalJson, checkModernMcpRequest, isRecord, openMcpEventStream, openRequestState, principalTag, sealRequestState, sha256Hex, stateSecrets, traceContextFrom, } from './mcp-modern.js';
5
6
  import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
7
+ import { createSlotlockChangeReaders, } from './change-watch.js';
6
8
  import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
7
9
  import { ignoreRejection } from './hooks.js';
8
10
  import { slotlockPackageVersion } from './package-version.js';
@@ -1185,6 +1187,8 @@ export async function invokeSlotlockAgentOperation(args) {
1185
1187
  };
1186
1188
  if (args.trace)
1187
1189
  context.trace = args.trace;
1190
+ if (args.origin)
1191
+ context.origin = args.origin;
1188
1192
  const data = await method.call(backend, context, parsed.data);
1189
1193
  if (!operation.output.safeParse(data).success) {
1190
1194
  // Neither the result nor the schema's complaint about it: either may hold tenant data.
@@ -1226,12 +1230,14 @@ function traceIdOf(trace) {
1226
1230
  /** Failures that are server faults, not operation outcomes an agent could act on. */
1227
1231
  const INTERNAL_FAILURES = new Set(['internal_error', 'invalid_backend_result']);
1228
1232
  /**
1229
- * A2A 1.0 methods (a2a.proto `A2AService`) this synchronous, stateless binding does not implement.
1230
- * A2A §3.3.4 fixes the error for the capability-gated ones: push-notification configuration answers
1231
- * `PushNotificationNotSupportedError`; streaming, task subscription and the extended card answer
1232
- * `UnsupportedOperationError`. GetTask, CancelTask and ListTasks are core operations every agent
1233
- * answers; since every reply here is a message, no task id exists: `TaskNotFoundError` for the
1234
- * first two (§3.1) and an empty page for the list.
1233
+ * A2A 1.0 methods (a2a.proto `A2AService`) this binding does not implement. A2A §3.3.4 fixes the
1234
+ * error for the capability-gated ones: push-notification configuration answers
1235
+ * `PushNotificationNotSupportedError` (a tenant's changes reach a URL as signed webhooks instead);
1236
+ * task subscription and the extended card answer `UnsupportedOperationError`. `SendStreamingMessage`
1237
+ * is served: a skill's reply as a one-message stream, and the watch skill as a task whose stream is
1238
+ * the only place it lives, so it cannot be subscribed to again. GetTask, CancelTask and ListTasks are
1239
+ * core operations every agent answers; no task is kept to find: `TaskNotFoundError` for the first
1240
+ * two (§3.1) and an empty page for the list.
1235
1241
  */
1236
1242
  const A2A_PUSH_NOTIFICATION_METHODS = new Set([
1237
1243
  'CreateTaskPushNotificationConfig',
@@ -1240,7 +1246,6 @@ const A2A_PUSH_NOTIFICATION_METHODS = new Set([
1240
1246
  'DeleteTaskPushNotificationConfig',
1241
1247
  ]);
1242
1248
  const A2A_UNSUPPORTED_METHODS = new Set([
1243
- 'SendStreamingMessage',
1244
1249
  'SubscribeToTask',
1245
1250
  'GetExtendedAgentCard',
1246
1251
  ]);
@@ -1450,9 +1455,19 @@ const INVISIBLE_STATUSES = new Set([400, 403, 404]);
1450
1455
  const MAX_RESOURCE_SUBSCRIPTIONS = 20;
1451
1456
  /** Consecutive failed re-reads after which a subscription is abandoned. */
1452
1457
  const MAX_POLL_FAILURES = 3;
1458
+ /** The least time between the starts of two re-reads of one subscription a change asked for, in ms. */
1459
+ const CHANGE_REREAD_MIN_MS = 1_000;
1453
1460
  function validateSubscriptions(value) {
1454
1461
  if (value === false)
1455
1462
  return null;
1463
+ const changes = value?.changes ?? null;
1464
+ if (changes !== null &&
1465
+ (typeof changes !== 'object' ||
1466
+ typeof changes.head !== 'function' ||
1467
+ typeof changes.listChanges !== 'function' ||
1468
+ (changes.watch !== undefined && typeof changes.watch !== 'function'))) {
1469
+ throw new Error('Slotlock agent server subscriptions.changes is invalid');
1470
+ }
1456
1471
  const bounded = (name, min, max, fallback) => {
1457
1472
  const setting = value?.[name] ?? fallback;
1458
1473
  if (!Number.isInteger(setting) || setting < min || setting > max) {
@@ -1462,7 +1477,8 @@ function validateSubscriptions(value) {
1462
1477
  };
1463
1478
  const maxTotal = bounded('maxTotal', 1, 10_000, 256);
1464
1479
  return {
1465
- pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
1480
+ pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, changes ? 15_000 : 10_000),
1481
+ changes,
1466
1482
  maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
1467
1483
  keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
1468
1484
  maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
@@ -2161,8 +2177,15 @@ function routePath(pathname, basePath) {
2161
2177
  return '/';
2162
2178
  return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : null;
2163
2179
  }
2180
+ /**
2181
+ * The A2A skill that watches calendars: `SendStreamingMessage` only. Its stream opens with a task
2182
+ * in TASK_STATE_WORKING and carries a status update for each change to a watched resource's
2183
+ * free/busy, from the same feed as MCP resource subscriptions, until the watch's duration is up.
2184
+ */
2185
+ export const SLOTLOCK_A2A_WATCH_SKILL = 'slotlock_watch_resources';
2186
+ const A2A_WATCH_EXAMPLE = { resource_ids: ['018f2f8e-7f8a-7c3e-9b1a-2a4c6e8f0a1b'] };
2164
2187
  /** The A2A 1.0 agent card, in the JSON form of a2a.proto's `AgentCard`. */
2165
- function agentCard(publicBaseUrl) {
2188
+ function agentCard(publicBaseUrl, watch) {
2166
2189
  return Object.freeze({
2167
2190
  name: 'Slotlock Agent Calendar',
2168
2191
  description: `Tenant-isolated resource events, recurrence, free-busy, and scheduling. Invoke a skill with one application/json data part {"skill": "<skill id>", "arguments": {…}}; argument and result JSON Schemas are published at ${publicBaseUrl}/manifest.json.`,
@@ -2174,20 +2197,37 @@ function agentCard(publicBaseUrl) {
2174
2197
  },
2175
2198
  ],
2176
2199
  version: SLOTLOCK_AGENT_SERVER_VERSION,
2177
- capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
2200
+ capabilities: { streaming: true, pushNotifications: false, extendedAgentCard: false },
2178
2201
  securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: 'Bearer' } } },
2179
2202
  securityRequirements: [{ schemes: { bearer: { list: [] } } }],
2180
2203
  defaultInputModes: ['application/json'],
2181
2204
  defaultOutputModes: ['application/json'],
2182
- skills: OPERATION_DEFINITIONS.map((operation) => ({
2183
- id: operation.name,
2184
- name: operation.title,
2185
- description: operation.description,
2186
- tags: ['calendar', 'scheduling', 'resources'],
2187
- examples: [JSON.stringify({ skill: operation.name, arguments: operation.example })],
2188
- inputModes: ['application/json'],
2189
- outputModes: ['application/json'],
2190
- })),
2205
+ skills: [
2206
+ ...OPERATION_DEFINITIONS.map((operation) => ({
2207
+ id: operation.name,
2208
+ name: operation.title,
2209
+ description: operation.description,
2210
+ tags: ['calendar', 'scheduling', 'resources'],
2211
+ examples: [JSON.stringify({ skill: operation.name, arguments: operation.example })],
2212
+ inputModes: ['application/json'],
2213
+ outputModes: ['application/json'],
2214
+ })),
2215
+ ...(watch
2216
+ ? [
2217
+ {
2218
+ id: SLOTLOCK_A2A_WATCH_SKILL,
2219
+ name: 'Watch calendar resources',
2220
+ description: 'SendStreamingMessage only. Watch up to 20 resources (resource_ids) for changes to their free/busy: the stream opens with a task in TASK_STATE_WORKING and carries a status update {"resource_id", "uri"} for each change, until the watch ends (TASK_STATE_COMPLETED) or access is withdrawn (TASK_STATE_FAILED). Read the resource again on each update.',
2221
+ tags: ['calendar', 'resources', 'watch'],
2222
+ examples: [
2223
+ JSON.stringify({ skill: SLOTLOCK_A2A_WATCH_SKILL, arguments: A2A_WATCH_EXAMPLE }),
2224
+ ],
2225
+ inputModes: ['application/json'],
2226
+ outputModes: ['application/json'],
2227
+ },
2228
+ ]
2229
+ : []),
2230
+ ],
2191
2231
  });
2192
2232
  }
2193
2233
  export function createSlotlockAgentServer(options) {
@@ -2241,7 +2281,7 @@ export function createSlotlockAgentServer(options) {
2241
2281
  },
2242
2282
  capabilities: slotlockAgentTools(),
2243
2283
  });
2244
- const card = agentCard(config.publicBaseUrl);
2284
+ const card = agentCard(config.publicBaseUrl, options.subscriptions !== false);
2245
2285
  const confirmation = validateConfirmation(options.confirmation);
2246
2286
  const emit = (event) => {
2247
2287
  try {
@@ -2268,7 +2308,7 @@ export function createSlotlockAgentServer(options) {
2268
2308
  /** Run an operation an MCP or A2A caller named, and report how it ended as a `call` event. */
2269
2309
  const invokeReported = async (protocol, args) => {
2270
2310
  const started = performance.now();
2271
- const outcome = await invokeSlotlockAgentOperation(args);
2311
+ const outcome = await invokeSlotlockAgentOperation({ ...args, origin: protocol });
2272
2312
  reportCall(protocol, args.operation, outcome.ok ? 'ok' : outcome.code, started, args.trace);
2273
2313
  return outcome;
2274
2314
  };
@@ -2306,6 +2346,10 @@ export function createSlotlockAgentServer(options) {
2306
2346
  /** Open subscriptions (and slots reserved by ones still opening), for the caps and shutdown. */
2307
2347
  const openSubscriptions = new Set();
2308
2348
  let shuttingDown = false;
2349
+ /** One feed reader per tenant with an open subscription, when there is a feed to follow. */
2350
+ const changeReaders = subscriptionPolicy?.changes
2351
+ ? createSlotlockChangeReaders(subscriptionPolicy.changes, (error) => reportUnexpected(options.onError, error, null))
2352
+ : null;
2309
2353
  const discoverResult = Object.freeze({
2310
2354
  supportedVersions: [...SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS],
2311
2355
  capabilities: {
@@ -2444,6 +2488,7 @@ export function createSlotlockAgentServer(options) {
2444
2488
  input: { resource_ids: resourceIds, start: window.start, end: window.end },
2445
2489
  request: call.request,
2446
2490
  options: dispatch,
2491
+ origin: 'mcp',
2447
2492
  ...(call.trace ? { trace: call.trace } : {}),
2448
2493
  });
2449
2494
  /**
@@ -2462,6 +2507,7 @@ export function createSlotlockAgentServer(options) {
2462
2507
  input: cursor === undefined ? {} : { cursor },
2463
2508
  request: call.request,
2464
2509
  options: dispatchOptions(call.principal),
2510
+ origin: 'mcp',
2465
2511
  ...(call.trace ? { trace: call.trace } : {}),
2466
2512
  });
2467
2513
  if (!outcome.ok) {
@@ -2560,22 +2606,10 @@ export function createSlotlockAgentServer(options) {
2560
2606
  return { ok: true, digests };
2561
2607
  }
2562
2608
  /**
2563
- * `subscriptions/listen` (2026-07-28 basic/patterns/subscriptions). The response is an SSE stream
2564
- * that opens with `notifications/subscriptions/acknowledged` naming the calendar resources this
2565
- * principal may watch, then carries `notifications/resources/updated` for each change until the
2566
- * client closes it or the server ends it gracefully (its duration, shutdown, lost access) with the
2567
- * request's result. Nothing is honored but resource subscriptions; a subscription honoring nothing
2568
- * ends right after its acknowledgment.
2609
+ * A subscription slot for the principal, held while it opens so concurrent opens cannot
2610
+ * overshoot the caps; null when a cap is reached. MCP subscriptions and A2A watches share them.
2569
2611
  */
2570
- async function modernListen(call) {
2571
- const { id, principal } = call;
2572
- if (!subscriptionPolicy)
2573
- return modernError(404, id, -32601, 'Method not found');
2574
- const filter = parseSubscriptionFilter(call.params.notifications);
2575
- if (!filter.ok)
2576
- return modernError(200, id, -32602, filter.message, filter.data);
2577
- if (shuttingDown)
2578
- return modernError(503, id, -32603, 'Server is shutting down');
2612
+ function reserveSubscriptionSlot(policy, principal) {
2579
2613
  const principalKey = `${principal.tenantRef}\0${principal.subject}`;
2580
2614
  let held = 0;
2581
2615
  let heldByTenant = 0;
@@ -2585,9 +2619,9 @@ export function createSlotlockAgentServer(options) {
2585
2619
  if (open.tenantRef === principal.tenantRef)
2586
2620
  heldByTenant += 1;
2587
2621
  }
2588
- if (openSubscriptions.size >= subscriptionPolicy.maxTotal &&
2589
- held < subscriptionPolicy.maxPerPrincipal &&
2590
- heldByTenant < subscriptionPolicy.maxPerTenant) {
2622
+ if (openSubscriptions.size >= policy.maxTotal &&
2623
+ held < policy.maxPerPrincipal &&
2624
+ heldByTenant < policy.maxPerTenant) {
2591
2625
  // A full server is shared out: a tenant holding fewer than the one holding most, less one,
2592
2626
  // ends that tenant's oldest stream, so many tenants within their share cannot keep another
2593
2627
  // from holding any. A slot still opening has nothing to end yet.
@@ -2608,12 +2642,11 @@ export function createSlotlockAgentServer(options) {
2608
2642
  if (most.length > heldByTenant + 1)
2609
2643
  most[0]?.end('preempted');
2610
2644
  }
2611
- if (openSubscriptions.size >= subscriptionPolicy.maxTotal ||
2612
- held >= subscriptionPolicy.maxPerPrincipal ||
2613
- heldByTenant >= subscriptionPolicy.maxPerTenant) {
2614
- return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
2645
+ if (openSubscriptions.size >= policy.maxTotal ||
2646
+ held >= policy.maxPerPrincipal ||
2647
+ heldByTenant >= policy.maxPerTenant) {
2648
+ return null;
2615
2649
  }
2616
- // The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
2617
2650
  const slot = {
2618
2651
  principalKey,
2619
2652
  tenantRef: principal.tenantRef,
@@ -2621,13 +2654,43 @@ export function createSlotlockAgentServer(options) {
2621
2654
  end: () => { },
2622
2655
  };
2623
2656
  openSubscriptions.add(slot);
2657
+ return slot;
2658
+ }
2659
+ /**
2660
+ * The window a watch covers. A read's window rolls forward with the clock; a watch covers one
2661
+ * fixed window that holds every window a read can return while it is open (from now to its last
2662
+ * possible read's end), so a booking entering the rolling end is announced, and the clock alone
2663
+ * changes nothing.
2664
+ */
2665
+ function watchWindow(policy) {
2666
+ const window = calendarResourceWindow(Date.now(), resourceWindowDays);
2667
+ window.end = new Date(Date.parse(window.end) + policy.maxDurationMs).toISOString();
2668
+ return window;
2669
+ }
2670
+ /**
2671
+ * `subscriptions/listen` (2026-07-28 basic/patterns/subscriptions). The response is an SSE stream
2672
+ * that opens with `notifications/subscriptions/acknowledged` naming the calendar resources this
2673
+ * principal may watch, then carries `notifications/resources/updated` for each change until the
2674
+ * client closes it or the server ends it gracefully (its duration, shutdown, lost access) with the
2675
+ * request's result. Nothing is honored but resource subscriptions; a subscription honoring nothing
2676
+ * ends right after its acknowledgment.
2677
+ */
2678
+ async function modernListen(call) {
2679
+ const { id, principal } = call;
2680
+ if (!subscriptionPolicy)
2681
+ return modernError(404, id, -32601, 'Method not found');
2682
+ const filter = parseSubscriptionFilter(call.params.notifications);
2683
+ if (!filter.ok)
2684
+ return modernError(200, id, -32602, filter.message, filter.data);
2685
+ if (shuttingDown)
2686
+ return modernError(503, id, -32603, 'Server is shutting down');
2687
+ const slot = reserveSubscriptionSlot(subscriptionPolicy, principal);
2688
+ if (!slot) {
2689
+ return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
2690
+ }
2624
2691
  let streaming = false;
2625
2692
  try {
2626
- // A read's window rolls forward with the clock; a subscription watches one fixed window that
2627
- // holds every window a read can return while it is open (from now to its last possible read's
2628
- // end), so a booking entering the rolling end is announced, and the clock alone changes nothing.
2629
- const window = calendarResourceWindow(Date.now(), resourceWindowDays);
2630
- window.end = new Date(Date.parse(window.end) + subscriptionPolicy.maxDurationMs).toISOString();
2693
+ const window = watchWindow(subscriptionPolicy);
2631
2694
  const watchable = await watchableCalendars(call, filter.resourceIds, window);
2632
2695
  if (!watchable.ok)
2633
2696
  return modernFailure(id, watchable);
@@ -2644,12 +2707,153 @@ export function createSlotlockAgentServer(options) {
2644
2707
  openSubscriptions.delete(slot);
2645
2708
  }
2646
2709
  }
2710
+ /**
2711
+ * Watch `watched.resourceIds` for the principal until the returned function is called: re-read
2712
+ * their free/busy when the tenant's feed says a change touched one (and every `pollIntervalMs`
2713
+ * regardless), and call `updated` for each whose free/busy changed. Every re-read authenticates
2714
+ * the request's credentials again and must find the same principal, and is authorized like any
2715
+ * read; neither the rate limiter nor a confirmation applies to reads the server makes on its own.
2716
+ */
2717
+ function watchResources(call, policy, window, watched, sink) {
2718
+ const { principal, request } = call;
2719
+ const stop = new AbortController();
2720
+ let stopped = false;
2721
+ let refreshing = false;
2722
+ let again = false;
2723
+ // The feed's cursor expired: the next read that succeeds announces every resource.
2724
+ let owedAll = false;
2725
+ let failures = 0;
2726
+ let nextPoll;
2727
+ // Changes coalesce: a re-read starts at most once every CHANGE_REREAD_MIN_MS.
2728
+ let lastRefresh = 0;
2729
+ let touchTimer;
2730
+ const pollRequest = new Request(request.url, {
2731
+ method: 'POST',
2732
+ headers: request.headers,
2733
+ signal: AbortSignal.any([request.signal, stop.signal]),
2734
+ });
2735
+ const pollDispatch = {
2736
+ backend: options.backend,
2737
+ authenticate: async (current) => {
2738
+ const again = await options.authenticate(current);
2739
+ return again?.subject === principal.subject && again.tenantRef === principal.tenantRef
2740
+ ? again
2741
+ : null;
2742
+ },
2743
+ authorize: options.authorize,
2744
+ ...(options.onError ? { onError: options.onError } : {}),
2745
+ };
2746
+ const schedule = () => {
2747
+ clearTimeout(nextPoll);
2748
+ nextPoll = unref(setTimeout(() => void refresh(false), policy.pollIntervalMs));
2749
+ };
2750
+ const refresh = async (all) => {
2751
+ if (stopped)
2752
+ return;
2753
+ if (all)
2754
+ owedAll = true;
2755
+ if (refreshing) {
2756
+ again = true;
2757
+ return;
2758
+ }
2759
+ refreshing = true;
2760
+ lastRefresh = Date.now();
2761
+ clearTimeout(nextPoll);
2762
+ clearTimeout(touchTimer);
2763
+ touchTimer = undefined;
2764
+ try {
2765
+ const outcome = await readFreeBusy({ request: pollRequest, trace: call.trace }, pollDispatch, watched.resourceIds, window);
2766
+ if (stopped)
2767
+ return;
2768
+ if (!outcome.ok) {
2769
+ if (outcome.status === 401 || INVISIBLE_STATUSES.has(outcome.status)) {
2770
+ sink.end('revoked');
2771
+ return;
2772
+ }
2773
+ failures += 1;
2774
+ if (failures >= MAX_POLL_FAILURES) {
2775
+ sink.end('failed');
2776
+ return;
2777
+ }
2778
+ }
2779
+ else {
2780
+ failures = 0;
2781
+ const announceAll = owedAll;
2782
+ owedAll = false;
2783
+ const digests = freeBusyDigests(outcome.data);
2784
+ for (const resourceId of watched.resourceIds) {
2785
+ const digest = digests.get(resourceId) ?? '';
2786
+ if (!announceAll && digest === watched.digests.get(resourceId))
2787
+ continue;
2788
+ watched.digests.set(resourceId, digest);
2789
+ sink.updated(resourceId);
2790
+ }
2791
+ if (!sink.healthy()) {
2792
+ sink.end('failed');
2793
+ return;
2794
+ }
2795
+ }
2796
+ }
2797
+ catch {
2798
+ sink.end('failed');
2799
+ return;
2800
+ }
2801
+ finally {
2802
+ refreshing = false;
2803
+ }
2804
+ if (stopped)
2805
+ return;
2806
+ schedule();
2807
+ if (again) {
2808
+ again = false;
2809
+ touch(false);
2810
+ }
2811
+ };
2812
+ const touch = (all) => {
2813
+ if (stopped)
2814
+ return;
2815
+ if (all)
2816
+ owedAll = true;
2817
+ if (refreshing) {
2818
+ again = true;
2819
+ return;
2820
+ }
2821
+ const wait = lastRefresh + CHANGE_REREAD_MIN_MS - Date.now();
2822
+ if (wait <= 0) {
2823
+ void refresh(false);
2824
+ }
2825
+ else if (touchTimer === undefined) {
2826
+ touchTimer = unref(setTimeout(() => {
2827
+ touchTimer = undefined;
2828
+ void refresh(false);
2829
+ }, wait));
2830
+ }
2831
+ };
2832
+ // The tenant's reader is shared and runs outside any request; this subscription's re-reads run
2833
+ // in its own request's async context.
2834
+ const watcher = {
2835
+ resourceIds: new Set(watched.resourceIds),
2836
+ touched: AsyncResource.bind(() => touch(false)),
2837
+ resync: AsyncResource.bind(() => touch(true)),
2838
+ };
2839
+ const leave = changeReaders?.join(principal.tenantRef, watcher);
2840
+ schedule();
2841
+ return () => {
2842
+ if (stopped)
2843
+ return;
2844
+ stopped = true;
2845
+ clearTimeout(nextPoll);
2846
+ clearTimeout(touchTimer);
2847
+ stop.abort();
2848
+ leave?.();
2849
+ };
2850
+ }
2647
2851
  function streamSubscription(call, policy, slot, window, watched) {
2648
- const { id, principal, request } = call;
2852
+ const { id, request } = call;
2649
2853
  const subscriptionMeta = { [MCP_META_SUBSCRIPTION_ID]: id };
2650
- const stop = new AbortController();
2651
2854
  let ended = false;
2652
2855
  const timers = {};
2856
+ let unwatch;
2653
2857
  const stream = openMcpEventStream(() => end('client'));
2654
2858
  const onAbort = () => end('client');
2655
2859
  function end(reason) {
@@ -2658,8 +2862,7 @@ export function createSlotlockAgentServer(options) {
2658
2862
  ended = true;
2659
2863
  clearInterval(timers.keepAlive);
2660
2864
  clearTimeout(timers.lifetime);
2661
- clearTimeout(timers.nextPoll);
2662
- stop.abort();
2865
+ unwatch?.();
2663
2866
  request.signal.removeEventListener('abort', onAbort);
2664
2867
  openSubscriptions.delete(slot);
2665
2868
  // A server-side end the client should not retry is graceful: the listen request's result.
@@ -2704,73 +2907,176 @@ export function createSlotlockAgentServer(options) {
2704
2907
  return stream.response;
2705
2908
  }
2706
2909
  request.signal.addEventListener('abort', onAbort, { once: true });
2707
- // Every re-read authenticates the listen request's credentials again and must find the same
2708
- // principal, and is authorized like any read; neither the rate limiter nor a confirmation applies
2709
- // to reads the server makes on its own schedule.
2710
- const pollRequest = new Request(request.url, {
2711
- method: 'POST',
2712
- headers: request.headers,
2713
- signal: AbortSignal.any([request.signal, stop.signal]),
2910
+ unwatch = watchResources(call, policy, window, watched, {
2911
+ updated: (resourceId) => stream.send({
2912
+ jsonrpc: '2.0',
2913
+ method: 'notifications/resources/updated',
2914
+ params: { _meta: subscriptionMeta, uri: slotlockCalendarResourceUri(resourceId) },
2915
+ }),
2916
+ healthy: () => !stream.lagging(),
2917
+ end,
2714
2918
  });
2715
- const pollDispatch = {
2716
- backend: options.backend,
2717
- authenticate: async (current) => {
2718
- const again = await options.authenticate(current);
2719
- return again?.subject === principal.subject && again.tenantRef === principal.tenantRef
2720
- ? again
2721
- : null;
2722
- },
2723
- authorize: options.authorize,
2724
- ...(options.onError ? { onError: options.onError } : {}),
2725
- };
2726
- let failures = 0;
2727
- const poll = async () => {
2728
- try {
2729
- const outcome = await readFreeBusy({ request: pollRequest, trace: call.trace }, pollDispatch, watched.resourceIds, window);
2730
- if (ended)
2731
- return;
2732
- if (!outcome.ok) {
2733
- if (outcome.status === 401 || INVISIBLE_STATUSES.has(outcome.status)) {
2734
- end('revoked');
2735
- return;
2736
- }
2737
- failures += 1;
2738
- if (failures >= MAX_POLL_FAILURES) {
2739
- end('failed');
2740
- return;
2741
- }
2742
- }
2743
- else {
2744
- failures = 0;
2745
- const digests = freeBusyDigests(outcome.data);
2746
- for (const resourceId of watched.resourceIds) {
2747
- const digest = digests.get(resourceId) ?? '';
2748
- if (digest === watched.digests.get(resourceId))
2749
- continue;
2750
- watched.digests.set(resourceId, digest);
2751
- stream.send({
2752
- jsonrpc: '2.0',
2753
- method: 'notifications/resources/updated',
2754
- params: { _meta: subscriptionMeta, uri: slotlockCalendarResourceUri(resourceId) },
2755
- });
2756
- }
2757
- if (stream.lagging()) {
2758
- end('failed');
2759
- return;
2760
- }
2919
+ timers.keepAlive = unref(setInterval(() => {
2920
+ if (!stream.keepAlive() || stream.lagging())
2921
+ end('failed');
2922
+ }, policy.keepAliveMs));
2923
+ timers.lifetime = unref(setTimeout(() => end('duration'), policy.maxDurationMs));
2924
+ return stream.response;
2925
+ }
2926
+ /**
2927
+ * The A2A watch skill over `SendStreamingMessage`: the same caps, visibility check, feed and
2928
+ * re-reads as an MCP subscription, framed as A2A 1.0 stream responses. The stream opens with the
2929
+ * task (TASK_STATE_WORKING, naming the resources watched), carries a status update naming each
2930
+ * resource whose free/busy changed, and ends with TASK_STATE_COMPLETED when its duration is up or
2931
+ * the server shuts down, or TASK_STATE_FAILED when the credential or permission is withdrawn. A
2932
+ * refusal before it opens is the skill's reply, as for any skill.
2933
+ */
2934
+ async function a2aWatch(args) {
2935
+ const { id, request, principal, trace, contextId, reply } = args;
2936
+ const policy = subscriptionPolicy;
2937
+ if (!policy)
2938
+ return reply({ error: { code: 'not_supported' } });
2939
+ const given = args.input;
2940
+ const requested = isRecord(given) ? given.resource_ids : undefined;
2941
+ if (!isRecord(given) ||
2942
+ Object.keys(given).some((key) => key !== 'resource_ids') ||
2943
+ !Array.isArray(requested) ||
2944
+ requested.length === 0 ||
2945
+ requested.length > MAX_RESOURCE_SUBSCRIPTIONS ||
2946
+ !requested.every((resourceId) => isCanonicalBoundedIdentifier(resourceId, 500))) {
2947
+ return reply({ error: { code: 'invalid_arguments' } });
2948
+ }
2949
+ if (shuttingDown)
2950
+ return json(200, rpcError(id, -32603, 'Server is shutting down'));
2951
+ const slot = reserveSubscriptionSlot(policy, principal);
2952
+ if (!slot) {
2953
+ return a2aError(id, -32029, 'Too many subscriptions', {
2954
+ reason: 'RATE_LIMITED',
2955
+ domain: 'slotlock',
2956
+ });
2957
+ }
2958
+ let streaming = false;
2959
+ try {
2960
+ const resourceIds = [...new Set(requested)];
2961
+ const window = watchWindow(policy);
2962
+ const call = { request, principal, trace };
2963
+ const watchable = await watchableCalendars(call, resourceIds, window);
2964
+ if (!watchable.ok) {
2965
+ if (INTERNAL_FAILURES.has(watchable.code)) {
2966
+ return json(200, rpcError(id, -32603, 'Internal error'));
2761
2967
  }
2762
- timers.nextPoll = unref(setTimeout(poll, policy.pollIntervalMs));
2968
+ return reply({ error: { code: watchable.code } });
2763
2969
  }
2764
- catch {
2765
- end('failed');
2970
+ const watched = {
2971
+ resourceIds: resourceIds.filter((resourceId) => watchable.digests.has(resourceId)),
2972
+ digests: watchable.digests,
2973
+ };
2974
+ if (watched.resourceIds.length === 0)
2975
+ return reply({ error: { code: 'not_found' } });
2976
+ if (shuttingDown)
2977
+ return json(200, rpcError(id, -32603, 'Server is shutting down'));
2978
+ streaming = true;
2979
+ return streamA2AWatch({ id, request, principal, trace, contextId }, policy, slot, window, watched);
2980
+ }
2981
+ finally {
2982
+ if (!streaming)
2983
+ openSubscriptions.delete(slot);
2984
+ }
2985
+ }
2986
+ function streamA2AWatch(call, policy, slot, window, watched) {
2987
+ const { id, request, contextId } = call;
2988
+ const taskId = `watch_${randomBytes(16).toString('base64url')}`;
2989
+ let ended = false;
2990
+ let unwatch;
2991
+ const timers = {};
2992
+ const stream = openMcpEventStream(() => end('client'));
2993
+ const onAbort = () => end('client');
2994
+ const status = (state, data) => ({
2995
+ state,
2996
+ timestamp: new Date().toISOString(),
2997
+ ...(data === undefined
2998
+ ? {}
2999
+ : {
3000
+ message: {
3001
+ messageId: `msg_${randomBytes(16).toString('base64url')}`,
3002
+ contextId,
3003
+ taskId,
3004
+ role: 'ROLE_AGENT',
3005
+ parts: [{ data, mediaType: 'application/json' }],
3006
+ },
3007
+ }),
3008
+ });
3009
+ const send = (result) => stream.send({ jsonrpc: '2.0', id, result });
3010
+ function end(reason) {
3011
+ if (ended)
3012
+ return;
3013
+ ended = true;
3014
+ clearInterval(timers.keepAlive);
3015
+ clearTimeout(timers.lifetime);
3016
+ unwatch?.();
3017
+ request.signal.removeEventListener('abort', onAbort);
3018
+ openSubscriptions.delete(slot);
3019
+ // A server-side end is the task's terminal state; a failure closes the stream without one,
3020
+ // which a client may treat as a reason to watch again.
3021
+ if (reason === 'revoked') {
3022
+ send({
3023
+ statusUpdate: {
3024
+ taskId,
3025
+ contextId,
3026
+ status: status('TASK_STATE_FAILED', { error: { code: 'forbidden' } }),
3027
+ },
3028
+ });
2766
3029
  }
2767
- };
3030
+ else if (reason !== 'client' && reason !== 'failed') {
3031
+ send({ statusUpdate: { taskId, contextId, status: status('TASK_STATE_COMPLETED') } });
3032
+ }
3033
+ stream.close();
3034
+ emit({
3035
+ type: 'subscription',
3036
+ outcome: 'closed',
3037
+ reason,
3038
+ resources: watched.resourceIds.length,
3039
+ });
3040
+ }
3041
+ slot.end = end;
3042
+ slot.streaming = true;
3043
+ send({
3044
+ task: {
3045
+ id: taskId,
3046
+ contextId,
3047
+ status: status('TASK_STATE_WORKING', {
3048
+ resources: watched.resourceIds.map((resourceId) => ({
3049
+ resource_id: resourceId,
3050
+ uri: slotlockCalendarResourceUri(resourceId),
3051
+ })),
3052
+ }),
3053
+ },
3054
+ });
3055
+ emit({ type: 'subscription', outcome: 'opened', resources: watched.resourceIds.length });
3056
+ if (request.signal.aborted) {
3057
+ end('client');
3058
+ return stream.response;
3059
+ }
3060
+ request.signal.addEventListener('abort', onAbort, { once: true });
3061
+ unwatch = watchResources(call, policy, window, watched, {
3062
+ updated: (resourceId) => send({
3063
+ statusUpdate: {
3064
+ taskId,
3065
+ contextId,
3066
+ status: status('TASK_STATE_WORKING', {
3067
+ resource_id: resourceId,
3068
+ uri: slotlockCalendarResourceUri(resourceId),
3069
+ }),
3070
+ },
3071
+ }),
3072
+ healthy: () => !stream.lagging(),
3073
+ end,
3074
+ });
2768
3075
  timers.keepAlive = unref(setInterval(() => {
2769
3076
  if (!stream.keepAlive() || stream.lagging())
2770
3077
  end('failed');
2771
3078
  }, policy.keepAliveMs));
2772
3079
  timers.lifetime = unref(setTimeout(() => end('duration'), policy.maxDurationMs));
2773
- timers.nextPoll = unref(setTimeout(poll, policy.pollIntervalMs));
2774
3080
  return stream.response;
2775
3081
  }
2776
3082
  /** One MCP 2026-07-28 request: stateless, validated against its own `_meta` and headers. */
@@ -2847,6 +3153,7 @@ export function createSlotlockAgentServer(options) {
2847
3153
  principal,
2848
3154
  operation: definition.name,
2849
3155
  signal: request.signal,
3156
+ origin: 'application',
2850
3157
  };
2851
3158
  if (trace)
2852
3159
  context.trace = trace;
@@ -2985,6 +3292,7 @@ export function createSlotlockAgentServer(options) {
2985
3292
  input: { ...given, ...params },
2986
3293
  request,
2987
3294
  options: withoutConfirmationRound(principal, route.operation),
3295
+ origin: 'rest',
2988
3296
  ...(trace ? { trace } : {}),
2989
3297
  });
2990
3298
  const internal = !outcome.ok && INTERNAL_FAILURES.has(outcome.code);
@@ -3263,7 +3571,7 @@ export function createSlotlockAgentServer(options) {
3263
3571
  return a2aError(id, -32004, 'Unsupported operation', {
3264
3572
  reason: 'UNSUPPORTED_OPERATION',
3265
3573
  domain: 'a2a-protocol.org',
3266
- metadata: { supportedMethods: 'SendMessage' },
3574
+ metadata: { supportedMethods: 'SendMessage, SendStreamingMessage' },
3267
3575
  });
3268
3576
  }
3269
3577
  if (A2A_TASK_METHOD_FIELDS.has(rpc.method)) {
@@ -3282,7 +3590,8 @@ export function createSlotlockAgentServer(options) {
3282
3590
  const pageSize = rpc.params?.pageSize ?? SLOTLOCK_A2A_TASK_PAGE_SIZE.default;
3283
3591
  return json(200, rpcResult(id, { tasks: [], nextPageToken: '', pageSize, totalSize: 0 }));
3284
3592
  }
3285
- if (rpc.method !== 'SendMessage') {
3593
+ const streamingRequest = rpc.method === 'SendStreamingMessage';
3594
+ if (rpc.method !== 'SendMessage' && !streamingRequest) {
3286
3595
  return json(200, rpcError(id, -32601, 'Method not found'));
3287
3596
  }
3288
3597
  const started = performance.now();
@@ -3291,7 +3600,8 @@ export function createSlotlockAgentServer(options) {
3291
3600
  const parts = Array.isArray(message?.parts) ? message.parts : [];
3292
3601
  const part = parts.length === 1 ? parts[0] : null;
3293
3602
  const data = part?.data;
3294
- const operation = resolveSlotlockAgentOperation(data?.skill);
3603
+ const watchSkill = data?.skill === SLOTLOCK_A2A_WATCH_SKILL;
3604
+ const operation = watchSkill ? null : resolveSlotlockAgentOperation(data?.skill);
3295
3605
  // A refused message that names an operation is still a call, reported by the error's reason.
3296
3606
  const refuse = (outcome, response) => {
3297
3607
  if (operation)
@@ -3312,7 +3622,7 @@ export function createSlotlockAgentServer(options) {
3312
3622
  }
3313
3623
  // `arguments` may be left out or null; anything else but an object is not arguments.
3314
3624
  const input = data?.arguments;
3315
- if (!operation ||
3625
+ if ((!operation && !watchSkill) ||
3316
3626
  message?.role !== 'ROLE_USER' ||
3317
3627
  !isCanonicalBoundedIdentifier(message.messageId, 200) ||
3318
3628
  message.taskId !== undefined ||
@@ -3326,6 +3636,44 @@ export function createSlotlockAgentServer(options) {
3326
3636
  },
3327
3637
  }));
3328
3638
  }
3639
+ const requestedContextId = message.contextId;
3640
+ const contextId = typeof requestedContextId === 'string' &&
3641
+ requestedContextId.length > 0 &&
3642
+ requestedContextId.length <= 200
3643
+ ? requestedContextId
3644
+ : `ctx_${createHash('sha256')
3645
+ .update(`${principal.tenantRef}\0${message.messageId}`)
3646
+ .digest('base64url')
3647
+ .slice(0, 32)}`;
3648
+ const skillName = operation ?? SLOTLOCK_A2A_WATCH_SKILL;
3649
+ // A skill's outcome is the agent's reply, success or not — the same contract as an MCP
3650
+ // `isError` result, so the calling agent reads `{"error":{"code":…}}` and can act on it.
3651
+ // Streamed, the reply is the stream's one event (A2A: a Message, then the stream closes).
3652
+ const reply = (replyData) => {
3653
+ const replyId = createHash('sha256')
3654
+ .update(`${principal.subject}\0${message.messageId}\0${skillName}`)
3655
+ .digest('base64url')
3656
+ .slice(0, 32);
3657
+ const result = {
3658
+ message: {
3659
+ messageId: `reply_${replyId}`,
3660
+ contextId,
3661
+ role: 'ROLE_AGENT',
3662
+ parts: [{ data: replyData, mediaType: 'application/json' }],
3663
+ },
3664
+ };
3665
+ if (!streamingRequest)
3666
+ return json(200, rpcResult(id, result));
3667
+ const stream = openMcpEventStream(() => { });
3668
+ stream.send({ jsonrpc: '2.0', id, result });
3669
+ stream.close();
3670
+ return stream.response;
3671
+ };
3672
+ if (!operation) {
3673
+ if (!streamingRequest)
3674
+ return reply({ error: { code: 'streaming_required' } });
3675
+ return a2aWatch({ id, request, principal, trace, input, contextId, reply });
3676
+ }
3329
3677
  try {
3330
3678
  const outcome = await invokeReported('a2a', {
3331
3679
  operation,
@@ -3337,36 +3685,7 @@ export function createSlotlockAgentServer(options) {
3337
3685
  if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
3338
3686
  return json(200, rpcError(id, -32603, 'Internal error'));
3339
3687
  }
3340
- const replyId = createHash('sha256')
3341
- .update(`${principal.subject}\0${message.messageId}\0${operation}`)
3342
- .digest('base64url')
3343
- .slice(0, 32);
3344
- const requestedContextId = message.contextId;
3345
- const contextId = typeof requestedContextId === 'string' &&
3346
- requestedContextId.length > 0 &&
3347
- requestedContextId.length <= 200
3348
- ? requestedContextId
3349
- : `ctx_${createHash('sha256')
3350
- .update(`${principal.tenantRef}\0${message.messageId}`)
3351
- .digest('base64url')
3352
- .slice(0, 32)}`;
3353
- // A skill's outcome is the agent's reply, success or not — the same contract as an MCP
3354
- // `isError` result, so the calling agent reads `{"error":{"code":…}}` and can act on it.
3355
- return json(200, rpcResult(id, {
3356
- message: {
3357
- messageId: `reply_${replyId}`,
3358
- contextId,
3359
- role: 'ROLE_AGENT',
3360
- parts: [
3361
- {
3362
- data: outcome.ok
3363
- ? outcome.data
3364
- : { error: { ...outcome.details, code: outcome.code } },
3365
- mediaType: 'application/json',
3366
- },
3367
- ],
3368
- },
3369
- }));
3688
+ return reply(outcome.ok ? outcome.data : { error: { ...outcome.details, code: outcome.code } });
3370
3689
  }
3371
3690
  catch {
3372
3691
  return json(200, rpcError(id, -32603, 'Internal error'));