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.
Files changed (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. package/dist/scripts/cleanCache.js +0 -30
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Registry-level unit tests — no browser, no React.
3
+ *
4
+ * These pin the two behaviours the headless E2E can only observe indirectly:
5
+ * - reapUnmatched() actually removes route-scoped tools whose route is gone
6
+ * (in the browser, React unmount cleanup usually gets there first, so the
7
+ * E2E can't tell which mechanism fired).
8
+ * - a same-name collision across two owners is rejected, not silently
9
+ * clobbered.
10
+ */
11
+ import { describe, it, expect, beforeEach } from "vitest"
12
+ import {
13
+ registerTool,
14
+ unregisterTool,
15
+ updateToolSpec,
16
+ reapUnmatched,
17
+ invokeTool,
18
+ inspect,
19
+ newOwnerKey,
20
+ __resetForTests,
21
+ __getBackendTools,
22
+ } from "./registry.js"
23
+
24
+ const spec = (name, execute = async () => `ran ${name}`) => ({
25
+ name,
26
+ description: `test ${name}`,
27
+ inputSchema: { type: "object", properties: {} },
28
+ execute,
29
+ })
30
+
31
+ describe("registry", () => {
32
+ beforeEach(() => {
33
+ __resetForTests()
34
+ })
35
+
36
+ it("reapUnmatched removes tools whose route is not in the keep-set", () => {
37
+ const k = newOwnerKey()
38
+ const res = registerTool(k, "/ghost/:id", spec("ghost_tool"))
39
+ expect(res.ok).toBe(true)
40
+ expect(inspect().tools.map((t) => t.name)).toContain("ghost_tool")
41
+
42
+ const reaped = reapUnmatched(new Set(["/cart", "/products"]))
43
+ expect(reaped).toEqual(["ghost_tool"])
44
+ expect(inspect().tools.map((t) => t.name)).not.toContain("ghost_tool")
45
+ })
46
+
47
+ it("reapUnmatched keeps tools whose route IS still matched", () => {
48
+ const k = newOwnerKey()
49
+ registerTool(k, "/cart", spec("checkout"))
50
+ const reaped = reapUnmatched(new Set(["/cart"]))
51
+ expect(reaped).toEqual([])
52
+ expect(inspect().tools.map((t) => t.name)).toContain("checkout")
53
+ unregisterTool(k)
54
+ })
55
+
56
+ it("reapUnmatched never touches app-scoped tools (routePath '')", () => {
57
+ const k = newOwnerKey()
58
+ registerTool(k, "", spec("global_help"))
59
+ reapUnmatched(new Set(["/literally-nothing"]))
60
+ expect(inspect().tools.map((t) => t.name)).toContain("global_help")
61
+ unregisterTool(k)
62
+ })
63
+
64
+ it("rejects a same-name tool from a different owner", () => {
65
+ const a = newOwnerKey()
66
+ const b = newOwnerKey()
67
+ const first = registerTool(a, "/product/:id", spec("add_to_cart"))
68
+ expect(first.ok).toBe(true)
69
+
70
+ const second = registerTool(b, "/other/:id", spec("add_to_cart"))
71
+ expect(second.ok).toBe(false)
72
+ expect(second.error.code).toBe("WEBMCP_DUPLICATE_TOOL")
73
+
74
+ // the original still works
75
+ expect(inspect().tools.filter((t) => t.name === "add_to_cart")).toHaveLength(1)
76
+ unregisterTool(a)
77
+ })
78
+
79
+ it("invokeTool runs the registered execute and surfaces arg-validation errors", async () => {
80
+ const k = newOwnerKey()
81
+ registerTool(k, "/product/:id", {
82
+ name: "buy",
83
+ description: "buy N",
84
+ inputSchema: { type: "object", properties: { qty: { type: "number", minimum: 1 } } },
85
+ execute: async ({ qty }) => `bought ${qty}`,
86
+ })
87
+
88
+ await expect(invokeTool("buy", { qty: 3 })).resolves.toBe("bought 3")
89
+ await expect(invokeTool("buy", { qty: "lots" })).rejects.toMatchObject({
90
+ code: "WEBMCP_INVALID_ARGS",
91
+ })
92
+ await expect(invokeTool("no_such_tool", {})).rejects.toMatchObject({
93
+ code: "WEBMCP_TOOL_NOT_FOUND",
94
+ })
95
+ unregisterTool(k)
96
+ })
97
+
98
+ it("a reaped tool cannot be invoked afterwards", async () => {
99
+ const k = newOwnerKey()
100
+ registerTool(k, "/gone", spec("stale_tool"))
101
+ reapUnmatched(new Set(["/elsewhere"]))
102
+ await expect(invokeTool("stale_tool", {})).rejects.toMatchObject({
103
+ code: "WEBMCP_TOOL_NOT_FOUND",
104
+ })
105
+ })
106
+
107
+ it("updateToolSpec refreshes the AGENT-VISIBLE schema, and an in-flight call still survives", async () => {
108
+ const k = newOwnerKey()
109
+ let releaseCall
110
+ const callStarted = new Promise((resolve) => {
111
+ registerTool(k, "/product/:id", {
112
+ name: "add_to_cart",
113
+ description: "v1",
114
+ inputSchema: { type: "object", properties: { qty: { type: "number", minimum: 1 } } },
115
+ execute: async ({ qty }) => {
116
+ resolve()
117
+ await new Promise((r) => {
118
+ releaseCall = r
119
+ })
120
+ return `added ${qty}`
121
+ },
122
+ })
123
+ })
124
+
125
+ // Before the swap, the backend (what an agent actually sees) has the
126
+ // v1 schema — no `size` property.
127
+ const before = __getBackendTools().find((t) => t.name === "add_to_cart")
128
+ expect(before.description).toBe("v1")
129
+ expect(before.inputSchema.properties.size).toBeUndefined()
130
+
131
+ const inFlight = invokeTool("add_to_cart", { qty: 2 })
132
+ await callStarted
133
+
134
+ // Simulate a deps-array change mid-call (e.g. product changed size options).
135
+ updateToolSpec(k, {
136
+ description: "v2",
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: { qty: { type: "number", minimum: 1 }, size: { type: "string" } },
140
+ },
141
+ })
142
+
143
+ // Same registration (same owner, same entry) — NOT torn down. The
144
+ // in-flight call, dispatched before the swap, still completes.
145
+ releaseCall()
146
+ await expect(inFlight).resolves.toBe("added 2")
147
+
148
+ // The backend now serves the NEW schema — this is the part that
149
+ // matters for an agent actually seeing the update.
150
+ const after = __getBackendTools().find((t) => t.name === "add_to_cart")
151
+ expect(after.description).toBe("v2")
152
+ expect(after.inputSchema.properties.size).toBeDefined()
153
+
154
+ // A call made AFTER the swap validates against the NEW schema.
155
+ await expect(invokeTool("add_to_cart", { qty: "nope" })).rejects.toMatchObject({
156
+ code: "WEBMCP_INVALID_ARGS",
157
+ })
158
+
159
+ // Still exactly one registration for this tool — the swap replaced,
160
+ // it didn't duplicate.
161
+ expect(inspect().tools.filter((t) => t.name === "add_to_cart")).toHaveLength(1)
162
+ unregisterTool(k)
163
+ })
164
+
165
+ it("updateToolSpec is a no-op when nothing actually changed", () => {
166
+ const k = newOwnerKey()
167
+ const schema = { type: "object", properties: {} }
168
+ registerTool(k, "/cart", { name: "checkout", description: "d", inputSchema: schema, execute: async () => "ok" })
169
+ const idBefore = inspect().tools.find((t) => t.name === "checkout").receiptId
170
+
171
+ updateToolSpec(k, { description: "d", inputSchema: schema })
172
+
173
+ // Re-register never ran (nothing changed), so the receipt is untouched.
174
+ const idAfter = inspect().tools.find((t) => t.name === "checkout").receiptId
175
+ expect(idAfter).toBe(idBefore)
176
+ unregisterTool(k)
177
+ })
178
+ })
179
+
180
+ describe("registry in-memory fallback backend (no document.modelContext)", () => {
181
+ beforeEach(() => {
182
+ __resetForTests()
183
+ })
184
+
185
+ it("registers, lists and executes through the fallback, and reports not-native", async () => {
186
+ const k = newOwnerKey()
187
+ expect(registerTool(k, "", spec("fb_tool")).ok).toBe(true)
188
+ expect(__getBackendTools()).toEqual([
189
+ { name: "fb_tool", description: "test fb_tool", inputSchema: { type: "object", properties: {} } },
190
+ ])
191
+ expect(inspect().isNative).toBe(false)
192
+ expect(await invokeTool("fb_tool")).toBe("ran fb_tool")
193
+ })
194
+
195
+ it("updateToolSpec treats a structurally equal schema as unchanged and a different one as a change", () => {
196
+ const k = newOwnerKey()
197
+ const schema = { type: "object", properties: { a: { type: "string" } } }
198
+ registerTool(k, "", { ...spec("u"), inputSchema: schema })
199
+ const before = inspect().tools[0].receiptId
200
+ updateToolSpec(k, { inputSchema: JSON.parse(JSON.stringify(schema)) })
201
+ expect(inspect().tools[0].receiptId).toBe(before)
202
+ updateToolSpec(k, { inputSchema: { type: "object", properties: { a: { type: "string" }, b: { type: "number" } } } })
203
+ expect(inspect().tools[0].receiptId).not.toBe(before)
204
+ })
205
+
206
+ it("rejects a second owner registering the same tool name", () => {
207
+ registerTool(newOwnerKey(), "/a", spec("dup"))
208
+ const res = registerTool(newOwnerKey(), "/b", spec("dup"))
209
+ expect(res.ok).toBe(false)
210
+ expect(res.error.code).toBe("WEBMCP_DUPLICATE_TOOL")
211
+ })
212
+ })
213
+
214
+ describe("required-argument validation", () => {
215
+ beforeEach(() => {
216
+ __resetForTests()
217
+ })
218
+
219
+ it("rejects a call missing a required arg with INVALID_ARGS", async () => {
220
+ registerTool(newOwnerKey(), "", {
221
+ ...spec("needs_id"),
222
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
223
+ })
224
+ await expect(invokeTool("needs_id", {})).rejects.toMatchObject({ code: "WEBMCP_INVALID_ARGS" })
225
+ await expect(invokeTool("needs_id", { id: null })).rejects.toThrow(/Missing required argument: id/)
226
+ expect(await invokeTool("needs_id", { id: "x" })).toBe("ran needs_id")
227
+ })
228
+ })
@@ -0,0 +1,11 @@
1
+ import { createContext } from "react"
2
+
3
+ /**
4
+ * Carries the *route path pattern* of the nearest matched route ("/product/:id",
5
+ * "/cart", …) down to every `useTool` call, so each tool knows which route it
6
+ * belongs to and WebMcpProvider can reap it on navigation.
7
+ *
8
+ * Empty string = "not under a known route" → the tool is treated as
9
+ * app-lifetime, not route-scoped, and is never reaped.
10
+ */
11
+ export const RouteContext = createContext("")
@@ -0,0 +1,8 @@
1
+ /**
2
+ * catalyst-core/webmcp/shim — opt-in DOM shim for `document.modelContext`.
3
+ *
4
+ * Not imported by `catalyst-core/webmcp`'s own entry point. Call
5
+ * `installShim()` once, before mounting WebMcpProvider, in any app that
6
+ * wants WebMCP to work in a browser without native support.
7
+ */
8
+ export { installShim } from "./shim.js"
@@ -0,0 +1,149 @@
1
+ /**
2
+ * document.modelContext shim — published as the opt-in `catalyst-core/webmcp/shim`
3
+ * subpath, never imported by WebMcpProvider or the core `./webmcp` entry
4
+ * point itself.
5
+ *
6
+ * WebMCP's real entry point (Chrome origin trial, unstable) hangs a
7
+ * `modelContext` object off `navigator` and/or `document` with `registerTool`,
8
+ * `getTools`, and an agent-side `executeTool`. This shim provides the same
9
+ * surface so an app runs in any browser, not just one with native WebMCP.
10
+ *
11
+ * Import-time side-effect-free: nothing happens until an app calls
12
+ * `installShim()` itself (typically once, before WebMcpProvider mounts).
13
+ * Core ships provider/hooks/framework-tools only (no shim, no dev panel) —
14
+ * an app that wants WebMCP to work in a browser without native support opts
15
+ * into this subpath explicitly.
16
+ *
17
+ * Design choices:
18
+ * - Installs only when there is no native implementation — unless forced
19
+ * via `?webmcp=shim` or `localStorage.webmcp=shim` (useful for demoing/
20
+ * testing the shim path even on a browser that does have native support).
21
+ * - `registerTool(spec)` returns a *registration receipt* — `{ id, name,
22
+ * registeredAt, unregister() }` — deliberately distinct from the value
23
+ * `executeTool` resolves to: discovery/registration is distinguishable
24
+ * from successful execution.
25
+ * - Lifecycle *events* are emitted by registry.js, not here, so a dev
26
+ * panel or other observer behaves the same whether the backend is this
27
+ * shim or a native implementation.
28
+ */
29
+
30
+ import { nativeModelContext } from "./native.js"
31
+
32
+ let _seq = 0
33
+ const nextId = () => `tool_${Date.now().toString(36)}_${(++_seq).toString(36)}`
34
+
35
+ /** True when ?webmcp=shim (or localStorage webmcp=shim) forces the shim even if native exists. */
36
+ function shimForced() {
37
+ if (typeof window === "undefined") return false
38
+ try {
39
+ const q = new URLSearchParams(window.location.search)
40
+ if (q.get("webmcp") === "shim") return true
41
+ if (window.localStorage && window.localStorage.getItem("webmcp") === "shim") return true
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ return false
46
+ }
47
+
48
+ function createShim() {
49
+ /** name -> { spec, id, registeredAt } */
50
+ const tools = new Map()
51
+
52
+ const api = {
53
+ __isWebMcpShim: true,
54
+
55
+ registerTool(spec) {
56
+ if (!spec || typeof spec.name !== "string" || !spec.name) {
57
+ throw new Error("registerTool: spec.name is required")
58
+ }
59
+ if (typeof spec.execute !== "function") {
60
+ throw new Error(`registerTool("${spec.name}"): spec.execute must be a function`)
61
+ }
62
+ // Last-registration-wins at this layer; the framework layer decides
63
+ // whether a same-name collision across owners is an error.
64
+ const replaced = tools.has(spec.name)
65
+ const id = nextId()
66
+ const registeredAt = Date.now()
67
+ tools.set(spec.name, { spec, id, registeredAt })
68
+
69
+ return {
70
+ id,
71
+ name: spec.name,
72
+ registeredAt,
73
+ replaced,
74
+ unregister: () => {
75
+ const cur = tools.get(spec.name)
76
+ if (cur && cur.id === id) {
77
+ tools.delete(spec.name)
78
+ return true
79
+ }
80
+ return false
81
+ },
82
+ }
83
+ },
84
+
85
+ /** Discovery: the shape an agent sees. No execute fn, JSON-serialisable. */
86
+ getTools() {
87
+ return Array.from(tools.values()).map(({ spec, id, registeredAt }) => ({
88
+ id,
89
+ name: spec.name,
90
+ description: spec.description || "",
91
+ inputSchema: spec.inputSchema || { type: "object", properties: {} },
92
+ annotations: spec.annotations || {},
93
+ registeredAt,
94
+ }))
95
+ },
96
+
97
+ /** Agent-side call. Returns whatever the tool's execute() resolves to. */
98
+ async executeTool(name, args = {}) {
99
+ const entry = tools.get(name)
100
+ if (!entry) {
101
+ const err = new Error(`executeTool: no tool named "${name}"`)
102
+ err.code = "TOOL_NOT_FOUND"
103
+ throw err
104
+ }
105
+ return entry.spec.execute(args)
106
+ },
107
+
108
+ _debug: { tools },
109
+ }
110
+
111
+ return api
112
+ }
113
+
114
+ let _installed = null // { api, isNative }
115
+
116
+ /**
117
+ * Ensure a modelContext implementation is available, installing the shim
118
+ * onto `document.modelContext` if there is no native one (or the shim is
119
+ * forced). Idempotent — safe to call more than once. Call this once, before
120
+ * WebMcpProvider mounts, in any app that wants WebMCP to work on a browser
121
+ * without native support.
122
+ *
123
+ * @returns {{ api: object|null, isNative: boolean }}
124
+ */
125
+ export function installShim() {
126
+ if (_installed) return _installed
127
+
128
+ if (typeof document === "undefined") {
129
+ // SSR — no-op stand-in so imports don't explode. Never registers.
130
+ return { api: null, isNative: false }
131
+ }
132
+
133
+ const native = nativeModelContext()
134
+ if (native && !shimForced()) {
135
+ _installed = { api: native, isNative: true }
136
+ return _installed
137
+ }
138
+
139
+ // Reuse an already-installed shim if present (e.g. from a prior call).
140
+ if (document.modelContext && document.modelContext.__isWebMcpShim) {
141
+ _installed = { api: document.modelContext, isNative: false }
142
+ return _installed
143
+ }
144
+
145
+ const api = createShim()
146
+ document.modelContext = api
147
+ _installed = { api, isNative: false }
148
+ return _installed
149
+ }
@@ -0,0 +1,124 @@
1
+ // The optional deps array is the caller's, passed through on purpose, which
2
+ // the compiler cannot verify -- same exemption as the native bridge hooks.
3
+ /* eslint-disable react-compiler/react-compiler */
4
+ import { useContext, useEffect, useRef, useState } from "react"
5
+ import { RouteContext } from "./routeContext.js"
6
+ import { registerTool, unregisterTool, updateToolRoute, updateToolSpec, newOwnerKey } from "./registry.js"
7
+
8
+ /**
9
+ * useTool — register an imperative WebMCP action tool for the lifetime of the
10
+ * calling component, or for the lifetime of a `deps` window within it.
11
+ *
12
+ * What the hook adds on top of a raw `document.modelContext.registerTool`:
13
+ *
14
+ * - Lifetime: registers after mount, unregisters (and aborts any in-flight
15
+ * execute) on unmount.
16
+ * - Route scoping: the tool is tagged with the route path it was mounted
17
+ * under, so WebMcpProvider can reap it the moment the agent navigates away
18
+ * — even if this component's own unmount hasn't flushed yet.
19
+ * - Deferred until the client: never registers during SSR (no document) or
20
+ * before hydration. React effects already run post-hydration, so simply
21
+ * living in `useEffect` is the deferral — no `hydrationReady` needed.
22
+ * - Stable identity: the tool spec is read through a ref, so re-renders with
23
+ * a new inline `execute` closure don't churn the registration.
24
+ * - `deps` (optional, useEffect-style array): when a value in `deps`
25
+ * changes, the tool's description/inputSchema/annotations/execute are
26
+ * swapped IN PLACE on the still-live registration via updateToolSpec() —
27
+ * never torn down and re-registered. A real unmount (or the route no
28
+ * longer matching) is still the only thing that unregisters. This matters
29
+ * because a naive effect-array re-registration would reopen the
30
+ * WEBMCP_ABORTED bug fixed earlier: an agent call in flight against the
31
+ * old AbortController would be orphaned the instant a dep (e.g. cart
32
+ * quantity) changed mid-call. Swapping in place keeps the same
33
+ * AbortController and receipt across a deps change, so an in-flight call
34
+ * always completes against a live entry.
35
+ *
36
+ * @param {{
37
+ * name: string,
38
+ * description: string,
39
+ * inputSchema: object,
40
+ * annotations?: object,
41
+ * execute: (args: any, ctx: { signal: AbortSignal }) => Promise<any>,
42
+ * }} spec
43
+ * @param {any[]} [deps] omit to behave exactly as before (register once on
44
+ * mount, keyed only by tool name); pass an array to also refresh the spec
45
+ * in place whenever an entry changes.
46
+ *
47
+ * @returns {{
48
+ * status: "idle" | "registered" | "error",
49
+ * receipt: object | null, // registration receipt — NOT an execution result
50
+ * error: import("./errors.js").WebMcpError | null,
51
+ * }}
52
+ */
53
+ export function useTool(spec, deps) {
54
+ const specRef = useRef(spec)
55
+ specRef.current = spec
56
+
57
+ const ownerKeyRef = useRef(null)
58
+ if (ownerKeyRef.current === null) ownerKeyRef.current = newOwnerKey()
59
+
60
+ // The nearest matched route path, provided by WebMcpProvider.
61
+ const routePath = useContext(RouteContext)
62
+ const routePathRef = useRef(routePath)
63
+ routePathRef.current = routePath
64
+
65
+ const [state, setState] = useState({ status: "idle", receipt: null, error: null })
66
+
67
+ // Register ONCE per mounted component (keyed only by tool name). The tool's
68
+ // route scope can change while mounted — e.g. a transient re-match during a
69
+ // navigation — without tearing the registration down and back up, which
70
+ // would orphan the AbortController the agent is mid-call against. Route
71
+ // changes are pushed to the registry in place via updateToolRoute().
72
+ useEffect(() => {
73
+ if (typeof document === "undefined") return undefined
74
+
75
+ const ownerKey = ownerKeyRef.current
76
+ const s = specRef.current
77
+ const liveSpec = {
78
+ name: s.name,
79
+ description: s.description,
80
+ inputSchema: s.inputSchema,
81
+ annotations: s.annotations,
82
+ // Always dispatch to the latest execute + latest route.
83
+ execute: (args, ctx) => specRef.current.execute(args, ctx),
84
+ }
85
+
86
+ const res = registerTool(ownerKey, routePathRef.current || "", liveSpec)
87
+ if (res.ok) {
88
+ setState({ status: "registered", receipt: res.receipt, error: null })
89
+ } else {
90
+ setState({ status: "error", receipt: null, error: res.error })
91
+ // eslint-disable-next-line no-console
92
+ console.warn(`[webmcp] useTool("${s.name}") failed to register:`, res.error)
93
+ }
94
+
95
+ return () => {
96
+ unregisterTool(ownerKey)
97
+ }
98
+ }, [spec.name])
99
+
100
+ // Keep the registry's notion of this tool's route current, in place.
101
+ useEffect(() => {
102
+ if (typeof document === "undefined") return
103
+ updateToolRoute(ownerKeyRef.current, routePath || "")
104
+ }, [routePath])
105
+
106
+ // Optional deps array: refresh description/inputSchema/annotations/execute
107
+ // on the existing registration in place whenever a dep changes. Skipped
108
+ // entirely when the caller doesn't pass `deps` — that's the "register
109
+ // once, execute always reads specRef.current" behaviour from above, which
110
+ // already reflects the latest closure on every call.
111
+ useEffect(() => {
112
+ if (deps === undefined) return
113
+ if (typeof document === "undefined") return
114
+ const s = specRef.current
115
+ updateToolSpec(ownerKeyRef.current, {
116
+ description: s.description,
117
+ inputSchema: s.inputSchema,
118
+ annotations: s.annotations,
119
+ })
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ }, deps || [])
122
+
123
+ return state
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "1.0.1",
3
+ "version": "1.0.2-canary.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.jsx",
6
6
  "description": "Web framework that provides great performance out of the box",
@@ -35,6 +35,8 @@
35
35
  "default": "./dist/native/bridge/hooks.js"
36
36
  },
37
37
  "./errors": "./dist/errors/index.js",
38
+ "./webmcp": "./dist/webmcp/index.js",
39
+ "./webmcp/shim": "./dist/webmcp/shim-entry.js",
38
40
  "./sentry": "./dist/sentry.cjs",
39
41
  "./otel": "./dist/otel.js"
40
42
  },
@@ -54,7 +56,7 @@
54
56
  "scripts": {
55
57
  "lint": "eslint .",
56
58
  "lint-staged": "lint-staged",
57
- "test:unit": "node --test \"test/**/*.test.js\"",
59
+ "test:unit": "node --test \"test/**/*.test.js\" \"test/**/*.test.cjs\"",
58
60
  "typecheck": "tsc --noEmit",
59
61
  "prettify": "prettier . --write",
60
62
  "prepare": "node scripts/safe-rimraf.cjs ./dist && node scripts/build-dist.mjs && cp -r ./src/native ./dist/native && babel src/native --out-dir dist/native --config-file ./babel.native.config.cjs --extensions '.js,.jsx,.ts,.tsx' --ignore '**/.build/**' && node scripts/strip-ts-sources.mjs ./dist/native && babel src/sentry.js --out-file dist/sentry.cjs --config-file ./babel.native.config.cjs && tsc -p tsconfig.declarations.json && node ./mcp_v2/scripts/copyErrorsIndex.cjs",
@@ -78,7 +80,6 @@
78
80
  "express-static-gzip": "^2.1.7",
79
81
  "react": "19.0.0",
80
82
  "react-dom": "19.0.0",
81
- "react-helmet-async": "^1.3.0",
82
83
  "react-redux": "^9.2.0",
83
84
  "react-refresh": "^0.14.0",
84
85
  "redux": "^4.2.1",
@@ -137,10 +138,6 @@
137
138
  ]
138
139
  },
139
140
  "overrides": {
140
- "react-helmet-async": {
141
- "react": "19.0.0",
142
- "react-dom": "19.0.0"
143
- },
144
141
  "react-redux": {
145
142
  "react": "19.0.0",
146
143
  "react-dom": "19.0.0"