@orkestrel/scaffold 0.0.69 → 0.0.71

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.
@@ -911,22 +911,38 @@ resemblance to one.
911
911
 
912
912
  ### Configure modern subscriptions
913
913
 
914
- `subscription.notifications` declares what the server can actually honour;
915
- `subscription.producer` opens the event-driven source for the intersected filter.
916
- The built-in owns wire acknowledgement, filtering, id stamping, and graceful
917
- closure. A producer only yields project notifications and ends its iterable when
918
- the source closes; while idle it parks on its own events and may observe the
919
- supplied abort signal.
920
-
921
- **Every produced notification is owned before it is judged.** The built-in snapshots each
922
- one into bounded exact JSON before it matches the filter or stamps the id, so the values
923
- that admitted a notification are the values that reach the wire — a producer answering
924
- differently on a second read cannot have one URI pass the filter and another ride out.
925
- A notification that is not bounded exact JSON is dropped and the stream continues; a
926
- producer that throws ends the subscription with one detail-free `-32603` terminal, its
927
- caught value reported on the server's `error` event. Ending the source normally closes
928
- with the complete result; an abort closes with no terminal at all, because a cancelled
929
- request is not an answered one.
914
+ The server supplies `toolsListChanged` from the tool registry's `add`, `remove`, and `clear`
915
+ events. It registers the listeners before acknowledging the stream and releases them when
916
+ `options.signal` aborts. Registry destruction delivers its final `clear`; the stream then
917
+ stays open until its signal aborts. An already-destroyed registry registers no listeners; the
918
+ server acknowledges the subscription, produces no tools frames, and waits for its signal. A
919
+ stream read between changes receives one frame per change; changes that arrive together, or
920
+ while the previous frame is unread, coalesce into one.
921
+
922
+ `subscription.notifications` declares the consumer's supported families through
923
+ `MCPConsumerFilter`, whose optional `toolsListChanged` member accepts only `false`;
924
+ `subscription.producer` opens the event-driven source for the intersected filter, beside the
925
+ built-in tools producer. The consumer producer advances on the stream's demand. A producer
926
+ failure terminates the stream after the notifications it already produced. A consumer filter
927
+ that is malformed, and one claiming `toolsListChanged: true`, are both invalid: construction
928
+ throws `MCPError` with `JSONRPC_INVALID_PARAMS` (`-32602`). Consumer-produced tools
929
+ notifications are dropped, so the tools family has only the registry as its source. The
930
+ built-in owns wire acknowledgement, filtering, id stamping, and graceful closure. A producer
931
+ only yields project notifications and ends its iterable when the source closes; while idle it
932
+ parks on its own events and may observe the supplied abort signal. Releasing the producer
933
+ cancels its source with that signal's abort reason, so a `ReadableStream` source sees its
934
+ pending writes reject with the reason the request was aborted with.
935
+
936
+ **Every produced notification is owned before it is judged.** The built-in snapshots each one
937
+ into bounded exact JSON before it matches the filter or stamps the id, so the values that
938
+ admitted a notification are the values that reach the wire — a producer answering differently
939
+ on a second read cannot have one URI pass the filter and another ride out. A notification
940
+ that is not bounded exact JSON is dropped and the stream continues; a producer that throws
941
+ ends the subscription after queued notifications with one detail-free `-32603` terminal, its
942
+ caught value reported on the server's `error` event. Ending the consumer source normally
943
+ closes with the complete result only when the honoured filter omits `toolsListChanged`. A
944
+ stream honouring the tools family stays open until failure or signal abort. An abort closes
945
+ with no terminal at all, because a cancelled request is not an answered one.
930
946
 
931
947
  #### Configure the subscription producer
932
948
 
@@ -946,6 +962,7 @@ import {
946
962
  buildSubscriptionFilter,
947
963
  buildSubscriptionResult,
948
964
  createMCPServer,
965
+ isMCPConsumerFilter,
949
966
  isMCPSubscriptionFilter,
950
967
  matchesSubscriptionNotification,
951
968
  stampSubscriptionNotification,
@@ -953,11 +970,13 @@ import {
953
970
  import { createToolManager } from '@orkestrel/tool'
954
971
 
955
972
  const identity = { name: 'docs', version: '1.0.0' }
956
- const supported = { toolsListChanged: true, resourceSubscriptions: ['resource://guide'] }
957
- const input: unknown = { toolsListChanged: true, promptsListChanged: true }
973
+ const supported = { promptsListChanged: true, resourceSubscriptions: ['resource://guide'] }
974
+ isMCPConsumerFilter(supported) // true
975
+ isMCPConsumerFilter({ toolsListChanged: true }) // false — the server owns this family
976
+ const input: unknown = { promptsListChanged: true, resourcesListChanged: true }
958
977
  if (!isMCPSubscriptionFilter(input)) throw new Error('invalid filter')
959
978
  const honoured = buildSubscriptionFilter(input, supported)
960
- const event: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/tools/list_changed' }
979
+ const event: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/prompts/list_changed' }
961
980
  matchesSubscriptionNotification(event, honoured) // true
962
981
  stampSubscriptionNotification(event, 'listen-1') // every delivery carries the reserved id
963
982
  buildSubscriptionAcknowledgement(honoured, 'listen-1') // the first id-carrying message
@@ -1114,7 +1133,7 @@ await client.connect()
1114
1133
 
1115
1134
  const subscription = new AbortController()
1116
1135
  const stream = client.listen(
1117
- { toolsListChanged: true, resourceSubscriptions: ['resource://guide'] },
1136
+ { promptsListChanged: true, resourceSubscriptions: ['resource://guide'] },
1118
1137
  { signal: subscription.signal, capacity: 16 },
1119
1138
  )
1120
1139
 
@@ -1146,7 +1165,11 @@ subscription.abort()
1146
1165
 
1147
1166
  `MCPServerOptions.execution` is the explicit modern execution port over the live
1148
1167
  `ToolManagerInterface`. Its input contains the original `request`, canonical `call`,
1149
- real `tools` manager, effective `signal`, and an optional `progress` reporter. Returning
1168
+ real `tools` manager, effective `signal`, optional `caller`, and an optional `progress` reporter.
1169
+ The default path calls `tools.execute(call, { signal, caller })`; caller identity is omitted
1170
+ when absent and never placed on the JSON call envelope. A delegating handler calls
1171
+ `tools.execute(context.call, { signal: context.signal, ...(context.caller === undefined ? {} : { caller: context.caller }) })`.
1172
+ A tool handler observes `context.signal` to stop work when its request is cancelled. Returning
1150
1173
  a `ToolResult` uses the normal text/structured normalization; returning a validated
1151
1174
  `MCPCallResult` preserves exact text, image, audio, resource-link, and embedded-resource
1152
1175
  content without guessing from an ordinary domain value.
@@ -2254,6 +2277,7 @@ A `Shape` cell holds the constant's declared type.
2254
2277
  | `isMCPContent` | function | Determines whether a value is one exact dated-schema MCP tool content block. |
2255
2278
  | `isMCPPaginationParams` | function | Determines whether a value carries the shared optional pagination cursor. |
2256
2279
  | `isMCPResource` | function | Determines whether a value is one `resources/list` descriptor. |
2280
+ | `isMCPToolAnnotations` | function | Checks whether a value carries valid consumed MCP 2026-07-28 tool annotation hints. |
2257
2281
  | `isMCPResourceTemplate` | function | Determines whether a value is one resource-template descriptor. |
2258
2282
  | `isMCPResourceContents` | function | Determines whether a value is structurally discriminated resource contents. |
2259
2283
  | `isMCPResourcePage` | function | Determines whether a value is one consumer-owned resource page. |
@@ -2287,6 +2311,7 @@ A `Shape` cell holds the constant's declared type.
2287
2311
  | `isInitializeRequest` | function | Determines whether a parsed value is an MCP `initialize` invocation. |
2288
2312
  | `isMCPVersion` | function | Determines whether a value is a supported `MCPVersion`. |
2289
2313
  | `isMCPSubscriptionFilter` | function | Determines whether a value is an MCP `MCPSubscriptionFilter`. |
2314
+ | `isMCPConsumerFilter` | function | Checks whether a subscription filter leaves the built-in tools family to the server. |
2290
2315
  | `isMCPSubscriptionResult` | function | Determines whether a value is a graceful `subscriptions/listen` result. |
2291
2316
  | `supportsFormElicitation` | function | Determines whether a client capability record declares form-mode elicitation. |
2292
2317
  | `isMCPElicitFieldSchema` | function | Determines whether a value is one restricted primitive form-elicitation schema. |
@@ -2326,6 +2351,8 @@ A `Shape` cell holds the constant's declared type.
2326
2351
  | `buildJSONRPCError` | function | Builds a JSON-RPC error `JSONRPCErrorResponse` — the `id` echoed, the failure as an `error` object. |
2327
2352
  | `buildMethodOptions` | function | Resolves the caller-facing dispatch options into the options a dispatched method receives. |
2328
2353
  | `buildToolDescriptors` | function | Maps a `ToolManagerInterface`'s definitions to MCP `tools/list` descriptors — renaming `parameters` to the wire's `inputSchema`. |
2354
+ | `toolAnnotationsToMCP` | function | Projects domain tool annotations onto MCP wire hints without inventing defaults. |
2355
+ | `mcpAnnotationsToTool` | function | Projects MCP wire hints onto domain tool annotations without inventing defaults. |
2329
2356
  | `buildToolCall` | function | Builds the canonical Tool call for one validated MCP `tools/call` request. |
2330
2357
  | `buildProgressNotification` | function | Builds one official progress notification for the original request stream. |
2331
2358
  | `buildCancelledNotification` | function | Builds one official cancellation notification for a request already sent. |
@@ -2368,6 +2395,20 @@ A `Shape` cell holds the constant's declared type.
2368
2395
  | `readEventStream` | function | Decodes a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it carried — the client-side inverse of a server's Streamable-HTTP SSE response. |
2369
2396
  | `buildResponseError` | function | Builds the error for a non-success HTTP response that carried no JSON-RPC message. |
2370
2397
 
2398
+ ### Project tool annotation hints
2399
+
2400
+ The projection keeps explicit false values and omits hints with no counterpart.
2401
+
2402
+ ```ts
2403
+ import { toolAnnotationsToMCP, mcpAnnotationsToTool, isMCPToolAnnotations } from '@orkestrel/mcp'
2404
+
2405
+ toolAnnotationsToMCP({ pure: true, consequential: false, untrusted: true })
2406
+ // { readOnlyHint: true, destructiveHint: false }
2407
+ mcpAnnotationsToTool({ readOnlyHint: false, destructiveHint: true })
2408
+ // { pure: false, consequential: true }
2409
+ isMCPToolAnnotations({ readOnlyHint: false }) // true
2410
+ ```
2411
+
2371
2412
  ### Types
2372
2413
 
2373
2414
  A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
@@ -2481,16 +2522,18 @@ An extended interface's name comes before `plus`, with the members it adds after
2481
2522
  | `MCPProgress` | interface | `{ progress, total?, message? }` | Represents one official request-scoped progress payload. |
2482
2523
  | `MCPProgressInterface` | interface | `{} plus report` | Reports request-scoped progress under backpressure — the reporter supplied to an explicit executor. |
2483
2524
  | `MCPProgressOwnerInterface` | interface | `MCPProgressInterface plus take, stop` | Represents the owning half of one progress slot — `MCPProgressInterface` plus the consuming and stopping the slot's owner performs. |
2484
- | `MCPExecutionContext` | interface | `{ request, call, tools, signal, progress? }` | Represents the explicit, host-neutral context for one modern tool execution. |
2525
+ | `MCPExecutionContext` | interface | `{ request, call, tools, signal, caller?, progress? }` | Represents the explicit, host-neutral context for one modern tool execution. |
2485
2526
  | `MCPExecutionHandler` | type | `(context: MCPExecutionContext,) => ToolResult \| MCPCallResult \| Promise<ToolResult \| MCPCallResult>` | Executes one canonical tool call or returns a fully formed complete MCP result. |
2486
2527
  | `MCPListResult` | type | `{ tools, resultType: 'complete', ttlMs, cacheScope: 'public' \| 'private', _meta? }` | Represents the MCP `tools/list` result — tool descriptors plus optional modern result stamps. |
2487
- | `MCPToolDescriptor` | interface | `{ name, description?, inputSchema }` | Represents one entry of the MCP `tools/list` result — a tool's `name`, optional `description`, and its JSON-Schema `inputSchema`. |
2528
+ | `MCPToolAnnotations` | interface | `{ title?, readOnlyHint?, destructiveHint?, idempotentHint?, openWorldHint? }` | Describes tool hints using the MCP 2026-07-28 specification's wire field names. |
2529
+ | `MCPToolDescriptor` | interface | `{ name, title?, description?, inputSchema, annotations? }` | Represents one entry of the MCP `tools/list` result with its display metadata and JSON-Schema input. |
2488
2530
  | `MCPHeaderPrimitive` | type | `'boolean' \| 'integer' \| 'string'` | Names the JSON Schema types an `x-mcp-header` annotation may sit on. |
2489
2531
  | `MCPHeaderParameter` | interface | `{ name, path, primitive }` | Represents one `x-mcp-header` projection a tool's `inputSchema` declares. |
2490
2532
  | `MCPIdentity` | type | `MCPMetaObject & { name, version, title?, description?, websiteUrl?, icons? }` | Represents the complete dated identity of an MCP server or client. |
2491
2533
  | `MCPRequestContext` | interface | `{ version, capabilities, identity? }` | Represents the validated per-request context projected from a modern request's reserved `_meta` keys. |
2492
2534
  | `MCPDiscoverResult` | type | `{ supportedVersions, capabilities, resultType: 'complete', ttlMs, cacheScope: 'public' \| 'private', instructions?, _meta? }` | Represents the mandatory modern `server/discover` result. |
2493
- | `MCPSubscriptionFilter` | interface | `{ toolsListChanged?, promptsListChanged?, resourcesListChanged?, resourceSubscriptions?, taskIds? }` | Names the notification families a client may opt in to on a `subscriptions/listen` stream. |
2535
+ | `MCPSubscriptionFilter` | interface | `{ toolsListChanged?, promptsListChanged?, resourcesListChanged?, resourceSubscriptions?, taskIds? }` | Names the notification families a client may opt in to on a `subscriptions/listen` stream, including built-in registry changes. |
2536
+ | `MCPConsumerFilter` | interface | `MCPSubscriptionFilter plus { toolsListChanged?: false }` | Declares the consumer-produced notification families, excluding registry-owned tools changes. |
2494
2537
  | `MCPSubscriptionResultMetaObject` | type | `MCPResultMetaObject & { 'io.modelcontextprotocol/subscriptionId' }` | Represents the required metadata on a graceful `subscriptions/listen` result. |
2495
2538
  | `MCPSubscriptionResult` | type | `{ resultType: 'complete', _meta }` | Represents the terminating result returned when a `subscriptions/listen` stream closes gracefully. |
2496
2539
  | `MCPSubscriptionStream` | type | `AsyncGenerator< JSONRPCNotification, MCPSubscriptionResult, unknown >` | Represents a client subscription's owned notifications and graceful terminal result. |
@@ -2595,8 +2638,8 @@ HTTP header validation have all passed, immediately before `mcp.dispatch`. With
2595
2638
  extractor, or when it returns `undefined`, `caller` is omitted through the
2596
2639
  package's conditional-spread idiom, preserving the former dispatch-options shape
2597
2640
  exactly. A present value flows through both modern and legacy `tools/call` onto
2598
- `ToolCall.caller`; the tool manager then supplies it to the real tool body's
2599
- caller parameter.
2641
+ the execution context's `caller` member; the tool manager supplies that context to the
2642
+ real tool body's `context` parameter.
2600
2643
 
2601
2644
  This remains an asserted seam, never protocol authentication. A session id names
2602
2645
  an HTTP transport session, not a caller, and the session middleware preserves
@@ -3162,9 +3205,45 @@ parameter defaults to `globalThis`, so a worker boots with
3162
3205
  `createScopeServer({ tools })` alone and a test drives the same wiring by passing a scope
3163
3206
  double instead of a real worker.
3164
3207
 
3165
- This face is DOM-free by construction (type-checked against `lib: ["ESNext",
3166
- "WebWorker"]`, no `"dom"`), so it runs identically in a page, a Web Worker,
3167
- and a Service Worker.
3208
+ `createPageServer` is that bootstrap's page twin, and the in-page pair as one call. It owns a
3209
+ native `MessageChannel`, binds an `MCPServer` to one half and an `MCPClientInterface` to the
3210
+ other, and hands the client back on `PageServerInterface`. Assembling the same pair by hand
3211
+ means writing the channel, two transports, `bindServer`, `createDuplexClientTransport`,
3212
+ `createMCPClient`, and `bindClient` in an order `MessagePortTransport`'s own doc warns about —
3213
+ a port starts dispatching at construction, so an `await` interleaved between a transport and
3214
+ its binder drops whatever arrived in the gap. The factory never suspends between the two. The
3215
+ returned client is bound and not connected, because connection is a protocol round trip and a
3216
+ factory that returned a promise could not return the terminal beside it: call
3217
+ `await page.client.connect()` yourself. `stop` closes the client's port first, so the client
3218
+ observes the close and reports `connected` as `false`, then unbinds both sides and closes the
3219
+ server's port; it is idempotent, and it is the verb `createScopeServer`'s handle already
3220
+ publishes for the same action.
3221
+
3222
+ `createModelContext` is the WebMCP bridge: it publishes a `ToolManagerInterface`'s tools to the
3223
+ document's `document.modelContext` registry and reads that registry back as
3224
+ `@orkestrel/tool` tools. Feature detection is the return value — `undefined` means the document
3225
+ exposes no registry — so there is no `supported` flag to read and no polyfill behind the
3226
+ factory. `publish` registers each advertised tool through `registerTool` with the projected
3227
+ annotations, retains the `AbortController` whose abort is WebMCP's own unregistration path, and
3228
+ subscribes to the manager's own `emitter` so a later `add`, `remove`, or `clear` reaches the
3229
+ document registry without a second call; `adopt` reads `getTools` and wraps each
3230
+ `RegisteredTool` as a tool whose `execute` runs `executeTool` with the caller's
3231
+ `ToolContext.signal`; `destroy` aborts the registrations this handle made, releases that
3232
+ subscription, and leaves a name it never registered alone. WebMCP keys a registration by tool
3233
+ name per document, so a release takes whatever now stands under that name. See
3234
+ [WebMCP parity](#webmcp-parity) for the surface comparison, and
3235
+ [Declared conformance gaps](#declared-conformance-gaps) for what a browser that ships no
3236
+ registry leaves unproven.
3237
+
3238
+ **The transports are host-neutral; the WebMCP bridge is not, and it says so at runtime.** This
3239
+ face type-checks against `lib: ["ESNext", "DOM", "DOM.Iterable"]`
3240
+ (`configs/src/tsconfig.browser.json`), because `createModelContext` takes a `Document` and
3241
+ `WebMCPRegisteredTool` carries a `Window`. Nothing else here touches a DOM-only global:
3242
+ `WebSocket`, `fetch`, `MessagePort`, and `MessageChannel` exist in a page, a Web Worker, and a
3243
+ Service Worker alike, so every transport, `createScopeServer`, and `createPageServer` run
3244
+ identically in all three. `createModelContext` reads `globalThis.document`, which is `undefined`
3245
+ in a worker, and answers `undefined` there — the same answer it gives a page whose browser
3246
+ ships no registry.
3168
3247
 
3169
3248
  ```ts
3170
3249
  import { createMCPClient } from '@orkestrel/mcp'
@@ -3194,6 +3273,8 @@ const tools = await http.tools()
3194
3273
  | `createScopeServer` | function | Creates an `MCPServer` hosted inside a worker scope and wires that scope's message events to it — the browser face's bootstrap, and the twin of the Node face's `createStdioServer`. |
3195
3274
  | `createScopeTransport` | function | Adapts a hostable `ScopeInterface` (`self` in a dedicated Web Worker, or any structurally matching double) into a `ScopeTransportInterface` — the implicit, portless message channel `createScopeServer` binds for the dedicated-worker shape. |
3196
3275
  | `createScopeMessageListener` | function | Builds `createScopeServer`'s `message`-event listener — the unified dispatcher that routes every inbound event on a hostable scope, portless or port-bearing, to the right binding. |
3276
+ | `createPageServer` | function | Creates an `MCPServer` hosted inside the calling page and hands back the client bound to it — the page twin of `createScopeServer`, and the in-page MCP pair as one call. |
3277
+ | `createModelContext` | function | Creates the bridge between a tool registry and a document's WebMCP registry, or reports that the document exposes none. |
3197
3278
 
3198
3279
  #### Classes
3199
3280
 
@@ -3201,6 +3282,7 @@ const tools = await http.tools()
3201
3282
  | -------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3202
3283
  | `WebSocketClientTransport` | class | Drives a remote MCP server over the native `WebSocket` global from the browser face, as a client `MCPMessageTransportInterface`. This class is the browser sibling of the Node face's `WebSocketClientTransport`. |
3203
3284
  | `MessagePortTransport` | class | Carries the Model Context Protocol over a native `MessagePort` from the browser face — a `MCPTransportInterface`, the genuinely new capability this face adds: MCP over `postMessage`. |
3285
+ | `ModelContext` | class | Bridges a `ToolManagerInterface` and a document's WebMCP tool registry — the `ModelContextInterface` `createModelContext` returns. |
3204
3286
 
3205
3287
  #### Constants
3206
3288
 
@@ -3210,11 +3292,30 @@ A `Shape` cell holds the constant's declared type.
3210
3292
  | ---------------------------- | ----- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
3211
3293
  | `DEFAULT_MCP_SERVER_NAME` | const | `'@orkestrel/mcp'` | Supplies the default server name `createScopeServer` reports (`initialize`'s `serverInfo.name`) when `options.name` is omitted. |
3212
3294
  | `DEFAULT_MCP_SERVER_VERSION` | const | `'1.0.0'` | Supplies the default server version `createScopeServer` reports (`initialize`'s `serverInfo.version`) when `options.version` is omitted. |
3295
+ | `WEBMCP_CHANGE_EVENT` | const | `'toolchange'` | Names the WebMCP registry event the bridge republishes as its own `change`. |
3213
3296
 
3214
3297
  #### Helpers
3215
3298
 
3216
- _This face declares none. The SSE decoders `decodeEvent` and `readEventStream` are
3217
- host-independent and ship from `@orkestrel/mcp`; see [Core § Helpers](#helpers)._
3299
+ This table lists the WebMCP projections, the batches `publish` and a followed change project
3300
+ through, and the registry guards. Each projection is the WebMCP direction of the translation
3301
+ `toolAnnotationsToMCP` and `mcpAnnotationsToTool` perform for the MCP wire; see
3302
+ [Core § Helpers](#helpers) for those.
3303
+
3304
+ | API | Kind | Summary |
3305
+ | -------------------------- | -------- | --------------------------------------------------------------------------------------------- |
3306
+ | `toolAnnotationsToWebMCP` | function | Projects domain tool annotations onto WebMCP registry hints without inventing defaults. |
3307
+ | `webMCPAnnotationsToTool` | function | Projects WebMCP registry hints onto domain tool annotations without inventing defaults. |
3308
+ | `toolToWebMCP` | function | Projects one advertised tool definition onto the WebMCP descriptor a registration carries. |
3309
+ | `describeWebMCPTool` | function | Projects the descriptor a registry advertises for one tool name, or reports that it has none. |
3310
+ | `webMCPToTool` | function | Projects one registered WebMCP tool onto the tool definition an adopted tool advertises. |
3311
+ | `matchesDescriptor` | function | Determines whether two WebMCP descriptors advertise the same tool to the registry. |
3312
+ | `buildWebMCPProjections` | function | Builds the WebMCP projection of every tool a registry advertises, or refuses the batch. |
3313
+ | `collectWebMCPProjections` | function | Collects the WebMCP projection of every tool a registry advertises that WebMCP can carry. |
3314
+ | `isWebMCPRegistry` | function | Determines whether an unknown value is a WebMCP tool registry. |
3315
+ | `isWebMCPDocument` | function | Determines whether an unknown value is a document exposing the WebMCP tool registry. |
3316
+
3317
+ The SSE decoders `decodeEvent` and `readEventStream` are host-independent and ship from
3318
+ `@orkestrel/mcp`; see [Core § Helpers](#helpers).
3218
3319
 
3219
3320
  #### Types
3220
3321
 
@@ -3223,14 +3324,33 @@ optional member and `plus` introducing its call-signature members, and a type al
3223
3324
  literal with a union's arms escaped as `\|`.
3224
3325
  An extended interface's name comes before `plus`, with the members it adds after.
3225
3326
 
3226
- | Type | Kind | Shape | Summary |
3227
- | --------------------------------- | --------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3228
- | `WebSocketClientTransportOptions` | interface | `{ url, protocols? }` | Options for `createWebSocketClientTransport` (browser face) — the remote MCP WebSocket endpoint and any negotiated subprotocols. |
3229
- | `MessagePortTransportOptions` | interface | `{ port }` | Options for `createMessagePortTransport` — the native `MessagePort` a `MessagePortTransport` sends and listens on. |
3230
- | `ScopeInterface` | interface | `{} plus postMessage, addEventListener, removeEventListener` | Describes the structural shape `createScopeServer` needs from a hostable scope — `self` in a dedicated Web Worker or a Service Worker (or any double matching this shape). |
3231
- | `ScopeTransportInterface` | interface | `MCPTransportInterface plus deliver` | Adapts a message-event-bearing scope (`self` in a dedicated Web Worker, or any object shaped the same way) as a duplex `MCPTransportInterface` — the internal carrier `createScopeServer` binds to route the implicit (portless) message channel, plus the `deliver` entry point the scope's own `message` listener pushes an inbound string through (the scope itself never registers `listen`'s handler for the caller — the scope server's dispatcher does, through this `deliver`). |
3232
- | `ScopeServerInterface` | interface | `{} plus stop` | Represents one MCP server hosted inside a worker scope — what `createScopeServer` returns. |
3233
- | `ScopeServerOptions` | interface | `{ tools, name?, version? } plus accept?` | Options for `createScopeServer` — the live `ToolManagerInterface` to expose plus the optional server identity, mirroring `createMCPServer`'s `MCPServerOptions` (`@orkestrel/mcp`) but with `name`/`version` optional (defaulting to `DEFAULT_MCP_SERVER_NAME` / `DEFAULT_MCP_SERVER_VERSION`). |
3327
+ | Type | Kind | Shape | Summary |
3328
+ | --------------------------------- | --------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3329
+ | `WebSocketClientTransportOptions` | interface | `{ url, protocols? }` | Options for `createWebSocketClientTransport` (browser face) — the remote MCP WebSocket endpoint and any negotiated subprotocols. |
3330
+ | `MessagePortTransportOptions` | interface | `{ port }` | Options for `createMessagePortTransport` — the native `MessagePort` a `MessagePortTransport` sends and listens on. |
3331
+ | `ScopeInterface` | interface | `{} plus postMessage, addEventListener, removeEventListener` | Describes the structural shape `createScopeServer` needs from a hostable scope — `self` in a dedicated Web Worker or a Service Worker (or any double matching this shape). |
3332
+ | `ScopeTransportInterface` | interface | `MCPTransportInterface plus deliver` | Adapts a message-event-bearing scope (`self` in a dedicated Web Worker, or any object shaped the same way) as a duplex `MCPTransportInterface` — the internal carrier `createScopeServer` binds to route the implicit (portless) message channel, plus the `deliver` entry point the scope's own `message` listener pushes an inbound string through (the scope itself never registers `listen`'s handler for the caller — the scope server's dispatcher does, through this `deliver`). |
3333
+ | `ScopeServerInterface` | interface | `{} plus stop` | Represents one MCP server hosted inside a worker scope — what `createScopeServer` returns. |
3334
+ | `ScopeServerOptions` | interface | `{ tools, name?, version? } plus accept?` | Options for `createScopeServer` — the live `ToolManagerInterface` to expose plus the optional server identity, mirroring `createMCPServer`'s `MCPServerOptions` (`@orkestrel/mcp`) but with `name`/`version` optional (defaulting to `DEFAULT_MCP_SERVER_NAME` / `DEFAULT_MCP_SERVER_VERSION`). |
3335
+ | `PageServerOptions` | interface | `{ tools, name?, version?, client? }` | Options for `createPageServer` — the live `ToolManagerInterface` to expose, the optional server identity, and the optional settings for the client half the pair owns. |
3336
+ | `PageServerInterface` | interface | `{ client } plus stop` | Represents one MCP server hosted inside the calling page — what `createPageServer` returns. |
3337
+ | `WebMCPAnnotations` | interface | `{ readOnlyHint?, untrustedContentHint?, consequentialHint? }` | Describes a tool's observable effects as the WebMCP registry declares them. |
3338
+ | `WebMCPDescriptor` | interface | `{ name, title?, description, inputSchema?, annotations? }` | Describes the members a WebMCP tool carries into the registry and back out of it. |
3339
+ | `WebMCPHandlerOptions` | interface | `{ signal }` | Carries the execution signal the WebMCP registry hands a registered tool's callback. |
3340
+ | `WebMCPExecuteHandler` | type | `(input: Readonly<Record<string, unknown>>, options: WebMCPHandlerOptions,) => Promise<unknown>` | Runs one registered WebMCP tool with the caller's input and the registry's signal. |
3341
+ | `WebMCPTool` | interface | `WebMCPDescriptor plus execute` | Describes one tool as handed to the WebMCP registry for registration. |
3342
+ | `WebMCPRegisteredTool` | interface | `WebMCPDescriptor plus window, origin` | Describes one tool as the WebMCP registry reports it back. |
3343
+ | `WebMCPRegisterOptions` | interface | `{ exposedTo?, signal? }` | Options for the WebMCP registry's `registerTool` — the exposure list and the unregistration signal. |
3344
+ | `WebMCPToolsOptions` | interface | `{ fromOrigins? }` | Options for the WebMCP registry's `getTools` — the origins whose tools are read. |
3345
+ | `WebMCPExecuteOptions` | interface | `{ signal? }` | Options for the WebMCP registry's `executeTool` — the caller's cancellation signal. |
3346
+ | `WebMCPRegistryInterface` | interface | `{} plus registerTool, getTools, executeTool, addEventListener, removeEventListener` | Represents the WebMCP tool registry a document exposes as `document.modelContext`. |
3347
+ | `WebMCPDocument` | interface | `{ modelContext }` | Describes a document that exposes the WebMCP tool registry. |
3348
+ | `WebMCPProjection` | interface | `{ tool, descriptor }` | Pairs one tool with the WebMCP descriptor a registry advertises for it. |
3349
+ | `ModelContextEventMap` | type | `{ change: readonly [] }` | Reports the moments a WebMCP registry's contents changed. |
3350
+ | `ModelContextOptions` | interface | `{ document?, on?, error? }` | Options for `createModelContext` — the document to bridge and the emitter's initial wiring. |
3351
+ | `ModelContextPublishOptions` | interface | `{ origins? }` | Options for `ModelContextInterface.publish` — the origins each registration is exposed to. |
3352
+ | `ModelContextAdoptOptions` | interface | `{ origins? }` | Options for `ModelContextInterface.adopt` — the origins whose tools are read. |
3353
+ | `ModelContextInterface` | interface | `{ emitter } plus publish, adopt, destroy` | Represents the bridge between a `ToolManagerInterface` and a document's WebMCP registry — what `createModelContext` returns. |
3234
3354
 
3235
3355
  _This face declares no `HTTPClientTransportOptions`. It is host-independent and ships from
3236
3356
  `@orkestrel/mcp`; see [Core § Types](#types)._
@@ -3336,7 +3456,8 @@ const reply = await server.handle(
3336
3456
  { signal: controller.signal, caller: authenticatedPrincipal },
3337
3457
  )
3338
3458
  // reply → {"jsonrpc":"2.0","id":2,"result":{"supportedVersions":["2026-07-28"],
3339
- // "capabilities":{"tools":{}},"resultType":"complete","ttlMs":60000,"cacheScope":"private",
3459
+ // "capabilities":{"tools":{"listChanged":true}},"resultType":"complete","ttlMs":60000,
3460
+ // "cacheScope":"private",
3340
3461
  // "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs","version":"1.0.0"}}}}
3341
3462
  ```
3342
3463
 
@@ -3516,6 +3637,33 @@ The completion port, configured independently of the `resources` and `prompts` p
3516
3637
  | ---------- | ------------------------------------------- | -------------------------------------------------------- |
3517
3638
  | `complete` | `MCPCompletion \| undefined` (or a promise) | Completes one argument against its host-owned reference. |
3518
3639
 
3640
+ #### `MCPContinuationInterface`
3641
+
3642
+ The integrity and storage port `MCPInputOptions.continuation` requires, implemented by
3643
+ `createMCPContinuation` (`@orkestrel/mcp/server`) and by any consumer port that can protect an
3644
+ opaque string. Core supplies no signer of its own, so the integrity of every binding inside a
3645
+ continuation carrier — principal, expiry, original id, revision, method, tool name, argument
3646
+ digest, and the issued round — rests on whatever port is passed here.
3647
+
3648
+ | Method | Returns | Summary |
3649
+ | ------ | ------------------------------ | ------------------------------------------------------------------------- |
3650
+ | `seal` | `Promise<string>` | Protects a canonical state string and returns the opaque client carrier. |
3651
+ | `open` | `Promise<string \| undefined>` | Recovers a protected canonical state string, or `undefined` when invalid. |
3652
+
3653
+ This example seals a canonical state string and recovers it, then shows a tampered carrier
3654
+ opening as `undefined` — which is what turns a rewritten `requestState` into `-32602` rather
3655
+ than into a trusted claim.
3656
+
3657
+ ```ts
3658
+ import { createMCPContinuation } from '@orkestrel/mcp/server'
3659
+
3660
+ const continuation = createMCPContinuation(['current-secret', 'older-secret'])
3661
+ const carrier = await continuation.seal('{"principal":"user-42"}')
3662
+
3663
+ log(await continuation.open(carrier)) // '{"principal":"user-42"}'
3664
+ log(await continuation.open(`${carrier}x`)) // undefined
3665
+ ```
3666
+
3519
3667
  #### `MCPClientInterface`
3520
3668
 
3521
3669
  The egress mirror: `connect` negotiates the modern revision and stores the selected
@@ -3527,6 +3675,38 @@ closes the connection it owns. Subscribe to client events through `emitter.on`.
3527
3675
  The `tasks` data member is the stable Tasks extension's client half — see
3528
3676
  [`MCPTaskClientInterface`](#mcptaskclientinterface).
3529
3677
 
3678
+ `tools()` returns a snapshot. The wire preserves `title`, the advertised `description`,
3679
+ `inputSchema` (wrapped as `parameters`), and the mapped `annotations`. The server maps
3680
+ `pure` to `readOnlyHint` and `consequential` to `destructiveHint`; the client applies the
3681
+ inverse projection. For a schema-less tool, the wire adds `inputSchema: { type: 'object' }`.
3682
+ `untrusted` has no MCP counterpart. Unmapped hints remain absent from
3683
+ the wrapped tool, and omitted hints receive no invented defaults. These hints do not authorize
3684
+ execution. The wire loses `summary` as a separate field and, when a summary was authored,
3685
+ the full description: the registry advertises that summary as `description`.
3686
+ The wrapped tool forwards `context.signal` into `call`, so agent-side abort reaches the
3687
+ remote request. Refresh snapshots explicitly between agent runs; see
3688
+ [Refresh the tools an agent holds](#refresh-the-tools-an-agent-holds).
3689
+
3690
+ **Every session-bound request refuses at once while the client holds no connection.** `call`,
3691
+ `tools`, each `tasks/*` method reached through `client.tasks`, and a `listen` stream all carry
3692
+ the same refusal, because they all issue through one correlated-request door. A client that has
3693
+ never connected, or whose connection has ended, rejects with an `MCPError` carrying `-32600`
3694
+ and writes nothing to the transport. A stream refuses on its first `next()`, which is when a
3695
+ generator's body runs and therefore the first moment `listen` could refuse without making the
3696
+ stream eager. The alternative is the symptom this closes: a carrier that has already closed
3697
+ drops the frame without reporting it, so the caller waits out the whole request deadline —
3698
+ `DEFAULT_MCP_REQUEST_TIMEOUT`, 30 seconds, unless `timeout` shortened it — and is then told
3699
+ about a deadline rather than about the connection. A `connect` exempts its own round trips, and
3700
+ only while it is still the attempt this client is making: `connect` is emitted from inside the
3701
+ attempt, so a transport lost from a `connect` listener — a closed port, a stopped page server —
3702
+ supersedes that attempt at once, and every request issued after that loss refuses rather than
3703
+ riding an exemption the loss already ended. A `disconnect` from that same listener is the other
3704
+ sequence. It defers its teardown through the microtask queue, so the transport is still open
3705
+ when the listener's next request is issued: that request is admitted, and the teardown's own
3706
+ drain then rejects it with `MCP client disconnected`. It settles well inside its deadline rather
3707
+ than refusing, and it never parks. `discover` is outside the rule and stays available on a
3708
+ client that has never connected, because it is the probe a connection is built from.
3709
+
3530
3710
  | Method | Returns | Summary |
3531
3711
  | ------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
3532
3712
  | `connect` | `Promise<void>` | Connects to the remote server — opens a connection on the transport and negotiates the modern wire revision. |
@@ -3808,6 +3988,38 @@ stdio.start() // a repeat arms nothing further — one reply per request
3808
3988
  stdio.stop() // unbind, release stdin, and end this handle
3809
3989
  ```
3810
3990
 
3991
+ #### `ScopeInterface`
3992
+
3993
+ The hostable scope `createScopeServer` binds — `self` in a dedicated Web Worker or a Service
3994
+ Worker, or any object of this shape. No class implements it: the host does. It declares the
3995
+ members the scope server touches and stops there, so a real `self` satisfies it structurally
3996
+ while exposing far more.
3997
+
3998
+ | Method | Returns | Summary |
3999
+ | --------------------- | ------- | ---------------------------------------------------------------------------- |
4000
+ | `postMessage` | `void` | Posts one reply onto the scope's implicit channel. |
4001
+ | `addEventListener` | `void` | Subscribes to the scope's `message` events, portless and port-bearing alike. |
4002
+ | `removeEventListener` | `void` | Drops a `message` subscription. |
4003
+
4004
+ The fence under [Start a worker scope server](#start-a-worker-scope-server) drives a scope of
4005
+ exactly this shape, which is what lets that example round-trip a `tools/list` with no worker
4006
+ harness.
4007
+
4008
+ #### `ScopeTransportInterface`
4009
+
4010
+ The scope's duplex carrier, returned by `createScopeTransport` and bound by
4011
+ `createScopeServer`. It is an `MCPTransportInterface` — `send`, `listen`, `closed`, `close` —
4012
+ plus the one member the scope shape needs: the scope's own `message` listener has to push an
4013
+ inbound string into it, because the scope never registers `listen`'s handler itself.
4014
+
4015
+ | Method | Returns | Summary |
4016
+ | --------- | ------- | ------------------------------------------------------------------- |
4017
+ | `deliver` | `void` | Pushes one inbound message string into the active `listen` handler. |
4018
+
4019
+ The in-memory duplex channel under
4020
+ [Bind a server or a client to any duplex transport](#bind-a-server-or-a-client-to-any-duplex-transport)
4021
+ is the same shape, and its `deliver` is what the peer calls to hand a frame across.
4022
+
3811
4023
  #### `ScopeServerInterface`
3812
4024
 
3813
4025
  The worker-scope handle `createScopeServer` returns, and the browser twin of
@@ -3831,6 +4043,196 @@ worker.stop() // release every binding this call owns
3831
4043
  worker.stop() // a repeat releases nothing further
3832
4044
  ```
3833
4045
 
4046
+ #### `PageServerInterface`
4047
+
4048
+ The in-page handle `createPageServer` returns. No class implements it: the factory owns the
4049
+ `MessageChannel`, the hosted `MCPServer`, and both bindings behind it, and publishes the client
4050
+ beside the one door that ends them. The `client` data member is bound and not connected —
4051
+ `connect` is yours to await, because it is a protocol round trip rather than a construction
4052
+ step.
4053
+
4054
+ The terminal is `stop`, the same verb `ScopeServerInterface` publishes for the same action, so
4055
+ a consumer who learned one factory reads the other without checking. The published `client`
4056
+ outlives the pair and is inert afterwards: every session-bound request — `call`, `tools`, each
4057
+ `tasks/*` method, and a `listen` stream — rejects at once with an `MCPError` carrying `-32600`,
4058
+ per [`MCPClientInterface`](#mcpclientinterface).
4059
+
4060
+ | Method | Returns | Summary |
4061
+ | ------ | ------- | ------------------------------------------------------------------------------- |
4062
+ | `stop` | `void` | Closes both ports, unbinds both sides, and disconnects the client — idempotent. |
4063
+
4064
+ This example hosts a server in the page, drives it, and shows that repeated cleanup is inert.
4065
+
4066
+ ```ts
4067
+ import { createPageServer } from '@orkestrel/mcp/browser'
4068
+ import { createTool, createToolManager } from '@orkestrel/tool'
4069
+
4070
+ const tools = createToolManager()
4071
+ tools.add(createTool({ name: 'add', execute: () => 5 }))
4072
+
4073
+ const page = createPageServer({ tools })
4074
+ await page.client.connect() // the pair negotiates over the channel; nothing touches the network
4075
+ page.stop() // both ports closed, both sides unbound, the client disconnected
4076
+ page.stop() // a repeat releases nothing further
4077
+ ```
4078
+
4079
+ #### `ModelContextInterface`
4080
+
4081
+ The WebMCP bridge `createModelContext` returns, implemented by `ModelContext`. `publish` sends
4082
+ this page's tools out to `document.modelContext`; `adopt` brings that registry's tools back as
4083
+ `@orkestrel/tool` tools. The `emitter` data member republishes the registry's `toolchange` as
4084
+ `change`. Subscribe through `emitter.on`.
4085
+
4086
+ `publish` takes a snapshot **and** subscribes. The snapshot is taken when `publish` is called,
4087
+ before the work queues behind an earlier publication, so a manager mutated while a call waits
4088
+ its turn does not change what that call registers. The same call then subscribes to the
4089
+ manager's own `emitter` — `@orkestrel/tool` publishes `add`, `remove`, and `clear`. The
4090
+ document registry tracks the tool registry, and a consumer that changes the tool registry calls
4091
+ nothing.
4092
+
4093
+ **A followed change is a trigger, not a fact.** Each `add`, `remove`, and `clear` queues one
4094
+ synchronisation of the registrations this handle holds for that manager against what the
4095
+ manager holds when that queued work runs, so the bridge converges on the manager's state after
4096
+ every change it follows. One synchronisation covers every change that reaches it before it
4097
+ starts. A `publish` call made in between ends that cover: the publication runs after the queued
4098
+ synchronisation and prunes what it registered, so a change made after that call takes a
4099
+ synchronisation of its own, which runs after the publication. What the event carries cannot
4100
+ decide the outcome, and each of these cases is why: `remove` and `clear` name tools the manager
4101
+ no longer holds; a listener that ran earlier in the same dispatch can add a tool back under one
4102
+ of those names, with the same descriptor or another; the manager empties its map before it
4103
+ publishes `clear`, so such an addition stays in the manager; and `tools.destroy()` publishes
4104
+ `clear`, destroys its emitter, and then empties the map again, erasing an addition with no
4105
+ event left to report it. Reading the manager answers all of them the same way. A registration
4106
+ whose name the manager no longer advertises is aborted, and only registrations bound to that
4107
+ manager are — a name another manager's publication put there is left standing.
4108
+
4109
+ One manager is followed at a time. A `publish` naming another manager releases the subscription
4110
+ and takes up the new one, and `destroy` releases it outright. An event from a manager this
4111
+ handle no longer follows is ignored: releasing a subscription inside a dispatch does not
4112
+ withdraw the handler from the listener array that dispatch is walking, so a listener that
4113
+ republishes another manager leaves this handle's own handler still to run for a subscription
4114
+ that no longer exists. A followed change reaches no caller, so a tool advertising neither a
4115
+ `description` nor a `summary` is left unregistered rather than refusing anything, and a
4116
+ registration the document registry refuses is dropped, so the next `publish` or the next
4117
+ synchronisation registers that name again. Refusal is the caller's answer, and a followed
4118
+ change has no caller to give it to. A synchronisation registers only what it can carry, so such
4119
+ a tool standing under a name this handle already registered releases that registration instead
4120
+ of leaving it advertising a descriptor the manager no longer stands behind. Under a name this
4121
+ handle never registered the skip emits no `change`, because nothing reached the document
4122
+ registry; releasing a name this handle did register emits the registry's `change` like any
4123
+ other release. Compare the manager's own `definitions()` with what `adopt()` returns to read
4124
+ the mismatch, and `describeWebMCPTool` answers `undefined` for a name `definitions()` still
4125
+ lists, which is that mismatch read from the manager's own side.
4126
+
4127
+ Every name reconciles against what this handle registered for it, whether it arrives in a
4128
+ snapshot or through a synchronisation. The same manager still holding the same tool leaves the
4129
+ registration untouched, so a repeat registers nothing and the registry fires no `toolchange`
4130
+ reporting a change nobody made — and a descriptor JSON cannot encode, such as a cyclic
4131
+ `inputSchema`, is left alone rather than re-registered on every change to another name. Another
4132
+ tool of that manager advertising an equal descriptor leaves the registration standing too:
4133
+ execution routes through the manager by name, so the replacement's handler is already what a
4134
+ foreign agent reaches. A changed projection, or the same name arriving from a different
4135
+ manager, releases the registration this handle holds and registers the new descriptor bound to
4136
+ the new manager. A name the snapshot dropped is released, and so is a name the followed manager
4137
+ no longer holds — after everything the batch carries has reconciled, so the registry never
4138
+ withdraws a name while the tools replacing it are still being registered. A failed batch
4139
+ releases them too, because the prune runs whether or not every registration the batch asked
4140
+ for was made, and it still withdraws nothing the batch carries. Descriptor equality is
4141
+ structural and key-order-independent, so re-authoring one schema in another key order is not
4142
+ a change.
4143
+
4144
+ Every tool is projected before anything is registered, so a manager holding a tool that
4145
+ advertises neither a `description` nor a `summary` is refused whole, with an `MCPError`
4146
+ carrying `-32602` and naming the tool, and nothing registered.
4147
+
4148
+ **A registration's identity is its name, per document.** WebMCP keys the registry by tool name,
4149
+ so a later registration of a name replaces the earlier one whichever handle made it, and the
4150
+ release the earlier handle performs takes whatever now stands under that name. This is the
4151
+ whole of what `destroy` touching "only its own registrations" means: the handle aborts its own
4152
+ controllers, and a name it never registered is left alone, but a same-name registration another
4153
+ handle made later goes with the release. Publish overlapping names from one handle, or give
4154
+ each handle names of its own.
4155
+
4156
+ **A published tool's `execute` rejects with an `Error` carrying the failure's message.** The
4157
+ manager contains a throwing handler and reports `ToolFailure.error`, which is text, so the
4158
+ value that was thrown no longer exists to forward and a foreign caller receives a fresh
4159
+ `Error` whose `message` is that text.
4160
+
4161
+ **An adopted tool validates nothing.** It advertises the foreign `inputSchema` as its
4162
+ `parameters` and forwards the arguments it is given. Compiling that schema into a contract
4163
+ would let one page's unreadable or hostile schema refuse the whole `adopt` call, and the
4164
+ arguments reach a handler in another document that has to validate them anyway.
4165
+
4166
+ | Method | Returns | Summary |
4167
+ | --------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
4168
+ | `publish` | `Promise<void>` | Registers every tool the manager holds at this moment, then follows it. |
4169
+ | `adopt` | `Promise<readonly ToolInterface[]>` | Reads the document's registered tools as locally executable tools. |
4170
+ | `destroy` | `void` | Aborts every registration this handle made, stops following the tool registry, and releases the emitter — idempotent. |
4171
+
4172
+ This example publishes a registry to a document that exposes WebMCP, reads the document's own
4173
+ tools back, and releases only what it registered.
4174
+
4175
+ ```ts
4176
+ import { createModelContext } from '@orkestrel/mcp/browser'
4177
+ import { createTool, createToolManager } from '@orkestrel/tool'
4178
+
4179
+ const tools = createToolManager()
4180
+ tools.add(createTool({ name: 'add', description: 'Adds two numbers', execute: () => 5 }))
4181
+
4182
+ const bridge = createModelContext() // undefined where the document exposes no registry
4183
+ if (bridge !== undefined) {
4184
+ bridge.emitter.on('change', () => console.log('the registry changed'))
4185
+ await bridge.publish(tools, { origins: ['https://partner.example'] })
4186
+ const foreign = await bridge.adopt()
4187
+ bridge.destroy() // aborts this handle's registrations and stops following `tools`
4188
+ }
4189
+ ```
4190
+
4191
+ #### `WebMCPRegistryInterface`
4192
+
4193
+ The foreign registry itself, as `document.modelContext` exposes it, declared here because
4194
+ TypeScript's DOM library declares none of it. No class implements it: a user agent does, and
4195
+ `isWebMCPDocument` is what narrows a document onto it. The declaration carries the members the
4196
+ bridge dereferences and stops there, exactly as `ScopeInterface` carries what
4197
+ `createScopeServer` touches — a real registry satisfies it structurally and so does an
4198
+ IDL-faithful double. Reach it directly only to do something `ModelContextInterface` does not;
4199
+ the bridge is the supported path, and it is what owns the registration lifetime.
4200
+
4201
+ `executeTool` resolves `unknown` because the primary source disagrees with itself: the WebIDL
4202
+ types it `Promise<DOMString>` while the specification's own README sample answers the MCP
4203
+ content shape `{ content: [...] }`. See [WebMCP parity](#webmcp-parity) for that row.
4204
+
4205
+ | Method | Returns | Summary |
4206
+ | --------------------- | ------------------------------------------ | --------------------------------------------------------------------- |
4207
+ | `registerTool` | `Promise<void>` | Registers one tool, resolving when the registry has accepted it. |
4208
+ | `getTools` | `Promise<readonly WebMCPRegisteredTool[]>` | Reads the registered tools this document may see. |
4209
+ | `executeTool` | `Promise<unknown>` | Runs one registered tool and resolves whatever its callback returned. |
4210
+ | `addEventListener` | `void` | Subscribes to the registry's `toolchange` event. |
4211
+ | `removeEventListener` | `void` | Drops a `toolchange` subscription. |
4212
+
4213
+ This example drives the registry directly: it subscribes, registers a tool, reads the registry
4214
+ back, runs one entry, releases the subscription, and unregisters by aborting the registration
4215
+ signal — which is WebMCP's own removal path.
4216
+
4217
+ ```ts
4218
+ import { isWebMCPDocument } from '@orkestrel/mcp/browser'
4219
+
4220
+ if (isWebMCPDocument(document)) {
4221
+ const registry = document.modelContext
4222
+ const controller = new AbortController()
4223
+ const onChange = () => console.log('the registry changed')
4224
+ registry.addEventListener('toolchange', onChange)
4225
+ await registry.registerTool(
4226
+ { name: 'add', description: 'Adds two numbers', execute: async () => '5' },
4227
+ { exposedTo: ['https://partner.example'], signal: controller.signal },
4228
+ )
4229
+ const [registered] = await registry.getTools()
4230
+ if (registered !== undefined) await registry.executeTool(registered, { x: 2, y: 3 })
4231
+ registry.removeEventListener('toolchange', onChange)
4232
+ controller.abort() // the unregistration path: aborting the registration signal
4233
+ }
4234
+ ```
4235
+
3834
4236
  ## Patterns
3835
4237
 
3836
4238
  ### Expose a tool registry over MCP
@@ -3868,6 +4270,72 @@ const listed = await server.handle(
3868
4270
  // listed → '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"search","inputSchema":{"type":"object"},"description":"Search the docs"},{"name":"add","inputSchema":{"type":"object"}}],"resultType":"complete","ttlMs":60000,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"docs","version":"1.0.0"}}}}'
3869
4271
  ```
3870
4272
 
4273
+ ### Refresh the tools an agent holds
4274
+
4275
+ The server produces each tools notification directly from its registry. A server-side `add`
4276
+ triggers this refresh without a consumer producer or a second notification written by the
4277
+ application. Apply each snapshot between agent runs. The refresh owns the names it installed
4278
+ from the client and replaces or removes tools by name. You must not register a local tool under a
4279
+ name the refresh installed. A collision with a local tool records the remote name and
4280
+ keeps the local tool. A failed fetch records the error and leaves the last snapshot installed.
4281
+ Open the subscription before fetching the initial snapshot so changes during that fetch stay
4282
+ queued. The loop processes notifications serially and performs no polling.
4283
+
4284
+ The following example uses an already-connected `client`. The application owns when this loop
4285
+ runs relative to its agent and aborts `subscription` when it stops consuming notifications.
4286
+
4287
+ ```ts
4288
+ import type { MCPClientInterface } from '@orkestrel/mcp'
4289
+ import type { ToolInterface, ToolManagerInterface } from '@orkestrel/tool'
4290
+ import { createTool, createToolManager } from '@orkestrel/tool'
4291
+
4292
+ export interface ToolRefreshResult {
4293
+ readonly installed: readonly string[]
4294
+ readonly collisions: readonly string[]
4295
+ readonly failures: readonly unknown[]
4296
+ }
4297
+
4298
+ export async function refreshTools(
4299
+ client: MCPClientInterface,
4300
+ tools: ToolManagerInterface,
4301
+ installed: readonly string[],
4302
+ ): Promise<ToolRefreshResult> {
4303
+ const collisions: string[] = []
4304
+ let snapshot: readonly ToolInterface[]
4305
+ try {
4306
+ snapshot = await client.tools()
4307
+ } catch (error) {
4308
+ return { installed, collisions, failures: [error] }
4309
+ }
4310
+ const accepted = snapshot.filter((tool) => {
4311
+ if (tools.tool(tool.name) !== undefined && !installed.includes(tool.name)) {
4312
+ collisions.push(tool.name)
4313
+ return false
4314
+ }
4315
+ return true
4316
+ })
4317
+ tools.remove(installed)
4318
+ tools.add(accepted)
4319
+ return { installed: accepted.map((tool) => tool.name), collisions, failures: [] }
4320
+ }
4321
+
4322
+ const tools = createToolManager()
4323
+ tools.add(createTool({ name: 'local', execute: () => 'local value' }))
4324
+ const subscription = new AbortController()
4325
+ const notifications = client.listen({ toolsListChanged: true }, { signal: subscription.signal })
4326
+ try {
4327
+ await notifications.next() // Consume the subscription acknowledgement.
4328
+ let outcome = await refreshTools(client, tools, [])
4329
+ for await (const notification of notifications) {
4330
+ if (notification.method === 'notifications/tools/list_changed') {
4331
+ outcome = await refreshTools(client, tools, outcome.installed)
4332
+ }
4333
+ }
4334
+ } finally {
4335
+ subscription.abort()
4336
+ }
4337
+ ```
4338
+
3871
4339
  ### Drive the typed core directly
3872
4340
 
3873
4341
  When the request is already parsed (a test, an in-process bridge), call
@@ -4318,8 +4786,47 @@ closed — while ordinary upstream completion closes the response without invent
4318
4786
  - [HTTP response lifecycle composition](../tests/src/server/HTTPDisconnect.test.ts)
4319
4787
  - [HTTP handler integration](../tests/src/server/handlers.test.ts)
4320
4788
  - [Session middleware integration](../tests/src/server/middlewares.test.ts)
4321
- - [Guide/source/public-barrel parity; legacy-removability and public-face boundaries; native guide-input, fence-language, summary, titled-example, and README-pitch checks; what the spawned stdio child receives; how the composed stdio server answers a legacy `initialize`; and the client subscription, progress, and transport demonstrations](../tests/guides.test.ts)
4789
+ - [Guide/source/public-barrel parity; legacy-removability and public-face boundaries; native guide-input, fence-language, summary, titled-example, and README-pitch checks; what the spawned stdio child receives; how the composed stdio server answers a legacy `initialize`; and the client subscription, progress, transport, and Refresh the tools an agent holds demonstrations](../tests/guides.test.ts)
4322
4790
  - [The packed artifact a consumer installs, across its faces and its ESM and CommonJS builds](../tests/distribution.test.ts)
4791
+ - [The packed artifact composed with the installed agent, tool, and parser artifacts in a real Chromium page](../tests/distribution.test.ts)
4792
+
4793
+ The `distribution` project runs from `prepublishOnly` as
4794
+ `npm run test:distribution -- --mode release`. Under `--mode release` the proof fails when it
4795
+ cannot reach the registry or a browser; anywhere else it skips and names what it could not
4796
+ reach.
4797
+
4798
+ Beside the checks that read this package's published surface, that project composes its packed
4799
+ artifact with the installed `@orkestrel/agent`, `@orkestrel/tool`, and `@orkestrel/ndjson`
4800
+ artifacts inside a real Chromium page, served by the isolated consumer's own Node fixture. The
4801
+ page loads every one of those artifacts over an import map answered from the consumer's own
4802
+ `node_modules`, so no bundler stands between the published files and the page. The recorders arm
4803
+ after the page and its modules have loaded — the browser's request log and a counter around
4804
+ the page's global transport — so each reading covers the composition alone. These are the
4805
+ receipts that project reads:
4806
+
4807
+ - `evaluates every @orkestrel entry the installed agent imports` — the page evaluates each root
4808
+ entry the installed agent's own module names, and every one of them publishes a defined
4809
+ export.
4810
+ - `runs a page tool through an installed agent with no request at all` — an agent driven by a
4811
+ scripted provider dispatches a tool that writes into the document, and neither recorder
4812
+ reports a request.
4813
+ - `completes an in-page MCP pair with no request at all` — `createPageServer` connects, lists,
4814
+ and calls, the listing carries the tool's `title` and its `pure` annotation back off the wire,
4815
+ and the client its `stop` leaves behind refuses a later call with `-32600`.
4816
+ - `dispatches an agent call into the page server with no request at all` — an agent whose
4817
+ registry holds the pair's tools runs one in the hosted server, the registry's own entry keeps
4818
+ that `title` and annotation, and a name that server does not hold comes back as a failed tool
4819
+ result.
4820
+ - `carries a caller abort into the page server handler` — `abort` on the agent's run reaches the
4821
+ hosted handler's own execution-context signal.
4822
+ - `spends one relay request per model turn and runs the tool in the page` — an agent over
4823
+ `createRelayProvider` against a `createRelay` fixture on `127.0.0.1` executes its page tool in
4824
+ the page.
4825
+ - `refuses a relay turn presenting a credential the fixture does not hold` — the installed relay
4826
+ refuses a credential its `authorize` callback rejects, answering `401`, and the route records
4827
+ the request it refused.
4828
+ - `reports one deliberate request on the request log and the counter` — the control that shows
4829
+ the request log and the counter report traffic.
4323
4830
 
4324
4831
  ## Declared non-goals
4325
4832
 
@@ -4391,6 +4898,37 @@ the session middleware). A deployment that already validates origin upstream say
4391
4898
  `origin: { enabled: false }` rather than by passing an empty list, because delegation
4392
4899
  is a different decision from an empty allowlist and deserves its own word.
4393
4900
 
4901
+ ## WebMCP parity
4902
+
4903
+ WebMCP puts a tool registry on the document — `document.modelContext` — so a page can offer its
4904
+ own capabilities to an agent running in the browser. It is a different door onto the same idea
4905
+ this package serves over a wire, so each surface it defines is compared here against what this
4906
+ package publishes, and every row ends implement, retain, or exclude with the source it was read
4907
+ from. `G5c` is the verbatim WebIDL, samples, and chromestatus reading taken from the
4908
+ [WebMCP specification](https://webmachinelearning.github.io/webmcp) on 2026-09-15; `G5` is the
4909
+ surrounding research context. `createModelContext` is where the implemented rows live.
4910
+
4911
+ | WebMCP surface | Ours | Verdict | Source |
4912
+ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
4913
+ | `registerTool(tool, { exposedTo, signal })` | `ModelContextInterface.publish(tools, { origins })` | implement — `origins` becomes `exposedTo`, and the retained controller's abort is the unregistration path | `G5c` § 1, § 2 |
4914
+ | `getTools({ fromOrigins })` | `ModelContextInterface.adopt({ origins })` | implement — `origins` becomes `fromOrigins`, and each `RegisteredTool` comes back as a `ToolInterface` | `G5c` § 1 |
4915
+ | `executeTool(tool, input, { signal })` | an adopted tool's `execute(args, context)`, and `ToolManagerInterface.execute` on the other side | implement — the caller's `ToolContext.signal` becomes WebMCP's `signal` in both directions | `G5c` § 1 |
4916
+ | `toolchange` event | `ModelContextEventMap.change` | implement — the registry names no changed tool, so ours is a bare signal and a listener re-reads `adopt` | `G5c` § 2 |
4917
+ | Registry change events on our own tool registry | `ToolManagerEventMap` (`add`, `remove`, `clear`), followed by `publish` | implement — `@orkestrel/tool` publishes registry events, so `publish` subscribes to the manager's `emitter` and the document registry tracks the tool registry with no second call | `G5c` § 2 |
4918
+ | `inputSchema` (JSON Schema) | `ToolDefinition.parameters`, derived from `ToolOptions.contract` | implement, exceeds in the publish direction — a published tool carries the contract that validates arguments before execution; an adopted tool carries the foreign `inputSchema` as `parameters` and validates nothing | `G5c` § 1; `G5` § 8 |
4919
+ | `ModelContextTool.title` | `ToolDefinition.title` | implement | `G5c` § 1 |
4920
+ | `ToolAnnotations` hints | `ToolAnnotations { pure, untrusted, consequential }`, projected by `toolAnnotationsToWebMCP` | implement — `pure` to `readOnlyHint`, `untrusted` to `untrustedContentHint`, `consequential` to `consequentialHint`; the MCP wire carries `pure` to `readOnlyHint` and `consequential` to `destructiveHint` and drops `untrusted` | `G5c` § 1, § 4 |
4921
+ | Declarative form attributes | none | exclude — `index.bs` marks that section "entirely a TODO", the submission path is a fetch-tool paraphrase, and markup is application policy | `G5c` § 3 |
4922
+ | Consent and user activation | none; `ToolAnnotations.consequential` is the datum a consent layer reads | exclude — no source states an activation requirement, and consent belongs to the user agent | `G5` § 5 |
4923
+ | Origin scoping (`Permissions-Policy: tools`, `allow="tools"`) | `createScopeServer`'s `accept` gate, and `publish`/`adopt`'s `origins` | implement the library half; the header is the application's | `G5c` § 5 |
4924
+ | Cross-document handshake from the client side | none | exclude — separate origin, navigation, and lifecycle work with its own design round | `G5c` § 5 |
4925
+ | Result shape (`Promise<DOMString>` in the IDL against `{ content: [...] }` in the README sample) | the executed tool's value, resolved unchanged | exclude the contradiction, retain ours — the primary source disagrees with itself, and the bridge projects to MCP content blocks in neither direction | `G5c` § 1, § 4 |
4926
+ | Structured refusal | a rejection carrying the failure's message, which is what the specification's own sample catches | exclude — the specification's issue #282 leaves the shape open; do not invent one | `G5` § 8 |
4927
+ | Streaming and partial result on abort | `notifications/progress` plus `MCPCallOptions.progress` | retain, exceeds — WebMCP defines none | `G5` § 8 |
4928
+ | Resources, prompts, sampling, elicitation, tasks | published by `MCPServer` today | retain — no WebMCP counterpart was found in the sources reached, and whether WebMCP addresses any of them is recorded as unknown rather than as an absence | `G5` § 6 |
4929
+ | Server-initiated requests | none | exclude — a modern-protocol non-goal recorded under [Declared non-goals](#declared-non-goals), not a WebMCP gap | `G5` § 6 |
4930
+ | Shipping status | `createModelContext` returning `undefined` | exclude as a dependency — the detection is the return value, there is no `supported` flag, and the chromestatus record, read 2026-09-15 and last updated 2026-08-12, reports `Proposed` with `"flag": false` and `"origintrial": false` | `G5c` § 7 |
4931
+
4394
4932
  ## Declared conformance gaps
4395
4933
 
4396
4934
  The conformance project is a foreign-runner exchange and a schema-authority comparison,
@@ -4645,17 +5183,6 @@ after its caller has gone. **The consumer's options:** bound the tool itself, or
4645
5183
  a held-open `MCPStream` from a registered method so the keepalive seam applies.
4646
5184
  **Closer:** none named; the limit is structural to a unary HTTP response.
4647
5185
 
4648
- **A tool run through the default registry cannot observe cancellation.** Dispatch resolves
4649
- one signal per request and hands it to every method, selector, principal resolver, and
4650
- subscription producer the request reaches — but the default execution path calls
4651
- `ToolManagerInterface.execute(call)`, whose signature takes a call and nothing else. There is
4652
- no seam to hand a signal through, so a server configured without `execution` runs its tool to
4653
- completion after the request that asked for it has ended, and abandons the result. **What it
4654
- costs:** a long or expensive tool keeps spending after its caller is gone. **The consumer's
4655
- options:** supply `MCPServerOptions.execution`, whose `MCPExecutionContext` carries `signal`
4656
- and can stop the work; or bound the tool itself. **Closer:** none inside this package — the
4657
- limit is in the `execute` signature, which `@orkestrel/tool` owns.
4658
-
4659
5186
  **A producer that ignores its signal cannot be forced to finish.** A controlled stream
4660
5187
  settles its consumer promptly whatever the producer is doing, and aborts the request's
4661
5188
  signal before delegating cleanup — but JavaScript cannot settle work a generator is
@@ -4670,9 +5197,9 @@ frame as the message-based cancellation path for the transports that still have
4670
5197
  WebSocket, and `MessagePort`. `bindServer` holds one `AbortController` per live request, keyed
4671
5198
  by request id and retired whenever that request leaves, and supplies its signal to `handle`,
4672
5199
  so an inbound cancellation aborts the named request and the cancelled request writes no
4673
- response. A tool observes that abort only through `MCPServerOptions.execution`, whose
4674
- `MCPExecutionContext` carries `signal`; the default `ToolManagerInterface.execute` path has no
4675
- seam to hand one through, which is a separate declared task limit. **What remains:** on
5200
+ response. The default execution path forwards that signal through the tool context. A custom
5201
+ `MCPServerOptions.execution` handler receives `signal` and `caller` and forwards them
5202
+ when delegating to `ToolManagerInterface.execute`. **What remains:** on
4676
5203
  Streamable HTTP there is no such frame at all — there, closing the response stream is the
4677
5204
  cancellation signal, and only a streamed response has one to close. **Closer:** none possible
4678
5205
  for the HTTP face; the limit is the dated revision's.
@@ -4696,7 +5223,9 @@ server `SHOULD` send the empty `subscriptions/listen` result to signal a gracefu
4696
5223
  attributes the notification to the client alone. The schema carries only the generic
4697
5224
  `CancelledNotification` with `requestId` and an optional `reason` — no subscription-specific
4698
5225
  field or variant — so it corroborates neither page. This server sends the empty result,
4699
- correlated by the original request id through `buildSubscriptionResult`, on every transport.
5226
+ correlated by the original request id through `buildSubscriptionResult`, on every transport
5227
+ when a consumer producer ends and the honoured filter omits `toolsListChanged`. A stream
5228
+ honouring the tools family has no graceful end; it stays open until failure or signal abort.
4700
5229
  **What it costs:** a client written against the cancellation page, watching for a notification
4701
5230
  it believes is required, sees the result instead. **Do not "fix" this toward the cancellation
4702
5231
  page** — emitting the notification as well would send a frame the governing page does not
@@ -4748,11 +5277,31 @@ The modern-only scope of `subscriptions/listen` is a stated limit rather than a
4748
5277
  it is recorded under [Declared non-goals](#declared-non-goals) with the other era-scoped
4749
5278
  surfaces.
4750
5279
 
5280
+ **`document.modelContext` is proven against an IDL-faithful double alone.** `createModelContext`,
5281
+ `ModelContext`, and the `WebMCP*` wire types translate the registry the
5282
+ [WebMCP specification](https://webmachinelearning.github.io/webmcp) defines, and the
5283
+ chromestatus record, read 2026-09-15 and last updated 2026-08-12, reports that feature as
5284
+ `Proposed` with `"flag": false` and `"origintrial": false` — no browser ships it, behind a flag or in an origin trial. The real
5285
+ Chromium the browser suite drives therefore exposes no `document.modelContext`, which
5286
+ `tests/src/browser/factories.test.ts` records as its own reading of the page rather than
5287
+ assuming, and every bridge scenario runs against `tests/fixtures/modelContext.ts`: an in-memory
5288
+ implementation of that WebIDL, member for member and nothing beyond it. **What that proves:**
5289
+ the translation each way — the projected annotations, the exposure and origin options, the
5290
+ signal carried into a published tool and out to an adopted one, the `toolchange` republication,
5291
+ and the registration ownership `destroy` releases. **What it does not prove:** that a user
5292
+ agent's own registry accepts these registrations, or that an agent driving one reaches this
5293
+ page's tools. **What it costs:** nothing a consumer can reach today, because
5294
+ `createModelContext` answers `undefined` on every shipping browser, so feature absence stays
5295
+ absence instead of becoming a passing integration claim. **Closer:** a browser that ships the
5296
+ registry, after which the same scenarios run against the real global and this entry names the
5297
+ version that closed it.
5298
+
4751
5299
  **Not every guide fence is executed.** `tests/guides.test.ts` transcribes and drives the
4752
5300
  flagship ones: the `tools/list` metadata pair, the stdio child's merged environment, its piped
4753
5301
  stderr and the retained evidence tail, the composed stdio server's legacy handshake, the
4754
- consumer-visible client's modern version, and the subscription stream's delivery order and
4755
- capacity refusal. Every other fence carries named-import, symbol, and link parity alone. **What
5302
+ consumer-visible client's modern version, the subscription stream's delivery order and
5303
+ capacity refusal, and `Refresh the tools an agent holds`. Every other fence carries
5304
+ named-import, symbol, and link parity alone. **What
4756
5305
  it costs:** a fence whose comment claims a value nothing asserts is checked for its names and
4757
5306
  not for its answer, so a behaviour that drifts under one of those fences reddens no gate.
4758
5307
  **Closer:** a transcription per fence, added with the claim it pins.
@@ -4878,9 +5427,10 @@ capabilities: { tools: {} }, serverInfo: { name, version } }`, the version negot
4878
5427
  absent one to the shared frozen `EMPTY_MCP_ARGUMENTS`),
4879
5428
  narrowed with `@orkestrel/contract`'s guards (no `as`); a missing /
4880
5429
  non-string `name` → a `-32602` invalid-params error. Otherwise it runs
4881
- `tools.execute({ id, name, arguments, ...(options.caller === undefined ? {} : { caller: options.caller }) })`
4882
- under the modern and legacy wire eras, so a present asserted caller reaches the real tool body while
4883
- absence preserves the former `ToolCall` shape exactly. Because the `ToolManager`
5430
+ `tools.execute(call, context)` under the modern and legacy wire eras. The call carries
5431
+ `{ id, name, arguments }`; the context carries the request's `signal` and the optional
5432
+ `caller` from `options.caller`, omitted when absent. The tool body receives that context.
5433
+ Because the `ToolManager`
4884
5434
  (`@orkestrel/tool`) already isolates a thrown tool (and an unknown name)
4885
5435
  into a `success: false` result, the server adds no try/catch: that branch's
4886
5436
  `error` maps to `{ content: [{ type: 'text', text: <error> }], isError: true }`;
@@ -4916,9 +5466,11 @@ JSON.stringify(value) }], structuredContent: value }`, carrying the value unchan
4916
5466
  requires `params.notifications`; the server acknowledges the exact intersection
4917
5467
  with its configured support, and that acknowledgement is the first message
4918
5468
  carrying this request's reserved subscription id. Every delivered notification
4919
- carries the same stamp. Ending the event-driven producer closes gracefully with
5469
+ carries the same stamp. Ending the consumer's producer closes gracefully when the
5470
+ honoured filter omits `toolsListChanged`, with
4920
5471
  `{ resultType: 'complete', _meta: { 'io.modelcontextprotocol/subscriptionId': id,
4921
- … } }`. The request id is only stream identity: a later request does not supersede
5472
+ … } }`. A stream honouring the tools family stays open until failure or signal abort.
5473
+ The request id is only stream identity: a later request does not supersede
4922
5474
  an earlier one. The legacy method remains absent and answers `-32601`.
4923
5475
  7. **`handle` maps the boundary failures.** A `JSON.parse` throw (malformed
4924
5476
  JSON) → a serialized `-32700` (Parse error) response with no `id` member; a