@nifrajs/webmcp 3.5.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/README.md +126 -0
- package/dist/index.d.ts +219 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +692 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +1031 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nifra's typed Agent Surface contract and WebMCP adapter.
|
|
3
|
+
*
|
|
4
|
+
* WebMCP is the page-local transport: a compatible host discovers the standard
|
|
5
|
+
* `document.modelContext.registerTool` surface. Nifra adds typed core-tool
|
|
6
|
+
* execution, deterministic prediction/reconciliation, bounded receipts, and a
|
|
7
|
+
* host-independent conformance runner around that transport.
|
|
8
|
+
*/
|
|
9
|
+
import { validateStandard } from "@nifrajs/core/schema";
|
|
10
|
+
import { toCatalogEntry } from "@nifrajs/core/tool-catalog";
|
|
11
|
+
import { defineTool, executeTool, } from "@nifrajs/core/tool-contract";
|
|
12
|
+
const MAX_ID_LENGTH = 128;
|
|
13
|
+
const MAX_PATH_LENGTH = 256;
|
|
14
|
+
const MAX_PATCH_OPERATIONS = 256;
|
|
15
|
+
const MAX_ACTIVE_PREDICTIONS = 256;
|
|
16
|
+
const MAX_PATCH_VALUE_BYTES = 64 * 1024;
|
|
17
|
+
const DEFAULT_MAX_RECEIPT_BYTES = 16 * 1024;
|
|
18
|
+
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
19
|
+
const MAX_REGISTRATION_ERROR_LENGTH = 256;
|
|
20
|
+
const VERSION_RE = /^[-A-Za-z0-9._:/]{1,128}$/;
|
|
21
|
+
const STATE_PATH_RE = /^[a-z][A-Za-z0-9._:/-]{0,127}$/;
|
|
22
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
23
|
+
/** Create a capability from the same core tool contract used by Nifra's other adapters. */
|
|
24
|
+
export function defineAgentCapability(options) {
|
|
25
|
+
const tool = defineTool(options);
|
|
26
|
+
const scope = options.scope ?? "page";
|
|
27
|
+
if (scope !== "page" && scope !== "session" && scope !== "server")
|
|
28
|
+
throw new TypeError("agent surface: scope is invalid");
|
|
29
|
+
const reads = validateStatePaths(options.reads ?? [], "reads");
|
|
30
|
+
const writes = validateStatePaths(options.writes ?? [], "writes");
|
|
31
|
+
const reconciliation = options.reconciliation ?? "manual";
|
|
32
|
+
if (reconciliation !== "accept-server-state" &&
|
|
33
|
+
reconciliation !== "reload" &&
|
|
34
|
+
reconciliation !== "manual")
|
|
35
|
+
throw new TypeError("agent surface: reconciliation is invalid");
|
|
36
|
+
if (options.predict !== undefined && options.reconcile === undefined)
|
|
37
|
+
throw new TypeError("agent surface: predict requires reconcile");
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
tool,
|
|
40
|
+
surface: Object.freeze({
|
|
41
|
+
scope,
|
|
42
|
+
reads: Object.freeze(reads),
|
|
43
|
+
writes: Object.freeze(writes),
|
|
44
|
+
reconciliation,
|
|
45
|
+
}),
|
|
46
|
+
...(options.predict === undefined ? {} : { predict: options.predict }),
|
|
47
|
+
...(options.reconcile === undefined ? {} : { reconcile: options.reconcile }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function validateStatePaths(paths, label) {
|
|
51
|
+
if (!Array.isArray(paths))
|
|
52
|
+
throw new TypeError(`agent surface: ${label} must be an array`);
|
|
53
|
+
const unique = new Set();
|
|
54
|
+
for (const path of paths) {
|
|
55
|
+
if (typeof path !== "string" || !STATE_PATH_RE.test(path))
|
|
56
|
+
throw new TypeError(`agent surface: ${label} contains an invalid path`);
|
|
57
|
+
unique.add(path);
|
|
58
|
+
}
|
|
59
|
+
return [...unique];
|
|
60
|
+
}
|
|
61
|
+
/** Adapt one canonical capability to the proposed WebMCP tool shape. */
|
|
62
|
+
export function toWebMcpTool(capability, options = {}) {
|
|
63
|
+
const maxReceiptBytes = validateReceiptLimit(options.maxReceiptBytes);
|
|
64
|
+
const entry = toCatalogEntry(capability.tool);
|
|
65
|
+
const fixedCapabilities = [capability.tool.capability, ...(options.capabilities ?? [])];
|
|
66
|
+
return {
|
|
67
|
+
name: entry.name,
|
|
68
|
+
description: entry.description,
|
|
69
|
+
inputSchema: entry.inputSchema,
|
|
70
|
+
annotations: entry.annotations,
|
|
71
|
+
execute: async (input) => {
|
|
72
|
+
if (options.authorize !== undefined) {
|
|
73
|
+
let authorized = false;
|
|
74
|
+
try {
|
|
75
|
+
authorized = await options.authorize(input);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// A page-local guard is untrusted adapter code. Treat a thrown guard as a denial rather
|
|
79
|
+
// than leaking its exception through the WebMCP host.
|
|
80
|
+
authorized = false;
|
|
81
|
+
}
|
|
82
|
+
if (!authorized)
|
|
83
|
+
return deniedReceipt(options.dryRun === true);
|
|
84
|
+
}
|
|
85
|
+
const result = await executeAgentCapability(capability, input, {
|
|
86
|
+
...options,
|
|
87
|
+
capabilities: fixedCapabilities,
|
|
88
|
+
});
|
|
89
|
+
const receipt = toReceipt(result, maxReceiptBytes);
|
|
90
|
+
// Raw output mode is explicitly opt-in and exists for hosts that already impose their own
|
|
91
|
+
// response limits. The default receipt path stays bounded.
|
|
92
|
+
if (options.resultMode === "output" && result.ok)
|
|
93
|
+
return result.output;
|
|
94
|
+
return receipt;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function deniedReceipt(dryRun) {
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
ok: false,
|
|
101
|
+
outcome: "denied",
|
|
102
|
+
dryRun,
|
|
103
|
+
error: { code: "capability_denied", stage: "capability" },
|
|
104
|
+
evidence: Object.freeze([]),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Execute through Nifra's existing validation, policy, approval, idempotency, and evidence path. */
|
|
108
|
+
export function executeAgentCapability(capability, input, options = {}) {
|
|
109
|
+
return executeTool(capability.tool, input, {
|
|
110
|
+
...options,
|
|
111
|
+
capabilities: [capability.tool.capability, ...(options.capabilities ?? [])],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function toReceipt(result, maxReceiptBytes) {
|
|
115
|
+
if (result.ok) {
|
|
116
|
+
const bounded = boundReceiptOutput(result.output, maxReceiptBytes);
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
ok: true,
|
|
119
|
+
outcome: result.dryRun ? "dry-run" : "committed",
|
|
120
|
+
dryRun: result.dryRun,
|
|
121
|
+
...(bounded.output === undefined ? {} : { output: bounded.output }),
|
|
122
|
+
...(bounded.truncated ? { outputTruncated: true } : {}),
|
|
123
|
+
evidence: result.evidence,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
ok: false,
|
|
128
|
+
outcome: "failed",
|
|
129
|
+
dryRun: result.dryRun,
|
|
130
|
+
error: { code: result.error.code, stage: result.error.stage },
|
|
131
|
+
evidence: result.evidence,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function validateReceiptLimit(value) {
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return DEFAULT_MAX_RECEIPT_BYTES;
|
|
137
|
+
if (!Number.isSafeInteger(value) || value < 256 || value > MAX_RECEIPT_BYTES)
|
|
138
|
+
throw new RangeError(`agent surface: maxReceiptBytes must be a safe integer between 256 and ${MAX_RECEIPT_BYTES}`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
function boundReceiptOutput(output, maxBytes) {
|
|
142
|
+
if (output === undefined)
|
|
143
|
+
return {};
|
|
144
|
+
try {
|
|
145
|
+
const encoded = new TextEncoder().encode(JSON.stringify(output));
|
|
146
|
+
return encoded.byteLength <= maxBytes ? { output } : { truncated: true };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// A non-JSON output is not safe to put in a portable receipt. The typed output mode remains
|
|
150
|
+
// available to an embedding host that has its own serialization contract.
|
|
151
|
+
return { truncated: true };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const targetRegistrations = new WeakMap();
|
|
155
|
+
function defaultTarget() {
|
|
156
|
+
const page = globalThis.document;
|
|
157
|
+
return page;
|
|
158
|
+
}
|
|
159
|
+
/** Register an explicit allowlist of capabilities, safely degrading on ordinary browsers. */
|
|
160
|
+
export async function registerWebMcpTools(capabilities, target = defaultTarget(), options = {}) {
|
|
161
|
+
const context = target?.modelContext;
|
|
162
|
+
if (context === undefined || typeof context.registerTool !== "function")
|
|
163
|
+
return Object.freeze({
|
|
164
|
+
supported: false,
|
|
165
|
+
registered: Object.freeze([]),
|
|
166
|
+
failed: Object.freeze([]),
|
|
167
|
+
cleanup: async () => { },
|
|
168
|
+
});
|
|
169
|
+
const key = context;
|
|
170
|
+
const existing = targetRegistrations.get(key) ?? new Set();
|
|
171
|
+
targetRegistrations.set(key, existing);
|
|
172
|
+
const registered = [];
|
|
173
|
+
const failed = [];
|
|
174
|
+
for (const capability of capabilities) {
|
|
175
|
+
const name = capability.tool.name;
|
|
176
|
+
if (existing.has(name)) {
|
|
177
|
+
failed.push({ name, error: "duplicate tool name is already registered" });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Reserve before awaiting the host. `registerTool` may be asynchronous, and another caller can
|
|
181
|
+
// enter this function while it is pending. Keeping the reservation in the same set as committed
|
|
182
|
+
// names makes duplicate detection atomic from JavaScript's point of view; failures release only
|
|
183
|
+
// their own reservation so a later registration can retry safely.
|
|
184
|
+
existing.add(name);
|
|
185
|
+
try {
|
|
186
|
+
await context.registerTool(toWebMcpTool(capability, options));
|
|
187
|
+
registered.push(name);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
existing.delete(name);
|
|
191
|
+
if (existing.size === 0 && targetRegistrations.get(key) === existing)
|
|
192
|
+
targetRegistrations.delete(key);
|
|
193
|
+
failed.push({ name, error: describeError(error) });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
let cleaned = false;
|
|
197
|
+
const cleanup = async () => {
|
|
198
|
+
if (cleaned)
|
|
199
|
+
return;
|
|
200
|
+
cleaned = true;
|
|
201
|
+
if (context.unregisterTool === undefined)
|
|
202
|
+
return;
|
|
203
|
+
for (const name of [...registered].reverse()) {
|
|
204
|
+
try {
|
|
205
|
+
await context.unregisterTool(name);
|
|
206
|
+
existing.delete(name);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Cleanup is best effort; a host failure must not break the application.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (existing.size === 0 && targetRegistrations.get(key) === existing)
|
|
213
|
+
targetRegistrations.delete(key);
|
|
214
|
+
};
|
|
215
|
+
return Object.freeze({
|
|
216
|
+
supported: true,
|
|
217
|
+
registered: Object.freeze([...registered]),
|
|
218
|
+
failed: Object.freeze([...failed]),
|
|
219
|
+
cleanup,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function describeError(error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
return message.length <= MAX_REGISTRATION_ERROR_LENGTH
|
|
225
|
+
? message
|
|
226
|
+
: `${message.slice(0, MAX_REGISTRATION_ERROR_LENGTH - 3)}...`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* An atomic, defensive prediction store. Predictions are overlays over authoritative state; a server
|
|
230
|
+
* commit replaces the base and remaining overlays are replayed or conflict-dropped.
|
|
231
|
+
*/
|
|
232
|
+
export function createPredictionStore(initial) {
|
|
233
|
+
let authoritative = cloneValue(initial, "initial state");
|
|
234
|
+
validateVersion(initial.version);
|
|
235
|
+
const active = new Map();
|
|
236
|
+
const conflicted = new Map();
|
|
237
|
+
const order = [];
|
|
238
|
+
const listeners = new Set();
|
|
239
|
+
const removeActive = (id) => {
|
|
240
|
+
active.delete(id);
|
|
241
|
+
const index = order.indexOf(id);
|
|
242
|
+
if (index !== -1)
|
|
243
|
+
order.splice(index, 1);
|
|
244
|
+
};
|
|
245
|
+
const rememberConflict = (id) => {
|
|
246
|
+
conflicted.delete(id);
|
|
247
|
+
if (conflicted.size >= MAX_ACTIVE_PREDICTIONS) {
|
|
248
|
+
const oldest = conflicted.keys().next().value;
|
|
249
|
+
if (oldest !== undefined)
|
|
250
|
+
conflicted.delete(oldest);
|
|
251
|
+
}
|
|
252
|
+
conflicted.set(id, true);
|
|
253
|
+
};
|
|
254
|
+
const snapshot = () => {
|
|
255
|
+
let state = cloneValue(authoritative.state, "authoritative state");
|
|
256
|
+
const invalid = [];
|
|
257
|
+
for (const id of [...order]) {
|
|
258
|
+
const stored = active.get(id);
|
|
259
|
+
if (stored === undefined)
|
|
260
|
+
continue;
|
|
261
|
+
try {
|
|
262
|
+
state = applyPredictionPatch(state, stored.prediction.patch);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
invalid.push(id);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const id of invalid) {
|
|
269
|
+
removeActive(id);
|
|
270
|
+
rememberConflict(id);
|
|
271
|
+
}
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
state,
|
|
274
|
+
version: authoritative.version,
|
|
275
|
+
authoritativeVersion: authoritative.version,
|
|
276
|
+
activePredictionIds: Object.freeze(order.filter((id) => active.has(id))),
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
const emit = (event) => {
|
|
280
|
+
for (const listener of listeners) {
|
|
281
|
+
try {
|
|
282
|
+
listener(event);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Observer failures cannot change the state machine.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const predict = (predictionId, prediction, now = Date.now()) => {
|
|
290
|
+
validateIdentifier(predictionId, "prediction id");
|
|
291
|
+
validatePrediction(prediction);
|
|
292
|
+
const current = snapshot();
|
|
293
|
+
if (active.has(predictionId) || conflicted.has(predictionId))
|
|
294
|
+
return { outcome: "invalid", predictionId, snapshot: current };
|
|
295
|
+
if (active.size >= MAX_ACTIVE_PREDICTIONS)
|
|
296
|
+
return { outcome: "invalid", predictionId, snapshot: current };
|
|
297
|
+
if (prediction.baseVersion !== authoritative.version)
|
|
298
|
+
return { outcome: "stale", predictionId, snapshot: current };
|
|
299
|
+
if (prediction.expiresAt !== undefined && prediction.expiresAt <= now)
|
|
300
|
+
return { outcome: "expired", predictionId, snapshot: current };
|
|
301
|
+
const candidate = applyPredictionPatch(current.state, prediction.patch);
|
|
302
|
+
// Clone before storing so caller mutation after predict cannot change the overlay.
|
|
303
|
+
const stored = {
|
|
304
|
+
id: predictionId,
|
|
305
|
+
prediction: Object.freeze({
|
|
306
|
+
baseVersion: prediction.baseVersion,
|
|
307
|
+
patch: Object.freeze(prediction.patch.map((op) => clonePatchOperation(op))),
|
|
308
|
+
...(prediction.expiresAt === undefined ? {} : { expiresAt: prediction.expiresAt }),
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
active.set(predictionId, stored);
|
|
312
|
+
order.push(predictionId);
|
|
313
|
+
const next = snapshot();
|
|
314
|
+
// This should be impossible after candidate was applied; retain the guard as an atomicity check.
|
|
315
|
+
if (!deepEqual(candidate, next.state)) {
|
|
316
|
+
removeActive(predictionId);
|
|
317
|
+
return { outcome: "invalid", predictionId, snapshot: snapshot() };
|
|
318
|
+
}
|
|
319
|
+
const result = { outcome: "predicted", predictionId, snapshot: next };
|
|
320
|
+
emit({ type: "predicted", predictionId, snapshot: next });
|
|
321
|
+
return result;
|
|
322
|
+
};
|
|
323
|
+
const discardUnreplayablePredictions = () => {
|
|
324
|
+
const discarded = [];
|
|
325
|
+
let projected = cloneValue(authoritative.state, "authoritative state");
|
|
326
|
+
for (const id of [...order]) {
|
|
327
|
+
const stored = active.get(id);
|
|
328
|
+
if (stored === undefined)
|
|
329
|
+
continue;
|
|
330
|
+
if (stored.prediction.baseVersion !== authoritative.version) {
|
|
331
|
+
removeActive(id);
|
|
332
|
+
rememberConflict(id);
|
|
333
|
+
discarded.push(id);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
projected = applyPredictionPatch(projected, stored.prediction.patch);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
removeActive(id);
|
|
341
|
+
rememberConflict(id);
|
|
342
|
+
discarded.push(id);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return Object.freeze(discarded);
|
|
346
|
+
};
|
|
347
|
+
const commit = (predictionId, nextAuthoritative, now = Date.now()) => {
|
|
348
|
+
validateIdentifier(predictionId, "prediction id");
|
|
349
|
+
const stored = active.get(predictionId);
|
|
350
|
+
const current = snapshot();
|
|
351
|
+
if (stored === undefined) {
|
|
352
|
+
if (conflicted.delete(predictionId))
|
|
353
|
+
return { outcome: "conflicted", predictionId, snapshot: current };
|
|
354
|
+
return { outcome: "missing", predictionId, snapshot: current };
|
|
355
|
+
}
|
|
356
|
+
if (stored.prediction.expiresAt !== undefined && stored.prediction.expiresAt <= now) {
|
|
357
|
+
removeActive(predictionId);
|
|
358
|
+
const expired = snapshot();
|
|
359
|
+
emit({ type: "expired", predictionId, snapshot: expired });
|
|
360
|
+
return { outcome: "expired", predictionId, snapshot: expired };
|
|
361
|
+
}
|
|
362
|
+
if (stored.prediction.baseVersion !== authoritative.version) {
|
|
363
|
+
removeActive(predictionId);
|
|
364
|
+
rememberConflict(predictionId);
|
|
365
|
+
const conflicted = snapshot();
|
|
366
|
+
emit({ type: "conflicted", predictionId, snapshot: conflicted });
|
|
367
|
+
return { outcome: "conflicted", predictionId, snapshot: conflicted };
|
|
368
|
+
}
|
|
369
|
+
validateVersion(nextAuthoritative.version);
|
|
370
|
+
const nextState = cloneValue(nextAuthoritative.state, "authoritative state");
|
|
371
|
+
removeActive(predictionId);
|
|
372
|
+
authoritative = { state: nextState, version: nextAuthoritative.version };
|
|
373
|
+
const discarded = discardUnreplayablePredictions();
|
|
374
|
+
const committed = snapshot();
|
|
375
|
+
emit({ type: "committed", predictionId, snapshot: committed });
|
|
376
|
+
for (const id of discarded)
|
|
377
|
+
emit({ type: "conflicted", predictionId: id, snapshot: committed });
|
|
378
|
+
return { outcome: "committed", predictionId, snapshot: committed };
|
|
379
|
+
};
|
|
380
|
+
const rollback = (predictionId) => {
|
|
381
|
+
validateIdentifier(predictionId, "prediction id");
|
|
382
|
+
if (!active.has(predictionId))
|
|
383
|
+
return { outcome: "missing", predictionId, snapshot: snapshot() };
|
|
384
|
+
removeActive(predictionId);
|
|
385
|
+
const rolledBack = snapshot();
|
|
386
|
+
emit({ type: "rolled-back", predictionId, snapshot: rolledBack });
|
|
387
|
+
return { outcome: "rolled-back", predictionId, snapshot: rolledBack };
|
|
388
|
+
};
|
|
389
|
+
const expire = (now = Date.now()) => {
|
|
390
|
+
const expired = [];
|
|
391
|
+
for (const id of [...order]) {
|
|
392
|
+
const stored = active.get(id);
|
|
393
|
+
if (stored?.prediction.expiresAt !== undefined && stored.prediction.expiresAt <= now) {
|
|
394
|
+
removeActive(id);
|
|
395
|
+
expired.push(id);
|
|
396
|
+
emit({ type: "expired", predictionId: id, snapshot: snapshot() });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return Object.freeze(expired);
|
|
400
|
+
};
|
|
401
|
+
return {
|
|
402
|
+
snapshot,
|
|
403
|
+
predict,
|
|
404
|
+
commit,
|
|
405
|
+
rollback,
|
|
406
|
+
expire,
|
|
407
|
+
subscribe(listener) {
|
|
408
|
+
listeners.add(listener);
|
|
409
|
+
return () => listeners.delete(listener);
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/** Apply a pure prediction, execute the core tool, then commit or roll it back atomically. */
|
|
414
|
+
export async function executePredicted(capability, input, store, options = {}) {
|
|
415
|
+
const now = options.now ?? Date.now;
|
|
416
|
+
const validated = await validateStandard(capability.tool.input, input);
|
|
417
|
+
if (options.signal?.aborted)
|
|
418
|
+
return { outcome: "rolled-back", snapshot: store.snapshot() };
|
|
419
|
+
if (!validated.ok) {
|
|
420
|
+
const tool = await executeAgentCapability(capability, input, options);
|
|
421
|
+
return {
|
|
422
|
+
outcome: "executed",
|
|
423
|
+
tool,
|
|
424
|
+
snapshot: store.snapshot(),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (capability.predict === undefined) {
|
|
428
|
+
const tool = await executeAgentCapability(capability, input, options);
|
|
429
|
+
return {
|
|
430
|
+
outcome: tool.ok ? "executed" : "rolled-back",
|
|
431
|
+
tool,
|
|
432
|
+
snapshot: store.snapshot(),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (capability.reconcile === undefined)
|
|
436
|
+
throw new TypeError("agent surface: prediction capability has no reconciler");
|
|
437
|
+
const before = store.snapshot();
|
|
438
|
+
const predictionInput = cloneValue(validated.value, "prediction input");
|
|
439
|
+
const prediction = await capability.predict({
|
|
440
|
+
input: predictionInput,
|
|
441
|
+
snapshot: before.state,
|
|
442
|
+
version: before.version,
|
|
443
|
+
now: now(),
|
|
444
|
+
});
|
|
445
|
+
if (options.signal?.aborted)
|
|
446
|
+
return { outcome: "rolled-back", snapshot: store.snapshot() };
|
|
447
|
+
const predictionId = options.predictionId ?? crypto.randomUUID();
|
|
448
|
+
const proposed = store.predict(predictionId, prediction, now());
|
|
449
|
+
if (proposed.outcome !== "predicted")
|
|
450
|
+
return {
|
|
451
|
+
outcome: proposed.outcome === "missing" ? "invalid" : proposed.outcome,
|
|
452
|
+
predictionId,
|
|
453
|
+
prediction,
|
|
454
|
+
snapshot: proposed.snapshot,
|
|
455
|
+
};
|
|
456
|
+
const tool = await executeAgentCapability(capability, input, options);
|
|
457
|
+
if (!tool.ok || options.signal?.aborted) {
|
|
458
|
+
const rolledBack = store.rollback(predictionId);
|
|
459
|
+
return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot };
|
|
460
|
+
}
|
|
461
|
+
let authoritative;
|
|
462
|
+
try {
|
|
463
|
+
authoritative = await capability.reconcile({
|
|
464
|
+
input: cloneValue(predictionInput, "reconciliation input"),
|
|
465
|
+
output: cloneValue(tool.output, "reconciliation output"),
|
|
466
|
+
previous: before,
|
|
467
|
+
prediction,
|
|
468
|
+
now: now(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
const rolledBack = store.rollback(predictionId);
|
|
473
|
+
return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot };
|
|
474
|
+
}
|
|
475
|
+
if (options.signal?.aborted) {
|
|
476
|
+
const rolledBack = store.rollback(predictionId);
|
|
477
|
+
return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot };
|
|
478
|
+
}
|
|
479
|
+
const committed = store.commit(predictionId, authoritative, now());
|
|
480
|
+
return {
|
|
481
|
+
outcome: committed.outcome === "committed"
|
|
482
|
+
? "committed"
|
|
483
|
+
: committed.outcome === "expired"
|
|
484
|
+
? "expired"
|
|
485
|
+
: "conflicted",
|
|
486
|
+
predictionId,
|
|
487
|
+
prediction,
|
|
488
|
+
tool,
|
|
489
|
+
snapshot: committed.snapshot,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** Run deterministic host-independent checks over the WebMCP projection and optional calls. */
|
|
493
|
+
export async function runAgentSurfaceConformance(capabilities, options = {}) {
|
|
494
|
+
const checks = [];
|
|
495
|
+
const names = new Set();
|
|
496
|
+
for (const capability of capabilities) {
|
|
497
|
+
if (names.has(capability.tool.name))
|
|
498
|
+
throw new Error(`agent surface conformance: duplicate ${capability.tool.name}`);
|
|
499
|
+
names.add(capability.tool.name);
|
|
500
|
+
const tool = toWebMcpTool(capability);
|
|
501
|
+
if (tool.name !== capability.tool.name || typeof tool.execute !== "function")
|
|
502
|
+
throw new Error(`agent surface conformance: invalid descriptor ${capability.tool.name}`);
|
|
503
|
+
}
|
|
504
|
+
checks.push("unique standard descriptors");
|
|
505
|
+
const report = await registerWebMcpTools(capabilities, options.target, options.registration);
|
|
506
|
+
if (!report.supported) {
|
|
507
|
+
checks.push("unsupported host degrades safely");
|
|
508
|
+
return Object.freeze({
|
|
509
|
+
supported: false,
|
|
510
|
+
registered: report.registered,
|
|
511
|
+
failed: report.failed,
|
|
512
|
+
checks: Object.freeze(checks),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
checks.push("host registration");
|
|
517
|
+
const byName = new Map(capabilities.map((capability) => [capability.tool.name, capability]));
|
|
518
|
+
for (const testCase of options.cases ?? []) {
|
|
519
|
+
const capability = byName.get(testCase.capability.tool.name);
|
|
520
|
+
if (capability === undefined)
|
|
521
|
+
throw new Error(`agent surface conformance: unknown case ${testCase.capability.tool.name}`);
|
|
522
|
+
const result = await toWebMcpTool(capability, options.registration).execute(testCase.input);
|
|
523
|
+
await testCase.assert?.(result);
|
|
524
|
+
checks.push(`call ${capability.tool.name}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
await report.cleanup();
|
|
529
|
+
}
|
|
530
|
+
return Object.freeze({
|
|
531
|
+
supported: true,
|
|
532
|
+
registered: report.registered,
|
|
533
|
+
failed: report.failed,
|
|
534
|
+
checks: Object.freeze(checks),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
function validateIdentifier(value, label) {
|
|
538
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID_LENGTH)
|
|
539
|
+
throw new TypeError(`agent surface: ${label} is invalid`);
|
|
540
|
+
}
|
|
541
|
+
function validateVersion(version) {
|
|
542
|
+
if (typeof version !== "string" || !VERSION_RE.test(version))
|
|
543
|
+
throw new TypeError("agent surface: version is invalid");
|
|
544
|
+
}
|
|
545
|
+
function validatePrediction(prediction) {
|
|
546
|
+
validateVersion(prediction.baseVersion);
|
|
547
|
+
if (!Array.isArray(prediction.patch) || prediction.patch.length > MAX_PATCH_OPERATIONS)
|
|
548
|
+
throw new TypeError("agent surface: prediction patch is invalid");
|
|
549
|
+
if (prediction.expiresAt !== undefined && !Number.isSafeInteger(prediction.expiresAt))
|
|
550
|
+
throw new TypeError("agent surface: prediction expiry is invalid");
|
|
551
|
+
for (const operation of prediction.patch) {
|
|
552
|
+
if (operation === null || typeof operation !== "object")
|
|
553
|
+
throw new TypeError("agent surface: prediction operation is invalid");
|
|
554
|
+
if (operation.op !== "add" && operation.op !== "replace" && operation.op !== "remove")
|
|
555
|
+
throw new TypeError("agent surface: prediction operation is unsupported");
|
|
556
|
+
if (typeof operation.path !== "string" || operation.path.length > MAX_PATH_LENGTH)
|
|
557
|
+
throw new TypeError("agent surface: prediction path is invalid");
|
|
558
|
+
decodePointer(operation.path);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function cloneValue(value, label) {
|
|
562
|
+
try {
|
|
563
|
+
return structuredClone(value);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
throw new TypeError(`agent surface: ${label} must be structured-cloneable`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function clonePatchOperation(operation) {
|
|
570
|
+
if (operation.op === "remove")
|
|
571
|
+
return { op: "remove", path: operation.path };
|
|
572
|
+
return {
|
|
573
|
+
op: operation.op,
|
|
574
|
+
path: operation.path,
|
|
575
|
+
value: clonePatchValue(operation.value),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function clonePatchValue(value) {
|
|
579
|
+
try {
|
|
580
|
+
const cloned = structuredClone(value);
|
|
581
|
+
const encoded = JSON.stringify(cloned);
|
|
582
|
+
if (encoded === undefined)
|
|
583
|
+
throw new TypeError("agent surface: patch value must be JSON-serializable");
|
|
584
|
+
if (new TextEncoder().encode(encoded).byteLength > MAX_PATCH_VALUE_BYTES)
|
|
585
|
+
throw new RangeError("agent surface: patch value exceeds its size limit");
|
|
586
|
+
return cloned;
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
if (error instanceof RangeError || error instanceof TypeError)
|
|
590
|
+
throw error;
|
|
591
|
+
throw new TypeError("agent surface: patch value must be structured-cloneable JSON");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function deepEqual(a, b) {
|
|
595
|
+
try {
|
|
596
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function decodePointer(path) {
|
|
603
|
+
if (path === "")
|
|
604
|
+
return Object.freeze([]);
|
|
605
|
+
if (!path.startsWith("/"))
|
|
606
|
+
throw new TypeError("agent surface: patch path must be a JSON Pointer");
|
|
607
|
+
const segments = path
|
|
608
|
+
.slice(1)
|
|
609
|
+
.split("/")
|
|
610
|
+
.map((segment) => {
|
|
611
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
612
|
+
if (segment[index] === "~" && segment[index + 1] !== "0" && segment[index + 1] !== "1")
|
|
613
|
+
throw new TypeError("agent surface: patch pointer escape is invalid");
|
|
614
|
+
}
|
|
615
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
616
|
+
});
|
|
617
|
+
if (segments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment)))
|
|
618
|
+
throw new TypeError("agent surface: prototype path is forbidden");
|
|
619
|
+
return Object.freeze(segments);
|
|
620
|
+
}
|
|
621
|
+
function isRecord(value) {
|
|
622
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
623
|
+
}
|
|
624
|
+
function applyPredictionPatch(state, operations) {
|
|
625
|
+
let current = cloneValue(state, "prediction state");
|
|
626
|
+
for (const operation of operations)
|
|
627
|
+
current = applyPredictionOperation(current, operation);
|
|
628
|
+
return current;
|
|
629
|
+
}
|
|
630
|
+
function applyPredictionOperation(document, operation) {
|
|
631
|
+
const segments = decodePointer(operation.path);
|
|
632
|
+
if (segments.length === 0) {
|
|
633
|
+
if (operation.op === "remove")
|
|
634
|
+
throw new TypeError("agent surface: root removal is forbidden");
|
|
635
|
+
return clonePatchValue(operation.value);
|
|
636
|
+
}
|
|
637
|
+
const parentSegments = segments.slice(0, -1);
|
|
638
|
+
const leaf = segments[segments.length - 1];
|
|
639
|
+
const parent = readPointer(document, parentSegments);
|
|
640
|
+
if (Array.isArray(parent)) {
|
|
641
|
+
if (operation.op === "add") {
|
|
642
|
+
if (leaf === "-")
|
|
643
|
+
parent.push(clonePatchValue(operation.value));
|
|
644
|
+
else {
|
|
645
|
+
const index = arrayIndex(leaf, parent.length, true);
|
|
646
|
+
parent.splice(index, 0, clonePatchValue(operation.value));
|
|
647
|
+
}
|
|
648
|
+
return document;
|
|
649
|
+
}
|
|
650
|
+
const index = arrayIndex(leaf, parent.length, false);
|
|
651
|
+
if (operation.op === "replace")
|
|
652
|
+
parent[index] = clonePatchValue(operation.value);
|
|
653
|
+
else
|
|
654
|
+
parent.splice(index, 1);
|
|
655
|
+
return document;
|
|
656
|
+
}
|
|
657
|
+
if (!isRecord(parent))
|
|
658
|
+
throw new TypeError("agent surface: patch parent is not an object");
|
|
659
|
+
if (operation.op === "replace" && !Object.hasOwn(parent, leaf))
|
|
660
|
+
throw new TypeError("agent surface: replace target is missing");
|
|
661
|
+
if (operation.op === "remove" && !Object.hasOwn(parent, leaf))
|
|
662
|
+
throw new TypeError("agent surface: remove target is missing");
|
|
663
|
+
if (operation.op === "remove")
|
|
664
|
+
delete parent[leaf];
|
|
665
|
+
else
|
|
666
|
+
parent[leaf] = clonePatchValue(operation.value);
|
|
667
|
+
return document;
|
|
668
|
+
}
|
|
669
|
+
function readPointer(document, segments) {
|
|
670
|
+
let current = document;
|
|
671
|
+
for (const segment of segments) {
|
|
672
|
+
if (Array.isArray(current))
|
|
673
|
+
current = current[arrayIndex(segment, current.length, false)];
|
|
674
|
+
else if (isRecord(current) && Object.hasOwn(current, segment))
|
|
675
|
+
current = current[segment];
|
|
676
|
+
else
|
|
677
|
+
throw new TypeError("agent surface: patch parent is missing");
|
|
678
|
+
}
|
|
679
|
+
return current;
|
|
680
|
+
}
|
|
681
|
+
function arrayIndex(segment, length, allowEnd) {
|
|
682
|
+
if (!/^(?:0|[1-9]\d*)$/.test(segment))
|
|
683
|
+
throw new TypeError("agent surface: array index is invalid");
|
|
684
|
+
const index = Number(segment);
|
|
685
|
+
if (!Number.isSafeInteger(index) ||
|
|
686
|
+
index < 0 ||
|
|
687
|
+
index > length ||
|
|
688
|
+
(!allowEnd && index === length))
|
|
689
|
+
throw new TypeError("agent surface: array index is out of bounds");
|
|
690
|
+
return index;
|
|
691
|
+
}
|
|
692
|
+
//# sourceMappingURL=index.js.map
|