slotlock 0.1.1 → 0.3.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 +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/agent-server.js
CHANGED
|
@@ -2,8 +2,11 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
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
4
|
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
|
+
import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
|
|
6
|
+
import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
|
|
7
|
+
import { ignoreRejection } from './hooks.js';
|
|
5
8
|
import { slotlockPackageVersion } from './package-version.js';
|
|
6
|
-
import { matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
|
|
9
|
+
import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
|
|
7
10
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
|
|
8
11
|
import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
|
|
9
12
|
import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
|
|
@@ -59,14 +62,6 @@ const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
|
59
62
|
const EVENT_ID = z.string().trim().min(1).max(500);
|
|
60
63
|
const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
|
|
61
64
|
const REVISION = z.number().int().positive();
|
|
62
|
-
// Output bounds mirror the trusted store's byte ceilings. A value admitted by a UTF-8 byte
|
|
63
|
-
// ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
|
|
64
|
-
// serializable without truncating recurrence semantics or calendar identities.
|
|
65
|
-
const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
|
|
66
|
-
const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
|
|
67
|
-
const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
|
|
68
|
-
const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
|
|
69
|
-
const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
|
|
70
65
|
const MAX_AGENT_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
|
|
71
66
|
/** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
|
|
72
67
|
const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
|
|
@@ -93,41 +88,6 @@ const COVERAGE = z
|
|
|
93
88
|
reason: z.string().max(100).nullable(),
|
|
94
89
|
})
|
|
95
90
|
.strict();
|
|
96
|
-
const ORGANIZER = z
|
|
97
|
-
.object({
|
|
98
|
-
address: z.string().email().max(320),
|
|
99
|
-
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
100
|
-
})
|
|
101
|
-
.strict();
|
|
102
|
-
const ATTENDEE = z
|
|
103
|
-
.object({
|
|
104
|
-
address: z.string().email().max(320),
|
|
105
|
-
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
106
|
-
role: z.enum(['chair', 'required', 'optional', 'non_participant']),
|
|
107
|
-
participation_status: z.enum([
|
|
108
|
-
'needs_action',
|
|
109
|
-
'accepted',
|
|
110
|
-
'declined',
|
|
111
|
-
'tentative',
|
|
112
|
-
'delegated',
|
|
113
|
-
]),
|
|
114
|
-
rsvp: z.boolean(),
|
|
115
|
-
})
|
|
116
|
-
.strict();
|
|
117
|
-
const REMINDER = z
|
|
118
|
-
.object({
|
|
119
|
-
minutes_before: z.number().int().min(0).max(TRUSTED_REMINDER_MINUTES_OUTPUT_MAX),
|
|
120
|
-
channel: z.enum(['display', 'email']),
|
|
121
|
-
})
|
|
122
|
-
.strict();
|
|
123
|
-
const RECURRENCE_EXCEPTION = z
|
|
124
|
-
.object({
|
|
125
|
-
recurrence_id: DATE_TIME,
|
|
126
|
-
cancelled: z.boolean(),
|
|
127
|
-
starts_at: DATE_TIME.nullable(),
|
|
128
|
-
ends_at: DATE_TIME.nullable(),
|
|
129
|
-
})
|
|
130
|
-
.strict();
|
|
131
91
|
const ORGANIZER_INPUT = z
|
|
132
92
|
.object({
|
|
133
93
|
address: z.string().email().max(320),
|
|
@@ -185,30 +145,6 @@ const MCP_INITIALIZE_PARAMS = z
|
|
|
185
145
|
_meta: z.record(z.string(), z.unknown()).optional(),
|
|
186
146
|
})
|
|
187
147
|
.passthrough();
|
|
188
|
-
const EVENT = z
|
|
189
|
-
.object({
|
|
190
|
-
id: EVENT_ID,
|
|
191
|
-
resource_id: RESOURCE_ID,
|
|
192
|
-
starts_at: DATE_TIME,
|
|
193
|
-
ends_at: DATE_TIME,
|
|
194
|
-
timezone: z.string().min(1).max(100),
|
|
195
|
-
title: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
196
|
-
description: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
197
|
-
location: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
198
|
-
organizer: ORGANIZER.nullable(),
|
|
199
|
-
attendees: z.array(ATTENDEE).max(100),
|
|
200
|
-
reminders: z.array(REMINDER).max(20),
|
|
201
|
-
status: z.enum(['tentative', 'confirmed', 'cancelled']),
|
|
202
|
-
transparency: z.enum(['opaque', 'transparent']),
|
|
203
|
-
sequence: z.number().int().nonnegative(),
|
|
204
|
-
revision: REVISION,
|
|
205
|
-
recurrence_rule: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
206
|
-
recurrence_exceptions: z
|
|
207
|
-
.array(RECURRENCE_EXCEPTION)
|
|
208
|
-
.max(TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX),
|
|
209
|
-
recurrence_id: DATE_TIME.nullable(),
|
|
210
|
-
})
|
|
211
|
-
.strict();
|
|
212
148
|
/**
|
|
213
149
|
* Names earlier builds published, which every entry point still accepts and dispatches as the same
|
|
214
150
|
* operation; nothing advertises them. The dotted names came first, before tool names had to be
|
|
@@ -425,7 +361,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
425
361
|
{
|
|
426
362
|
name: 'slotlock_update_event',
|
|
427
363
|
title: 'Update resource event',
|
|
428
|
-
description: 'Apply an expected-revision event patch and rematerialize occupancy atomically.',
|
|
364
|
+
description: 'Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found.',
|
|
429
365
|
input: z
|
|
430
366
|
.object({
|
|
431
367
|
event_id: EVENT_ID,
|
|
@@ -445,6 +381,8 @@ const OPERATION_DEFINITIONS = [
|
|
|
445
381
|
recurrence_rule: RECURRENCE_RULE.nullable().optional(),
|
|
446
382
|
recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
|
|
447
383
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
384
|
+
// Last, so the generated clients that take optional arguments by position keep their order.
|
|
385
|
+
expected_resource_id: RESOURCE_ID.optional(),
|
|
448
386
|
})
|
|
449
387
|
.strict()
|
|
450
388
|
.refine((value) => value.starts_at !== undefined ||
|
|
@@ -558,6 +496,35 @@ export function slotlockMcpToolResult(outcome) {
|
|
|
558
496
|
}
|
|
559
497
|
return { content: [{ type: 'text', text: JSON.stringify({ error }) }], isError: true };
|
|
560
498
|
}
|
|
499
|
+
/** The agent tools as REST serves them: each with the scope it needs. */
|
|
500
|
+
function restAgentTools() {
|
|
501
|
+
return slotlockAgentTools().map((tool) => ({ ...tool, scope: slotlockAgentOperationScope(tool.name) }));
|
|
502
|
+
}
|
|
503
|
+
/** The application operations with their input and output as JSON Schema, as REST serves them. */
|
|
504
|
+
export function slotlockApplicationOperations() {
|
|
505
|
+
return SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => ({
|
|
506
|
+
name: operation.name,
|
|
507
|
+
title: operation.title,
|
|
508
|
+
description: operation.description,
|
|
509
|
+
method: operation.method,
|
|
510
|
+
path: operation.path,
|
|
511
|
+
inputSchema: toJsonSchema(operation.input, 'input'),
|
|
512
|
+
outputSchema: toJsonSchema(operation.output, 'output'),
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The OpenAPI 3.1 document of the REST API: what a server with `rest` (and `application`) serves at
|
|
517
|
+
* /openapi.json. Without `publicBaseUrl` the server URL is a variable, `{baseUrl}`, as in the
|
|
518
|
+
* repository's openapi.json, which client generators read.
|
|
519
|
+
*/
|
|
520
|
+
export function slotlockOpenApi(options = {}) {
|
|
521
|
+
return slotlockOpenApiDocument({
|
|
522
|
+
...(options.publicBaseUrl !== undefined ? { publicBaseUrl: options.publicBaseUrl } : {}),
|
|
523
|
+
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
524
|
+
tools: restAgentTools(),
|
|
525
|
+
...(options.application ? { application: slotlockApplicationOperations() } : {}),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
561
528
|
export function slotlockAgentTools() {
|
|
562
529
|
return OPERATION_DEFINITIONS.map((operation) => ({
|
|
563
530
|
name: operation.name,
|
|
@@ -611,13 +578,44 @@ function rpcResult(id, result) {
|
|
|
611
578
|
function rpcError(id, code, message) {
|
|
612
579
|
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
613
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Whether a Content-Type names `application/json`, whatever its parameters. A media type's type
|
|
583
|
+
* and subtype are case-insensitive (RFC 9110 §8.3.1), so `Application/JSON` is the same type.
|
|
584
|
+
*/
|
|
585
|
+
function isJsonMediaType(contentType) {
|
|
586
|
+
return contentType?.split(';')[0]?.trim().toLowerCase() === 'application/json';
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Parse a JSON body of at most `maxBytes`. The body is counted as it arrives, so one sent without
|
|
590
|
+
* Content-Length (chunked) is refused, and its stream cancelled, as soon as it passes the limit,
|
|
591
|
+
* rather than buffered whole first.
|
|
592
|
+
*/
|
|
614
593
|
async function readJson(request, maxBytes) {
|
|
615
594
|
const declared = request.headers.get('content-length');
|
|
616
595
|
if (declared && Number(declared) > maxBytes)
|
|
617
596
|
throw new Error('request_too_large');
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
597
|
+
const chunks = [];
|
|
598
|
+
let size = 0;
|
|
599
|
+
if (request.body) {
|
|
600
|
+
const reader = request.body.getReader();
|
|
601
|
+
for (;;) {
|
|
602
|
+
const { done, value } = await reader.read();
|
|
603
|
+
if (done)
|
|
604
|
+
break;
|
|
605
|
+
size += value.byteLength;
|
|
606
|
+
if (size > maxBytes) {
|
|
607
|
+
await reader.cancel().catch(() => undefined);
|
|
608
|
+
throw new Error('request_too_large');
|
|
609
|
+
}
|
|
610
|
+
chunks.push(value);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const bytes = new Uint8Array(size);
|
|
614
|
+
let offset = 0;
|
|
615
|
+
for (const chunk of chunks) {
|
|
616
|
+
bytes.set(chunk, offset);
|
|
617
|
+
offset += chunk.byteLength;
|
|
618
|
+
}
|
|
621
619
|
try {
|
|
622
620
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
623
621
|
}
|
|
@@ -783,10 +781,11 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
783
781
|
if (!principalScopesCover(principal, operation.name)) {
|
|
784
782
|
return { ok: false, status: 403, code: 'forbidden' };
|
|
785
783
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
:
|
|
789
|
-
|
|
784
|
+
// No input, or null, is no arguments; anything else that is not an object is refused.
|
|
785
|
+
if (args.input != null && !isRecord(args.input)) {
|
|
786
|
+
return { ok: false, status: 400, code: 'invalid_arguments' };
|
|
787
|
+
}
|
|
788
|
+
const parsed = operation.input.safeParse(args.input ?? {});
|
|
790
789
|
if (!parsed.success)
|
|
791
790
|
return { ok: false, status: 400, code: 'invalid_arguments' };
|
|
792
791
|
if (!(await args.options.authorize({
|
|
@@ -838,12 +837,20 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
838
837
|
/** Hand an unexpected failure to `onError`; nothing it does can change the protocol answer. */
|
|
839
838
|
function reportUnexpected(onError, error, operation) {
|
|
840
839
|
try {
|
|
841
|
-
onError?.(error, { operation });
|
|
840
|
+
ignoreRejection(onError?.(error, { operation }));
|
|
842
841
|
}
|
|
843
842
|
catch {
|
|
844
843
|
// Observability must never change what a protocol client receives.
|
|
845
844
|
}
|
|
846
845
|
}
|
|
846
|
+
/** Milliseconds since `started` (a `performance.now()` reading), to a tenth. */
|
|
847
|
+
function elapsedMs(started) {
|
|
848
|
+
return Math.round((performance.now() - started) * 10) / 10;
|
|
849
|
+
}
|
|
850
|
+
/** The trace id of a valid `traceparent` (`version-traceid-parentid-flags`): its second field. */
|
|
851
|
+
function traceIdOf(trace) {
|
|
852
|
+
return trace.traceparent.split('-', 2)[1] ?? '';
|
|
853
|
+
}
|
|
847
854
|
/** Failures that are server faults, not operation outcomes an agent could act on. */
|
|
848
855
|
const INTERNAL_FAILURES = new Set(['internal_error', 'invalid_backend_result']);
|
|
849
856
|
/**
|
|
@@ -1081,12 +1088,14 @@ function validateSubscriptions(value) {
|
|
|
1081
1088
|
}
|
|
1082
1089
|
return setting;
|
|
1083
1090
|
};
|
|
1091
|
+
const maxTotal = bounded('maxTotal', 1, 10_000, 256);
|
|
1084
1092
|
return {
|
|
1085
1093
|
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
|
|
1086
1094
|
maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
|
|
1087
1095
|
keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
|
|
1088
1096
|
maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
|
|
1089
|
-
|
|
1097
|
+
maxPerTenant: bounded('maxPerTenant', 1, 10_000, maxTotal),
|
|
1098
|
+
maxTotal,
|
|
1090
1099
|
};
|
|
1091
1100
|
}
|
|
1092
1101
|
/**
|
|
@@ -1380,10 +1389,14 @@ function promptSeries(series, zone) {
|
|
|
1380
1389
|
const booked = expandCalendarEventOccurrences(content, horizon);
|
|
1381
1390
|
beyond = promptSeriesEnd(series.rule, booked.length, horizon.end);
|
|
1382
1391
|
const length = end.getTime() - start.getTime();
|
|
1392
|
+
// Moved: by an exception, to another time or length. One the clocks change inside, or that
|
|
1393
|
+
// the store ends where the next starts, is where its rule puts it.
|
|
1394
|
+
const excepted = new Set(exceptions.flatMap((exception) => exception.cancelled !== true && exception.start ? [exception.recurrenceId.getTime()] : []));
|
|
1383
1395
|
moved = promptMoved(booked.flatMap((occurrence) => {
|
|
1384
1396
|
const from = new Date(occurrence.recurrenceId);
|
|
1385
|
-
const shifted =
|
|
1386
|
-
occurrence.
|
|
1397
|
+
const shifted = excepted.has(from.getTime()) &&
|
|
1398
|
+
(occurrence.start.getTime() !== from.getTime() ||
|
|
1399
|
+
occurrence.end.getTime() - occurrence.start.getTime() !== length);
|
|
1387
1400
|
return shifted && Number.isFinite(from.getTime())
|
|
1388
1401
|
? [{ from, start: occurrence.start, end: occurrence.end }]
|
|
1389
1402
|
: [];
|
|
@@ -1540,10 +1553,14 @@ async function confirmationMessage(operation, input, lookup) {
|
|
|
1540
1553
|
}, zone)
|
|
1541
1554
|
: `for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}`;
|
|
1542
1555
|
const tentative = input.status === 'tentative' ? ', tentatively' : '';
|
|
1556
|
+
// A transparent booking does not make the resource busy: the person must know it stays free.
|
|
1557
|
+
const free = input.transparency === 'transparent' ? ', leaving the time free for other bookings' : '';
|
|
1558
|
+
const details = promptDetails(input);
|
|
1559
|
+
const also = details ? ` (${details})` : '';
|
|
1543
1560
|
const title = typeof input.title === 'string' && input.title.length > 0
|
|
1544
1561
|
? `: ${promptQuoted(input.title)}`
|
|
1545
1562
|
: '';
|
|
1546
|
-
return `Book ${resource.label} ${when}${tentative}${title}.`;
|
|
1563
|
+
return `Book ${resource.label} ${when}${tentative}${free}${also}${title}.`;
|
|
1547
1564
|
}
|
|
1548
1565
|
const event = await lookup.event(input.event_id);
|
|
1549
1566
|
const current = event ? await lookup.resource(event.resourceId) : null;
|
|
@@ -1598,8 +1615,18 @@ async function confirmationMessage(operation, input, lookup) {
|
|
|
1598
1615
|
changes.push('no longer repeats');
|
|
1599
1616
|
}
|
|
1600
1617
|
else if (typeof rule === 'string' && reshaped && seriesStart && seriesEnd) {
|
|
1601
|
-
const seriesZone = input.timezone ?? event?.timezone
|
|
1602
|
-
|
|
1618
|
+
const seriesZone = input.timezone ?? event?.timezone;
|
|
1619
|
+
if (seriesZone === undefined) {
|
|
1620
|
+
// An unread event and no new zone: the store expands in the event's own, which the question
|
|
1621
|
+
// cannot know, so it states no dates.
|
|
1622
|
+
changes.push(`repeats ${promptRule(rule)}, on dates that follow the event's existing time zone`);
|
|
1623
|
+
if (input.recurrence_exceptions !== undefined) {
|
|
1624
|
+
changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
else {
|
|
1628
|
+
changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
|
|
1629
|
+
}
|
|
1603
1630
|
}
|
|
1604
1631
|
else {
|
|
1605
1632
|
if (typeof input.recurrence_rule === 'string') {
|
|
@@ -1609,15 +1636,23 @@ async function confirmationMessage(operation, input, lookup) {
|
|
|
1609
1636
|
changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
|
|
1610
1637
|
}
|
|
1611
1638
|
}
|
|
1612
|
-
const details =
|
|
1613
|
-
if (details
|
|
1614
|
-
changes.push(
|
|
1639
|
+
const details = promptDetails(input);
|
|
1640
|
+
if (details)
|
|
1641
|
+
changes.push(details);
|
|
1615
1642
|
// The agent's own text goes last.
|
|
1616
1643
|
if (input.title !== undefined) {
|
|
1617
1644
|
changes.push(input.title === null ? 'title removed' : `title to ${promptQuoted(input.title)}`);
|
|
1618
1645
|
}
|
|
1619
1646
|
return `Change ${target}${changes.length > 0 ? `: ${changes.join('; ')}` : ''}.${titled}`;
|
|
1620
1647
|
}
|
|
1648
|
+
/**
|
|
1649
|
+
* The other fields a write sets, by name only (`also description, attendees`): their values are
|
|
1650
|
+
* the agent's text, and only the title is quoted. `null` when it sets none of them.
|
|
1651
|
+
*/
|
|
1652
|
+
function promptDetails(input) {
|
|
1653
|
+
const details = ['description', 'location', 'organizer', 'attendees', 'reminders'].filter((field) => input[field] !== undefined);
|
|
1654
|
+
return details.length > 0 ? `also ${details.join(', ')}` : null;
|
|
1655
|
+
}
|
|
1621
1656
|
/**
|
|
1622
1657
|
* Read the client's retry of a guarded call. State this server did not seal, sealed for another
|
|
1623
1658
|
* principal, another operation or other arguments is `invalid`; lapsed state, or an answer that is
|
|
@@ -1688,6 +1723,22 @@ function a2aError(id, code, message, info) {
|
|
|
1688
1723
|
},
|
|
1689
1724
|
});
|
|
1690
1725
|
}
|
|
1726
|
+
/**
|
|
1727
|
+
* The request's principal, or the error when `authenticate` throws a 503
|
|
1728
|
+
* `SlotlockAgentOperationError`, as createSlotlockApiKeyAuthenticator does when its lookup queue is
|
|
1729
|
+
* full: no answer about the credential, so the client is told to retry rather than that it is
|
|
1730
|
+
* anonymous.
|
|
1731
|
+
*/
|
|
1732
|
+
async function principalOrBusy(authenticate, request) {
|
|
1733
|
+
try {
|
|
1734
|
+
return await authenticate(request);
|
|
1735
|
+
}
|
|
1736
|
+
catch (error) {
|
|
1737
|
+
if (error instanceof SlotlockAgentOperationError && error.status === 503)
|
|
1738
|
+
return error;
|
|
1739
|
+
throw error;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1691
1742
|
/** Map a request path onto the server's base path; `null` when it lies outside it. */
|
|
1692
1743
|
function routePath(pathname, basePath) {
|
|
1693
1744
|
if (basePath === '')
|
|
@@ -1730,14 +1781,33 @@ export function createSlotlockAgentServer(options) {
|
|
|
1730
1781
|
if (options.rest !== undefined && typeof options.rest !== 'boolean') {
|
|
1731
1782
|
throw new Error('Slotlock agent server rest must be a boolean');
|
|
1732
1783
|
}
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1784
|
+
const application = options.application;
|
|
1785
|
+
if (application !== undefined) {
|
|
1786
|
+
if (options.rest !== true) {
|
|
1787
|
+
throw new Error('Slotlock agent server application needs rest: it is served only as REST');
|
|
1788
|
+
}
|
|
1789
|
+
const backend = application?.backend;
|
|
1790
|
+
if (typeof application !== 'object' ||
|
|
1791
|
+
application === null ||
|
|
1792
|
+
typeof application.authorize !== 'function' ||
|
|
1793
|
+
typeof backend !== 'object' ||
|
|
1794
|
+
backend === null ||
|
|
1795
|
+
SLOTLOCK_APPLICATION_OPERATIONS.some((operation) => typeof backend[operation.invoke] !== 'function')) {
|
|
1796
|
+
throw new Error('Slotlock agent server application needs an authorize function and a backend with every application method');
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const restTools = options.rest ? restAgentTools() : [];
|
|
1800
|
+
const applicationTools = application ? slotlockApplicationOperations() : [];
|
|
1801
|
+
const restInputSchemas = new Map([
|
|
1802
|
+
...restTools.map((tool) => [tool.name, tool.inputSchema]),
|
|
1803
|
+
...applicationTools.map((tool) => [tool.name, tool.inputSchema]),
|
|
1804
|
+
]);
|
|
1805
|
+
const restRoutes = [
|
|
1806
|
+
...SLOTLOCK_REST_ROUTES,
|
|
1807
|
+
...(application ? SLOTLOCK_APPLICATION_REST_ROUTES : []),
|
|
1808
|
+
];
|
|
1735
1809
|
const openApi = options.rest
|
|
1736
|
-
? Object.freeze(
|
|
1737
|
-
publicBaseUrl: config.publicBaseUrl,
|
|
1738
|
-
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
1739
|
-
tools: restTools,
|
|
1740
|
-
}))
|
|
1810
|
+
? Object.freeze(slotlockOpenApi({ publicBaseUrl: config.publicBaseUrl, application: application !== undefined }))
|
|
1741
1811
|
: null;
|
|
1742
1812
|
const manifest = Object.freeze({
|
|
1743
1813
|
name: 'Slotlock Agent Calendar',
|
|
@@ -1761,12 +1831,33 @@ export function createSlotlockAgentServer(options) {
|
|
|
1761
1831
|
const confirmation = validateConfirmation(options.confirmation);
|
|
1762
1832
|
const emit = (event) => {
|
|
1763
1833
|
try {
|
|
1764
|
-
options.onEvent?.(event);
|
|
1834
|
+
ignoreRejection(options.onEvent?.(event));
|
|
1765
1835
|
}
|
|
1766
1836
|
catch {
|
|
1767
1837
|
// Observability must never change what a protocol client receives.
|
|
1768
1838
|
}
|
|
1769
1839
|
};
|
|
1840
|
+
/**
|
|
1841
|
+
* Report how an MCP or A2A call that named an operation ended, as a `call` event: run, or refused
|
|
1842
|
+
* before it could run (a malformed message, a client that cannot confirm). Never its arguments.
|
|
1843
|
+
*/
|
|
1844
|
+
const reportCall = (protocol, operation, outcome, started, trace) => {
|
|
1845
|
+
emit({
|
|
1846
|
+
type: 'call',
|
|
1847
|
+
protocol,
|
|
1848
|
+
operation,
|
|
1849
|
+
outcome,
|
|
1850
|
+
durationMs: elapsedMs(started),
|
|
1851
|
+
...(trace ? { traceId: traceIdOf(trace) } : {}),
|
|
1852
|
+
});
|
|
1853
|
+
};
|
|
1854
|
+
/** Run an operation an MCP or A2A caller named, and report how it ended as a `call` event. */
|
|
1855
|
+
const invokeReported = async (protocol, args) => {
|
|
1856
|
+
const started = performance.now();
|
|
1857
|
+
const outcome = await invokeSlotlockAgentOperation(args);
|
|
1858
|
+
reportCall(protocol, args.operation, outcome.ok ? 'ok' : outcome.code, started, args.trace);
|
|
1859
|
+
return outcome;
|
|
1860
|
+
};
|
|
1770
1861
|
const dispatchOptions = (principal) => ({
|
|
1771
1862
|
backend: options.backend,
|
|
1772
1863
|
// The transport authenticates once before protocol parsing/rate-limiting; the canonical
|
|
@@ -1832,10 +1923,17 @@ export function createSlotlockAgentServer(options) {
|
|
|
1832
1923
|
const operation = resolveSlotlockAgentOperation(params.name);
|
|
1833
1924
|
if (!operation)
|
|
1834
1925
|
return modernError(200, id, -32602, 'Unknown tool');
|
|
1926
|
+
const started = performance.now();
|
|
1927
|
+
// `arguments` may be left out or null; anything else but an object is malformed params.
|
|
1928
|
+
if (params.arguments != null && !isRecord(params.arguments)) {
|
|
1929
|
+
reportCall('mcp', operation, 'invalid_arguments', started, call.trace);
|
|
1930
|
+
return modernError(200, id, -32602, 'Invalid params');
|
|
1931
|
+
}
|
|
1835
1932
|
const dispatch = dispatchOptions(principal);
|
|
1836
1933
|
const gate = {};
|
|
1837
1934
|
if (confirmation?.operations.has(operation)) {
|
|
1838
1935
|
if (!clientSupportsFormElicitation(call.capabilities)) {
|
|
1936
|
+
reportCall('mcp', operation, 'missing_capability', started, call.trace);
|
|
1839
1937
|
return modernError(400, id, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, 'Missing required client capability', { requiredCapabilities: { elicitation: { form: {} } } });
|
|
1840
1938
|
}
|
|
1841
1939
|
dispatch.confirm = async ({ input }) => {
|
|
@@ -1848,19 +1946,18 @@ export function createSlotlockAgentServer(options) {
|
|
|
1848
1946
|
operation,
|
|
1849
1947
|
argsDigest: gate.argsDigest,
|
|
1850
1948
|
});
|
|
1949
|
+
// Before the call it decides, as on every other protocol.
|
|
1950
|
+
emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
|
|
1851
1951
|
return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
|
|
1852
1952
|
};
|
|
1853
1953
|
}
|
|
1854
|
-
const outcome = await
|
|
1954
|
+
const outcome = await invokeReported('mcp', {
|
|
1855
1955
|
operation,
|
|
1856
1956
|
input: isRecord(params.arguments) ? params.arguments : {},
|
|
1857
1957
|
request: call.request,
|
|
1858
1958
|
options: dispatch,
|
|
1859
1959
|
...(call.trace ? { trace: call.trace } : {}),
|
|
1860
1960
|
});
|
|
1861
|
-
if (gate.decision) {
|
|
1862
|
-
emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
|
|
1863
|
-
}
|
|
1864
1961
|
if (confirmation &&
|
|
1865
1962
|
(gate.decision === 'prompt' || gate.decision === 'expired') &&
|
|
1866
1963
|
gate.input &&
|
|
@@ -2056,15 +2153,48 @@ export function createSlotlockAgentServer(options) {
|
|
|
2056
2153
|
return modernError(503, id, -32603, 'Server is shutting down');
|
|
2057
2154
|
const principalKey = `${principal.tenantRef}\0${principal.subject}`;
|
|
2058
2155
|
let held = 0;
|
|
2059
|
-
|
|
2156
|
+
let heldByTenant = 0;
|
|
2157
|
+
for (const open of openSubscriptions) {
|
|
2060
2158
|
if (open.principalKey === principalKey)
|
|
2061
2159
|
held += 1;
|
|
2160
|
+
if (open.tenantRef === principal.tenantRef)
|
|
2161
|
+
heldByTenant += 1;
|
|
2162
|
+
}
|
|
2163
|
+
if (openSubscriptions.size >= subscriptionPolicy.maxTotal &&
|
|
2164
|
+
held < subscriptionPolicy.maxPerPrincipal &&
|
|
2165
|
+
heldByTenant < subscriptionPolicy.maxPerTenant) {
|
|
2166
|
+
// A full server is shared out: a tenant holding fewer than the one holding most, less one,
|
|
2167
|
+
// ends that tenant's oldest stream, so many tenants within their share cannot keep another
|
|
2168
|
+
// from holding any. A slot still opening has nothing to end yet.
|
|
2169
|
+
const streamsOf = new Map();
|
|
2170
|
+
for (const open of openSubscriptions) {
|
|
2171
|
+
if (!open.streaming)
|
|
2172
|
+
continue;
|
|
2173
|
+
const streams = streamsOf.get(open.tenantRef);
|
|
2174
|
+
if (streams)
|
|
2175
|
+
streams.push(open);
|
|
2176
|
+
else
|
|
2177
|
+
streamsOf.set(open.tenantRef, [open]);
|
|
2178
|
+
}
|
|
2179
|
+
let most = [];
|
|
2180
|
+
for (const streams of streamsOf.values())
|
|
2181
|
+
if (streams.length > most.length)
|
|
2182
|
+
most = streams;
|
|
2183
|
+
if (most.length > heldByTenant + 1)
|
|
2184
|
+
most[0]?.end('preempted');
|
|
2185
|
+
}
|
|
2062
2186
|
if (openSubscriptions.size >= subscriptionPolicy.maxTotal ||
|
|
2063
|
-
held >= subscriptionPolicy.maxPerPrincipal
|
|
2187
|
+
held >= subscriptionPolicy.maxPerPrincipal ||
|
|
2188
|
+
heldByTenant >= subscriptionPolicy.maxPerTenant) {
|
|
2064
2189
|
return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
|
|
2065
2190
|
}
|
|
2066
2191
|
// The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
|
|
2067
|
-
const slot = {
|
|
2192
|
+
const slot = {
|
|
2193
|
+
principalKey,
|
|
2194
|
+
tenantRef: principal.tenantRef,
|
|
2195
|
+
streaming: false,
|
|
2196
|
+
end: () => { },
|
|
2197
|
+
};
|
|
2068
2198
|
openSubscriptions.add(slot);
|
|
2069
2199
|
let streaming = false;
|
|
2070
2200
|
try {
|
|
@@ -2128,6 +2258,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2128
2258
|
});
|
|
2129
2259
|
}
|
|
2130
2260
|
slot.end = end;
|
|
2261
|
+
slot.streaming = true;
|
|
2131
2262
|
stream.send({
|
|
2132
2263
|
jsonrpc: '2.0',
|
|
2133
2264
|
method: 'notifications/subscriptions/acknowledged',
|
|
@@ -2267,6 +2398,48 @@ export function createSlotlockAgentServer(options) {
|
|
|
2267
2398
|
return modernError(404, id, -32601, 'Method not found');
|
|
2268
2399
|
}
|
|
2269
2400
|
}
|
|
2401
|
+
/**
|
|
2402
|
+
* One application operation for an authenticated principal: the admin scope (when the principal
|
|
2403
|
+
* carries scopes), the operation's strict input schema, `application.authorize`, the backend, and
|
|
2404
|
+
* its output schema. A refusal the backend throws as a `SlotlockAgentOperationError` is the answer;
|
|
2405
|
+
* anything else is reported to `onError` and answers only `internal_error`.
|
|
2406
|
+
*/
|
|
2407
|
+
async function invokeApplicationOperation(definition, principal, given, request, trace) {
|
|
2408
|
+
const served = application;
|
|
2409
|
+
try {
|
|
2410
|
+
const scopes = principal.scopes;
|
|
2411
|
+
if (scopes !== undefined &&
|
|
2412
|
+
!(Array.isArray(scopes) && scopes.includes(SLOTLOCK_APPLICATION_SCOPE))) {
|
|
2413
|
+
return { ok: false, status: 403, code: 'forbidden' };
|
|
2414
|
+
}
|
|
2415
|
+
const parsed = definition.input.safeParse(given);
|
|
2416
|
+
if (!parsed.success)
|
|
2417
|
+
return { ok: false, status: 400, code: 'invalid_arguments' };
|
|
2418
|
+
if (!(await served.authorize({ principal, operation: definition.name, input: parsed.data }))) {
|
|
2419
|
+
return { ok: false, status: 403, code: 'forbidden' };
|
|
2420
|
+
}
|
|
2421
|
+
const context = {
|
|
2422
|
+
principal,
|
|
2423
|
+
operation: definition.name,
|
|
2424
|
+
signal: request.signal,
|
|
2425
|
+
};
|
|
2426
|
+
if (trace)
|
|
2427
|
+
context.trace = trace;
|
|
2428
|
+
const data = await served.backend[definition.invoke].call(served.backend, context, parsed.data);
|
|
2429
|
+
if (!definition.output.safeParse(data).success) {
|
|
2430
|
+
reportUnexpected(options.onError, Object.assign(new Error(`The application backend's ${definition.name} result does not match the operation's output schema`), { code: 'invalid_backend_result' }), definition.name);
|
|
2431
|
+
return { ok: false, status: 500, code: 'invalid_backend_result' };
|
|
2432
|
+
}
|
|
2433
|
+
return { ok: true, data };
|
|
2434
|
+
}
|
|
2435
|
+
catch (error) {
|
|
2436
|
+
if (error instanceof SlotlockAgentOperationError) {
|
|
2437
|
+
return { ok: false, status: error.status, code: error.code };
|
|
2438
|
+
}
|
|
2439
|
+
reportUnexpected(options.onError, error, definition.name);
|
|
2440
|
+
return { ok: false, status: 500, code: 'internal_error' };
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2270
2443
|
/**
|
|
2271
2444
|
* One REST call, checked as /mcp and /a2a are (origin, credential, rate limit), then run through
|
|
2272
2445
|
* the same dispatcher: scopes, arguments, `authorize`, and confirmation, which REST cannot ask
|
|
@@ -2279,7 +2452,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2279
2452
|
? json(200, openApi)
|
|
2280
2453
|
: refuse(405, 'method_not_allowed', { Allow: 'GET' });
|
|
2281
2454
|
}
|
|
2282
|
-
const match = matchSlotlockRestRoute(request.method, path);
|
|
2455
|
+
const match = matchSlotlockRestRoute(request.method, path, restRoutes);
|
|
2283
2456
|
if (match === null)
|
|
2284
2457
|
return refuse(404, 'not_found');
|
|
2285
2458
|
if ('allow' in match) {
|
|
@@ -2292,7 +2465,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2292
2465
|
return refuse(403, 'origin_not_allowed');
|
|
2293
2466
|
}
|
|
2294
2467
|
}
|
|
2295
|
-
const principal = await options.authenticate
|
|
2468
|
+
const principal = await principalOrBusy(options.authenticate, request);
|
|
2469
|
+
if (principal instanceof SlotlockAgentOperationError) {
|
|
2470
|
+
return refuse(503, principal.code, { 'Retry-After': '1' });
|
|
2471
|
+
}
|
|
2296
2472
|
if (!principal ||
|
|
2297
2473
|
!isCanonicalPrincipalIdentity(principal.subject) ||
|
|
2298
2474
|
!isCanonicalPrincipalIdentity(principal.tenantRef)) {
|
|
@@ -2307,41 +2483,71 @@ export function createSlotlockAgentServer(options) {
|
|
|
2307
2483
|
if ('invalid' in match)
|
|
2308
2484
|
return refuse(400, 'invalid_arguments');
|
|
2309
2485
|
const { route, params } = match;
|
|
2486
|
+
const trace = traceContextFrom(undefined, request.headers);
|
|
2487
|
+
const started = performance.now();
|
|
2488
|
+
const report = (status) => emit({
|
|
2489
|
+
type: 'rest',
|
|
2490
|
+
operation: route.operation,
|
|
2491
|
+
status,
|
|
2492
|
+
durationMs: elapsedMs(started),
|
|
2493
|
+
...(trace ? { traceId: traceIdOf(trace) } : {}),
|
|
2494
|
+
});
|
|
2495
|
+
// A call refused before its operation runs is reported as one that ran, as on MCP and A2A.
|
|
2496
|
+
const refuseCall = (status, code) => {
|
|
2497
|
+
report(status);
|
|
2498
|
+
return refuse(status, code);
|
|
2499
|
+
};
|
|
2310
2500
|
let given;
|
|
2311
2501
|
if (restInputFromBody(route)) {
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2502
|
+
if (!isJsonMediaType(request.headers.get('content-type'))) {
|
|
2503
|
+
return refuseCall(415, 'unsupported_media_type');
|
|
2504
|
+
}
|
|
2315
2505
|
let body;
|
|
2316
2506
|
try {
|
|
2317
2507
|
body = await readJson(request, config.maxRequestBytes);
|
|
2318
2508
|
}
|
|
2319
2509
|
catch (error) {
|
|
2320
2510
|
const tooLarge = error instanceof Error && error.message === 'request_too_large';
|
|
2321
|
-
return
|
|
2511
|
+
return refuseCall(tooLarge ? 413 : 400, tooLarge ? 'request_too_large' : 'invalid_json');
|
|
2322
2512
|
}
|
|
2323
2513
|
if (!isRecord(body))
|
|
2324
|
-
return
|
|
2514
|
+
return refuseCall(400, 'invalid_arguments');
|
|
2325
2515
|
given = body;
|
|
2326
2516
|
}
|
|
2327
2517
|
else {
|
|
2328
|
-
given = restQueryInput(url.searchParams, restInputSchemas.get(route.operation) ?? {});
|
|
2518
|
+
given = restQueryInput(url.searchParams, (restInputSchemas.get(route.operation) ?? {}));
|
|
2329
2519
|
}
|
|
2330
2520
|
// The path names the event; the body or query may not name another.
|
|
2331
2521
|
if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
|
|
2332
|
-
return
|
|
2522
|
+
return refuseCall(400, 'invalid_arguments');
|
|
2333
2523
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2524
|
+
// An application write's retry identity may come as the Idempotency-Key header, where HTTP
|
|
2525
|
+
// clients put it, as well as its `idempotency_key` argument. On an operation that keeps none it
|
|
2526
|
+
// is refused rather than ignored: a key that was dropped would promise a replay it never gives.
|
|
2527
|
+
// The agent tools take theirs as an argument alone, as they always have.
|
|
2528
|
+
const idempotencyHeader = request.headers.get('idempotency-key');
|
|
2529
|
+
if (idempotencyHeader !== null && isSlotlockApplicationOperation(route.operation)) {
|
|
2530
|
+
const fields = (restInputSchemas.get(route.operation)?.properties ?? {});
|
|
2531
|
+
if (!Object.hasOwn(fields, 'idempotency_key')) {
|
|
2532
|
+
return refuseCall(400, 'idempotency_key_not_supported');
|
|
2533
|
+
}
|
|
2534
|
+
if (Object.hasOwn(given, 'idempotency_key') && given.idempotency_key !== idempotencyHeader) {
|
|
2535
|
+
return refuseCall(400, 'invalid_arguments');
|
|
2536
|
+
}
|
|
2537
|
+
given = { ...given, idempotency_key: idempotencyHeader };
|
|
2538
|
+
}
|
|
2539
|
+
const outcome = isSlotlockApplicationOperation(route.operation)
|
|
2540
|
+
? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
|
|
2541
|
+
: await invokeSlotlockAgentOperation({
|
|
2542
|
+
operation: route.operation,
|
|
2543
|
+
input: { ...given, ...params },
|
|
2544
|
+
request,
|
|
2545
|
+
options: withoutConfirmationRound(principal, route.operation),
|
|
2546
|
+
...(trace ? { trace } : {}),
|
|
2547
|
+
});
|
|
2342
2548
|
const internal = !outcome.ok && INTERNAL_FAILURES.has(outcome.code);
|
|
2343
2549
|
const status = outcome.ok ? 200 : internal ? 500 : outcome.status;
|
|
2344
|
-
|
|
2550
|
+
report(status);
|
|
2345
2551
|
if (outcome.ok)
|
|
2346
2552
|
return json(200, outcome.data);
|
|
2347
2553
|
return refuse(status, internal ? 'internal_error' : outcome.code);
|
|
@@ -2411,8 +2617,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2411
2617
|
if (request.method !== 'POST' || (path !== '/mcp' && path !== '/a2a')) {
|
|
2412
2618
|
return json(404, { error: { code: 'not_found' } });
|
|
2413
2619
|
}
|
|
2414
|
-
|
|
2415
|
-
if (contentType !== 'application/json') {
|
|
2620
|
+
if (!isJsonMediaType(request.headers.get('content-type'))) {
|
|
2416
2621
|
return json(415, { error: { code: 'unsupported_media_type' } });
|
|
2417
2622
|
}
|
|
2418
2623
|
const origin = request.headers.get('origin');
|
|
@@ -2422,7 +2627,17 @@ export function createSlotlockAgentServer(options) {
|
|
|
2422
2627
|
return json(403, rpcError(null, -32600, 'Origin not allowed'));
|
|
2423
2628
|
}
|
|
2424
2629
|
}
|
|
2425
|
-
const principal = await options.authenticate
|
|
2630
|
+
const principal = await principalOrBusy(options.authenticate, request);
|
|
2631
|
+
if (principal instanceof SlotlockAgentOperationError) {
|
|
2632
|
+
// As `slotlock serve` answers it: A2A names the reason in an ErrorInfo, as for a rate limit.
|
|
2633
|
+
const error = { code: -32603, message: 'Server is busy' };
|
|
2634
|
+
const info = {
|
|
2635
|
+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
2636
|
+
reason: 'SERVER_BUSY',
|
|
2637
|
+
domain: 'slotlock',
|
|
2638
|
+
};
|
|
2639
|
+
return json(503, { jsonrpc: '2.0', id: null, error: path === '/a2a' ? { ...error, data: [info] } : error }, { 'Retry-After': '1' });
|
|
2640
|
+
}
|
|
2426
2641
|
if (!principal ||
|
|
2427
2642
|
!isCanonicalPrincipalIdentity(principal.subject) ||
|
|
2428
2643
|
!isCanonicalPrincipalIdentity(principal.tenantRef)) {
|
|
@@ -2432,7 +2647,14 @@ export function createSlotlockAgentServer(options) {
|
|
|
2432
2647
|
}
|
|
2433
2648
|
if (options.consumeRateLimit &&
|
|
2434
2649
|
!(await options.consumeRateLimit({ principal, operation: 'protocol' }))) {
|
|
2435
|
-
// Answered before the body is read
|
|
2650
|
+
// Answered before the body is read: the request's id is unknown, and the MCP era comes
|
|
2651
|
+
// from the version header alone. A2A keeps the error in its 200 envelope with an ErrorInfo.
|
|
2652
|
+
if (path === '/a2a') {
|
|
2653
|
+
return a2aError(null, -32029, 'Rate limit exceeded', {
|
|
2654
|
+
reason: 'RATE_LIMITED',
|
|
2655
|
+
domain: 'slotlock',
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2436
2658
|
const modernClient = path === '/mcp' &&
|
|
2437
2659
|
MODERN_MCP_VERSIONS.includes(request.headers.get('mcp-protocol-version') ?? '');
|
|
2438
2660
|
return json(429, rpcError(null, modernClient ? SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE : -32029, 'Rate limit exceeded'));
|
|
@@ -2541,14 +2763,16 @@ export function createSlotlockAgentServer(options) {
|
|
|
2541
2763
|
const operation = resolveSlotlockAgentOperation(rpc.params?.name);
|
|
2542
2764
|
if (!operation)
|
|
2543
2765
|
return json(200, rpcError(id, -32602, 'Unknown tool'));
|
|
2544
|
-
const
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2766
|
+
const started = performance.now();
|
|
2767
|
+
const trace = traceContextFrom(isRecord(rpc.params?._meta) ? rpc.params._meta : undefined, request.headers);
|
|
2768
|
+
// `arguments` may be left out or null; anything else but an object is malformed params.
|
|
2769
|
+
const input = rpc.params?.arguments;
|
|
2770
|
+
if (input != null && !isRecord(input)) {
|
|
2771
|
+
reportCall('mcp', operation, 'invalid_arguments', started, trace);
|
|
2772
|
+
return json(200, rpcError(id, -32602, 'Invalid params'));
|
|
2773
|
+
}
|
|
2549
2774
|
try {
|
|
2550
|
-
const
|
|
2551
|
-
const outcome = await invokeSlotlockAgentOperation({
|
|
2775
|
+
const outcome = await invokeReported('mcp', {
|
|
2552
2776
|
operation,
|
|
2553
2777
|
input,
|
|
2554
2778
|
request,
|
|
@@ -2606,38 +2830,49 @@ export function createSlotlockAgentServer(options) {
|
|
|
2606
2830
|
if (rpc.method !== 'SendMessage') {
|
|
2607
2831
|
return json(200, rpcError(id, -32601, 'Method not found'));
|
|
2608
2832
|
}
|
|
2609
|
-
const
|
|
2610
|
-
|
|
2611
|
-
!isCallerTenant(rpc.params?.tenant, principal.tenantRef)) {
|
|
2612
|
-
return a2aError(id, -32602, 'Invalid message parameters', {
|
|
2613
|
-
reason: 'INVALID_MESSAGE_PARAMETERS',
|
|
2614
|
-
domain: 'slotlock',
|
|
2615
|
-
});
|
|
2616
|
-
}
|
|
2833
|
+
const started = performance.now();
|
|
2834
|
+
const trace = traceContextFrom(undefined, request.headers);
|
|
2617
2835
|
const message = rpc.params?.message;
|
|
2618
2836
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
2619
2837
|
const part = parts.length === 1 ? parts[0] : null;
|
|
2620
2838
|
const data = part?.data;
|
|
2621
2839
|
const operation = resolveSlotlockAgentOperation(data?.skill);
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2840
|
+
// A refused message that names an operation is still a call, reported by the error's reason.
|
|
2841
|
+
const refuse = (outcome, response) => {
|
|
2842
|
+
if (operation)
|
|
2843
|
+
reportCall('a2a', operation, outcome, started, trace);
|
|
2844
|
+
return response;
|
|
2845
|
+
};
|
|
2846
|
+
const a2aParamKeys = Object.keys(rpc.params ?? {});
|
|
2847
|
+
if (a2aParamKeys.some((key) => !['message', 'configuration', 'metadata', 'tenant'].includes(key)) ||
|
|
2848
|
+
!isCallerTenant(rpc.params?.tenant, principal.tenantRef) ||
|
|
2849
|
+
// a2a.proto: a SendMessageConfiguration and a Struct, each optional (null is unset in proto3
|
|
2850
|
+
// JSON). What A2A 1.0 defines inside them (output modes, history length, ...) is the client's.
|
|
2851
|
+
(rpc.params?.configuration != null && !isRecord(rpc.params.configuration)) ||
|
|
2852
|
+
(rpc.params?.metadata != null && !isRecord(rpc.params.metadata))) {
|
|
2853
|
+
return refuse('invalid_message_parameters', a2aError(id, -32602, 'Invalid message parameters', {
|
|
2854
|
+
reason: 'INVALID_MESSAGE_PARAMETERS',
|
|
2855
|
+
domain: 'slotlock',
|
|
2856
|
+
}));
|
|
2857
|
+
}
|
|
2858
|
+
// `arguments` may be left out or null; anything else but an object is not arguments.
|
|
2859
|
+
const input = data?.arguments;
|
|
2625
2860
|
if (!operation ||
|
|
2626
2861
|
message?.role !== 'ROLE_USER' ||
|
|
2627
2862
|
!isCanonicalBoundedIdentifier(message.messageId, 200) ||
|
|
2628
2863
|
message.taskId !== undefined ||
|
|
2629
|
-
(message.contextId !== undefined && !isCanonicalBoundedIdentifier(message.contextId, 200))
|
|
2630
|
-
|
|
2864
|
+
(message.contextId !== undefined && !isCanonicalBoundedIdentifier(message.contextId, 200)) ||
|
|
2865
|
+
(input != null && !isRecord(input))) {
|
|
2866
|
+
return refuse('invalid_message', a2aError(id, -32602, 'Invalid message', {
|
|
2631
2867
|
reason: 'INVALID_MESSAGE',
|
|
2632
2868
|
domain: 'slotlock',
|
|
2633
2869
|
metadata: {
|
|
2634
2870
|
expected: 'one application/json data part {"skill": "<skill id>", "arguments": {…}}',
|
|
2635
2871
|
},
|
|
2636
|
-
});
|
|
2872
|
+
}));
|
|
2637
2873
|
}
|
|
2638
2874
|
try {
|
|
2639
|
-
const
|
|
2640
|
-
const outcome = await invokeSlotlockAgentOperation({
|
|
2875
|
+
const outcome = await invokeReported('a2a', {
|
|
2641
2876
|
operation,
|
|
2642
2877
|
input,
|
|
2643
2878
|
request,
|