catalyst-core 1.0.1 → 1.0.2-canary.2
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/bin/catalyst.js +105 -1
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +50 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +61 -94
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +335 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +314 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +115 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +65 -9
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +19 -1
- package/dist/vite/vite.config.js +96 -19
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +20 -14
- package/dist/web-router/components/MetaTag.test.jsx +108 -32
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +124 -0
- package/package.json +4 -7
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/preflight.test.ts +100 -2
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/manifestCache.test.ts +13 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/vitest.config.ts +4 -0
- package/dist/scripts/checkVersion.js +0 -3
- package/dist/scripts/cleanCache.js +0 -30
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework-side WebMCP registry — the piece the discussion argues only
|
|
3
|
+
* a framework (not a component-scoped library) can own.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Single point that talks to `document.modelContext` (native, or a shim
|
|
7
|
+
* the app opted into via `catalyst-core/webmcp/shim`).
|
|
8
|
+
* - Track every live tool with its *owner* (a hook instance) and the
|
|
9
|
+
* *route path* it was registered under.
|
|
10
|
+
* - Give `WebMcpProvider` a way to abort every tool belonging to routes
|
|
11
|
+
* that are no longer matched (agent navigated away → stale tools die).
|
|
12
|
+
* - Namespacing: if two routes both want "add_to_cart", the later one is
|
|
13
|
+
* rejected loudly rather than silently clobbering.
|
|
14
|
+
*
|
|
15
|
+
* This module holds process-wide singleton state on purpose — there is one
|
|
16
|
+
* `document.modelContext` per document, so there is one registry.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { WebMcpError, WEBMCP_ERROR_CODES, toWebMcpError } from "./errors.js"
|
|
20
|
+
|
|
21
|
+
/** ownerKey -> { name, routePath, receipt, abortController, spec } */
|
|
22
|
+
const live = new Map()
|
|
23
|
+
let _ownerSeq = 0
|
|
24
|
+
let _callSeq = 0
|
|
25
|
+
|
|
26
|
+
export const newOwnerKey = () => `owner_${(++_ownerSeq).toString(36)}`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Structural equality for JSON-schema-shaped values (inputSchema,
|
|
30
|
+
* annotations) — good enough here because both are always plain
|
|
31
|
+
* JSON-serialisable objects (no functions, no cycles, key order doesn't
|
|
32
|
+
* matter for a schema's meaning).
|
|
33
|
+
*/
|
|
34
|
+
function deepEqual(a, b) {
|
|
35
|
+
if (a === b) return true
|
|
36
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false
|
|
37
|
+
const aKeys = Object.keys(a)
|
|
38
|
+
const bKeys = Object.keys(b)
|
|
39
|
+
if (aKeys.length !== bKeys.length) return false
|
|
40
|
+
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The backend `document.modelContext` — native, or whatever the app
|
|
45
|
+
* installed there itself (e.g. `catalyst-core/webmcp/shim`'s `installShim()`
|
|
46
|
+
* for a browser with no native WebMCP support yet). Registry code makes no
|
|
47
|
+
* distinction between the two; whoever put an object at `document.
|
|
48
|
+
* modelContext` (or `navigator.modelContext`) owns that decision, not this
|
|
49
|
+
* module.
|
|
50
|
+
*
|
|
51
|
+
* When there is NONE (SSR, unit tests with no DOM, or a browser where the
|
|
52
|
+
* app chose not to install a shim), falls back to an in-memory stand-in so
|
|
53
|
+
* the registry's own logic (collision detection, reap, invoke) stays
|
|
54
|
+
* exercisable. The stand-in never touches globals and never persists past
|
|
55
|
+
* the process — it is a safety net, not a substitute for real discovery: an
|
|
56
|
+
* agent watching `document.modelContext` sees nothing while it's active. See
|
|
57
|
+
* WebMcpProvider's one-time console.warn for surfacing that to a developer.
|
|
58
|
+
*/
|
|
59
|
+
function ctx() {
|
|
60
|
+
const api = existingModelContext()
|
|
61
|
+
if (api) return api
|
|
62
|
+
if (!_fallbackApi) _fallbackApi = createFallbackBackend()
|
|
63
|
+
return _fallbackApi
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function existingModelContext() {
|
|
67
|
+
if (typeof document !== "undefined" && document.modelContext) return document.modelContext
|
|
68
|
+
if (typeof navigator !== "undefined" && navigator.modelContext) return navigator.modelContext
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let _fallbackApi = null
|
|
73
|
+
function createFallbackBackend() {
|
|
74
|
+
const map = new Map()
|
|
75
|
+
let seq = 0
|
|
76
|
+
return {
|
|
77
|
+
__isFallback: true,
|
|
78
|
+
registerTool(spec) {
|
|
79
|
+
const id = `fb_${(++seq).toString(36)}`
|
|
80
|
+
const replaced = map.has(spec.name)
|
|
81
|
+
map.set(spec.name, spec)
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
name: spec.name,
|
|
85
|
+
registeredAt: Date.now(),
|
|
86
|
+
replaced,
|
|
87
|
+
unregister: () => map.delete(spec.name),
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
getTools() {
|
|
91
|
+
return Array.from(map.values()).map((s) => ({
|
|
92
|
+
name: s.name,
|
|
93
|
+
description: s.description || "",
|
|
94
|
+
inputSchema: s.inputSchema || { type: "object", properties: {} },
|
|
95
|
+
}))
|
|
96
|
+
},
|
|
97
|
+
executeTool(name, args) {
|
|
98
|
+
const s = map.get(name)
|
|
99
|
+
if (!s) {
|
|
100
|
+
const e = new Error(`no tool "${name}"`)
|
|
101
|
+
e.code = "TOOL_NOT_FOUND"
|
|
102
|
+
throw e
|
|
103
|
+
}
|
|
104
|
+
return s.execute(args)
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Lifecycle events for a dev panel or other observer. Emitted here
|
|
111
|
+
* (framework layer) rather than inside any shim, so behaviour is identical
|
|
112
|
+
* whether the backend is native or a shim.
|
|
113
|
+
* webmcp:register { id, name, routePath, replaced }
|
|
114
|
+
* webmcp:unregister { id, name }
|
|
115
|
+
* webmcp:execute:start { callId, name, args }
|
|
116
|
+
* webmcp:execute:end { callId, name, ok, result?, error? }
|
|
117
|
+
*/
|
|
118
|
+
function emit(type, detail) {
|
|
119
|
+
if (typeof document === "undefined") return
|
|
120
|
+
try {
|
|
121
|
+
document.dispatchEvent(new CustomEvent(type, { detail }))
|
|
122
|
+
} catch {
|
|
123
|
+
/* CustomEvent unavailable — an observer just won't live-update */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether ANY document.modelContext is present — native or an app-installed
|
|
129
|
+
* shim. False means registrations land in the invisible in-memory fallback.
|
|
130
|
+
*/
|
|
131
|
+
export function hasBackend() {
|
|
132
|
+
return !!existingModelContext()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether the active backend is genuinely native — as opposed to nothing
|
|
137
|
+
* (the in-memory fallback) or an app-installed shim (which self-marks with
|
|
138
|
+
* `__isWebMcpShim`, the same convention `catalyst-core/webmcp/shim` uses).
|
|
139
|
+
* A dev panel or other observer can use this to label what it's showing.
|
|
140
|
+
*/
|
|
141
|
+
export function isNative() {
|
|
142
|
+
const api = existingModelContext()
|
|
143
|
+
return !!api && !api.__isWebMcpShim
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Register a tool on behalf of a hook instance.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} ownerKey stable id for the calling useTool() instance
|
|
150
|
+
* @param {string} routePath the route pattern this tool lives under ("" if unknown)
|
|
151
|
+
* @param {object} spec { name, description, inputSchema, annotations, execute }
|
|
152
|
+
* @returns {{ ok: true, receipt: object } | { ok: false, error: WebMcpError }}
|
|
153
|
+
*/
|
|
154
|
+
export function registerTool(ownerKey, routePath, spec) {
|
|
155
|
+
const api = ctx()
|
|
156
|
+
|
|
157
|
+
// Same owner re-registering (StrictMode remount, fast refresh): tear the
|
|
158
|
+
// old one down first so we don't trip our own collision check.
|
|
159
|
+
if (live.has(ownerKey)) {
|
|
160
|
+
unregisterTool(ownerKey)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Collision check across *other* owners — a genuine "two components want the
|
|
164
|
+
// same tool name right now" clash.
|
|
165
|
+
for (const [key, entry] of live) {
|
|
166
|
+
if (key !== ownerKey && entry.name === spec.name) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: new WebMcpError(WEBMCP_ERROR_CODES.DUPLICATE_TOOL, {
|
|
170
|
+
message: `Tool "${spec.name}" is already registered by route "${entry.routePath || "?"}".`,
|
|
171
|
+
details: "Two components asked for the same tool name at the same time. Rename one, or scope it per route.",
|
|
172
|
+
}),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const abortController = new AbortController()
|
|
178
|
+
|
|
179
|
+
// The mutable, user-facing half of the spec. updateToolSpec() swaps
|
|
180
|
+
// fields on this object in place, then re-registers with the backend so
|
|
181
|
+
// the agent-visible schema (what Chrome/a shim actually serialised at
|
|
182
|
+
// registerTool time) reflects the change too — mutating this object
|
|
183
|
+
// alone only affects our own validation, not what the agent sees.
|
|
184
|
+
const userSpec = {
|
|
185
|
+
description: spec.description || "",
|
|
186
|
+
inputSchema: spec.inputSchema || { type: "object", properties: {} },
|
|
187
|
+
annotations: spec.annotations || {},
|
|
188
|
+
execute: spec.execute,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wrap execute so: (a) the AbortSignal is passed through to the tool,
|
|
192
|
+
// (b) a call that arrives after this owner is genuinely gone from the
|
|
193
|
+
// registry is rejected, (c) thrown errors become WebMcpError.
|
|
194
|
+
//
|
|
195
|
+
// Note: we check `live` membership, NOT `abortController.signal.aborted`.
|
|
196
|
+
// A native `document.modelContext` keeps its own reference to this wrapped
|
|
197
|
+
// execute; if the hook re-registers (StrictMode double-invoke, a transient
|
|
198
|
+
// re-render) the old controller is aborted but the tool is still very much
|
|
199
|
+
// usable under a fresh entry. Only a real unmount / route-reap removes the
|
|
200
|
+
// owner from `live`, and that is the true "no longer active" signal.
|
|
201
|
+
function buildWrappedSpec() {
|
|
202
|
+
return {
|
|
203
|
+
name: spec.name,
|
|
204
|
+
description: userSpec.description,
|
|
205
|
+
inputSchema: userSpec.inputSchema,
|
|
206
|
+
annotations: userSpec.annotations,
|
|
207
|
+
execute: async (args = {}) => {
|
|
208
|
+
const callId = `call_${(++_callSeq).toString(36)}`
|
|
209
|
+
emit("webmcp:execute:start", { callId, name: spec.name, args })
|
|
210
|
+
try {
|
|
211
|
+
const current = live.get(ownerKey)
|
|
212
|
+
const stillActive =
|
|
213
|
+
!!current || Array.from(live.values()).some((e) => e.name === spec.name)
|
|
214
|
+
if (!stillActive) {
|
|
215
|
+
throw new WebMcpError(WEBMCP_ERROR_CODES.ABORTED, {
|
|
216
|
+
message: `Tool "${spec.name}" is no longer active (its component unmounted or its route was left).`,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
// Read inputSchema/execute through userSpec (by reference,
|
|
220
|
+
// captured in this closure) so a call in flight when
|
|
221
|
+
// updateToolSpec() mutates it still validates + dispatches
|
|
222
|
+
// against whatever is current right now — not what was
|
|
223
|
+
// current when this particular wrapper was built.
|
|
224
|
+
const signal = current ? current.abortController.signal : abortController.signal
|
|
225
|
+
const argErr = shallowValidate(userSpec.inputSchema, args)
|
|
226
|
+
if (argErr) throw argErr
|
|
227
|
+
const result = await userSpec.execute(args, { signal })
|
|
228
|
+
emit("webmcp:execute:end", { callId, name: spec.name, ok: true, result })
|
|
229
|
+
return result
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const wrapped = toWebMcpError(err)
|
|
232
|
+
emit("webmcp:execute:end", {
|
|
233
|
+
callId,
|
|
234
|
+
name: spec.name,
|
|
235
|
+
ok: false,
|
|
236
|
+
error: { message: wrapped.message, code: wrapped.code },
|
|
237
|
+
})
|
|
238
|
+
throw wrapped
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const wrappedSpec = buildWrappedSpec()
|
|
245
|
+
|
|
246
|
+
let rawReceipt
|
|
247
|
+
try {
|
|
248
|
+
rawReceipt = api.registerTool(wrappedSpec)
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return { ok: false, error: toWebMcpError(err) }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Normalise: native `registerTool` shapes vary. It may return nothing, a
|
|
254
|
+
// bare id string, a Promise, or an object without `unregister`. We always
|
|
255
|
+
// hand back a receipt with a stable shape; teardown falls back to a
|
|
256
|
+
// best-effort `api.unregisterTool(name)` when the receipt can't do it.
|
|
257
|
+
const receipt = normaliseReceipt(rawReceipt, spec.name, api)
|
|
258
|
+
|
|
259
|
+
live.set(ownerKey, {
|
|
260
|
+
name: spec.name,
|
|
261
|
+
routePath: routePath || "",
|
|
262
|
+
receipt,
|
|
263
|
+
abortController,
|
|
264
|
+
spec: wrappedSpec,
|
|
265
|
+
userSpec,
|
|
266
|
+
api,
|
|
267
|
+
buildWrappedSpec,
|
|
268
|
+
})
|
|
269
|
+
emit("webmcp:register", {
|
|
270
|
+
id: receipt.id,
|
|
271
|
+
name: spec.name,
|
|
272
|
+
routePath: routePath || "",
|
|
273
|
+
replaced: !!receipt.replaced,
|
|
274
|
+
})
|
|
275
|
+
return { ok: true, receipt }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Update a live tool's route scope in place, without a re-register. Called when
|
|
280
|
+
* the component stays mounted but its matched route changes (transient re-match
|
|
281
|
+
* during navigation, nested-route shuffle). Keeps the same AbortController, so
|
|
282
|
+
* an agent call already in flight is unaffected.
|
|
283
|
+
*/
|
|
284
|
+
export function updateToolRoute(ownerKey, routePath) {
|
|
285
|
+
const entry = live.get(ownerKey)
|
|
286
|
+
if (entry) entry.routePath = routePath || ""
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Swap description/inputSchema/annotations on a live tool — used by
|
|
291
|
+
* useTool(spec, deps) when a dep changes. Two things happen, both without
|
|
292
|
+
* touching `ownerKey` or `abortController`:
|
|
293
|
+
*
|
|
294
|
+
* 1. `entry.userSpec` is mutated in place, so a call ALREADY in flight
|
|
295
|
+
* (dispatched through the OLD wrappedSpec.execute closure, which reads
|
|
296
|
+
* userSpec by reference) validates + resolves against the new schema
|
|
297
|
+
* the instant this runs — it never sees a torn-down registration.
|
|
298
|
+
* 2. The backend registration is refreshed: the old receipt is
|
|
299
|
+
* unregistered and a fresh wrappedSpec is registered under the SAME
|
|
300
|
+
* `live` entry, so `document.modelContext` actually serves the updated
|
|
301
|
+
* description/inputSchema to the agent. Mutating `userSpec` alone (as
|
|
302
|
+
* an earlier version of this function did) only fixed our own
|
|
303
|
+
* validation — the backend had already captured the stale schema at
|
|
304
|
+
* the original `registerTool` call and never saw the update.
|
|
305
|
+
*
|
|
306
|
+
* This is NOT the WEBMCP_ABORTED bug from useTool's mount effect: that bug
|
|
307
|
+
* was re-running `registerTool` with a FRESH ownerKey/AbortController on
|
|
308
|
+
* every route recompute. Here the owner and controller are identical across
|
|
309
|
+
* the swap — `live.get(ownerKey)` still resolves for an in-flight call, and
|
|
310
|
+
* `current.abortController` is the same object before and after.
|
|
311
|
+
*
|
|
312
|
+
* `execute` itself is deliberately NOT accepted here — useTool's own
|
|
313
|
+
* specRef-through-liveSpec.execute indirection already means every call
|
|
314
|
+
* dispatches to the latest closure regardless of deps, so there is nothing
|
|
315
|
+
* for a deps change to update on that front.
|
|
316
|
+
*/
|
|
317
|
+
export function updateToolSpec(ownerKey, { description, inputSchema, annotations } = {}) {
|
|
318
|
+
const entry = live.get(ownerKey)
|
|
319
|
+
if (!entry) return
|
|
320
|
+
|
|
321
|
+
// Deep-equal, not reference-equal: callers (useTool's deps effect) pass a
|
|
322
|
+
// freshly-built inputSchema/annotations object on every dep change, even
|
|
323
|
+
// when a particular dep (e.g. a UI-only selection that doesn't affect the
|
|
324
|
+
// schema shape) didn't actually change the CONTENT. Re-registering with
|
|
325
|
+
// the backend on every such no-op call would spam unregister/register
|
|
326
|
+
// (visible as spurious REGISTERED events to any observer) for no reason.
|
|
327
|
+
const changed =
|
|
328
|
+
(description !== undefined && description !== entry.userSpec.description) ||
|
|
329
|
+
(inputSchema !== undefined && !deepEqual(inputSchema, entry.userSpec.inputSchema)) ||
|
|
330
|
+
(annotations !== undefined && !deepEqual(annotations, entry.userSpec.annotations))
|
|
331
|
+
if (!changed) return
|
|
332
|
+
|
|
333
|
+
if (description !== undefined) entry.userSpec.description = description
|
|
334
|
+
if (inputSchema !== undefined) entry.userSpec.inputSchema = inputSchema
|
|
335
|
+
if (annotations !== undefined) entry.userSpec.annotations = annotations
|
|
336
|
+
|
|
337
|
+
// Re-register with the backend so the agent-visible schema updates too.
|
|
338
|
+
// Best-effort teardown of the old receipt (native impls vary in whether
|
|
339
|
+
// unregister exists/throws); the new registerTool call is what actually
|
|
340
|
+
// matters — the old entry in `live` is overwritten with the new receipt
|
|
341
|
+
// and wrappedSpec, everything else (ownerKey, abortController) untouched.
|
|
342
|
+
try {
|
|
343
|
+
entry.receipt && entry.receipt.unregister && entry.receipt.unregister()
|
|
344
|
+
} catch {
|
|
345
|
+
/* best effort, same as unregisterTool */
|
|
346
|
+
}
|
|
347
|
+
const newWrappedSpec = entry.buildWrappedSpec()
|
|
348
|
+
let rawReceipt
|
|
349
|
+
try {
|
|
350
|
+
rawReceipt = entry.api.registerTool(newWrappedSpec)
|
|
351
|
+
} catch {
|
|
352
|
+
// Backend rejected the re-register (e.g. a transient native error).
|
|
353
|
+
// Leave the entry's userSpec updated (our own validation is still
|
|
354
|
+
// correct) but keep the OLD receipt/spec live rather than losing the
|
|
355
|
+
// registration entirely.
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
entry.receipt = normaliseReceipt(rawReceipt, entry.name, entry.api)
|
|
359
|
+
entry.spec = newWrappedSpec
|
|
360
|
+
emit("webmcp:register", {
|
|
361
|
+
id: entry.receipt.id,
|
|
362
|
+
name: entry.name,
|
|
363
|
+
routePath: entry.routePath,
|
|
364
|
+
replaced: true,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Unregister one owner's tool (hook unmount). Idempotent. */
|
|
369
|
+
export function unregisterTool(ownerKey) {
|
|
370
|
+
const entry = live.get(ownerKey)
|
|
371
|
+
if (!entry) return
|
|
372
|
+
entry.abortController.abort()
|
|
373
|
+
try {
|
|
374
|
+
entry.receipt && entry.receipt.unregister && entry.receipt.unregister()
|
|
375
|
+
} catch {
|
|
376
|
+
/* native impl may not expose unregister on the receipt — best effort */
|
|
377
|
+
}
|
|
378
|
+
live.delete(ownerKey)
|
|
379
|
+
emit("webmcp:unregister", { id: entry.receipt && entry.receipt.id, name: entry.name })
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Called by WebMcpProvider after every navigation with the set of still-matched
|
|
384
|
+
* route paths. Any tool whose routePath is not in that set is aborted +
|
|
385
|
+
* unregistered. Tools with routePath "" (not route-scoped) are left alone.
|
|
386
|
+
*
|
|
387
|
+
* @param {Set<string>} matchedPaths
|
|
388
|
+
* @returns {string[]} names of tools that were reaped (for logging / a panel)
|
|
389
|
+
*/
|
|
390
|
+
export function reapUnmatched(matchedPaths) {
|
|
391
|
+
const reaped = []
|
|
392
|
+
for (const [key, entry] of live) {
|
|
393
|
+
if (!entry.routePath) continue
|
|
394
|
+
if (!matchedPaths.has(entry.routePath)) {
|
|
395
|
+
reaped.push(entry.name)
|
|
396
|
+
unregisterTool(key)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return reaped
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Invoke a tool by name through the registry's own wrapped execute (which
|
|
404
|
+
* emits the execute:start/end events and applies arg validation + abort
|
|
405
|
+
* checks). Useful to a dev panel so it works regardless of whether the
|
|
406
|
+
* backend exposes a page-side executeTool().
|
|
407
|
+
*/
|
|
408
|
+
export async function invokeTool(name, args = {}) {
|
|
409
|
+
for (const entry of live.values()) {
|
|
410
|
+
if (entry.name === name) return entry.spec.execute(args)
|
|
411
|
+
}
|
|
412
|
+
const err = new WebMcpError(WEBMCP_ERROR_CODES.TOOL_NOT_FOUND, {
|
|
413
|
+
message: `No tool named "${name}" is registered.`,
|
|
414
|
+
})
|
|
415
|
+
emit("webmcp:execute:end", { callId: `call_${(++_callSeq).toString(36)}`, name, ok: false, error: { message: err.message, code: err.code } })
|
|
416
|
+
throw err
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let _receiptSeq = 0
|
|
420
|
+
function normaliseReceipt(raw, name, api) {
|
|
421
|
+
// Object receipt with an unregister fn — use as-is, fill missing fields.
|
|
422
|
+
if (raw && typeof raw === "object" && typeof raw.unregister === "function") {
|
|
423
|
+
return {
|
|
424
|
+
id: raw.id || `r_${(++_receiptSeq).toString(36)}`,
|
|
425
|
+
name: raw.name || name,
|
|
426
|
+
registeredAt: raw.registeredAt || Date.now(),
|
|
427
|
+
replaced: !!raw.replaced,
|
|
428
|
+
unregister: raw.unregister.bind(raw),
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Anything else (undefined, string id, plain object, Promise): synthesise.
|
|
432
|
+
const id =
|
|
433
|
+
typeof raw === "string"
|
|
434
|
+
? raw
|
|
435
|
+
: raw && typeof raw === "object" && raw.id
|
|
436
|
+
? raw.id
|
|
437
|
+
: `r_${(++_receiptSeq).toString(36)}`
|
|
438
|
+
return {
|
|
439
|
+
id,
|
|
440
|
+
name,
|
|
441
|
+
registeredAt: Date.now(),
|
|
442
|
+
replaced: false,
|
|
443
|
+
unregister: () => {
|
|
444
|
+
if (api && typeof api.unregisterTool === "function") {
|
|
445
|
+
try {
|
|
446
|
+
api.unregisterTool(name)
|
|
447
|
+
return true
|
|
448
|
+
} catch {
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return false
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Test-only: drop all live tools and the fallback backend. */
|
|
458
|
+
export function __resetForTests() {
|
|
459
|
+
for (const key of Array.from(live.keys())) unregisterTool(key)
|
|
460
|
+
live.clear()
|
|
461
|
+
_fallbackApi = null
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Test-only: what the BACKEND (native document.modelContext, an installed
|
|
466
|
+
* shim, or the in-memory fallback in unit tests) thinks the current tool
|
|
467
|
+
* list/schemas are — as opposed to `inspect()`, which reports the
|
|
468
|
+
* registry's own bookkeeping. Used to assert that updateToolSpec()'s
|
|
469
|
+
* re-register actually reaches the agent-visible side, not just our
|
|
470
|
+
* internal validation.
|
|
471
|
+
*/
|
|
472
|
+
export function __getBackendTools() {
|
|
473
|
+
return ctx().getTools()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Snapshot for a dev panel or other observer. */
|
|
477
|
+
export function inspect() {
|
|
478
|
+
return {
|
|
479
|
+
isNative: isNative(),
|
|
480
|
+
tools: Array.from(live.values()).map((e) => ({
|
|
481
|
+
name: e.name,
|
|
482
|
+
routePath: e.routePath,
|
|
483
|
+
receiptId: e.receipt && e.receipt.id,
|
|
484
|
+
registeredAt: e.receipt && e.receipt.registeredAt,
|
|
485
|
+
aborted: e.abortController.signal.aborted,
|
|
486
|
+
})),
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Shallow inputSchema check — top-level `required`, then `properties`,
|
|
492
|
+
* `type: number|string|boolean|integer`, and `minimum`. Enough to reject
|
|
493
|
+
* obviously-wrong agent args with a typed error without pulling in a full
|
|
494
|
+
* JSON Schema validator.
|
|
495
|
+
*/
|
|
496
|
+
function shallowValidate(schema, args) {
|
|
497
|
+
if (!schema || schema.type !== "object") return null
|
|
498
|
+
if (Array.isArray(schema.required)) {
|
|
499
|
+
const missing = schema.required.filter((k) => !args || args[k] === undefined || args[k] === null)
|
|
500
|
+
if (missing.length) {
|
|
501
|
+
return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
502
|
+
message: `Missing required argument${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}.`,
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (!schema.properties) return null
|
|
507
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
508
|
+
if (!(key in args) || args[key] === undefined) continue // presence handled by `required` above
|
|
509
|
+
const val = args[key]
|
|
510
|
+
const t = propSchema.type
|
|
511
|
+
if (t === "number" || t === "integer") {
|
|
512
|
+
if (typeof val !== "number" || Number.isNaN(val)) {
|
|
513
|
+
return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
514
|
+
message: `Argument "${key}" must be a number.`,
|
|
515
|
+
details: `Received ${JSON.stringify(val)} (${typeof val}).`,
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
if (t === "integer" && !Number.isInteger(val)) {
|
|
519
|
+
return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
520
|
+
message: `Argument "${key}" must be an integer.`,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
if (typeof propSchema.minimum === "number" && val < propSchema.minimum) {
|
|
524
|
+
return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
525
|
+
message: `Argument "${key}" must be >= ${propSchema.minimum}.`,
|
|
526
|
+
details: `Received ${val}.`,
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
} else if (t === "string" && typeof val !== "string") {
|
|
530
|
+
return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
531
|
+
message: `Argument "${key}" must be a string.`,
|
|
532
|
+
})
|
|
533
|
+
} else if (t === "boolean" && typeof val !== "boolean") {
|
|
534
|
+
return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
535
|
+
message: `Argument "${key}" must be a boolean.`,
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null
|
|
540
|
+
}
|