@orkestrel/mcp 0.0.30 → 0.0.31

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.
@@ -1,11 +1,527 @@
1
- import { isString } from "@orkestrel/contract";
2
- import { HTTPClientTransport, MCP_WEBSOCKET_SUBPROTOCOL, bindServer, createMCPServer, deliverMessage } from "../core/index.js";
1
+ import { attempt, canonicalStringify, isFunction, isString, objectOf } from "@orkestrel/contract";
2
+ import { HTTPClientTransport, JSONRPC_INVALID_PARAMS, MCPError, MCP_WEBSOCKET_SUBPROTOCOL, bindClient, bindServer, createDuplexClientTransport, createMCPClient, createMCPServer, deliverMessage } from "../core/index.js";
3
+ import { createTool, toolToDefinition } from "@orkestrel/tool";
3
4
  import { Emitter } from "@orkestrel/emitter";
4
5
  //#region src/browser/constants.ts
5
6
  /** Supplies the default server name `createScopeServer` reports (`initialize`'s `serverInfo.name`) when `options.name` is omitted. */
6
7
  var DEFAULT_MCP_SERVER_NAME = "@orkestrel/mcp";
7
8
  /** Supplies the default server version `createScopeServer` reports (`initialize`'s `serverInfo.version`) when `options.version` is omitted. */
8
9
  var DEFAULT_MCP_SERVER_VERSION = "1.0.0";
10
+ /** Names the WebMCP registry event the bridge republishes as its own `change`. */
11
+ var WEBMCP_CHANGE_EVENT = "toolchange";
12
+ //#endregion
13
+ //#region src/browser/validators.ts
14
+ /**
15
+ * Determines whether an unknown value is a WebMCP tool registry.
16
+ *
17
+ * @remarks
18
+ * Reads the members the bridge dereferences — the IDL's `registerTool`, `getTools`, and
19
+ * `executeTool` operations, plus the `EventTarget` pair the `toolchange` subscription needs —
20
+ * and nothing else. A registry carrying extra members is still a registry, and a user agent's
21
+ * own implementation reaches every one of these through its prototype.
22
+ *
23
+ * @param value - The unknown value to inspect
24
+ * @returns True if the value exposes every WebMCP registry operation; false otherwise
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * isWebMCPRegistry({}) // false
29
+ * ```
30
+ */
31
+ function isWebMCPRegistry(value) {
32
+ return objectOf({
33
+ registerTool: isFunction,
34
+ getTools: isFunction,
35
+ executeTool: isFunction,
36
+ addEventListener: isFunction,
37
+ removeEventListener: isFunction
38
+ })(value);
39
+ }
40
+ /**
41
+ * Determines whether an unknown value is a document exposing the WebMCP tool registry.
42
+ *
43
+ * @remarks
44
+ * This is the feature detection {@link import('./factories.js').createModelContext} performs,
45
+ * published so a consumer can run it before deciding to build a bridge at all. It reads
46
+ * `modelContext` and checks it with {@link isWebMCPRegistry}; it asserts nothing about the rest
47
+ * of a `Document`, because that member is the whole of what the bridge needs.
48
+ *
49
+ * @param value - The unknown value to inspect
50
+ * @returns True if the value carries a WebMCP registry; false otherwise
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * isWebMCPDocument(globalThis.document) // false in a browser that ships no WebMCP
55
+ * ```
56
+ */
57
+ function isWebMCPDocument(value) {
58
+ return objectOf({ modelContext: isWebMCPRegistry })(value);
59
+ }
60
+ //#endregion
61
+ //#region src/browser/helpers.ts
62
+ /**
63
+ * Projects domain tool annotations onto WebMCP registry hints without inventing defaults.
64
+ *
65
+ * @param annotations - The authored domain annotations
66
+ * @returns The mapped hints; an omitted annotation stays omitted
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * toolAnnotationsToWebMCP({ pure: true, untrusted: true }) // { readOnlyHint: true, untrustedContentHint: true }
71
+ * ```
72
+ */
73
+ function toolAnnotationsToWebMCP(annotations) {
74
+ return {
75
+ ...annotations.pure === void 0 ? {} : { readOnlyHint: annotations.pure },
76
+ ...annotations.untrusted === void 0 ? {} : { untrustedContentHint: annotations.untrusted },
77
+ ...annotations.consequential === void 0 ? {} : { consequentialHint: annotations.consequential }
78
+ };
79
+ }
80
+ /**
81
+ * Projects WebMCP registry hints onto domain tool annotations without inventing defaults.
82
+ *
83
+ * @param annotations - The registry's hints, as the WebMCP dictionary declares them
84
+ * @returns The mapped annotations; an omitted hint stays omitted
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * webMCPAnnotationsToTool({ readOnlyHint: false, consequentialHint: true }) // { pure: false, consequential: true }
89
+ * ```
90
+ */
91
+ function webMCPAnnotationsToTool(annotations) {
92
+ return {
93
+ ...annotations.readOnlyHint === void 0 ? {} : { pure: annotations.readOnlyHint },
94
+ ...annotations.untrustedContentHint === void 0 ? {} : { untrusted: annotations.untrustedContentHint },
95
+ ...annotations.consequentialHint === void 0 ? {} : { consequential: annotations.consequentialHint }
96
+ };
97
+ }
98
+ /**
99
+ * Projects one advertised tool definition onto the WebMCP descriptor a registration carries.
100
+ *
101
+ * @remarks
102
+ * Takes the definition a `ToolManagerInterface` advertises rather than the tool itself, so the
103
+ * description here is the one the MCP wire advertises too — the registry substitutes an
104
+ * authored `summary` for the full `description`, and reading the same projection keeps one
105
+ * advertised description across both surfaces.
106
+ *
107
+ * WebMCP requires `description`, so a definition carrying none cannot be registered at all.
108
+ * Returning `undefined` is what lets the caller refuse the whole batch before registering any
109
+ * of it; registering an empty string instead would be an invented value a foreign agent reads
110
+ * as a real one.
111
+ *
112
+ * @param definition - The advertised definition to project
113
+ * @returns The WebMCP descriptor, or `undefined` when the definition advertises no description
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * toolToWebMCP({ name: 'add', description: 'Adds two numbers' })?.description // 'Adds two numbers'
118
+ * ```
119
+ */
120
+ function toolToWebMCP(definition) {
121
+ if (definition.description === void 0) return void 0;
122
+ const annotations = definition.annotations === void 0 ? {} : toolAnnotationsToWebMCP(definition.annotations);
123
+ return {
124
+ name: definition.name,
125
+ description: definition.description,
126
+ ...definition.title === void 0 ? {} : { title: definition.title },
127
+ ...definition.parameters === void 0 ? {} : { inputSchema: definition.parameters },
128
+ ...Object.keys(annotations).length === 0 ? {} : { annotations }
129
+ };
130
+ }
131
+ /**
132
+ * Projects one registered WebMCP tool onto the tool definition an adopted tool advertises.
133
+ *
134
+ * @remarks
135
+ * The inverse of {@link toolToWebMCP}, and deliberately lossy in the other direction: the
136
+ * registry's `window` and `origin` describe where the tool lives rather than what it does, and
137
+ * the bridge hands the whole registered record back to `executeTool` instead of rebuilding it.
138
+ *
139
+ * @param registered - The registered tool the registry reported
140
+ * @returns The definition an adopted tool advertises
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * webMCPToTool({ name: 'add', description: 'Adds', window, origin: 'https://a.example' }).name // 'add'
145
+ * ```
146
+ */
147
+ function webMCPToTool(registered) {
148
+ const annotations = registered.annotations === void 0 ? {} : webMCPAnnotationsToTool(registered.annotations);
149
+ return {
150
+ name: registered.name,
151
+ description: registered.description,
152
+ ...registered.title === void 0 ? {} : { title: registered.title },
153
+ ...registered.inputSchema === void 0 ? {} : { parameters: registered.inputSchema },
154
+ ...Object.keys(annotations).length === 0 ? {} : { annotations }
155
+ };
156
+ }
157
+ /**
158
+ * Determines whether two WebMCP descriptors advertise the same tool to the registry.
159
+ *
160
+ * @remarks
161
+ * The reading `ModelContextInterface.publish` reconciles a name against once the manager's
162
+ * tool has changed under it: equal descriptors leave the live registration standing, because
163
+ * execution routes through the manager by name, and anything else releases it and registers
164
+ * the new one.
165
+ *
166
+ * Equality is structural and key-order-independent, through `@orkestrel/contract`'s
167
+ * `canonicalStringify`: `inputSchema` is the author's own JSON Schema record, and two
168
+ * authorings of the same schema that differ only in key order describe the same tool. A
169
+ * descriptor JSON cannot encode — a cyclic or unreadable `inputSchema` — is reported as
170
+ * unequal, which re-registers rather than serving a descriptor nothing could compare.
171
+ *
172
+ * @param held - The descriptor the live registration carries
173
+ * @param projected - The descriptor this publication projected
174
+ * @returns True when both describe the same tool; false otherwise
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * matchesDescriptor({ name: 'add', description: 'Adds' }, { description: 'Adds', name: 'add' }) // true
179
+ * ```
180
+ */
181
+ function matchesDescriptor(held, projected) {
182
+ const left = attempt(() => canonicalStringify(held));
183
+ const right = attempt(() => canonicalStringify(projected));
184
+ if (!left.success || !right.success) return false;
185
+ return left.value !== void 0 && left.value === right.value;
186
+ }
187
+ /**
188
+ * Projects the descriptor a registry advertises for one tool name, or reports that it has none.
189
+ *
190
+ * @remarks
191
+ * Reads the manager's own `definitions()` rather than a tool instance, so the description here
192
+ * is the one the registry advertises — an authored `summary` in place of the full
193
+ * `description` — and one tool reaches the WebMCP registry and the MCP wire describing itself
194
+ * the same way.
195
+ *
196
+ * `undefined` covers both answers a caller must not conflate with a descriptor: the manager
197
+ * advertises no tool under that name, and the tool it advertises carries no description, which
198
+ * is the member WebMCP requires.
199
+ *
200
+ * @param manager - The tool registry to read
201
+ * @param name - The tool name to describe
202
+ * @returns The WebMCP descriptor, or `undefined` when the registry advertises none
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const tools = createToolManager()
207
+ * tools.add(createTool({ name: 'add', description: 'Adds two numbers', execute: () => 5 }))
208
+ * describeWebMCPTool(tools, 'add')?.description // 'Adds two numbers'
209
+ * ```
210
+ */
211
+ function describeWebMCPTool(manager, name) {
212
+ for (const definition of manager.definitions()) if (definition.name === name) return toolToWebMCP(definition);
213
+ }
214
+ /**
215
+ * Builds the WebMCP projection of every tool a registry advertises, or refuses the batch.
216
+ *
217
+ * @remarks
218
+ * The WebMCP twin of `@orkestrel/mcp`'s `buildToolDescriptors`. Each tool is projected through
219
+ * `@orkestrel/tool`'s own `toolToDefinition` — the projection `definitions()` applies — so a
220
+ * tool advertises one description across the MCP wire and the WebMCP registry alike. The tool
221
+ * travels beside its descriptor because a registration records which tool it was made for, and
222
+ * a descriptor cannot report that.
223
+ *
224
+ * It refuses rather than skips. WebMCP requires `description`, and each alternative to
225
+ * refusing is worse: an empty string is an invented value a foreign agent reads as a real one,
226
+ * and silently dropping the tool publishes a registry missing a tool its author asked for.
227
+ * Refusing before any registration happens is also what keeps `publish` atomic — nothing is
228
+ * registered when one tool cannot be.
229
+ *
230
+ * @param manager - The tool registry to project
231
+ * @returns One projection per advertised tool, in registry order
232
+ * @throws Thrown as an `MCPError` carrying `-32602` when a tool advertises no description,
233
+ * naming the tool
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * const tools = createToolManager()
238
+ * tools.add(createTool({ name: 'add', description: 'Adds two numbers', execute: () => 5 }))
239
+ * buildWebMCPProjections(tools).map((projection) => projection.descriptor.name) // ['add']
240
+ * ```
241
+ */
242
+ function buildWebMCPProjections(manager) {
243
+ const projections = [];
244
+ for (const tool of manager.tools()) {
245
+ const descriptor = toolToWebMCP(toolToDefinition(tool));
246
+ if (descriptor === void 0) throw new MCPError(`WebMCP requires a description for tool '${tool.name}'`, JSONRPC_INVALID_PARAMS);
247
+ projections.push({
248
+ tool,
249
+ descriptor
250
+ });
251
+ }
252
+ return projections;
253
+ }
254
+ /**
255
+ * Collects the WebMCP projection of every tool a registry advertises that WebMCP can carry.
256
+ *
257
+ * @remarks
258
+ * The skipping sibling of {@link buildWebMCPProjections}, and the reading a followed change
259
+ * reconciles against: a followed change reaches no caller, so a tool advertising neither a
260
+ * `description` nor a `summary` is left out of the collection rather than refusing a batch
261
+ * nobody asked for.
262
+ *
263
+ * @param manager - The tool registry to project
264
+ * @returns One projection per advertised tool WebMCP can carry, in registry order
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * const tools = createToolManager()
269
+ * tools.add(createTool({ name: 'bare', execute: () => 1 }))
270
+ * collectWebMCPProjections(tools) // []
271
+ * ```
272
+ */
273
+ function collectWebMCPProjections(manager) {
274
+ const projections = [];
275
+ for (const tool of manager.tools()) {
276
+ const descriptor = toolToWebMCP(toolToDefinition(tool));
277
+ if (descriptor !== void 0) projections.push({
278
+ tool,
279
+ descriptor
280
+ });
281
+ }
282
+ return projections;
283
+ }
284
+ //#endregion
285
+ //#region src/browser/ModelContext.ts
286
+ /**
287
+ * Bridges a `ToolManagerInterface` and a document's WebMCP tool registry — the
288
+ * {@link ModelContextInterface} {@link import('./factories.js').createModelContext} returns.
289
+ *
290
+ * @remarks
291
+ * - **It borrows the registry, it does not own it.** The handle registers tools, retains one
292
+ * `AbortController` per registration, and aborts exactly those on `destroy`. WebMCP's own
293
+ * unregistration path is that abort. Registration identity is the tool name, per document,
294
+ * so releasing a name releases whatever now stands under it — including a same-name
295
+ * registration another handle made later.
296
+ * - **`publish` snapshots at the call, then follows the manager.** The manager is projected
297
+ * when `publish` is called, before the work queues behind an earlier publication, so a
298
+ * registry mutated while this call waits its turn does not decide what this call registers.
299
+ * The same call subscribes to the manager's own `emitter`, so a later `add`, `remove`, or
300
+ * `clear` reaches the document registry without a second `publish`.
301
+ * - **One manager is followed at a time.** A `publish` naming another manager releases the
302
+ * subscription and takes up the new one, and `destroy` releases it outright. An event from a
303
+ * manager this handle no longer follows is ignored, which is what a listener republishing
304
+ * another manager from inside a dispatch produces. A followed change has no caller to refuse
305
+ * to, so a tool advertising neither a `description` nor a `summary` is left unregistered
306
+ * rather than refusing anything; `publish` still refuses such a batch whole. Under a name
307
+ * this handle never registered that skip emits no `change`, because nothing reached the
308
+ * document registry. A synchronisation registers only what it can carry, so such a tool
309
+ * standing under a name this handle already registered releases that registration rather
310
+ * than leaving it advertising a descriptor the manager no longer stands behind, and that
311
+ * release emits the registry's `change` like any other.
312
+ * - **A followed change is a trigger, not a fact.** Each `add`, `remove`, or `clear` queues one
313
+ * synchronisation of this handle's registrations for that manager against what the manager
314
+ * holds when that queued work runs. The event cannot decide the outcome: `remove` and
315
+ * `clear` name tools the manager no longer holds, an earlier listener in the same dispatch
316
+ * may already have put another tool under one of those names, and the manager's `destroy`
317
+ * empties its map after the `clear` it publishes. Reading the manager converges on its state
318
+ * however the change was reached, so a synchronisation queued and not yet started already
319
+ * covers every change that arrives before it runs and a second one is not queued. A
320
+ * publication queued behind that synchronisation ends its cover, because the publication
321
+ * prunes what the synchronisation registered: a change arriving after that call queues a
322
+ * synchronisation of its own, which runs after the publication.
323
+ * - **A later `publish` reconciles, and so does every synchronisation.** Each name is compared
324
+ * with what this handle already registered for it: the same manager holding the same tool
325
+ * leaves the registration alone, another tool of that same manager advertising an equal
326
+ * descriptor leaves it registered and records the tool it now stands for, and anything else
327
+ * releases it and registers the new descriptor bound to the new manager. A name the snapshot
328
+ * dropped, or a name the manager no longer holds, is released — after the batch has
329
+ * reconciled, never before, so no name is withdrawn while the tools replacing it are still
330
+ * being registered. A failed batch releases them too, and still withdraws nothing it
331
+ * carries. Nothing has to be removed from the manager to make the registry agree with it.
332
+ * - **Work serializes.** Registration is asynchronous and `destroy` is not, so overlapping
333
+ * calls would interleave registrations with the aborts meant to end them. Each publication
334
+ * and each synchronisation queues behind the previous one, and every step re-reads the
335
+ * destroyed flag, so a `destroy` issued mid-publish stops the registrations that have not
336
+ * happened yet instead of racing them.
337
+ * - **Nothing is polyfilled.** A document exposing no registry never reaches this class:
338
+ * {@link import('./factories.js').createModelContext} returns `undefined` instead, so feature
339
+ * absence stays absence rather than becoming a local implementation a caller mistakes for
340
+ * the platform.
341
+ * - **The result shape is the registry's.** An adopted tool resolves whatever `executeTool`
342
+ * resolved, unchanged. WebMCP's IDL types that `Promise<DOMString>` while the
343
+ * specification's README sample returns `{ content: [...] }`; the primary source disagrees
344
+ * with itself, and normalizing either way would encode a guess as a contract.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * import { isWebMCPDocument, ModelContext } from '@orkestrel/mcp/browser'
349
+ *
350
+ * if (isWebMCPDocument(document)) {
351
+ * const bridge = new ModelContext(document)
352
+ * await bridge.publish(tools)
353
+ * }
354
+ * ```
355
+ */
356
+ var ModelContext = class {
357
+ #registry;
358
+ #emitter;
359
+ #registrations = /* @__PURE__ */ new Map();
360
+ #listener;
361
+ #followed = void 0;
362
+ #pendingManager = void 0;
363
+ #queue = Promise.resolve();
364
+ #destroyed = false;
365
+ /**
366
+ * Binds a narrowed document's registry and arms the `toolchange` subscription.
367
+ *
368
+ * @param document - The document whose `modelContext` this handle bridges
369
+ * @param options - The emitter's initial hooks and listener-error handler; see
370
+ * {@link ModelContextOptions}
371
+ */
372
+ constructor(document, options) {
373
+ this.#registry = document.modelContext;
374
+ this.#emitter = new Emitter({
375
+ ...options?.on === void 0 ? {} : { on: options.on },
376
+ ...options?.error === void 0 ? {} : { error: options.error }
377
+ });
378
+ this.#listener = this.#republish.bind(this);
379
+ this.#registry.addEventListener(WEBMCP_CHANGE_EVENT, this.#listener);
380
+ }
381
+ get emitter() {
382
+ return this.#emitter;
383
+ }
384
+ publish(tools, options) {
385
+ const snapshot = attempt(() => buildWebMCPProjections(tools));
386
+ if (!snapshot.success) return Promise.reject(snapshot.error);
387
+ if (!this.#destroyed) this.#follow(tools, options);
388
+ this.#pendingManager = void 0;
389
+ const settled = this.#queue.then(() => this.#publish(tools, snapshot.value, options));
390
+ this.#queue = settled.catch(() => void 0);
391
+ return settled;
392
+ }
393
+ async adopt(options) {
394
+ return (await this.#registry.getTools(options?.origins === void 0 ? {} : { fromOrigins: options.origins })).map((tool) => createTool({
395
+ ...webMCPToTool(tool),
396
+ execute: this.#execute.bind(this, tool)
397
+ }));
398
+ }
399
+ destroy() {
400
+ if (this.#destroyed) return;
401
+ this.#destroyed = true;
402
+ this.#unfollow();
403
+ this.#pendingManager = void 0;
404
+ this.#registry.removeEventListener(WEBMCP_CHANGE_EVENT, this.#listener);
405
+ for (const held of this.#registrations.values()) held.controller.abort();
406
+ this.#registrations.clear();
407
+ this.#emitter.destroy();
408
+ }
409
+ #republish() {
410
+ this.#emitter.emit("change");
411
+ }
412
+ #follow(tools, options) {
413
+ this.#unfollow();
414
+ const changed = this.#changed.bind(this, tools, options);
415
+ tools.emitter.on("add", changed);
416
+ tools.emitter.on("remove", changed);
417
+ tools.emitter.on("clear", changed);
418
+ this.#followed = {
419
+ tools,
420
+ changed
421
+ };
422
+ }
423
+ #unfollow() {
424
+ const followed = this.#followed;
425
+ if (followed === void 0) return;
426
+ this.#followed = void 0;
427
+ followed.tools.emitter.off("add", followed.changed);
428
+ followed.tools.emitter.off("remove", followed.changed);
429
+ followed.tools.emitter.off("clear", followed.changed);
430
+ }
431
+ #follows(tools) {
432
+ return this.#followed?.tools === tools;
433
+ }
434
+ #changed(tools, options) {
435
+ if (!this.#follows(tools)) return;
436
+ if (this.#pendingManager === tools) return;
437
+ this.#pendingManager = tools;
438
+ this.#queue = this.#queue.then(this.#sync.bind(this, tools, options)).catch(() => void 0);
439
+ }
440
+ async #sync(tools, options) {
441
+ if (this.#pendingManager === tools) this.#pendingManager = void 0;
442
+ if (this.#destroyed) return;
443
+ const projections = collectWebMCPProjections(tools);
444
+ try {
445
+ for (const projection of projections) await this.#reconcile(tools, projection, options);
446
+ } finally {
447
+ this.#prune(projections, tools);
448
+ }
449
+ }
450
+ async #publish(tools, projections, options) {
451
+ if (this.#destroyed) return;
452
+ try {
453
+ for (const projection of projections) await this.#reconcile(tools, projection, options);
454
+ } finally {
455
+ this.#prune(projections);
456
+ }
457
+ }
458
+ async #reconcile(tools, projection, options) {
459
+ if (this.#destroyed) return;
460
+ const { descriptor, tool } = projection;
461
+ const held = this.#registrations.get(descriptor.name);
462
+ if (held !== void 0 && held.tools === tools) {
463
+ if (held.tool === tool) return;
464
+ if (matchesDescriptor(held.descriptor, descriptor)) {
465
+ this.#registrations.set(descriptor.name, {
466
+ ...held,
467
+ tool
468
+ });
469
+ return;
470
+ }
471
+ }
472
+ if (held !== void 0) {
473
+ this.#registrations.delete(descriptor.name);
474
+ held.controller.abort();
475
+ if (this.#destroyed) return;
476
+ }
477
+ await this.#register(tools, projection, options);
478
+ }
479
+ async #register(tools, projection, options) {
480
+ const { descriptor, tool } = projection;
481
+ const controller = new AbortController();
482
+ this.#registrations.set(descriptor.name, {
483
+ controller,
484
+ descriptor,
485
+ tool,
486
+ tools
487
+ });
488
+ try {
489
+ await this.#registry.registerTool({
490
+ ...descriptor,
491
+ execute: this.#run.bind(this, tools, descriptor.name)
492
+ }, {
493
+ ...options?.origins === void 0 ? {} : { exposedTo: options.origins },
494
+ signal: controller.signal
495
+ });
496
+ } catch (error) {
497
+ this.#registrations.delete(descriptor.name);
498
+ controller.abort();
499
+ throw error;
500
+ }
501
+ }
502
+ #prune(projections, tools) {
503
+ if (this.#destroyed) return;
504
+ const kept = new Set(projections.map((projection) => projection.descriptor.name));
505
+ for (const [name, held] of this.#registrations) {
506
+ if (kept.has(name) || tools !== void 0 && held.tools !== tools) continue;
507
+ this.#registrations.delete(name);
508
+ held.controller.abort();
509
+ if (this.#destroyed) return;
510
+ }
511
+ }
512
+ async #run(tools, name, input, options) {
513
+ const result = await tools.execute({
514
+ id: crypto.randomUUID(),
515
+ name,
516
+ arguments: input
517
+ }, { signal: options.signal });
518
+ if (!result.success) throw new Error(result.error);
519
+ return result.value;
520
+ }
521
+ #execute(registered, args, context) {
522
+ return this.#registry.executeTool(registered, args, { signal: context.signal });
523
+ }
524
+ };
9
525
  //#endregion
10
526
  //#region src/browser/transports/MessagePortTransport.ts
11
527
  /**
@@ -174,7 +690,7 @@ var WebSocketClientTransport = class {
174
690
  this.#emitter = new Emitter();
175
691
  this.#url = options.url;
176
692
  const protocols = options.protocols;
177
- this.#protocols = typeof protocols === "string" ? protocols : protocols === void 0 ? MCP_WEBSOCKET_SUBPROTOCOL : protocols.length === 0 ? void 0 : [...protocols];
693
+ this.#protocols = isString(protocols) ? protocols : protocols === void 0 ? MCP_WEBSOCKET_SUBPROTOCOL : protocols.length === 0 ? void 0 : [...protocols];
178
694
  }
179
695
  get emitter() {
180
696
  return this.#emitter;
@@ -549,7 +1065,123 @@ function createScopeTransport(scope) {
549
1065
  }
550
1066
  };
551
1067
  }
1068
+ /**
1069
+ * Creates an `MCPServer` hosted inside the calling page and hands back the client bound to it
1070
+ * — the page twin of {@link createScopeServer}, and the in-page MCP pair as one call.
1071
+ *
1072
+ * @remarks
1073
+ * The pair is a native `MessageChannel`: the server binds `port1`, the client drives `port2`,
1074
+ * and no byte leaves the page. That is the point of the factory — a consumer assembling it by
1075
+ * hand writes the channel, two transports, `bindServer`, `createDuplexClientTransport`,
1076
+ * `createMCPClient`, and `bindClient`, in an order {@link MessagePortTransport}'s own doc warns
1077
+ * about: a `MessagePort` starts dispatching at construction, so an `await` interleaved between
1078
+ * a transport and its binder drops whatever arrived in the gap. This factory never suspends
1079
+ * between the two.
1080
+ *
1081
+ * The returned client is bound but not connected. Connection is a protocol round trip, so it
1082
+ * stays the consumer's `await client.connect()` rather than a promise this call hides — and a
1083
+ * factory that returned a promise could not return the terminal beside it.
1084
+ *
1085
+ * `stop` closes the client's port first, so the client observes the close and reports
1086
+ * `connected` as `false` with its pending requests rejected, then unbinds both sides and
1087
+ * closes the server's port. It is idempotent, and it takes the twin's verb because it is the
1088
+ * twin's action: {@link createScopeServer} publishes `stop` for ending a hosted server's
1089
+ * bindings, and a consumer who learned one factory reads the other without checking.
1090
+ *
1091
+ * The published `client` outlives the pair and is inert after `stop`: every session-bound
1092
+ * request issued on it — `call`, `tools`, each `tasks/*` method, and a `listen` stream on its
1093
+ * first `next()` — rejects at once with an `MCPError` carrying `-32600`, rather than waiting
1094
+ * out its request deadline against a channel nothing is listening on.
1095
+ *
1096
+ * @param options - The tools, the optional server identity, and the optional client settings;
1097
+ * see {@link PageServerOptions}
1098
+ * @returns A {@link PageServerInterface} holding the bound client and the pair's `stop`
1099
+ *
1100
+ * @example
1101
+ * ```ts
1102
+ * import { createPageServer } from '@orkestrel/mcp/browser'
1103
+ * import { createTool, createToolManager } from '@orkestrel/tool'
1104
+ *
1105
+ * const tools = createToolManager()
1106
+ * tools.add(createTool({ name: 'add', execute: () => 5 }))
1107
+ *
1108
+ * const page = createPageServer({ tools })
1109
+ * await page.client.connect()
1110
+ * const value = await page.client.call('add', {}) // { resultType: 'complete', value: 5 }
1111
+ * page.stop()
1112
+ * ```
1113
+ */
1114
+ function createPageServer(options) {
1115
+ const { port1, port2 } = new MessageChannel();
1116
+ const server = createMCPServer({
1117
+ tools: options.tools,
1118
+ identity: {
1119
+ name: options.name ?? "@orkestrel/mcp",
1120
+ version: options.version ?? "1.0.0"
1121
+ }
1122
+ });
1123
+ const hosted = new MessagePortTransport({ port: port1 });
1124
+ const unbindServer = bindServer(server, hosted);
1125
+ const driven = new MessagePortTransport({ port: port2 });
1126
+ const client = createMCPClient({
1127
+ ...options.client,
1128
+ transport: createDuplexClientTransport(driven)
1129
+ });
1130
+ const unbindClient = bindClient(client, driven);
1131
+ let stopped = false;
1132
+ return {
1133
+ client,
1134
+ stop() {
1135
+ if (stopped) return;
1136
+ stopped = true;
1137
+ driven.close();
1138
+ unbindClient();
1139
+ unbindServer();
1140
+ hosted.close();
1141
+ }
1142
+ };
1143
+ }
1144
+ /**
1145
+ * Creates the bridge between a tool registry and a document's WebMCP registry, or reports that
1146
+ * the document exposes none.
1147
+ *
1148
+ * @remarks
1149
+ * Feature detection is the return value: `undefined` means this document has no
1150
+ * `document.modelContext`, which is the reading every browser gives today — the specification
1151
+ * is incubating in a Community Group, and the chromestatus record, read 2026-09-15 and last
1152
+ * updated 2026-08-12, reports `Proposed` with `"flag": false` and `"origintrial": false`. There
1153
+ * is no `supported` flag to read and no polyfill behind the factory, because a local
1154
+ * implementation of an absent platform feature is one a caller mistakes for the platform.
1155
+ *
1156
+ * The bridge borrows the registry. It aborts only the registrations it made, so a name it
1157
+ * never registered is left exactly as it found it. WebMCP keys a registration by tool name per
1158
+ * document, so releasing a name releases whatever now stands under it — a same-name
1159
+ * registration the page or another bridge made later goes with it.
1160
+ *
1161
+ * @param options - The document to bridge and the emitter's initial wiring; see
1162
+ * {@link ModelContextOptions}
1163
+ * @returns A {@link ModelContextInterface}, or `undefined` when the document exposes no
1164
+ * WebMCP registry
1165
+ *
1166
+ * @example
1167
+ * ```ts
1168
+ * import { createModelContext } from '@orkestrel/mcp/browser'
1169
+ * import { createToolManager } from '@orkestrel/tool'
1170
+ *
1171
+ * const bridge = createModelContext()
1172
+ * if (bridge !== undefined) {
1173
+ * await bridge.publish(createToolManager())
1174
+ * const foreign = await bridge.adopt()
1175
+ * bridge.destroy()
1176
+ * }
1177
+ * ```
1178
+ */
1179
+ function createModelContext(options) {
1180
+ const host = options?.document ?? globalThis.document;
1181
+ if (!isWebMCPDocument(host)) return void 0;
1182
+ return new ModelContext(host, options);
1183
+ }
552
1184
  //#endregion
553
- export { DEFAULT_MCP_SERVER_NAME, DEFAULT_MCP_SERVER_VERSION, MessagePortTransport, WebSocketClientTransport, createHTTPClientTransport, createMessagePortTransport, createScopeMessageListener, createScopeServer, createScopeTransport, createWebSocketClientTransport };
1185
+ export { DEFAULT_MCP_SERVER_NAME, DEFAULT_MCP_SERVER_VERSION, MessagePortTransport, ModelContext, WEBMCP_CHANGE_EVENT, WebSocketClientTransport, buildWebMCPProjections, collectWebMCPProjections, createHTTPClientTransport, createMessagePortTransport, createModelContext, createPageServer, createScopeMessageListener, createScopeServer, createScopeTransport, createWebSocketClientTransport, describeWebMCPTool, isWebMCPDocument, isWebMCPRegistry, matchesDescriptor, toolAnnotationsToWebMCP, toolToWebMCP, webMCPAnnotationsToTool, webMCPToTool };
554
1186
 
555
1187
  //# sourceMappingURL=index.js.map