@orkestrel/mcp 0.0.29 → 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.
- package/dist/src/browser/index.d.ts +786 -0
- package/dist/src/browser/index.js +636 -4
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +216 -42
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +144 -32
- package/dist/src/core/index.d.ts +144 -32
- package/dist/src/core/index.js +214 -44
- package/dist/src/core/index.js.map +1 -1
- package/package.json +9 -9
|
@@ -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 =
|
|
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
|