@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/src/index.ts ADDED
@@ -0,0 +1,1031 @@
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
+
10
+ import { type InferOutput, type StandardSchemaV1, validateStandard } from "@nifrajs/core/schema"
11
+ import { toCatalogEntry } from "@nifrajs/core/tool-catalog"
12
+ import {
13
+ defineTool,
14
+ executeTool,
15
+ type ToolApproval,
16
+ type ToolCallOptions,
17
+ type ToolCallResult,
18
+ type ToolContract,
19
+ type ToolContractOptions,
20
+ type ToolError,
21
+ type ToolEvidence,
22
+ } from "@nifrajs/core/tool-contract"
23
+
24
+ const MAX_ID_LENGTH = 128
25
+ const MAX_PATH_LENGTH = 256
26
+ const MAX_PATCH_OPERATIONS = 256
27
+ const MAX_ACTIVE_PREDICTIONS = 256
28
+ const MAX_PATCH_VALUE_BYTES = 64 * 1024
29
+ const DEFAULT_MAX_RECEIPT_BYTES = 16 * 1024
30
+ const MAX_RECEIPT_BYTES = 64 * 1024
31
+ const MAX_REGISTRATION_ERROR_LENGTH = 256
32
+ const VERSION_RE = /^[-A-Za-z0-9._:/]{1,128}$/
33
+ const STATE_PATH_RE = /^[a-z][A-Za-z0-9._:/-]{0,127}$/
34
+ const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"])
35
+
36
+ export type AgentSurfaceScope = "page" | "session" | "server"
37
+
38
+ export type ReconciliationMode = "accept-server-state" | "reload" | "manual"
39
+
40
+ export interface AgentSurfaceDescriptor {
41
+ readonly scope: AgentSurfaceScope
42
+ readonly reads: readonly string[]
43
+ readonly writes: readonly string[]
44
+ readonly reconciliation: ReconciliationMode
45
+ }
46
+
47
+ export interface AgentSurfaceSnapshot<State> {
48
+ readonly state: State
49
+ readonly version: string
50
+ }
51
+
52
+ /** The safe RFC 6902 subset accepted for speculative UI updates. */
53
+ export type PredictionPatchOperation =
54
+ | { readonly op: "add" | "replace"; readonly path: string; readonly value: unknown }
55
+ | { readonly op: "remove"; readonly path: string }
56
+
57
+ export interface AgentSurfacePrediction {
58
+ readonly baseVersion: string
59
+ readonly patch: readonly PredictionPatchOperation[]
60
+ readonly expiresAt?: number
61
+ }
62
+
63
+ export interface AgentSurfacePredictionContext<State, Input> {
64
+ readonly input: Input
65
+ readonly snapshot: State
66
+ readonly version: string
67
+ readonly now: number
68
+ }
69
+
70
+ export interface AgentSurfaceAuthoritativeState<State> {
71
+ readonly state: State
72
+ readonly version: string
73
+ }
74
+
75
+ export interface AgentSurfaceReconcileContext<Input, Output, State> {
76
+ readonly input: Input
77
+ readonly output: Output
78
+ readonly previous: AgentSurfaceSnapshot<State>
79
+ readonly prediction: AgentSurfacePrediction
80
+ readonly now: number
81
+ }
82
+
83
+ export type AgentSurfacePredictor<Input, State> = (
84
+ context: AgentSurfacePredictionContext<State, Input>,
85
+ ) => AgentSurfacePrediction | PromiseLike<AgentSurfacePrediction>
86
+
87
+ export type AgentSurfaceReconciler<Input, Output, State> = (
88
+ context: AgentSurfaceReconcileContext<Input, Output, State>,
89
+ ) => AgentSurfaceAuthoritativeState<State> | PromiseLike<AgentSurfaceAuthoritativeState<State>>
90
+
91
+ export interface AgentCapability<Input, Output, State = unknown> {
92
+ readonly tool: ToolContract<Input, Output>
93
+ readonly surface: AgentSurfaceDescriptor
94
+ readonly predict?: AgentSurfacePredictor<Input, State>
95
+ readonly reconcile?: AgentSurfaceReconciler<Input, Output, State>
96
+ }
97
+
98
+ /**
99
+ * The erased, read-only handle accepted by capability registries.
100
+ *
101
+ * A real application commonly registers tools with unrelated input and output types. Registry
102
+ * boundaries therefore use this structural view instead of pretending the whole array has one
103
+ * homogeneous generic type. Individual calls remain validated and typed at their capability's
104
+ * `tool` boundary.
105
+ */
106
+ export type AgentCapabilityReference = {
107
+ readonly tool: Omit<
108
+ ToolContract,
109
+ "input" | "output" | "execute" | "idempotency" | "cost" | "policy"
110
+ > & {
111
+ readonly input: StandardSchemaV1
112
+ readonly output: StandardSchemaV1
113
+ readonly execute: (...args: never[]) => unknown | PromiseLike<unknown>
114
+ readonly idempotency?: unknown
115
+ readonly cost?: unknown
116
+ readonly policy?: unknown
117
+ }
118
+ readonly surface: AgentSurfaceDescriptor
119
+ }
120
+
121
+ export type DefineAgentCapabilityOptions<
122
+ InputSchema extends StandardSchemaV1,
123
+ OutputSchema extends StandardSchemaV1,
124
+ State,
125
+ > = ToolContractOptions<
126
+ InferOutput<InputSchema>,
127
+ InferOutput<OutputSchema>,
128
+ InputSchema,
129
+ OutputSchema
130
+ > & {
131
+ readonly scope?: AgentSurfaceScope
132
+ readonly reads?: readonly string[]
133
+ readonly writes?: readonly string[]
134
+ readonly reconciliation?: ReconciliationMode
135
+ readonly predict?: AgentSurfacePredictor<InferOutput<InputSchema>, State>
136
+ readonly reconcile?: AgentSurfaceReconciler<
137
+ InferOutput<InputSchema>,
138
+ InferOutput<OutputSchema>,
139
+ State
140
+ >
141
+ }
142
+
143
+ /** Create a capability from the same core tool contract used by Nifra's other adapters. */
144
+ export function defineAgentCapability<
145
+ InputSchema extends StandardSchemaV1,
146
+ OutputSchema extends StandardSchemaV1,
147
+ State = unknown,
148
+ >(
149
+ options: DefineAgentCapabilityOptions<InputSchema, OutputSchema, State>,
150
+ ): AgentCapability<InferOutput<InputSchema>, InferOutput<OutputSchema>, State> {
151
+ const tool = defineTool(options)
152
+ const scope = options.scope ?? "page"
153
+ if (scope !== "page" && scope !== "session" && scope !== "server")
154
+ throw new TypeError("agent surface: scope is invalid")
155
+ const reads = validateStatePaths(options.reads ?? [], "reads")
156
+ const writes = validateStatePaths(options.writes ?? [], "writes")
157
+ const reconciliation = options.reconciliation ?? "manual"
158
+ if (
159
+ reconciliation !== "accept-server-state" &&
160
+ reconciliation !== "reload" &&
161
+ reconciliation !== "manual"
162
+ )
163
+ throw new TypeError("agent surface: reconciliation is invalid")
164
+ if (options.predict !== undefined && options.reconcile === undefined)
165
+ throw new TypeError("agent surface: predict requires reconcile")
166
+ return Object.freeze({
167
+ tool,
168
+ surface: Object.freeze({
169
+ scope,
170
+ reads: Object.freeze(reads),
171
+ writes: Object.freeze(writes),
172
+ reconciliation,
173
+ }),
174
+ ...(options.predict === undefined ? {} : { predict: options.predict }),
175
+ ...(options.reconcile === undefined ? {} : { reconcile: options.reconcile }),
176
+ })
177
+ }
178
+
179
+ function validateStatePaths(paths: readonly string[], label: string): readonly string[] {
180
+ if (!Array.isArray(paths)) throw new TypeError(`agent surface: ${label} must be an array`)
181
+ const unique = new Set<string>()
182
+ for (const path of paths) {
183
+ if (typeof path !== "string" || !STATE_PATH_RE.test(path))
184
+ throw new TypeError(`agent surface: ${label} contains an invalid path`)
185
+ unique.add(path)
186
+ }
187
+ return [...unique]
188
+ }
189
+
190
+ export interface WebMcpToolDefinition {
191
+ readonly name: string
192
+ readonly description: string
193
+ readonly inputSchema: Record<string, unknown>
194
+ readonly annotations: AgentCapability<unknown, unknown>["tool"]["annotations"]
195
+ readonly execute: (input: unknown) => unknown | PromiseLike<unknown>
196
+ }
197
+
198
+ export interface WebMcpExecutionOptions extends Omit<ToolCallOptions, "signal" | "capabilities"> {
199
+ /** Extra capability tokens may be supplied by an embedding host; the tool's own token is always added. */
200
+ readonly capabilities?: readonly string[]
201
+ /** A page-local authorization check. Server authorization remains mandatory for remote writes. */
202
+ readonly authorize?: (input: unknown) => boolean | PromiseLike<boolean>
203
+ /** Return the Nifra receipt envelope (default) or only the typed output on success. */
204
+ readonly resultMode?: "receipt" | "output"
205
+ /** Maximum JSON-encoded output bytes retained in the default receipt. */
206
+ readonly maxReceiptBytes?: number
207
+ }
208
+
209
+ export interface WebMcpReceipt<Output = unknown> {
210
+ readonly ok: boolean
211
+ readonly outcome: "committed" | "dry-run" | "failed" | "denied"
212
+ readonly dryRun: boolean
213
+ readonly output?: Output
214
+ readonly outputTruncated?: true
215
+ readonly error?: { readonly code: ToolError["code"]; readonly stage: ToolError["stage"] }
216
+ readonly evidence: readonly ToolEvidence[]
217
+ }
218
+
219
+ /** Adapt one canonical capability to the proposed WebMCP tool shape. */
220
+ export function toWebMcpTool<Input, Output, State>(
221
+ capability: AgentCapability<Input, Output, State>,
222
+ options: WebMcpExecutionOptions = {},
223
+ ): WebMcpToolDefinition {
224
+ const maxReceiptBytes = validateReceiptLimit(options.maxReceiptBytes)
225
+ const entry = toCatalogEntry(capability.tool)
226
+ const fixedCapabilities = [capability.tool.capability, ...(options.capabilities ?? [])]
227
+ return {
228
+ name: entry.name,
229
+ description: entry.description,
230
+ inputSchema: entry.inputSchema,
231
+ annotations: entry.annotations,
232
+ execute: async (input) => {
233
+ if (options.authorize !== undefined) {
234
+ let authorized = false
235
+ try {
236
+ authorized = await options.authorize(input)
237
+ } catch {
238
+ // A page-local guard is untrusted adapter code. Treat a thrown guard as a denial rather
239
+ // than leaking its exception through the WebMCP host.
240
+ authorized = false
241
+ }
242
+ if (!authorized) return deniedReceipt(options.dryRun === true)
243
+ }
244
+ const result = await executeAgentCapability(capability, input, {
245
+ ...options,
246
+ capabilities: fixedCapabilities,
247
+ })
248
+ const receipt = toReceipt(result, maxReceiptBytes)
249
+ // Raw output mode is explicitly opt-in and exists for hosts that already impose their own
250
+ // response limits. The default receipt path stays bounded.
251
+ if (options.resultMode === "output" && result.ok) return result.output
252
+ return receipt
253
+ },
254
+ }
255
+ }
256
+
257
+ function deniedReceipt(dryRun: boolean): WebMcpReceipt {
258
+ return Object.freeze({
259
+ ok: false,
260
+ outcome: "denied",
261
+ dryRun,
262
+ error: { code: "capability_denied" as const, stage: "capability" as const },
263
+ evidence: Object.freeze([]),
264
+ })
265
+ }
266
+
267
+ /** Execute through Nifra's existing validation, policy, approval, idempotency, and evidence path. */
268
+ export function executeAgentCapability<Input, Output, State>(
269
+ capability: AgentCapability<Input, Output, State>,
270
+ input: unknown,
271
+ options: ToolCallOptions = {},
272
+ ): Promise<ToolCallResult<Output>> {
273
+ return executeTool(capability.tool, input, {
274
+ ...options,
275
+ capabilities: [capability.tool.capability, ...(options.capabilities ?? [])],
276
+ })
277
+ }
278
+
279
+ function toReceipt<Output>(
280
+ result: ToolCallResult<Output>,
281
+ maxReceiptBytes: number,
282
+ ): WebMcpReceipt<Output> {
283
+ if (result.ok) {
284
+ const bounded = boundReceiptOutput(result.output, maxReceiptBytes)
285
+ return Object.freeze({
286
+ ok: true,
287
+ outcome: result.dryRun ? "dry-run" : "committed",
288
+ dryRun: result.dryRun,
289
+ ...(bounded.output === undefined ? {} : { output: bounded.output }),
290
+ ...(bounded.truncated ? { outputTruncated: true as const } : {}),
291
+ evidence: result.evidence,
292
+ })
293
+ }
294
+ return Object.freeze({
295
+ ok: false,
296
+ outcome: "failed",
297
+ dryRun: result.dryRun,
298
+ error: { code: result.error.code, stage: result.error.stage },
299
+ evidence: result.evidence,
300
+ })
301
+ }
302
+
303
+ function validateReceiptLimit(value: number | undefined): number {
304
+ if (value === undefined) return DEFAULT_MAX_RECEIPT_BYTES
305
+ if (!Number.isSafeInteger(value) || value < 256 || value > MAX_RECEIPT_BYTES)
306
+ throw new RangeError(
307
+ `agent surface: maxReceiptBytes must be a safe integer between 256 and ${MAX_RECEIPT_BYTES}`,
308
+ )
309
+ return value
310
+ }
311
+
312
+ function boundReceiptOutput<Output>(
313
+ output: Output | undefined,
314
+ maxBytes: number,
315
+ ): { readonly output?: Output; readonly truncated?: true } {
316
+ if (output === undefined) return {}
317
+ try {
318
+ const encoded = new TextEncoder().encode(JSON.stringify(output))
319
+ return encoded.byteLength <= maxBytes ? { output } : { truncated: true }
320
+ } catch {
321
+ // A non-JSON output is not safe to put in a portable receipt. The typed output mode remains
322
+ // available to an embedding host that has its own serialization contract.
323
+ return { truncated: true }
324
+ }
325
+ }
326
+
327
+ export interface WebMcpModelContext {
328
+ readonly registerTool: (tool: WebMcpToolDefinition) => void | PromiseLike<void>
329
+ readonly unregisterTool?: (name: string) => void | PromiseLike<void>
330
+ }
331
+
332
+ export interface WebMcpTarget {
333
+ readonly modelContext?: WebMcpModelContext
334
+ }
335
+
336
+ export interface WebMcpRegistrationFailure {
337
+ readonly name: string
338
+ readonly error: string
339
+ }
340
+
341
+ export interface WebMcpRegistrationReport {
342
+ readonly supported: boolean
343
+ readonly registered: readonly string[]
344
+ readonly failed: readonly WebMcpRegistrationFailure[]
345
+ readonly cleanup: () => Promise<void>
346
+ }
347
+
348
+ const targetRegistrations = new WeakMap<object, Set<string>>()
349
+
350
+ function defaultTarget(): WebMcpTarget | undefined {
351
+ const page = (globalThis as typeof globalThis & { readonly document?: WebMcpTarget }).document
352
+ return page
353
+ }
354
+
355
+ /** Register an explicit allowlist of capabilities, safely degrading on ordinary browsers. */
356
+ export async function registerWebMcpTools(
357
+ capabilities: readonly AgentCapabilityReference[],
358
+ target: WebMcpTarget | undefined = defaultTarget(),
359
+ options: WebMcpExecutionOptions = {},
360
+ ): Promise<WebMcpRegistrationReport> {
361
+ const context = target?.modelContext
362
+ if (context === undefined || typeof context.registerTool !== "function")
363
+ return Object.freeze({
364
+ supported: false,
365
+ registered: Object.freeze([]),
366
+ failed: Object.freeze([]),
367
+ cleanup: async () => {},
368
+ })
369
+
370
+ const key = context as object
371
+ const existing = targetRegistrations.get(key) ?? new Set<string>()
372
+ targetRegistrations.set(key, existing)
373
+ const registered: string[] = []
374
+ const failed: WebMcpRegistrationFailure[] = []
375
+ for (const capability of capabilities) {
376
+ const name = capability.tool.name
377
+ if (existing.has(name)) {
378
+ failed.push({ name, error: "duplicate tool name is already registered" })
379
+ continue
380
+ }
381
+ // Reserve before awaiting the host. `registerTool` may be asynchronous, and another caller can
382
+ // enter this function while it is pending. Keeping the reservation in the same set as committed
383
+ // names makes duplicate detection atomic from JavaScript's point of view; failures release only
384
+ // their own reservation so a later registration can retry safely.
385
+ existing.add(name)
386
+ try {
387
+ await context.registerTool(
388
+ toWebMcpTool(capability as unknown as AgentCapability<unknown, unknown, unknown>, options),
389
+ )
390
+ registered.push(name)
391
+ } catch (error) {
392
+ existing.delete(name)
393
+ if (existing.size === 0 && targetRegistrations.get(key) === existing)
394
+ targetRegistrations.delete(key)
395
+ failed.push({ name, error: describeError(error) })
396
+ }
397
+ }
398
+ let cleaned = false
399
+ const cleanup = async (): Promise<void> => {
400
+ if (cleaned) return
401
+ cleaned = true
402
+ if (context.unregisterTool === undefined) return
403
+ for (const name of [...registered].reverse()) {
404
+ try {
405
+ await context.unregisterTool(name)
406
+ existing.delete(name)
407
+ } catch {
408
+ // Cleanup is best effort; a host failure must not break the application.
409
+ }
410
+ }
411
+ if (existing.size === 0 && targetRegistrations.get(key) === existing)
412
+ targetRegistrations.delete(key)
413
+ }
414
+ return Object.freeze({
415
+ supported: true,
416
+ registered: Object.freeze([...registered]),
417
+ failed: Object.freeze([...failed]),
418
+ cleanup,
419
+ })
420
+ }
421
+
422
+ function describeError(error: unknown): string {
423
+ const message = error instanceof Error ? error.message : String(error)
424
+ return message.length <= MAX_REGISTRATION_ERROR_LENGTH
425
+ ? message
426
+ : `${message.slice(0, MAX_REGISTRATION_ERROR_LENGTH - 3)}...`
427
+ }
428
+
429
+ export interface PredictionStoreSnapshot<State> extends AgentSurfaceSnapshot<State> {
430
+ readonly authoritativeVersion: string
431
+ readonly activePredictionIds: readonly string[]
432
+ }
433
+
434
+ export type PredictionStoreEvent<State> =
435
+ | {
436
+ readonly type: "predicted"
437
+ readonly predictionId: string
438
+ readonly snapshot: PredictionStoreSnapshot<State>
439
+ }
440
+ | {
441
+ readonly type: "committed"
442
+ readonly predictionId: string
443
+ readonly snapshot: PredictionStoreSnapshot<State>
444
+ }
445
+ | {
446
+ readonly type: "rolled-back"
447
+ readonly predictionId: string
448
+ readonly snapshot: PredictionStoreSnapshot<State>
449
+ }
450
+ | {
451
+ readonly type: "conflicted"
452
+ readonly predictionId: string
453
+ readonly snapshot: PredictionStoreSnapshot<State>
454
+ }
455
+ | {
456
+ readonly type: "expired"
457
+ readonly predictionId: string
458
+ readonly snapshot: PredictionStoreSnapshot<State>
459
+ }
460
+
461
+ export type PredictionStoreResult<State> = {
462
+ readonly outcome:
463
+ | "predicted"
464
+ | "committed"
465
+ | "rolled-back"
466
+ | "conflicted"
467
+ | "expired"
468
+ | "stale"
469
+ | "invalid"
470
+ | "missing"
471
+ readonly predictionId: string
472
+ readonly snapshot: PredictionStoreSnapshot<State>
473
+ }
474
+
475
+ export interface PredictionStore<State> {
476
+ readonly snapshot: () => PredictionStoreSnapshot<State>
477
+ readonly predict: (
478
+ predictionId: string,
479
+ prediction: AgentSurfacePrediction,
480
+ now?: number,
481
+ ) => PredictionStoreResult<State>
482
+ readonly commit: (
483
+ predictionId: string,
484
+ authoritative: AgentSurfaceAuthoritativeState<State>,
485
+ now?: number,
486
+ ) => PredictionStoreResult<State>
487
+ readonly rollback: (predictionId: string) => PredictionStoreResult<State>
488
+ readonly expire: (now?: number) => readonly string[]
489
+ readonly subscribe: (listener: (event: PredictionStoreEvent<State>) => void) => () => void
490
+ }
491
+
492
+ interface StoredPrediction {
493
+ readonly id: string
494
+ readonly prediction: AgentSurfacePrediction
495
+ }
496
+
497
+ /**
498
+ * An atomic, defensive prediction store. Predictions are overlays over authoritative state; a server
499
+ * commit replaces the base and remaining overlays are replayed or conflict-dropped.
500
+ */
501
+ export function createPredictionStore<State>(
502
+ initial: AgentSurfaceAuthoritativeState<State>,
503
+ ): PredictionStore<State> {
504
+ let authoritative = cloneValue(initial, "initial state")
505
+ validateVersion(initial.version)
506
+ const active = new Map<string, StoredPrediction>()
507
+ const conflicted = new Map<string, true>()
508
+ const order: string[] = []
509
+ const listeners = new Set<(event: PredictionStoreEvent<State>) => void>()
510
+
511
+ const removeActive = (id: string): void => {
512
+ active.delete(id)
513
+ const index = order.indexOf(id)
514
+ if (index !== -1) order.splice(index, 1)
515
+ }
516
+
517
+ const rememberConflict = (id: string): void => {
518
+ conflicted.delete(id)
519
+ if (conflicted.size >= MAX_ACTIVE_PREDICTIONS) {
520
+ const oldest = conflicted.keys().next().value
521
+ if (oldest !== undefined) conflicted.delete(oldest)
522
+ }
523
+ conflicted.set(id, true)
524
+ }
525
+
526
+ const snapshot = (): PredictionStoreSnapshot<State> => {
527
+ let state = cloneValue(authoritative.state, "authoritative state")
528
+ const invalid: string[] = []
529
+ for (const id of [...order]) {
530
+ const stored = active.get(id)
531
+ if (stored === undefined) continue
532
+ try {
533
+ state = applyPredictionPatch(state, stored.prediction.patch)
534
+ } catch {
535
+ invalid.push(id)
536
+ }
537
+ }
538
+ for (const id of invalid) {
539
+ removeActive(id)
540
+ rememberConflict(id)
541
+ }
542
+ return Object.freeze({
543
+ state,
544
+ version: authoritative.version,
545
+ authoritativeVersion: authoritative.version,
546
+ activePredictionIds: Object.freeze(order.filter((id) => active.has(id))),
547
+ })
548
+ }
549
+
550
+ const emit = (event: PredictionStoreEvent<State>): void => {
551
+ for (const listener of listeners) {
552
+ try {
553
+ listener(event)
554
+ } catch {
555
+ // Observer failures cannot change the state machine.
556
+ }
557
+ }
558
+ }
559
+
560
+ const predict = (
561
+ predictionId: string,
562
+ prediction: AgentSurfacePrediction,
563
+ now = Date.now(),
564
+ ): PredictionStoreResult<State> => {
565
+ validateIdentifier(predictionId, "prediction id")
566
+ validatePrediction(prediction)
567
+ const current = snapshot()
568
+ if (active.has(predictionId) || conflicted.has(predictionId))
569
+ return { outcome: "invalid", predictionId, snapshot: current }
570
+ if (active.size >= MAX_ACTIVE_PREDICTIONS)
571
+ return { outcome: "invalid", predictionId, snapshot: current }
572
+ if (prediction.baseVersion !== authoritative.version)
573
+ return { outcome: "stale", predictionId, snapshot: current }
574
+ if (prediction.expiresAt !== undefined && prediction.expiresAt <= now)
575
+ return { outcome: "expired", predictionId, snapshot: current }
576
+ const candidate = applyPredictionPatch(current.state, prediction.patch)
577
+ // Clone before storing so caller mutation after predict cannot change the overlay.
578
+ const stored: StoredPrediction = {
579
+ id: predictionId,
580
+ prediction: Object.freeze({
581
+ baseVersion: prediction.baseVersion,
582
+ patch: Object.freeze(prediction.patch.map((op) => clonePatchOperation(op))),
583
+ ...(prediction.expiresAt === undefined ? {} : { expiresAt: prediction.expiresAt }),
584
+ }),
585
+ }
586
+ active.set(predictionId, stored)
587
+ order.push(predictionId)
588
+ const next = snapshot()
589
+ // This should be impossible after candidate was applied; retain the guard as an atomicity check.
590
+ if (!deepEqual(candidate, next.state)) {
591
+ removeActive(predictionId)
592
+ return { outcome: "invalid", predictionId, snapshot: snapshot() }
593
+ }
594
+ const result = { outcome: "predicted" as const, predictionId, snapshot: next }
595
+ emit({ type: "predicted", predictionId, snapshot: next })
596
+ return result
597
+ }
598
+
599
+ const discardUnreplayablePredictions = (): readonly string[] => {
600
+ const discarded: string[] = []
601
+ let projected = cloneValue(authoritative.state, "authoritative state")
602
+ for (const id of [...order]) {
603
+ const stored = active.get(id)
604
+ if (stored === undefined) continue
605
+ if (stored.prediction.baseVersion !== authoritative.version) {
606
+ removeActive(id)
607
+ rememberConflict(id)
608
+ discarded.push(id)
609
+ continue
610
+ }
611
+ try {
612
+ projected = applyPredictionPatch(projected, stored.prediction.patch)
613
+ } catch {
614
+ removeActive(id)
615
+ rememberConflict(id)
616
+ discarded.push(id)
617
+ }
618
+ }
619
+ return Object.freeze(discarded)
620
+ }
621
+
622
+ const commit = (
623
+ predictionId: string,
624
+ nextAuthoritative: AgentSurfaceAuthoritativeState<State>,
625
+ now = Date.now(),
626
+ ): PredictionStoreResult<State> => {
627
+ validateIdentifier(predictionId, "prediction id")
628
+ const stored = active.get(predictionId)
629
+ const current = snapshot()
630
+ if (stored === undefined) {
631
+ if (conflicted.delete(predictionId))
632
+ return { outcome: "conflicted", predictionId, snapshot: current }
633
+ return { outcome: "missing", predictionId, snapshot: current }
634
+ }
635
+ if (stored.prediction.expiresAt !== undefined && stored.prediction.expiresAt <= now) {
636
+ removeActive(predictionId)
637
+ const expired = snapshot()
638
+ emit({ type: "expired", predictionId, snapshot: expired })
639
+ return { outcome: "expired", predictionId, snapshot: expired }
640
+ }
641
+ if (stored.prediction.baseVersion !== authoritative.version) {
642
+ removeActive(predictionId)
643
+ rememberConflict(predictionId)
644
+ const conflicted = snapshot()
645
+ emit({ type: "conflicted", predictionId, snapshot: conflicted })
646
+ return { outcome: "conflicted", predictionId, snapshot: conflicted }
647
+ }
648
+ validateVersion(nextAuthoritative.version)
649
+ const nextState = cloneValue(nextAuthoritative.state, "authoritative state")
650
+ removeActive(predictionId)
651
+ authoritative = { state: nextState, version: nextAuthoritative.version }
652
+ const discarded = discardUnreplayablePredictions()
653
+ const committed = snapshot()
654
+ emit({ type: "committed", predictionId, snapshot: committed })
655
+ for (const id of discarded) emit({ type: "conflicted", predictionId: id, snapshot: committed })
656
+ return { outcome: "committed", predictionId, snapshot: committed }
657
+ }
658
+
659
+ const rollback = (predictionId: string): PredictionStoreResult<State> => {
660
+ validateIdentifier(predictionId, "prediction id")
661
+ if (!active.has(predictionId)) return { outcome: "missing", predictionId, snapshot: snapshot() }
662
+ removeActive(predictionId)
663
+ const rolledBack = snapshot()
664
+ emit({ type: "rolled-back", predictionId, snapshot: rolledBack })
665
+ return { outcome: "rolled-back", predictionId, snapshot: rolledBack }
666
+ }
667
+
668
+ const expire = (now = Date.now()): readonly string[] => {
669
+ const expired: string[] = []
670
+ for (const id of [...order]) {
671
+ const stored = active.get(id)
672
+ if (stored?.prediction.expiresAt !== undefined && stored.prediction.expiresAt <= now) {
673
+ removeActive(id)
674
+ expired.push(id)
675
+ emit({ type: "expired", predictionId: id, snapshot: snapshot() })
676
+ }
677
+ }
678
+ return Object.freeze(expired)
679
+ }
680
+
681
+ return {
682
+ snapshot,
683
+ predict,
684
+ commit,
685
+ rollback,
686
+ expire,
687
+ subscribe(listener) {
688
+ listeners.add(listener)
689
+ return () => listeners.delete(listener)
690
+ },
691
+ }
692
+ }
693
+
694
+ export interface PredictedExecutionResult<Output, State> {
695
+ readonly outcome:
696
+ | "committed"
697
+ | "rolled-back"
698
+ | "conflicted"
699
+ | "expired"
700
+ | "stale"
701
+ | "invalid"
702
+ | "executed"
703
+ readonly predictionId?: string
704
+ readonly prediction?: AgentSurfacePrediction
705
+ readonly tool?: ToolCallResult<Output>
706
+ readonly snapshot: PredictionStoreSnapshot<State>
707
+ }
708
+
709
+ export interface ExecutePredictedOptions extends Omit<ToolCallOptions, "capabilities"> {
710
+ readonly capabilities?: readonly string[]
711
+ readonly predictionId?: string
712
+ readonly now?: () => number
713
+ }
714
+
715
+ /** Apply a pure prediction, execute the core tool, then commit or roll it back atomically. */
716
+ export async function executePredicted<Input, Output, State>(
717
+ capability: AgentCapability<Input, Output, State>,
718
+ input: unknown,
719
+ store: PredictionStore<State>,
720
+ options: ExecutePredictedOptions = {},
721
+ ): Promise<PredictedExecutionResult<Output, State>> {
722
+ const now = options.now ?? Date.now
723
+ const validated = await validateStandard(capability.tool.input, input)
724
+ if (options.signal?.aborted) return { outcome: "rolled-back", snapshot: store.snapshot() }
725
+ if (!validated.ok) {
726
+ const tool = await executeAgentCapability(capability, input, options)
727
+ return {
728
+ outcome: "executed",
729
+ tool,
730
+ snapshot: store.snapshot(),
731
+ }
732
+ }
733
+ if (capability.predict === undefined) {
734
+ const tool = await executeAgentCapability(capability, input, options)
735
+ return {
736
+ outcome: tool.ok ? "executed" : "rolled-back",
737
+ tool,
738
+ snapshot: store.snapshot(),
739
+ }
740
+ }
741
+ if (capability.reconcile === undefined)
742
+ throw new TypeError("agent surface: prediction capability has no reconciler")
743
+ const before = store.snapshot()
744
+ const predictionInput = cloneValue(validated.value, "prediction input")
745
+ const prediction = await capability.predict({
746
+ input: predictionInput,
747
+ snapshot: before.state,
748
+ version: before.version,
749
+ now: now(),
750
+ })
751
+ if (options.signal?.aborted) return { outcome: "rolled-back", snapshot: store.snapshot() }
752
+ const predictionId = options.predictionId ?? crypto.randomUUID()
753
+ const proposed = store.predict(predictionId, prediction, now())
754
+ if (proposed.outcome !== "predicted")
755
+ return {
756
+ outcome: proposed.outcome === "missing" ? "invalid" : proposed.outcome,
757
+ predictionId,
758
+ prediction,
759
+ snapshot: proposed.snapshot,
760
+ }
761
+ const tool = await executeAgentCapability(capability, input, options)
762
+ if (!tool.ok || options.signal?.aborted) {
763
+ const rolledBack = store.rollback(predictionId)
764
+ return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot }
765
+ }
766
+ let authoritative: AgentSurfaceAuthoritativeState<State>
767
+ try {
768
+ authoritative = await capability.reconcile({
769
+ input: cloneValue(predictionInput, "reconciliation input"),
770
+ output: cloneValue(tool.output as Output, "reconciliation output"),
771
+ previous: before,
772
+ prediction,
773
+ now: now(),
774
+ })
775
+ } catch {
776
+ const rolledBack = store.rollback(predictionId)
777
+ return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot }
778
+ }
779
+ if (options.signal?.aborted) {
780
+ const rolledBack = store.rollback(predictionId)
781
+ return { outcome: "rolled-back", predictionId, prediction, tool, snapshot: rolledBack.snapshot }
782
+ }
783
+ const committed = store.commit(predictionId, authoritative, now())
784
+ return {
785
+ outcome:
786
+ committed.outcome === "committed"
787
+ ? "committed"
788
+ : committed.outcome === "expired"
789
+ ? "expired"
790
+ : "conflicted",
791
+ predictionId,
792
+ prediction,
793
+ tool,
794
+ snapshot: committed.snapshot,
795
+ }
796
+ }
797
+
798
+ export interface AgentSurfaceConformanceCase {
799
+ readonly capability: AgentCapabilityReference
800
+ readonly input: unknown
801
+ readonly assert?: (result: unknown) => void | PromiseLike<void>
802
+ }
803
+
804
+ export interface AgentSurfaceConformanceOptions {
805
+ readonly target?: WebMcpTarget
806
+ readonly registration?: WebMcpExecutionOptions
807
+ readonly cases?: readonly AgentSurfaceConformanceCase[]
808
+ }
809
+
810
+ export interface AgentSurfaceConformanceResult {
811
+ readonly supported: boolean
812
+ readonly registered: readonly string[]
813
+ readonly failed: readonly WebMcpRegistrationFailure[]
814
+ readonly checks: readonly string[]
815
+ }
816
+
817
+ /** Run deterministic host-independent checks over the WebMCP projection and optional calls. */
818
+ export async function runAgentSurfaceConformance(
819
+ capabilities: readonly AgentCapabilityReference[],
820
+ options: AgentSurfaceConformanceOptions = {},
821
+ ): Promise<AgentSurfaceConformanceResult> {
822
+ const checks: string[] = []
823
+ const names = new Set<string>()
824
+ for (const capability of capabilities) {
825
+ if (names.has(capability.tool.name))
826
+ throw new Error(`agent surface conformance: duplicate ${capability.tool.name}`)
827
+ names.add(capability.tool.name)
828
+ const tool = toWebMcpTool(capability as unknown as AgentCapability<unknown, unknown, unknown>)
829
+ if (tool.name !== capability.tool.name || typeof tool.execute !== "function")
830
+ throw new Error(`agent surface conformance: invalid descriptor ${capability.tool.name}`)
831
+ }
832
+ checks.push("unique standard descriptors")
833
+ const report = await registerWebMcpTools(capabilities, options.target, options.registration)
834
+ if (!report.supported) {
835
+ checks.push("unsupported host degrades safely")
836
+ return Object.freeze({
837
+ supported: false,
838
+ registered: report.registered,
839
+ failed: report.failed,
840
+ checks: Object.freeze(checks),
841
+ })
842
+ }
843
+ try {
844
+ checks.push("host registration")
845
+ const byName = new Map(capabilities.map((capability) => [capability.tool.name, capability]))
846
+ for (const testCase of options.cases ?? []) {
847
+ const capability = byName.get(testCase.capability.tool.name)
848
+ if (capability === undefined)
849
+ throw new Error(`agent surface conformance: unknown case ${testCase.capability.tool.name}`)
850
+ const result = await toWebMcpTool(
851
+ capability as unknown as AgentCapability<unknown, unknown, unknown>,
852
+ options.registration,
853
+ ).execute(testCase.input)
854
+ await testCase.assert?.(result)
855
+ checks.push(`call ${capability.tool.name}`)
856
+ }
857
+ } finally {
858
+ await report.cleanup()
859
+ }
860
+ return Object.freeze({
861
+ supported: true,
862
+ registered: report.registered,
863
+ failed: report.failed,
864
+ checks: Object.freeze(checks),
865
+ })
866
+ }
867
+
868
+ function validateIdentifier(value: string, label: string): void {
869
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID_LENGTH)
870
+ throw new TypeError(`agent surface: ${label} is invalid`)
871
+ }
872
+
873
+ function validateVersion(version: string): void {
874
+ if (typeof version !== "string" || !VERSION_RE.test(version))
875
+ throw new TypeError("agent surface: version is invalid")
876
+ }
877
+
878
+ function validatePrediction(prediction: AgentSurfacePrediction): void {
879
+ validateVersion(prediction.baseVersion)
880
+ if (!Array.isArray(prediction.patch) || prediction.patch.length > MAX_PATCH_OPERATIONS)
881
+ throw new TypeError("agent surface: prediction patch is invalid")
882
+ if (prediction.expiresAt !== undefined && !Number.isSafeInteger(prediction.expiresAt))
883
+ throw new TypeError("agent surface: prediction expiry is invalid")
884
+ for (const operation of prediction.patch) {
885
+ if (operation === null || typeof operation !== "object")
886
+ throw new TypeError("agent surface: prediction operation is invalid")
887
+ if (operation.op !== "add" && operation.op !== "replace" && operation.op !== "remove")
888
+ throw new TypeError("agent surface: prediction operation is unsupported")
889
+ if (typeof operation.path !== "string" || operation.path.length > MAX_PATH_LENGTH)
890
+ throw new TypeError("agent surface: prediction path is invalid")
891
+ decodePointer(operation.path)
892
+ }
893
+ }
894
+
895
+ function cloneValue<T>(value: T, label: string): T {
896
+ try {
897
+ return structuredClone(value)
898
+ } catch {
899
+ throw new TypeError(`agent surface: ${label} must be structured-cloneable`)
900
+ }
901
+ }
902
+
903
+ function clonePatchOperation(operation: PredictionPatchOperation): PredictionPatchOperation {
904
+ if (operation.op === "remove") return { op: "remove", path: operation.path }
905
+ return {
906
+ op: operation.op,
907
+ path: operation.path,
908
+ value: clonePatchValue(operation.value),
909
+ }
910
+ }
911
+
912
+ function clonePatchValue<T>(value: T): T {
913
+ try {
914
+ const cloned = structuredClone(value)
915
+ const encoded = JSON.stringify(cloned)
916
+ if (encoded === undefined)
917
+ throw new TypeError("agent surface: patch value must be JSON-serializable")
918
+ if (new TextEncoder().encode(encoded).byteLength > MAX_PATCH_VALUE_BYTES)
919
+ throw new RangeError("agent surface: patch value exceeds its size limit")
920
+ return cloned
921
+ } catch (error) {
922
+ if (error instanceof RangeError || error instanceof TypeError) throw error
923
+ throw new TypeError("agent surface: patch value must be structured-cloneable JSON")
924
+ }
925
+ }
926
+
927
+ function deepEqual(a: unknown, b: unknown): boolean {
928
+ try {
929
+ return JSON.stringify(a) === JSON.stringify(b)
930
+ } catch {
931
+ return false
932
+ }
933
+ }
934
+
935
+ function decodePointer(path: string): readonly string[] {
936
+ if (path === "") return Object.freeze([])
937
+ if (!path.startsWith("/")) throw new TypeError("agent surface: patch path must be a JSON Pointer")
938
+ const segments = path
939
+ .slice(1)
940
+ .split("/")
941
+ .map((segment) => {
942
+ for (let index = 0; index < segment.length; index += 1) {
943
+ if (segment[index] === "~" && segment[index + 1] !== "0" && segment[index + 1] !== "1")
944
+ throw new TypeError("agent surface: patch pointer escape is invalid")
945
+ }
946
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~")
947
+ })
948
+ if (segments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment)))
949
+ throw new TypeError("agent surface: prototype path is forbidden")
950
+ return Object.freeze(segments)
951
+ }
952
+
953
+ function isRecord(value: unknown): value is Record<string, unknown> {
954
+ return typeof value === "object" && value !== null && !Array.isArray(value)
955
+ }
956
+
957
+ function applyPredictionPatch<State>(
958
+ state: State,
959
+ operations: readonly PredictionPatchOperation[],
960
+ ): State {
961
+ let current: unknown = cloneValue(state, "prediction state")
962
+ for (const operation of operations) current = applyPredictionOperation(current, operation)
963
+ return current as State
964
+ }
965
+
966
+ function applyPredictionOperation(document: unknown, operation: PredictionPatchOperation): unknown {
967
+ const segments = decodePointer(operation.path)
968
+ if (segments.length === 0) {
969
+ if (operation.op === "remove") throw new TypeError("agent surface: root removal is forbidden")
970
+ return clonePatchValue(operation.value)
971
+ }
972
+ const parentSegments = segments.slice(0, -1)
973
+ const leaf = segments[segments.length - 1]!
974
+ const parent = readPointer(document, parentSegments)
975
+ if (Array.isArray(parent)) {
976
+ if (operation.op === "add") {
977
+ if (leaf === "-") parent.push(clonePatchValue(operation.value))
978
+ else {
979
+ const index = arrayIndex(leaf, parent.length, true)
980
+ parent.splice(index, 0, clonePatchValue(operation.value))
981
+ }
982
+ return document
983
+ }
984
+ const index = arrayIndex(leaf, parent.length, false)
985
+ if (operation.op === "replace") parent[index] = clonePatchValue(operation.value)
986
+ else parent.splice(index, 1)
987
+ return document
988
+ }
989
+ if (!isRecord(parent)) throw new TypeError("agent surface: patch parent is not an object")
990
+ if (operation.op === "replace" && !Object.hasOwn(parent, leaf))
991
+ throw new TypeError("agent surface: replace target is missing")
992
+ if (operation.op === "remove" && !Object.hasOwn(parent, leaf))
993
+ throw new TypeError("agent surface: remove target is missing")
994
+ if (operation.op === "remove") delete parent[leaf]
995
+ else parent[leaf] = clonePatchValue(operation.value)
996
+ return document
997
+ }
998
+
999
+ function readPointer(document: unknown, segments: readonly string[]): unknown {
1000
+ let current = document
1001
+ for (const segment of segments) {
1002
+ if (Array.isArray(current)) current = current[arrayIndex(segment, current.length, false)]
1003
+ else if (isRecord(current) && Object.hasOwn(current, segment)) current = current[segment]
1004
+ else throw new TypeError("agent surface: patch parent is missing")
1005
+ }
1006
+ return current
1007
+ }
1008
+
1009
+ function arrayIndex(segment: string, length: number, allowEnd: boolean): number {
1010
+ if (!/^(?:0|[1-9]\d*)$/.test(segment))
1011
+ throw new TypeError("agent surface: array index is invalid")
1012
+ const index = Number(segment)
1013
+ if (
1014
+ !Number.isSafeInteger(index) ||
1015
+ index < 0 ||
1016
+ index > length ||
1017
+ (!allowEnd && index === length)
1018
+ )
1019
+ throw new TypeError("agent surface: array index is out of bounds")
1020
+ return index
1021
+ }
1022
+
1023
+ export type {
1024
+ ToolApproval,
1025
+ ToolCallOptions,
1026
+ ToolCallResult,
1027
+ ToolContract,
1028
+ ToolContractOptions,
1029
+ ToolError,
1030
+ ToolEvidence,
1031
+ }