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/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +35 -9
- package/README.md +153 -79
- package/SECURITY.md +40 -4
- package/SPEC.md +19 -6
- package/agent-server.d.ts +64 -7
- package/agent-server.js +273 -68
- package/agent-store-backend.d.ts +16 -0
- package/agent-store-backend.js +257 -2
- package/api-keys.d.ts +11 -4
- package/api-keys.js +9 -2
- package/application-api.d.ts +54 -0
- package/application-api.js +290 -0
- package/dashboard.d.ts +4 -1
- package/dashboard.js +6 -4
- package/ddl.d.ts +2 -1
- package/ddl.js +143 -15
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/index.d.ts +8 -4
- package/index.js +4 -2
- package/maintenance.d.ts +57 -0
- package/maintenance.js +181 -0
- package/node-server.js +121 -5
- package/package.json +1 -1
- package/rest-api.d.ts +33 -18
- package/rest-api.js +131 -63
- package/rules.d.ts +19 -0
- package/rules.js +32 -4
- package/self-host.d.ts +5 -2
- package/self-host.js +127 -20
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +30 -8
- package/store.js +69 -27
- package/sync.js +530 -49
- package/timezone.d.ts +19 -0
- package/timezone.js +16 -1
- package/types.d.ts +4 -1
- package/weekly-hours.js +2 -2
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
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
:
|
|
789
|
-
|
|
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 =
|
|
1613
|
-
if (details
|
|
1614
|
-
changes.push(
|
|
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
|
|
1734
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
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
|
|
2506
|
+
return refuseCall(tooLarge ? 413 : 400, tooLarge ? 'request_too_large' : 'invalid_json');
|
|
2322
2507
|
}
|
|
2323
2508
|
if (!isRecord(body))
|
|
2324
|
-
return
|
|
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
|
|
2517
|
+
return refuseCall(400, 'invalid_arguments');
|
|
2333
2518
|
}
|
|
2334
|
-
const
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
2623
|
-
|
|
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
|
-
|
|
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
|
|
2640
|
-
const outcome = await invokeSlotlockAgentOperation({
|
|
2845
|
+
const outcome = await invokeReported('a2a', {
|
|
2641
2846
|
operation,
|
|
2642
2847
|
input,
|
|
2643
2848
|
request,
|
package/agent-store-backend.d.ts
CHANGED
|
@@ -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;
|