slotlock 0.1.0 → 0.2.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/agent-server.js CHANGED
@@ -2,8 +2,10 @@ 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 { ignoreRejection } from './hooks.js';
5
7
  import { slotlockPackageVersion } from './package-version.js';
6
- import { matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
8
+ import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
7
9
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
8
10
  import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
9
11
  import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
@@ -558,6 +560,35 @@ export function slotlockMcpToolResult(outcome) {
558
560
  }
559
561
  return { content: [{ type: 'text', text: JSON.stringify({ error }) }], isError: true };
560
562
  }
563
+ /** The agent tools as REST serves them: each with the scope it needs. */
564
+ function restAgentTools() {
565
+ return slotlockAgentTools().map((tool) => ({ ...tool, scope: slotlockAgentOperationScope(tool.name) }));
566
+ }
567
+ /** The application operations with their input and output as JSON Schema, as REST serves them. */
568
+ export function slotlockApplicationOperations() {
569
+ return SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => ({
570
+ name: operation.name,
571
+ title: operation.title,
572
+ description: operation.description,
573
+ method: operation.method,
574
+ path: operation.path,
575
+ inputSchema: toJsonSchema(operation.input, 'input'),
576
+ outputSchema: toJsonSchema(operation.output, 'output'),
577
+ }));
578
+ }
579
+ /**
580
+ * The OpenAPI 3.1 document of the REST API: what a server with `rest` (and `application`) serves at
581
+ * /openapi.json. Without `publicBaseUrl` the server URL is a variable, `{baseUrl}`, as in the
582
+ * repository's openapi.json, which client generators read.
583
+ */
584
+ export function slotlockOpenApi(options = {}) {
585
+ return slotlockOpenApiDocument({
586
+ ...(options.publicBaseUrl !== undefined ? { publicBaseUrl: options.publicBaseUrl } : {}),
587
+ version: SLOTLOCK_AGENT_SERVER_VERSION,
588
+ tools: restAgentTools(),
589
+ ...(options.application ? { application: slotlockApplicationOperations() } : {}),
590
+ });
591
+ }
561
592
  export function slotlockAgentTools() {
562
593
  return OPERATION_DEFINITIONS.map((operation) => ({
563
594
  name: operation.name,
@@ -611,13 +642,44 @@ function rpcResult(id, result) {
611
642
  function rpcError(id, code, message) {
612
643
  return { jsonrpc: '2.0', id, error: { code, message } };
613
644
  }
645
+ /**
646
+ * Whether a Content-Type names `application/json`, whatever its parameters. A media type's type
647
+ * and subtype are case-insensitive (RFC 9110 §8.3.1), so `Application/JSON` is the same type.
648
+ */
649
+ function isJsonMediaType(contentType) {
650
+ return contentType?.split(';')[0]?.trim().toLowerCase() === 'application/json';
651
+ }
652
+ /**
653
+ * Parse a JSON body of at most `maxBytes`. The body is counted as it arrives, so one sent without
654
+ * Content-Length (chunked) is refused, and its stream cancelled, as soon as it passes the limit,
655
+ * rather than buffered whole first.
656
+ */
614
657
  async function readJson(request, maxBytes) {
615
658
  const declared = request.headers.get('content-length');
616
659
  if (declared && Number(declared) > maxBytes)
617
660
  throw new Error('request_too_large');
618
- const bytes = new Uint8Array(await request.arrayBuffer());
619
- if (bytes.byteLength > maxBytes)
620
- throw new Error('request_too_large');
661
+ const chunks = [];
662
+ let size = 0;
663
+ if (request.body) {
664
+ const reader = request.body.getReader();
665
+ for (;;) {
666
+ const { done, value } = await reader.read();
667
+ if (done)
668
+ break;
669
+ size += value.byteLength;
670
+ if (size > maxBytes) {
671
+ await reader.cancel().catch(() => undefined);
672
+ throw new Error('request_too_large');
673
+ }
674
+ chunks.push(value);
675
+ }
676
+ }
677
+ const bytes = new Uint8Array(size);
678
+ let offset = 0;
679
+ for (const chunk of chunks) {
680
+ bytes.set(chunk, offset);
681
+ offset += chunk.byteLength;
682
+ }
621
683
  try {
622
684
  return JSON.parse(new TextDecoder().decode(bytes));
623
685
  }
@@ -783,10 +845,11 @@ export async function invokeSlotlockAgentOperation(args) {
783
845
  if (!principalScopesCover(principal, operation.name)) {
784
846
  return { ok: false, status: 403, code: 'forbidden' };
785
847
  }
786
- const input = args.input && typeof args.input === 'object' && !Array.isArray(args.input)
787
- ? args.input
788
- : {};
789
- const parsed = operation.input.safeParse(input);
848
+ // No input, or null, is no arguments; anything else that is not an object is refused.
849
+ if (args.input != null && !isRecord(args.input)) {
850
+ return { ok: false, status: 400, code: 'invalid_arguments' };
851
+ }
852
+ const parsed = operation.input.safeParse(args.input ?? {});
790
853
  if (!parsed.success)
791
854
  return { ok: false, status: 400, code: 'invalid_arguments' };
792
855
  if (!(await args.options.authorize({
@@ -838,12 +901,20 @@ export async function invokeSlotlockAgentOperation(args) {
838
901
  /** Hand an unexpected failure to `onError`; nothing it does can change the protocol answer. */
839
902
  function reportUnexpected(onError, error, operation) {
840
903
  try {
841
- onError?.(error, { operation });
904
+ ignoreRejection(onError?.(error, { operation }));
842
905
  }
843
906
  catch {
844
907
  // Observability must never change what a protocol client receives.
845
908
  }
846
909
  }
910
+ /** Milliseconds since `started` (a `performance.now()` reading), to a tenth. */
911
+ function elapsedMs(started) {
912
+ return Math.round((performance.now() - started) * 10) / 10;
913
+ }
914
+ /** The trace id of a valid `traceparent` (`version-traceid-parentid-flags`): its second field. */
915
+ function traceIdOf(trace) {
916
+ return trace.traceparent.split('-', 2)[1] ?? '';
917
+ }
847
918
  /** Failures that are server faults, not operation outcomes an agent could act on. */
848
919
  const INTERNAL_FAILURES = new Set(['internal_error', 'invalid_backend_result']);
849
920
  /**
@@ -1540,10 +1611,14 @@ async function confirmationMessage(operation, input, lookup) {
1540
1611
  }, zone)
1541
1612
  : `for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}`;
1542
1613
  const tentative = input.status === 'tentative' ? ', tentatively' : '';
1614
+ // A transparent booking does not make the resource busy: the person must know it stays free.
1615
+ const free = input.transparency === 'transparent' ? ', leaving the time free for other bookings' : '';
1616
+ const details = promptDetails(input);
1617
+ const also = details ? ` (${details})` : '';
1543
1618
  const title = typeof input.title === 'string' && input.title.length > 0
1544
1619
  ? `: ${promptQuoted(input.title)}`
1545
1620
  : '';
1546
- return `Book ${resource.label} ${when}${tentative}${title}.`;
1621
+ return `Book ${resource.label} ${when}${tentative}${free}${also}${title}.`;
1547
1622
  }
1548
1623
  const event = await lookup.event(input.event_id);
1549
1624
  const current = event ? await lookup.resource(event.resourceId) : null;
@@ -1609,15 +1684,23 @@ async function confirmationMessage(operation, input, lookup) {
1609
1684
  changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
1610
1685
  }
1611
1686
  }
1612
- const details = ['description', 'location', 'organizer', 'attendees', 'reminders'].filter((field) => input[field] !== undefined);
1613
- if (details.length > 0)
1614
- changes.push(`also ${details.join(', ')}`);
1687
+ const details = promptDetails(input);
1688
+ if (details)
1689
+ changes.push(details);
1615
1690
  // The agent's own text goes last.
1616
1691
  if (input.title !== undefined) {
1617
1692
  changes.push(input.title === null ? 'title removed' : `title to ${promptQuoted(input.title)}`);
1618
1693
  }
1619
1694
  return `Change ${target}${changes.length > 0 ? `: ${changes.join('; ')}` : ''}.${titled}`;
1620
1695
  }
1696
+ /**
1697
+ * The other fields a write sets, by name only (`also description, attendees`): their values are
1698
+ * the agent's text, and only the title is quoted. `null` when it sets none of them.
1699
+ */
1700
+ function promptDetails(input) {
1701
+ const details = ['description', 'location', 'organizer', 'attendees', 'reminders'].filter((field) => input[field] !== undefined);
1702
+ return details.length > 0 ? `also ${details.join(', ')}` : null;
1703
+ }
1621
1704
  /**
1622
1705
  * Read the client's retry of a guarded call. State this server did not seal, sealed for another
1623
1706
  * principal, another operation or other arguments is `invalid`; lapsed state, or an answer that is
@@ -1730,14 +1813,33 @@ export function createSlotlockAgentServer(options) {
1730
1813
  if (options.rest !== undefined && typeof options.rest !== 'boolean') {
1731
1814
  throw new Error('Slotlock agent server rest must be a boolean');
1732
1815
  }
1733
- const restTools = options.rest ? slotlockAgentTools() : [];
1734
- const restInputSchemas = new Map(restTools.map((tool) => [tool.name, tool.inputSchema]));
1816
+ const application = options.application;
1817
+ if (application !== undefined) {
1818
+ if (options.rest !== true) {
1819
+ throw new Error('Slotlock agent server application needs rest: it is served only as REST');
1820
+ }
1821
+ const backend = application?.backend;
1822
+ if (typeof application !== 'object' ||
1823
+ application === null ||
1824
+ typeof application.authorize !== 'function' ||
1825
+ typeof backend !== 'object' ||
1826
+ backend === null ||
1827
+ SLOTLOCK_APPLICATION_OPERATIONS.some((operation) => typeof backend[operation.invoke] !== 'function')) {
1828
+ throw new Error('Slotlock agent server application needs an authorize function and a backend with every application method');
1829
+ }
1830
+ }
1831
+ const restTools = options.rest ? restAgentTools() : [];
1832
+ const applicationTools = application ? slotlockApplicationOperations() : [];
1833
+ const restInputSchemas = new Map([
1834
+ ...restTools.map((tool) => [tool.name, tool.inputSchema]),
1835
+ ...applicationTools.map((tool) => [tool.name, tool.inputSchema]),
1836
+ ]);
1837
+ const restRoutes = [
1838
+ ...SLOTLOCK_REST_ROUTES,
1839
+ ...(application ? SLOTLOCK_APPLICATION_REST_ROUTES : []),
1840
+ ];
1735
1841
  const openApi = options.rest
1736
- ? Object.freeze(slotlockOpenApiDocument({
1737
- publicBaseUrl: config.publicBaseUrl,
1738
- version: SLOTLOCK_AGENT_SERVER_VERSION,
1739
- tools: restTools,
1740
- }))
1842
+ ? Object.freeze(slotlockOpenApi({ publicBaseUrl: config.publicBaseUrl, application: application !== undefined }))
1741
1843
  : null;
1742
1844
  const manifest = Object.freeze({
1743
1845
  name: 'Slotlock Agent Calendar',
@@ -1761,12 +1863,33 @@ export function createSlotlockAgentServer(options) {
1761
1863
  const confirmation = validateConfirmation(options.confirmation);
1762
1864
  const emit = (event) => {
1763
1865
  try {
1764
- options.onEvent?.(event);
1866
+ ignoreRejection(options.onEvent?.(event));
1765
1867
  }
1766
1868
  catch {
1767
1869
  // Observability must never change what a protocol client receives.
1768
1870
  }
1769
1871
  };
1872
+ /**
1873
+ * Report how an MCP or A2A call that named an operation ended, as a `call` event: run, or refused
1874
+ * before it could run (a malformed message, a client that cannot confirm). Never its arguments.
1875
+ */
1876
+ const reportCall = (protocol, operation, outcome, started, trace) => {
1877
+ emit({
1878
+ type: 'call',
1879
+ protocol,
1880
+ operation,
1881
+ outcome,
1882
+ durationMs: elapsedMs(started),
1883
+ ...(trace ? { traceId: traceIdOf(trace) } : {}),
1884
+ });
1885
+ };
1886
+ /** Run an operation an MCP or A2A caller named, and report how it ended as a `call` event. */
1887
+ const invokeReported = async (protocol, args) => {
1888
+ const started = performance.now();
1889
+ const outcome = await invokeSlotlockAgentOperation(args);
1890
+ reportCall(protocol, args.operation, outcome.ok ? 'ok' : outcome.code, started, args.trace);
1891
+ return outcome;
1892
+ };
1770
1893
  const dispatchOptions = (principal) => ({
1771
1894
  backend: options.backend,
1772
1895
  // The transport authenticates once before protocol parsing/rate-limiting; the canonical
@@ -1832,10 +1955,17 @@ export function createSlotlockAgentServer(options) {
1832
1955
  const operation = resolveSlotlockAgentOperation(params.name);
1833
1956
  if (!operation)
1834
1957
  return modernError(200, id, -32602, 'Unknown tool');
1958
+ const started = performance.now();
1959
+ // `arguments` may be left out or null; anything else but an object is malformed params.
1960
+ if (params.arguments != null && !isRecord(params.arguments)) {
1961
+ reportCall('mcp', operation, 'invalid_arguments', started, call.trace);
1962
+ return modernError(200, id, -32602, 'Invalid params');
1963
+ }
1835
1964
  const dispatch = dispatchOptions(principal);
1836
1965
  const gate = {};
1837
1966
  if (confirmation?.operations.has(operation)) {
1838
1967
  if (!clientSupportsFormElicitation(call.capabilities)) {
1968
+ reportCall('mcp', operation, 'missing_capability', started, call.trace);
1839
1969
  return modernError(400, id, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, 'Missing required client capability', { requiredCapabilities: { elicitation: { form: {} } } });
1840
1970
  }
1841
1971
  dispatch.confirm = async ({ input }) => {
@@ -1848,19 +1978,18 @@ export function createSlotlockAgentServer(options) {
1848
1978
  operation,
1849
1979
  argsDigest: gate.argsDigest,
1850
1980
  });
1981
+ // Before the call it decides, as on every other protocol.
1982
+ emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
1851
1983
  return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
1852
1984
  };
1853
1985
  }
1854
- const outcome = await invokeSlotlockAgentOperation({
1986
+ const outcome = await invokeReported('mcp', {
1855
1987
  operation,
1856
1988
  input: isRecord(params.arguments) ? params.arguments : {},
1857
1989
  request: call.request,
1858
1990
  options: dispatch,
1859
1991
  ...(call.trace ? { trace: call.trace } : {}),
1860
1992
  });
1861
- if (gate.decision) {
1862
- emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
1863
- }
1864
1993
  if (confirmation &&
1865
1994
  (gate.decision === 'prompt' || gate.decision === 'expired') &&
1866
1995
  gate.input &&
@@ -2267,6 +2396,48 @@ export function createSlotlockAgentServer(options) {
2267
2396
  return modernError(404, id, -32601, 'Method not found');
2268
2397
  }
2269
2398
  }
2399
+ /**
2400
+ * One application operation for an authenticated principal: the admin scope (when the principal
2401
+ * carries scopes), the operation's strict input schema, `application.authorize`, the backend, and
2402
+ * its output schema. A refusal the backend throws as a `SlotlockAgentOperationError` is the answer;
2403
+ * anything else is reported to `onError` and answers only `internal_error`.
2404
+ */
2405
+ async function invokeApplicationOperation(definition, principal, given, request, trace) {
2406
+ const served = application;
2407
+ try {
2408
+ const scopes = principal.scopes;
2409
+ if (scopes !== undefined &&
2410
+ !(Array.isArray(scopes) && scopes.includes(SLOTLOCK_APPLICATION_SCOPE))) {
2411
+ return { ok: false, status: 403, code: 'forbidden' };
2412
+ }
2413
+ const parsed = definition.input.safeParse(given);
2414
+ if (!parsed.success)
2415
+ return { ok: false, status: 400, code: 'invalid_arguments' };
2416
+ if (!(await served.authorize({ principal, operation: definition.name, input: parsed.data }))) {
2417
+ return { ok: false, status: 403, code: 'forbidden' };
2418
+ }
2419
+ const context = {
2420
+ principal,
2421
+ operation: definition.name,
2422
+ signal: request.signal,
2423
+ };
2424
+ if (trace)
2425
+ context.trace = trace;
2426
+ const data = await served.backend[definition.invoke].call(served.backend, context, parsed.data);
2427
+ if (!definition.output.safeParse(data).success) {
2428
+ 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);
2429
+ return { ok: false, status: 500, code: 'invalid_backend_result' };
2430
+ }
2431
+ return { ok: true, data };
2432
+ }
2433
+ catch (error) {
2434
+ if (error instanceof SlotlockAgentOperationError) {
2435
+ return { ok: false, status: error.status, code: error.code };
2436
+ }
2437
+ reportUnexpected(options.onError, error, definition.name);
2438
+ return { ok: false, status: 500, code: 'internal_error' };
2439
+ }
2440
+ }
2270
2441
  /**
2271
2442
  * One REST call, checked as /mcp and /a2a are (origin, credential, rate limit), then run through
2272
2443
  * the same dispatcher: scopes, arguments, `authorize`, and confirmation, which REST cannot ask
@@ -2279,7 +2450,7 @@ export function createSlotlockAgentServer(options) {
2279
2450
  ? json(200, openApi)
2280
2451
  : refuse(405, 'method_not_allowed', { Allow: 'GET' });
2281
2452
  }
2282
- const match = matchSlotlockRestRoute(request.method, path);
2453
+ const match = matchSlotlockRestRoute(request.method, path, restRoutes);
2283
2454
  if (match === null)
2284
2455
  return refuse(404, 'not_found');
2285
2456
  if ('allow' in match) {
@@ -2307,41 +2478,56 @@ export function createSlotlockAgentServer(options) {
2307
2478
  if ('invalid' in match)
2308
2479
  return refuse(400, 'invalid_arguments');
2309
2480
  const { route, params } = match;
2481
+ const trace = traceContextFrom(undefined, request.headers);
2482
+ const started = performance.now();
2483
+ const report = (status) => emit({
2484
+ type: 'rest',
2485
+ operation: route.operation,
2486
+ status,
2487
+ durationMs: elapsedMs(started),
2488
+ ...(trace ? { traceId: traceIdOf(trace) } : {}),
2489
+ });
2490
+ // A call refused before its operation runs is reported as one that ran, as on MCP and A2A.
2491
+ const refuseCall = (status, code) => {
2492
+ report(status);
2493
+ return refuse(status, code);
2494
+ };
2310
2495
  let given;
2311
2496
  if (restInputFromBody(route)) {
2312
- const contentType = request.headers.get('content-type')?.split(';')[0]?.trim();
2313
- if (contentType !== 'application/json')
2314
- return refuse(415, 'unsupported_media_type');
2497
+ if (!isJsonMediaType(request.headers.get('content-type'))) {
2498
+ return refuseCall(415, 'unsupported_media_type');
2499
+ }
2315
2500
  let body;
2316
2501
  try {
2317
2502
  body = await readJson(request, config.maxRequestBytes);
2318
2503
  }
2319
2504
  catch (error) {
2320
2505
  const tooLarge = error instanceof Error && error.message === 'request_too_large';
2321
- return refuse(tooLarge ? 413 : 400, tooLarge ? 'request_too_large' : 'invalid_json');
2506
+ return refuseCall(tooLarge ? 413 : 400, tooLarge ? 'request_too_large' : 'invalid_json');
2322
2507
  }
2323
2508
  if (!isRecord(body))
2324
- return refuse(400, 'invalid_arguments');
2509
+ return refuseCall(400, 'invalid_arguments');
2325
2510
  given = body;
2326
2511
  }
2327
2512
  else {
2328
- given = restQueryInput(url.searchParams, restInputSchemas.get(route.operation) ?? {});
2513
+ given = restQueryInput(url.searchParams, (restInputSchemas.get(route.operation) ?? {}));
2329
2514
  }
2330
2515
  // The path names the event; the body or query may not name another.
2331
2516
  if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
2332
- return refuse(400, 'invalid_arguments');
2517
+ return refuseCall(400, 'invalid_arguments');
2333
2518
  }
2334
- const trace = traceContextFrom(undefined, request.headers);
2335
- const outcome = await invokeSlotlockAgentOperation({
2336
- operation: route.operation,
2337
- input: { ...given, ...params },
2338
- request,
2339
- options: withoutConfirmationRound(principal, route.operation),
2340
- ...(trace ? { trace } : {}),
2341
- });
2519
+ const outcome = isSlotlockApplicationOperation(route.operation)
2520
+ ? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
2521
+ : await invokeSlotlockAgentOperation({
2522
+ operation: route.operation,
2523
+ input: { ...given, ...params },
2524
+ request,
2525
+ options: withoutConfirmationRound(principal, route.operation),
2526
+ ...(trace ? { trace } : {}),
2527
+ });
2342
2528
  const internal = !outcome.ok && INTERNAL_FAILURES.has(outcome.code);
2343
2529
  const status = outcome.ok ? 200 : internal ? 500 : outcome.status;
2344
- emit({ type: 'rest', operation: route.operation, status });
2530
+ report(status);
2345
2531
  if (outcome.ok)
2346
2532
  return json(200, outcome.data);
2347
2533
  return refuse(status, internal ? 'internal_error' : outcome.code);
@@ -2411,8 +2597,7 @@ export function createSlotlockAgentServer(options) {
2411
2597
  if (request.method !== 'POST' || (path !== '/mcp' && path !== '/a2a')) {
2412
2598
  return json(404, { error: { code: 'not_found' } });
2413
2599
  }
2414
- const contentType = request.headers.get('content-type')?.split(';')[0]?.trim();
2415
- if (contentType !== 'application/json') {
2600
+ if (!isJsonMediaType(request.headers.get('content-type'))) {
2416
2601
  return json(415, { error: { code: 'unsupported_media_type' } });
2417
2602
  }
2418
2603
  const origin = request.headers.get('origin');
@@ -2432,7 +2617,14 @@ export function createSlotlockAgentServer(options) {
2432
2617
  }
2433
2618
  if (options.consumeRateLimit &&
2434
2619
  !(await options.consumeRateLimit({ principal, operation: 'protocol' }))) {
2435
- // Answered before the body is read, so the era comes from the version header alone.
2620
+ // Answered before the body is read: the request's id is unknown, and the MCP era comes
2621
+ // from the version header alone. A2A keeps the error in its 200 envelope with an ErrorInfo.
2622
+ if (path === '/a2a') {
2623
+ return a2aError(null, -32029, 'Rate limit exceeded', {
2624
+ reason: 'RATE_LIMITED',
2625
+ domain: 'slotlock',
2626
+ });
2627
+ }
2436
2628
  const modernClient = path === '/mcp' &&
2437
2629
  MODERN_MCP_VERSIONS.includes(request.headers.get('mcp-protocol-version') ?? '');
2438
2630
  return json(429, rpcError(null, modernClient ? SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE : -32029, 'Rate limit exceeded'));
@@ -2541,14 +2733,16 @@ export function createSlotlockAgentServer(options) {
2541
2733
  const operation = resolveSlotlockAgentOperation(rpc.params?.name);
2542
2734
  if (!operation)
2543
2735
  return json(200, rpcError(id, -32602, 'Unknown tool'));
2544
- const input = rpc.params?.arguments &&
2545
- typeof rpc.params.arguments === 'object' &&
2546
- !Array.isArray(rpc.params.arguments)
2547
- ? rpc.params.arguments
2548
- : {};
2736
+ const started = performance.now();
2737
+ const trace = traceContextFrom(isRecord(rpc.params?._meta) ? rpc.params._meta : undefined, request.headers);
2738
+ // `arguments` may be left out or null; anything else but an object is malformed params.
2739
+ const input = rpc.params?.arguments;
2740
+ if (input != null && !isRecord(input)) {
2741
+ reportCall('mcp', operation, 'invalid_arguments', started, trace);
2742
+ return json(200, rpcError(id, -32602, 'Invalid params'));
2743
+ }
2549
2744
  try {
2550
- const trace = traceContextFrom(isRecord(rpc.params?._meta) ? rpc.params._meta : undefined, request.headers);
2551
- const outcome = await invokeSlotlockAgentOperation({
2745
+ const outcome = await invokeReported('mcp', {
2552
2746
  operation,
2553
2747
  input,
2554
2748
  request,
@@ -2606,38 +2800,49 @@ export function createSlotlockAgentServer(options) {
2606
2800
  if (rpc.method !== 'SendMessage') {
2607
2801
  return json(200, rpcError(id, -32601, 'Method not found'));
2608
2802
  }
2609
- const a2aParamKeys = Object.keys(rpc.params ?? {});
2610
- if (a2aParamKeys.some((key) => !['message', 'configuration', 'metadata', 'tenant'].includes(key)) ||
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
- }
2803
+ const started = performance.now();
2804
+ const trace = traceContextFrom(undefined, request.headers);
2617
2805
  const message = rpc.params?.message;
2618
2806
  const parts = Array.isArray(message?.parts) ? message.parts : [];
2619
2807
  const part = parts.length === 1 ? parts[0] : null;
2620
2808
  const data = part?.data;
2621
2809
  const operation = resolveSlotlockAgentOperation(data?.skill);
2622
- const input = data?.arguments && typeof data.arguments === 'object' && !Array.isArray(data.arguments)
2623
- ? data.arguments
2624
- : {};
2810
+ // A refused message that names an operation is still a call, reported by the error's reason.
2811
+ const refuse = (outcome, response) => {
2812
+ if (operation)
2813
+ reportCall('a2a', operation, outcome, started, trace);
2814
+ return response;
2815
+ };
2816
+ const a2aParamKeys = Object.keys(rpc.params ?? {});
2817
+ if (a2aParamKeys.some((key) => !['message', 'configuration', 'metadata', 'tenant'].includes(key)) ||
2818
+ !isCallerTenant(rpc.params?.tenant, principal.tenantRef) ||
2819
+ // a2a.proto: a SendMessageConfiguration and a Struct, each optional (null is unset in proto3
2820
+ // JSON). What A2A 1.0 defines inside them (output modes, history length, ...) is the client's.
2821
+ (rpc.params?.configuration != null && !isRecord(rpc.params.configuration)) ||
2822
+ (rpc.params?.metadata != null && !isRecord(rpc.params.metadata))) {
2823
+ return refuse('invalid_message_parameters', a2aError(id, -32602, 'Invalid message parameters', {
2824
+ reason: 'INVALID_MESSAGE_PARAMETERS',
2825
+ domain: 'slotlock',
2826
+ }));
2827
+ }
2828
+ // `arguments` may be left out or null; anything else but an object is not arguments.
2829
+ const input = data?.arguments;
2625
2830
  if (!operation ||
2626
2831
  message?.role !== 'ROLE_USER' ||
2627
2832
  !isCanonicalBoundedIdentifier(message.messageId, 200) ||
2628
2833
  message.taskId !== undefined ||
2629
- (message.contextId !== undefined && !isCanonicalBoundedIdentifier(message.contextId, 200))) {
2630
- return a2aError(id, -32602, 'Invalid message', {
2834
+ (message.contextId !== undefined && !isCanonicalBoundedIdentifier(message.contextId, 200)) ||
2835
+ (input != null && !isRecord(input))) {
2836
+ return refuse('invalid_message', a2aError(id, -32602, 'Invalid message', {
2631
2837
  reason: 'INVALID_MESSAGE',
2632
2838
  domain: 'slotlock',
2633
2839
  metadata: {
2634
2840
  expected: 'one application/json data part {"skill": "<skill id>", "arguments": {…}}',
2635
2841
  },
2636
- });
2842
+ }));
2637
2843
  }
2638
2844
  try {
2639
- const trace = traceContextFrom(undefined, request.headers);
2640
- const outcome = await invokeSlotlockAgentOperation({
2845
+ const outcome = await invokeReported('a2a', {
2641
2846
  operation,
2642
2847
  input,
2643
2848
  request,
@@ -1,4 +1,5 @@
1
1
  import { type SlotlockAgentCalendarBackend, type SlotlockAgentInvocationContext } from './agent-server.js';
2
+ import type { SlotlockApplicationBackend } from './application-api.js';
2
3
  import { type SlotlockStore } from './store.js';
3
4
  import type { SlotlockCalendarEvent, SlotlockResource, WeeklyAvailabilityRule } from './types.js';
4
5
  export interface SlotlockStoreAgentBackendOptions {
@@ -18,3 +19,18 @@ export interface SlotlockStoreAgentBackendOptions {
18
19
  export declare function projectSlotlockCalendarEvent(event: SlotlockCalendarEvent): Record<string, unknown>;
19
20
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
20
21
  export declare function createSlotlockStoreAgentBackend(store: SlotlockStore, options: SlotlockStoreAgentBackendOptions): SlotlockAgentCalendarBackend;
22
+ export interface SlotlockStoreApplicationBackendOptions {
23
+ /**
24
+ * Refuse a new resource (409 `resource_limit_reached`) once the tenant has this many, 1-100,000.
25
+ * An existing external reference is still updated. Unset: no limit.
26
+ */
27
+ maxTenantResources?: number;
28
+ /** Batches of each maintenance task one `slotlock_run_maintenance` call runs, 1-100 (default 10). */
29
+ maintenanceBatches?: number;
30
+ }
31
+ /**
32
+ * Serve the application API (application-api.ts) from the production store, in the principal's
33
+ * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
34
+ * reservations under the tenant's row-level security, so another tenant's hold is not found either.
35
+ */
36
+ export declare function createSlotlockStoreApplicationBackend(store: SlotlockStore, options?: SlotlockStoreApplicationBackendOptions): SlotlockApplicationBackend;