slotlock 0.5.2 → 0.6.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/CHANGELOG.md +194 -0
- package/README.md +159 -66
- package/SECURITY.md +26 -13
- package/SPEC.md +10 -7
- package/agent-server.d.ts +47 -4
- package/agent-server.js +492 -147
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +70 -7
- package/assistant.js +2 -1
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +21 -4
- package/ddl.js +555 -7
- package/event-feed.d.ts +54 -4
- package/event-feed.js +107 -8
- package/index.d.ts +8 -7
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1309 -82
- package/package.json +1 -1
- package/rest-api.js +12 -0
- package/self-host.js +58 -6
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +35 -3
- package/store.js +447 -135
- package/types.d.ts +14 -1
- package/webhooks.d.ts +10 -2
- package/webhooks.js +11 -3
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';
|
|
@@ -378,6 +380,21 @@ export class SlotlockAgentOperationError extends Error {
|
|
|
378
380
|
this.details = Object.freeze(Object.fromEntries(kept));
|
|
379
381
|
}
|
|
380
382
|
}
|
|
383
|
+
/** At most this many issues are returned, each message cut to {@link MAX_ISSUE_MESSAGE} characters. */
|
|
384
|
+
const MAX_ISSUES = 20;
|
|
385
|
+
const MAX_ISSUE_MESSAGE = 200;
|
|
386
|
+
/** Argument validation's refusal: 400 `invalid_arguments` with the fields zod refused. */
|
|
387
|
+
function invalidArguments(error) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
status: 400,
|
|
391
|
+
code: 'invalid_arguments',
|
|
392
|
+
issues: error.issues.slice(0, MAX_ISSUES).map((issue) => ({
|
|
393
|
+
path: issue.path.map((key) => (typeof key === 'number' ? key : String(key))),
|
|
394
|
+
message: issue.message.slice(0, MAX_ISSUE_MESSAGE),
|
|
395
|
+
})),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
381
398
|
const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
|
|
382
399
|
const EXAMPLE_EVENT_ID = 'agent:5e8ff9bf55ba3508199d22e984129be6b5b6a8a7e3e5f1f0d2b9ba0c4a6f7e21';
|
|
383
400
|
const EXAMPLE_WINDOW = { start: '2027-03-01T00:00:00Z', end: '2027-03-08T00:00:00Z' };
|
|
@@ -843,6 +860,8 @@ export function slotlockMcpToolResult(outcome) {
|
|
|
843
860
|
if (key !== 'code')
|
|
844
861
|
error[key] = value;
|
|
845
862
|
}
|
|
863
|
+
if (outcome.issues)
|
|
864
|
+
error.issues = outcome.issues;
|
|
846
865
|
return { content: [{ type: 'text', text: JSON.stringify({ error }) }], isError: true };
|
|
847
866
|
}
|
|
848
867
|
/** The agent tools as REST serves them: each with the scope it needs. */
|
|
@@ -1150,7 +1169,7 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
1150
1169
|
}
|
|
1151
1170
|
const parsed = operation.input.safeParse(args.input ?? {});
|
|
1152
1171
|
if (!parsed.success)
|
|
1153
|
-
return
|
|
1172
|
+
return invalidArguments(parsed.error);
|
|
1154
1173
|
if (!(await args.options.authorize({
|
|
1155
1174
|
principal,
|
|
1156
1175
|
operation: operation.name,
|
|
@@ -1185,6 +1204,8 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
1185
1204
|
};
|
|
1186
1205
|
if (args.trace)
|
|
1187
1206
|
context.trace = args.trace;
|
|
1207
|
+
if (args.origin)
|
|
1208
|
+
context.origin = args.origin;
|
|
1188
1209
|
const data = await method.call(backend, context, parsed.data);
|
|
1189
1210
|
if (!operation.output.safeParse(data).success) {
|
|
1190
1211
|
// Neither the result nor the schema's complaint about it: either may hold tenant data.
|
|
@@ -1226,12 +1247,14 @@ function traceIdOf(trace) {
|
|
|
1226
1247
|
/** Failures that are server faults, not operation outcomes an agent could act on. */
|
|
1227
1248
|
const INTERNAL_FAILURES = new Set(['internal_error', 'invalid_backend_result']);
|
|
1228
1249
|
/**
|
|
1229
|
-
* A2A 1.0 methods (a2a.proto `A2AService`) this
|
|
1230
|
-
*
|
|
1231
|
-
* `PushNotificationNotSupportedError
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1250
|
+
* A2A 1.0 methods (a2a.proto `A2AService`) this binding does not implement. A2A §3.3.4 fixes the
|
|
1251
|
+
* error for the capability-gated ones: push-notification configuration answers
|
|
1252
|
+
* `PushNotificationNotSupportedError` (a tenant's changes reach a URL as signed webhooks instead);
|
|
1253
|
+
* task subscription and the extended card answer `UnsupportedOperationError`. `SendStreamingMessage`
|
|
1254
|
+
* is served: a skill's reply as a one-message stream, and the watch skill as a task whose stream is
|
|
1255
|
+
* the only place it lives, so it cannot be subscribed to again. GetTask, CancelTask and ListTasks are
|
|
1256
|
+
* core operations every agent answers; no task is kept to find: `TaskNotFoundError` for the first
|
|
1257
|
+
* two (§3.1) and an empty page for the list.
|
|
1235
1258
|
*/
|
|
1236
1259
|
const A2A_PUSH_NOTIFICATION_METHODS = new Set([
|
|
1237
1260
|
'CreateTaskPushNotificationConfig',
|
|
@@ -1240,7 +1263,6 @@ const A2A_PUSH_NOTIFICATION_METHODS = new Set([
|
|
|
1240
1263
|
'DeleteTaskPushNotificationConfig',
|
|
1241
1264
|
]);
|
|
1242
1265
|
const A2A_UNSUPPORTED_METHODS = new Set([
|
|
1243
|
-
'SendStreamingMessage',
|
|
1244
1266
|
'SubscribeToTask',
|
|
1245
1267
|
'GetExtendedAgentCard',
|
|
1246
1268
|
]);
|
|
@@ -1450,9 +1472,19 @@ const INVISIBLE_STATUSES = new Set([400, 403, 404]);
|
|
|
1450
1472
|
const MAX_RESOURCE_SUBSCRIPTIONS = 20;
|
|
1451
1473
|
/** Consecutive failed re-reads after which a subscription is abandoned. */
|
|
1452
1474
|
const MAX_POLL_FAILURES = 3;
|
|
1475
|
+
/** The least time between the starts of two re-reads of one subscription a change asked for, in ms. */
|
|
1476
|
+
const CHANGE_REREAD_MIN_MS = 1_000;
|
|
1453
1477
|
function validateSubscriptions(value) {
|
|
1454
1478
|
if (value === false)
|
|
1455
1479
|
return null;
|
|
1480
|
+
const changes = value?.changes ?? null;
|
|
1481
|
+
if (changes !== null &&
|
|
1482
|
+
(typeof changes !== 'object' ||
|
|
1483
|
+
typeof changes.head !== 'function' ||
|
|
1484
|
+
typeof changes.listChanges !== 'function' ||
|
|
1485
|
+
(changes.watch !== undefined && typeof changes.watch !== 'function'))) {
|
|
1486
|
+
throw new Error('Slotlock agent server subscriptions.changes is invalid');
|
|
1487
|
+
}
|
|
1456
1488
|
const bounded = (name, min, max, fallback) => {
|
|
1457
1489
|
const setting = value?.[name] ?? fallback;
|
|
1458
1490
|
if (!Number.isInteger(setting) || setting < min || setting > max) {
|
|
@@ -1462,7 +1494,8 @@ function validateSubscriptions(value) {
|
|
|
1462
1494
|
};
|
|
1463
1495
|
const maxTotal = bounded('maxTotal', 1, 10_000, 256);
|
|
1464
1496
|
return {
|
|
1465
|
-
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
|
|
1497
|
+
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, changes ? 15_000 : 10_000),
|
|
1498
|
+
changes,
|
|
1466
1499
|
maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
|
|
1467
1500
|
keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
|
|
1468
1501
|
maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
|
|
@@ -2161,8 +2194,15 @@ function routePath(pathname, basePath) {
|
|
|
2161
2194
|
return '/';
|
|
2162
2195
|
return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : null;
|
|
2163
2196
|
}
|
|
2197
|
+
/**
|
|
2198
|
+
* The A2A skill that watches calendars: `SendStreamingMessage` only. Its stream opens with a task
|
|
2199
|
+
* in TASK_STATE_WORKING and carries a status update for each change to a watched resource's
|
|
2200
|
+
* free/busy, from the same feed as MCP resource subscriptions, until the watch's duration is up.
|
|
2201
|
+
*/
|
|
2202
|
+
export const SLOTLOCK_A2A_WATCH_SKILL = 'slotlock_watch_resources';
|
|
2203
|
+
const A2A_WATCH_EXAMPLE = { resource_ids: ['018f2f8e-7f8a-7c3e-9b1a-2a4c6e8f0a1b'] };
|
|
2164
2204
|
/** The A2A 1.0 agent card, in the JSON form of a2a.proto's `AgentCard`. */
|
|
2165
|
-
function agentCard(publicBaseUrl) {
|
|
2205
|
+
function agentCard(publicBaseUrl, watch) {
|
|
2166
2206
|
return Object.freeze({
|
|
2167
2207
|
name: 'Slotlock Agent Calendar',
|
|
2168
2208
|
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 +2214,37 @@ function agentCard(publicBaseUrl) {
|
|
|
2174
2214
|
},
|
|
2175
2215
|
],
|
|
2176
2216
|
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
2177
|
-
capabilities: { streaming:
|
|
2217
|
+
capabilities: { streaming: true, pushNotifications: false, extendedAgentCard: false },
|
|
2178
2218
|
securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: 'Bearer' } } },
|
|
2179
2219
|
securityRequirements: [{ schemes: { bearer: { list: [] } } }],
|
|
2180
2220
|
defaultInputModes: ['application/json'],
|
|
2181
2221
|
defaultOutputModes: ['application/json'],
|
|
2182
|
-
skills:
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2222
|
+
skills: [
|
|
2223
|
+
...OPERATION_DEFINITIONS.map((operation) => ({
|
|
2224
|
+
id: operation.name,
|
|
2225
|
+
name: operation.title,
|
|
2226
|
+
description: operation.description,
|
|
2227
|
+
tags: ['calendar', 'scheduling', 'resources'],
|
|
2228
|
+
examples: [JSON.stringify({ skill: operation.name, arguments: operation.example })],
|
|
2229
|
+
inputModes: ['application/json'],
|
|
2230
|
+
outputModes: ['application/json'],
|
|
2231
|
+
})),
|
|
2232
|
+
...(watch
|
|
2233
|
+
? [
|
|
2234
|
+
{
|
|
2235
|
+
id: SLOTLOCK_A2A_WATCH_SKILL,
|
|
2236
|
+
name: 'Watch calendar resources',
|
|
2237
|
+
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.',
|
|
2238
|
+
tags: ['calendar', 'resources', 'watch'],
|
|
2239
|
+
examples: [
|
|
2240
|
+
JSON.stringify({ skill: SLOTLOCK_A2A_WATCH_SKILL, arguments: A2A_WATCH_EXAMPLE }),
|
|
2241
|
+
],
|
|
2242
|
+
inputModes: ['application/json'],
|
|
2243
|
+
outputModes: ['application/json'],
|
|
2244
|
+
},
|
|
2245
|
+
]
|
|
2246
|
+
: []),
|
|
2247
|
+
],
|
|
2191
2248
|
});
|
|
2192
2249
|
}
|
|
2193
2250
|
export function createSlotlockAgentServer(options) {
|
|
@@ -2241,7 +2298,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2241
2298
|
},
|
|
2242
2299
|
capabilities: slotlockAgentTools(),
|
|
2243
2300
|
});
|
|
2244
|
-
const card = agentCard(config.publicBaseUrl);
|
|
2301
|
+
const card = agentCard(config.publicBaseUrl, options.subscriptions !== false);
|
|
2245
2302
|
const confirmation = validateConfirmation(options.confirmation);
|
|
2246
2303
|
const emit = (event) => {
|
|
2247
2304
|
try {
|
|
@@ -2268,7 +2325,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2268
2325
|
/** Run an operation an MCP or A2A caller named, and report how it ended as a `call` event. */
|
|
2269
2326
|
const invokeReported = async (protocol, args) => {
|
|
2270
2327
|
const started = performance.now();
|
|
2271
|
-
const outcome = await invokeSlotlockAgentOperation(args);
|
|
2328
|
+
const outcome = await invokeSlotlockAgentOperation({ ...args, origin: protocol });
|
|
2272
2329
|
reportCall(protocol, args.operation, outcome.ok ? 'ok' : outcome.code, started, args.trace);
|
|
2273
2330
|
return outcome;
|
|
2274
2331
|
};
|
|
@@ -2306,6 +2363,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2306
2363
|
/** Open subscriptions (and slots reserved by ones still opening), for the caps and shutdown. */
|
|
2307
2364
|
const openSubscriptions = new Set();
|
|
2308
2365
|
let shuttingDown = false;
|
|
2366
|
+
/** One feed reader per tenant with an open subscription, when there is a feed to follow. */
|
|
2367
|
+
const changeReaders = subscriptionPolicy?.changes
|
|
2368
|
+
? createSlotlockChangeReaders(subscriptionPolicy.changes, (error) => reportUnexpected(options.onError, error, null))
|
|
2369
|
+
: null;
|
|
2309
2370
|
const discoverResult = Object.freeze({
|
|
2310
2371
|
supportedVersions: [...SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
2311
2372
|
capabilities: {
|
|
@@ -2444,6 +2505,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2444
2505
|
input: { resource_ids: resourceIds, start: window.start, end: window.end },
|
|
2445
2506
|
request: call.request,
|
|
2446
2507
|
options: dispatch,
|
|
2508
|
+
origin: 'mcp',
|
|
2447
2509
|
...(call.trace ? { trace: call.trace } : {}),
|
|
2448
2510
|
});
|
|
2449
2511
|
/**
|
|
@@ -2462,6 +2524,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2462
2524
|
input: cursor === undefined ? {} : { cursor },
|
|
2463
2525
|
request: call.request,
|
|
2464
2526
|
options: dispatchOptions(call.principal),
|
|
2527
|
+
origin: 'mcp',
|
|
2465
2528
|
...(call.trace ? { trace: call.trace } : {}),
|
|
2466
2529
|
});
|
|
2467
2530
|
if (!outcome.ok) {
|
|
@@ -2560,22 +2623,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2560
2623
|
return { ok: true, digests };
|
|
2561
2624
|
}
|
|
2562
2625
|
/**
|
|
2563
|
-
*
|
|
2564
|
-
*
|
|
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.
|
|
2626
|
+
* A subscription slot for the principal, held while it opens so concurrent opens cannot
|
|
2627
|
+
* overshoot the caps; null when a cap is reached. MCP subscriptions and A2A watches share them.
|
|
2569
2628
|
*/
|
|
2570
|
-
|
|
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');
|
|
2629
|
+
function reserveSubscriptionSlot(policy, principal) {
|
|
2579
2630
|
const principalKey = `${principal.tenantRef}\0${principal.subject}`;
|
|
2580
2631
|
let held = 0;
|
|
2581
2632
|
let heldByTenant = 0;
|
|
@@ -2585,9 +2636,9 @@ export function createSlotlockAgentServer(options) {
|
|
|
2585
2636
|
if (open.tenantRef === principal.tenantRef)
|
|
2586
2637
|
heldByTenant += 1;
|
|
2587
2638
|
}
|
|
2588
|
-
if (openSubscriptions.size >=
|
|
2589
|
-
held <
|
|
2590
|
-
heldByTenant <
|
|
2639
|
+
if (openSubscriptions.size >= policy.maxTotal &&
|
|
2640
|
+
held < policy.maxPerPrincipal &&
|
|
2641
|
+
heldByTenant < policy.maxPerTenant) {
|
|
2591
2642
|
// A full server is shared out: a tenant holding fewer than the one holding most, less one,
|
|
2592
2643
|
// ends that tenant's oldest stream, so many tenants within their share cannot keep another
|
|
2593
2644
|
// from holding any. A slot still opening has nothing to end yet.
|
|
@@ -2608,12 +2659,11 @@ export function createSlotlockAgentServer(options) {
|
|
|
2608
2659
|
if (most.length > heldByTenant + 1)
|
|
2609
2660
|
most[0]?.end('preempted');
|
|
2610
2661
|
}
|
|
2611
|
-
if (openSubscriptions.size >=
|
|
2612
|
-
held >=
|
|
2613
|
-
heldByTenant >=
|
|
2614
|
-
return
|
|
2662
|
+
if (openSubscriptions.size >= policy.maxTotal ||
|
|
2663
|
+
held >= policy.maxPerPrincipal ||
|
|
2664
|
+
heldByTenant >= policy.maxPerTenant) {
|
|
2665
|
+
return null;
|
|
2615
2666
|
}
|
|
2616
|
-
// The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
|
|
2617
2667
|
const slot = {
|
|
2618
2668
|
principalKey,
|
|
2619
2669
|
tenantRef: principal.tenantRef,
|
|
@@ -2621,13 +2671,43 @@ export function createSlotlockAgentServer(options) {
|
|
|
2621
2671
|
end: () => { },
|
|
2622
2672
|
};
|
|
2623
2673
|
openSubscriptions.add(slot);
|
|
2674
|
+
return slot;
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* The window a watch covers. A read's window rolls forward with the clock; a watch covers one
|
|
2678
|
+
* fixed window that holds every window a read can return while it is open (from now to its last
|
|
2679
|
+
* possible read's end), so a booking entering the rolling end is announced, and the clock alone
|
|
2680
|
+
* changes nothing.
|
|
2681
|
+
*/
|
|
2682
|
+
function watchWindow(policy) {
|
|
2683
|
+
const window = calendarResourceWindow(Date.now(), resourceWindowDays);
|
|
2684
|
+
window.end = new Date(Date.parse(window.end) + policy.maxDurationMs).toISOString();
|
|
2685
|
+
return window;
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* `subscriptions/listen` (2026-07-28 basic/patterns/subscriptions). The response is an SSE stream
|
|
2689
|
+
* that opens with `notifications/subscriptions/acknowledged` naming the calendar resources this
|
|
2690
|
+
* principal may watch, then carries `notifications/resources/updated` for each change until the
|
|
2691
|
+
* client closes it or the server ends it gracefully (its duration, shutdown, lost access) with the
|
|
2692
|
+
* request's result. Nothing is honored but resource subscriptions; a subscription honoring nothing
|
|
2693
|
+
* ends right after its acknowledgment.
|
|
2694
|
+
*/
|
|
2695
|
+
async function modernListen(call) {
|
|
2696
|
+
const { id, principal } = call;
|
|
2697
|
+
if (!subscriptionPolicy)
|
|
2698
|
+
return modernError(404, id, -32601, 'Method not found');
|
|
2699
|
+
const filter = parseSubscriptionFilter(call.params.notifications);
|
|
2700
|
+
if (!filter.ok)
|
|
2701
|
+
return modernError(200, id, -32602, filter.message, filter.data);
|
|
2702
|
+
if (shuttingDown)
|
|
2703
|
+
return modernError(503, id, -32603, 'Server is shutting down');
|
|
2704
|
+
const slot = reserveSubscriptionSlot(subscriptionPolicy, principal);
|
|
2705
|
+
if (!slot) {
|
|
2706
|
+
return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
|
|
2707
|
+
}
|
|
2624
2708
|
let streaming = false;
|
|
2625
2709
|
try {
|
|
2626
|
-
|
|
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();
|
|
2710
|
+
const window = watchWindow(subscriptionPolicy);
|
|
2631
2711
|
const watchable = await watchableCalendars(call, filter.resourceIds, window);
|
|
2632
2712
|
if (!watchable.ok)
|
|
2633
2713
|
return modernFailure(id, watchable);
|
|
@@ -2644,12 +2724,153 @@ export function createSlotlockAgentServer(options) {
|
|
|
2644
2724
|
openSubscriptions.delete(slot);
|
|
2645
2725
|
}
|
|
2646
2726
|
}
|
|
2727
|
+
/**
|
|
2728
|
+
* Watch `watched.resourceIds` for the principal until the returned function is called: re-read
|
|
2729
|
+
* their free/busy when the tenant's feed says a change touched one (and every `pollIntervalMs`
|
|
2730
|
+
* regardless), and call `updated` for each whose free/busy changed. Every re-read authenticates
|
|
2731
|
+
* the request's credentials again and must find the same principal, and is authorized like any
|
|
2732
|
+
* read; neither the rate limiter nor a confirmation applies to reads the server makes on its own.
|
|
2733
|
+
*/
|
|
2734
|
+
function watchResources(call, policy, window, watched, sink) {
|
|
2735
|
+
const { principal, request } = call;
|
|
2736
|
+
const stop = new AbortController();
|
|
2737
|
+
let stopped = false;
|
|
2738
|
+
let refreshing = false;
|
|
2739
|
+
let again = false;
|
|
2740
|
+
// The feed's cursor expired: the next read that succeeds announces every resource.
|
|
2741
|
+
let owedAll = false;
|
|
2742
|
+
let failures = 0;
|
|
2743
|
+
let nextPoll;
|
|
2744
|
+
// Changes coalesce: a re-read starts at most once every CHANGE_REREAD_MIN_MS.
|
|
2745
|
+
let lastRefresh = 0;
|
|
2746
|
+
let touchTimer;
|
|
2747
|
+
const pollRequest = new Request(request.url, {
|
|
2748
|
+
method: 'POST',
|
|
2749
|
+
headers: request.headers,
|
|
2750
|
+
signal: AbortSignal.any([request.signal, stop.signal]),
|
|
2751
|
+
});
|
|
2752
|
+
const pollDispatch = {
|
|
2753
|
+
backend: options.backend,
|
|
2754
|
+
authenticate: async (current) => {
|
|
2755
|
+
const again = await options.authenticate(current);
|
|
2756
|
+
return again?.subject === principal.subject && again.tenantRef === principal.tenantRef
|
|
2757
|
+
? again
|
|
2758
|
+
: null;
|
|
2759
|
+
},
|
|
2760
|
+
authorize: options.authorize,
|
|
2761
|
+
...(options.onError ? { onError: options.onError } : {}),
|
|
2762
|
+
};
|
|
2763
|
+
const schedule = () => {
|
|
2764
|
+
clearTimeout(nextPoll);
|
|
2765
|
+
nextPoll = unref(setTimeout(() => void refresh(false), policy.pollIntervalMs));
|
|
2766
|
+
};
|
|
2767
|
+
const refresh = async (all) => {
|
|
2768
|
+
if (stopped)
|
|
2769
|
+
return;
|
|
2770
|
+
if (all)
|
|
2771
|
+
owedAll = true;
|
|
2772
|
+
if (refreshing) {
|
|
2773
|
+
again = true;
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
refreshing = true;
|
|
2777
|
+
lastRefresh = Date.now();
|
|
2778
|
+
clearTimeout(nextPoll);
|
|
2779
|
+
clearTimeout(touchTimer);
|
|
2780
|
+
touchTimer = undefined;
|
|
2781
|
+
try {
|
|
2782
|
+
const outcome = await readFreeBusy({ request: pollRequest, trace: call.trace }, pollDispatch, watched.resourceIds, window);
|
|
2783
|
+
if (stopped)
|
|
2784
|
+
return;
|
|
2785
|
+
if (!outcome.ok) {
|
|
2786
|
+
if (outcome.status === 401 || INVISIBLE_STATUSES.has(outcome.status)) {
|
|
2787
|
+
sink.end('revoked');
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
failures += 1;
|
|
2791
|
+
if (failures >= MAX_POLL_FAILURES) {
|
|
2792
|
+
sink.end('failed');
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
else {
|
|
2797
|
+
failures = 0;
|
|
2798
|
+
const announceAll = owedAll;
|
|
2799
|
+
owedAll = false;
|
|
2800
|
+
const digests = freeBusyDigests(outcome.data);
|
|
2801
|
+
for (const resourceId of watched.resourceIds) {
|
|
2802
|
+
const digest = digests.get(resourceId) ?? '';
|
|
2803
|
+
if (!announceAll && digest === watched.digests.get(resourceId))
|
|
2804
|
+
continue;
|
|
2805
|
+
watched.digests.set(resourceId, digest);
|
|
2806
|
+
sink.updated(resourceId);
|
|
2807
|
+
}
|
|
2808
|
+
if (!sink.healthy()) {
|
|
2809
|
+
sink.end('failed');
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
catch {
|
|
2815
|
+
sink.end('failed');
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
finally {
|
|
2819
|
+
refreshing = false;
|
|
2820
|
+
}
|
|
2821
|
+
if (stopped)
|
|
2822
|
+
return;
|
|
2823
|
+
schedule();
|
|
2824
|
+
if (again) {
|
|
2825
|
+
again = false;
|
|
2826
|
+
touch(false);
|
|
2827
|
+
}
|
|
2828
|
+
};
|
|
2829
|
+
const touch = (all) => {
|
|
2830
|
+
if (stopped)
|
|
2831
|
+
return;
|
|
2832
|
+
if (all)
|
|
2833
|
+
owedAll = true;
|
|
2834
|
+
if (refreshing) {
|
|
2835
|
+
again = true;
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
const wait = lastRefresh + CHANGE_REREAD_MIN_MS - Date.now();
|
|
2839
|
+
if (wait <= 0) {
|
|
2840
|
+
void refresh(false);
|
|
2841
|
+
}
|
|
2842
|
+
else if (touchTimer === undefined) {
|
|
2843
|
+
touchTimer = unref(setTimeout(() => {
|
|
2844
|
+
touchTimer = undefined;
|
|
2845
|
+
void refresh(false);
|
|
2846
|
+
}, wait));
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
// The tenant's reader is shared and runs outside any request; this subscription's re-reads run
|
|
2850
|
+
// in its own request's async context.
|
|
2851
|
+
const watcher = {
|
|
2852
|
+
resourceIds: new Set(watched.resourceIds),
|
|
2853
|
+
touched: AsyncResource.bind(() => touch(false)),
|
|
2854
|
+
resync: AsyncResource.bind(() => touch(true)),
|
|
2855
|
+
};
|
|
2856
|
+
const leave = changeReaders?.join(principal.tenantRef, watcher);
|
|
2857
|
+
schedule();
|
|
2858
|
+
return () => {
|
|
2859
|
+
if (stopped)
|
|
2860
|
+
return;
|
|
2861
|
+
stopped = true;
|
|
2862
|
+
clearTimeout(nextPoll);
|
|
2863
|
+
clearTimeout(touchTimer);
|
|
2864
|
+
stop.abort();
|
|
2865
|
+
leave?.();
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2647
2868
|
function streamSubscription(call, policy, slot, window, watched) {
|
|
2648
|
-
const { id,
|
|
2869
|
+
const { id, request } = call;
|
|
2649
2870
|
const subscriptionMeta = { [MCP_META_SUBSCRIPTION_ID]: id };
|
|
2650
|
-
const stop = new AbortController();
|
|
2651
2871
|
let ended = false;
|
|
2652
2872
|
const timers = {};
|
|
2873
|
+
let unwatch;
|
|
2653
2874
|
const stream = openMcpEventStream(() => end('client'));
|
|
2654
2875
|
const onAbort = () => end('client');
|
|
2655
2876
|
function end(reason) {
|
|
@@ -2658,8 +2879,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2658
2879
|
ended = true;
|
|
2659
2880
|
clearInterval(timers.keepAlive);
|
|
2660
2881
|
clearTimeout(timers.lifetime);
|
|
2661
|
-
|
|
2662
|
-
stop.abort();
|
|
2882
|
+
unwatch?.();
|
|
2663
2883
|
request.signal.removeEventListener('abort', onAbort);
|
|
2664
2884
|
openSubscriptions.delete(slot);
|
|
2665
2885
|
// A server-side end the client should not retry is graceful: the listen request's result.
|
|
@@ -2704,73 +2924,176 @@ export function createSlotlockAgentServer(options) {
|
|
|
2704
2924
|
return stream.response;
|
|
2705
2925
|
}
|
|
2706
2926
|
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2927
|
+
unwatch = watchResources(call, policy, window, watched, {
|
|
2928
|
+
updated: (resourceId) => stream.send({
|
|
2929
|
+
jsonrpc: '2.0',
|
|
2930
|
+
method: 'notifications/resources/updated',
|
|
2931
|
+
params: { _meta: subscriptionMeta, uri: slotlockCalendarResourceUri(resourceId) },
|
|
2932
|
+
}),
|
|
2933
|
+
healthy: () => !stream.lagging(),
|
|
2934
|
+
end,
|
|
2714
2935
|
});
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2936
|
+
timers.keepAlive = unref(setInterval(() => {
|
|
2937
|
+
if (!stream.keepAlive() || stream.lagging())
|
|
2938
|
+
end('failed');
|
|
2939
|
+
}, policy.keepAliveMs));
|
|
2940
|
+
timers.lifetime = unref(setTimeout(() => end('duration'), policy.maxDurationMs));
|
|
2941
|
+
return stream.response;
|
|
2942
|
+
}
|
|
2943
|
+
/**
|
|
2944
|
+
* The A2A watch skill over `SendStreamingMessage`: the same caps, visibility check, feed and
|
|
2945
|
+
* re-reads as an MCP subscription, framed as A2A 1.0 stream responses. The stream opens with the
|
|
2946
|
+
* task (TASK_STATE_WORKING, naming the resources watched), carries a status update naming each
|
|
2947
|
+
* resource whose free/busy changed, and ends with TASK_STATE_COMPLETED when its duration is up or
|
|
2948
|
+
* the server shuts down, or TASK_STATE_FAILED when the credential or permission is withdrawn. A
|
|
2949
|
+
* refusal before it opens is the skill's reply, as for any skill.
|
|
2950
|
+
*/
|
|
2951
|
+
async function a2aWatch(args) {
|
|
2952
|
+
const { id, request, principal, trace, contextId, reply } = args;
|
|
2953
|
+
const policy = subscriptionPolicy;
|
|
2954
|
+
if (!policy)
|
|
2955
|
+
return reply({ error: { code: 'not_supported' } });
|
|
2956
|
+
const given = args.input;
|
|
2957
|
+
const requested = isRecord(given) ? given.resource_ids : undefined;
|
|
2958
|
+
if (!isRecord(given) ||
|
|
2959
|
+
Object.keys(given).some((key) => key !== 'resource_ids') ||
|
|
2960
|
+
!Array.isArray(requested) ||
|
|
2961
|
+
requested.length === 0 ||
|
|
2962
|
+
requested.length > MAX_RESOURCE_SUBSCRIPTIONS ||
|
|
2963
|
+
!requested.every((resourceId) => isCanonicalBoundedIdentifier(resourceId, 500))) {
|
|
2964
|
+
return reply({ error: { code: 'invalid_arguments' } });
|
|
2965
|
+
}
|
|
2966
|
+
if (shuttingDown)
|
|
2967
|
+
return json(200, rpcError(id, -32603, 'Server is shutting down'));
|
|
2968
|
+
const slot = reserveSubscriptionSlot(policy, principal);
|
|
2969
|
+
if (!slot) {
|
|
2970
|
+
return a2aError(id, -32029, 'Too many subscriptions', {
|
|
2971
|
+
reason: 'RATE_LIMITED',
|
|
2972
|
+
domain: 'slotlock',
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
let streaming = false;
|
|
2976
|
+
try {
|
|
2977
|
+
const resourceIds = [...new Set(requested)];
|
|
2978
|
+
const window = watchWindow(policy);
|
|
2979
|
+
const call = { request, principal, trace };
|
|
2980
|
+
const watchable = await watchableCalendars(call, resourceIds, window);
|
|
2981
|
+
if (!watchable.ok) {
|
|
2982
|
+
if (INTERNAL_FAILURES.has(watchable.code)) {
|
|
2983
|
+
return json(200, rpcError(id, -32603, 'Internal error'));
|
|
2761
2984
|
}
|
|
2762
|
-
|
|
2985
|
+
return reply({ error: { code: watchable.code } });
|
|
2763
2986
|
}
|
|
2764
|
-
|
|
2765
|
-
|
|
2987
|
+
const watched = {
|
|
2988
|
+
resourceIds: resourceIds.filter((resourceId) => watchable.digests.has(resourceId)),
|
|
2989
|
+
digests: watchable.digests,
|
|
2990
|
+
};
|
|
2991
|
+
if (watched.resourceIds.length === 0)
|
|
2992
|
+
return reply({ error: { code: 'not_found' } });
|
|
2993
|
+
if (shuttingDown)
|
|
2994
|
+
return json(200, rpcError(id, -32603, 'Server is shutting down'));
|
|
2995
|
+
streaming = true;
|
|
2996
|
+
return streamA2AWatch({ id, request, principal, trace, contextId }, policy, slot, window, watched);
|
|
2997
|
+
}
|
|
2998
|
+
finally {
|
|
2999
|
+
if (!streaming)
|
|
3000
|
+
openSubscriptions.delete(slot);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
function streamA2AWatch(call, policy, slot, window, watched) {
|
|
3004
|
+
const { id, request, contextId } = call;
|
|
3005
|
+
const taskId = `watch_${randomBytes(16).toString('base64url')}`;
|
|
3006
|
+
let ended = false;
|
|
3007
|
+
let unwatch;
|
|
3008
|
+
const timers = {};
|
|
3009
|
+
const stream = openMcpEventStream(() => end('client'));
|
|
3010
|
+
const onAbort = () => end('client');
|
|
3011
|
+
const status = (state, data) => ({
|
|
3012
|
+
state,
|
|
3013
|
+
timestamp: new Date().toISOString(),
|
|
3014
|
+
...(data === undefined
|
|
3015
|
+
? {}
|
|
3016
|
+
: {
|
|
3017
|
+
message: {
|
|
3018
|
+
messageId: `msg_${randomBytes(16).toString('base64url')}`,
|
|
3019
|
+
contextId,
|
|
3020
|
+
taskId,
|
|
3021
|
+
role: 'ROLE_AGENT',
|
|
3022
|
+
parts: [{ data, mediaType: 'application/json' }],
|
|
3023
|
+
},
|
|
3024
|
+
}),
|
|
3025
|
+
});
|
|
3026
|
+
const send = (result) => stream.send({ jsonrpc: '2.0', id, result });
|
|
3027
|
+
function end(reason) {
|
|
3028
|
+
if (ended)
|
|
3029
|
+
return;
|
|
3030
|
+
ended = true;
|
|
3031
|
+
clearInterval(timers.keepAlive);
|
|
3032
|
+
clearTimeout(timers.lifetime);
|
|
3033
|
+
unwatch?.();
|
|
3034
|
+
request.signal.removeEventListener('abort', onAbort);
|
|
3035
|
+
openSubscriptions.delete(slot);
|
|
3036
|
+
// A server-side end is the task's terminal state; a failure closes the stream without one,
|
|
3037
|
+
// which a client may treat as a reason to watch again.
|
|
3038
|
+
if (reason === 'revoked') {
|
|
3039
|
+
send({
|
|
3040
|
+
statusUpdate: {
|
|
3041
|
+
taskId,
|
|
3042
|
+
contextId,
|
|
3043
|
+
status: status('TASK_STATE_FAILED', { error: { code: 'forbidden' } }),
|
|
3044
|
+
},
|
|
3045
|
+
});
|
|
2766
3046
|
}
|
|
2767
|
-
|
|
3047
|
+
else if (reason !== 'client' && reason !== 'failed') {
|
|
3048
|
+
send({ statusUpdate: { taskId, contextId, status: status('TASK_STATE_COMPLETED') } });
|
|
3049
|
+
}
|
|
3050
|
+
stream.close();
|
|
3051
|
+
emit({
|
|
3052
|
+
type: 'subscription',
|
|
3053
|
+
outcome: 'closed',
|
|
3054
|
+
reason,
|
|
3055
|
+
resources: watched.resourceIds.length,
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
slot.end = end;
|
|
3059
|
+
slot.streaming = true;
|
|
3060
|
+
send({
|
|
3061
|
+
task: {
|
|
3062
|
+
id: taskId,
|
|
3063
|
+
contextId,
|
|
3064
|
+
status: status('TASK_STATE_WORKING', {
|
|
3065
|
+
resources: watched.resourceIds.map((resourceId) => ({
|
|
3066
|
+
resource_id: resourceId,
|
|
3067
|
+
uri: slotlockCalendarResourceUri(resourceId),
|
|
3068
|
+
})),
|
|
3069
|
+
}),
|
|
3070
|
+
},
|
|
3071
|
+
});
|
|
3072
|
+
emit({ type: 'subscription', outcome: 'opened', resources: watched.resourceIds.length });
|
|
3073
|
+
if (request.signal.aborted) {
|
|
3074
|
+
end('client');
|
|
3075
|
+
return stream.response;
|
|
3076
|
+
}
|
|
3077
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
3078
|
+
unwatch = watchResources(call, policy, window, watched, {
|
|
3079
|
+
updated: (resourceId) => send({
|
|
3080
|
+
statusUpdate: {
|
|
3081
|
+
taskId,
|
|
3082
|
+
contextId,
|
|
3083
|
+
status: status('TASK_STATE_WORKING', {
|
|
3084
|
+
resource_id: resourceId,
|
|
3085
|
+
uri: slotlockCalendarResourceUri(resourceId),
|
|
3086
|
+
}),
|
|
3087
|
+
},
|
|
3088
|
+
}),
|
|
3089
|
+
healthy: () => !stream.lagging(),
|
|
3090
|
+
end,
|
|
3091
|
+
});
|
|
2768
3092
|
timers.keepAlive = unref(setInterval(() => {
|
|
2769
3093
|
if (!stream.keepAlive() || stream.lagging())
|
|
2770
3094
|
end('failed');
|
|
2771
3095
|
}, policy.keepAliveMs));
|
|
2772
3096
|
timers.lifetime = unref(setTimeout(() => end('duration'), policy.maxDurationMs));
|
|
2773
|
-
timers.nextPoll = unref(setTimeout(poll, policy.pollIntervalMs));
|
|
2774
3097
|
return stream.response;
|
|
2775
3098
|
}
|
|
2776
3099
|
/** One MCP 2026-07-28 request: stateless, validated against its own `_meta` and headers. */
|
|
@@ -2839,7 +3162,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2839
3162
|
}
|
|
2840
3163
|
const parsed = definition.input.safeParse(given);
|
|
2841
3164
|
if (!parsed.success)
|
|
2842
|
-
return
|
|
3165
|
+
return invalidArguments(parsed.error);
|
|
2843
3166
|
if (!(await served.authorize({ principal, operation: definition.name, input: parsed.data }))) {
|
|
2844
3167
|
return { ok: false, status: 403, code: 'forbidden' };
|
|
2845
3168
|
}
|
|
@@ -2847,6 +3170,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2847
3170
|
principal,
|
|
2848
3171
|
operation: definition.name,
|
|
2849
3172
|
signal: request.signal,
|
|
3173
|
+
origin: 'application',
|
|
2850
3174
|
};
|
|
2851
3175
|
if (trace)
|
|
2852
3176
|
context.trace = trace;
|
|
@@ -2985,6 +3309,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2985
3309
|
input: { ...given, ...params },
|
|
2986
3310
|
request,
|
|
2987
3311
|
options: withoutConfirmationRound(principal, route.operation),
|
|
3312
|
+
origin: 'rest',
|
|
2988
3313
|
...(trace ? { trace } : {}),
|
|
2989
3314
|
});
|
|
2990
3315
|
const internal = !outcome.ok && INTERNAL_FAILURES.has(outcome.code);
|
|
@@ -3001,6 +3326,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3001
3326
|
return refuse(status, outcome.code, {}, {
|
|
3002
3327
|
...(outcome.code === 'overlap' ? refusedInterval(given) : {}),
|
|
3003
3328
|
...outcome.details,
|
|
3329
|
+
...(outcome.issues ? { issues: outcome.issues } : {}),
|
|
3004
3330
|
});
|
|
3005
3331
|
}
|
|
3006
3332
|
return {
|
|
@@ -3263,7 +3589,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3263
3589
|
return a2aError(id, -32004, 'Unsupported operation', {
|
|
3264
3590
|
reason: 'UNSUPPORTED_OPERATION',
|
|
3265
3591
|
domain: 'a2a-protocol.org',
|
|
3266
|
-
metadata: { supportedMethods: 'SendMessage' },
|
|
3592
|
+
metadata: { supportedMethods: 'SendMessage, SendStreamingMessage' },
|
|
3267
3593
|
});
|
|
3268
3594
|
}
|
|
3269
3595
|
if (A2A_TASK_METHOD_FIELDS.has(rpc.method)) {
|
|
@@ -3282,7 +3608,8 @@ export function createSlotlockAgentServer(options) {
|
|
|
3282
3608
|
const pageSize = rpc.params?.pageSize ?? SLOTLOCK_A2A_TASK_PAGE_SIZE.default;
|
|
3283
3609
|
return json(200, rpcResult(id, { tasks: [], nextPageToken: '', pageSize, totalSize: 0 }));
|
|
3284
3610
|
}
|
|
3285
|
-
|
|
3611
|
+
const streamingRequest = rpc.method === 'SendStreamingMessage';
|
|
3612
|
+
if (rpc.method !== 'SendMessage' && !streamingRequest) {
|
|
3286
3613
|
return json(200, rpcError(id, -32601, 'Method not found'));
|
|
3287
3614
|
}
|
|
3288
3615
|
const started = performance.now();
|
|
@@ -3291,7 +3618,8 @@ export function createSlotlockAgentServer(options) {
|
|
|
3291
3618
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
3292
3619
|
const part = parts.length === 1 ? parts[0] : null;
|
|
3293
3620
|
const data = part?.data;
|
|
3294
|
-
const
|
|
3621
|
+
const watchSkill = data?.skill === SLOTLOCK_A2A_WATCH_SKILL;
|
|
3622
|
+
const operation = watchSkill ? null : resolveSlotlockAgentOperation(data?.skill);
|
|
3295
3623
|
// A refused message that names an operation is still a call, reported by the error's reason.
|
|
3296
3624
|
const refuse = (outcome, response) => {
|
|
3297
3625
|
if (operation)
|
|
@@ -3312,7 +3640,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3312
3640
|
}
|
|
3313
3641
|
// `arguments` may be left out or null; anything else but an object is not arguments.
|
|
3314
3642
|
const input = data?.arguments;
|
|
3315
|
-
if (!operation ||
|
|
3643
|
+
if ((!operation && !watchSkill) ||
|
|
3316
3644
|
message?.role !== 'ROLE_USER' ||
|
|
3317
3645
|
!isCanonicalBoundedIdentifier(message.messageId, 200) ||
|
|
3318
3646
|
message.taskId !== undefined ||
|
|
@@ -3326,6 +3654,44 @@ export function createSlotlockAgentServer(options) {
|
|
|
3326
3654
|
},
|
|
3327
3655
|
}));
|
|
3328
3656
|
}
|
|
3657
|
+
const requestedContextId = message.contextId;
|
|
3658
|
+
const contextId = typeof requestedContextId === 'string' &&
|
|
3659
|
+
requestedContextId.length > 0 &&
|
|
3660
|
+
requestedContextId.length <= 200
|
|
3661
|
+
? requestedContextId
|
|
3662
|
+
: `ctx_${createHash('sha256')
|
|
3663
|
+
.update(`${principal.tenantRef}\0${message.messageId}`)
|
|
3664
|
+
.digest('base64url')
|
|
3665
|
+
.slice(0, 32)}`;
|
|
3666
|
+
const skillName = operation ?? SLOTLOCK_A2A_WATCH_SKILL;
|
|
3667
|
+
// A skill's outcome is the agent's reply, success or not — the same contract as an MCP
|
|
3668
|
+
// `isError` result, so the calling agent reads `{"error":{"code":…}}` and can act on it.
|
|
3669
|
+
// Streamed, the reply is the stream's one event (A2A: a Message, then the stream closes).
|
|
3670
|
+
const reply = (replyData) => {
|
|
3671
|
+
const replyId = createHash('sha256')
|
|
3672
|
+
.update(`${principal.subject}\0${message.messageId}\0${skillName}`)
|
|
3673
|
+
.digest('base64url')
|
|
3674
|
+
.slice(0, 32);
|
|
3675
|
+
const result = {
|
|
3676
|
+
message: {
|
|
3677
|
+
messageId: `reply_${replyId}`,
|
|
3678
|
+
contextId,
|
|
3679
|
+
role: 'ROLE_AGENT',
|
|
3680
|
+
parts: [{ data: replyData, mediaType: 'application/json' }],
|
|
3681
|
+
},
|
|
3682
|
+
};
|
|
3683
|
+
if (!streamingRequest)
|
|
3684
|
+
return json(200, rpcResult(id, result));
|
|
3685
|
+
const stream = openMcpEventStream(() => { });
|
|
3686
|
+
stream.send({ jsonrpc: '2.0', id, result });
|
|
3687
|
+
stream.close();
|
|
3688
|
+
return stream.response;
|
|
3689
|
+
};
|
|
3690
|
+
if (!operation) {
|
|
3691
|
+
if (!streamingRequest)
|
|
3692
|
+
return reply({ error: { code: 'streaming_required' } });
|
|
3693
|
+
return a2aWatch({ id, request, principal, trace, input, contextId, reply });
|
|
3694
|
+
}
|
|
3329
3695
|
try {
|
|
3330
3696
|
const outcome = await invokeReported('a2a', {
|
|
3331
3697
|
operation,
|
|
@@ -3337,36 +3703,15 @@ export function createSlotlockAgentServer(options) {
|
|
|
3337
3703
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
3338
3704
|
return json(200, rpcError(id, -32603, 'Internal error'));
|
|
3339
3705
|
}
|
|
3340
|
-
|
|
3341
|
-
.
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
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
|
-
}));
|
|
3706
|
+
return reply(outcome.ok
|
|
3707
|
+
? outcome.data
|
|
3708
|
+
: {
|
|
3709
|
+
error: {
|
|
3710
|
+
...outcome.details,
|
|
3711
|
+
code: outcome.code,
|
|
3712
|
+
...(outcome.issues ? { issues: outcome.issues } : {}),
|
|
3713
|
+
},
|
|
3714
|
+
});
|
|
3370
3715
|
}
|
|
3371
3716
|
catch {
|
|
3372
3717
|
return json(200, rpcError(id, -32603, 'Internal error'));
|