catalyst-core 1.0.1 → 1.0.2-canary.1

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 (41) hide show
  1. package/dist/errors/registry.js +8 -0
  2. package/dist/errors-index.json +7 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.jsx +1 -1
  5. package/dist/scripts/build.js +3 -1
  6. package/dist/scripts/preflight.js +81 -1
  7. package/dist/scripts/serve.js +8 -1
  8. package/dist/server/renderer/document/Body.d.ts +0 -11
  9. package/dist/server/renderer/document/Body.jsx +0 -10
  10. package/dist/server/renderer/document/Head.d.ts +0 -13
  11. package/dist/server/renderer/document/Head.jsx +0 -12
  12. package/dist/vite/vite.config.client.js +1 -1
  13. package/dist/vite/vite.config.js +24 -6
  14. package/dist/web-router/components/MetaTag.jsx +6 -13
  15. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  16. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  17. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  18. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  19. package/dist/web-router/context.d.ts +15 -1
  20. package/dist/web-router/context.jsx +11 -0
  21. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  22. package/dist/webmcp/declarative.js +207 -0
  23. package/dist/webmcp/declarative.test.js +122 -0
  24. package/dist/webmcp/errors.js +59 -0
  25. package/dist/webmcp/errors.test.js +47 -0
  26. package/dist/webmcp/index.js +40 -0
  27. package/dist/webmcp/native.js +37 -0
  28. package/dist/webmcp/native.test.js +41 -0
  29. package/dist/webmcp/pageInfo.js +119 -0
  30. package/dist/webmcp/pageInfo.test.js +85 -0
  31. package/dist/webmcp/registry.dom.test.js +175 -0
  32. package/dist/webmcp/registry.js +540 -0
  33. package/dist/webmcp/registry.test.js +228 -0
  34. package/dist/webmcp/routeContext.js +11 -0
  35. package/dist/webmcp/shim-entry.js +8 -0
  36. package/dist/webmcp/shim.js +149 -0
  37. package/dist/webmcp/useTool.js +121 -0
  38. package/package.json +3 -6
  39. package/test/scripts/preflight.test.ts +100 -2
  40. package/test/server/manifestCache.test.ts +13 -0
  41. package/vitest.config.ts +4 -0
@@ -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,121 @@
1
+ import { useContext, useEffect, useRef, useState } from "react"
2
+ import { RouteContext } from "./routeContext.js"
3
+ import { registerTool, unregisterTool, updateToolRoute, updateToolSpec, newOwnerKey } from "./registry.js"
4
+
5
+ /**
6
+ * useTool — register an imperative WebMCP action tool for the lifetime of the
7
+ * calling component, or for the lifetime of a `deps` window within it.
8
+ *
9
+ * What the hook adds on top of a raw `document.modelContext.registerTool`:
10
+ *
11
+ * - Lifetime: registers after mount, unregisters (and aborts any in-flight
12
+ * execute) on unmount.
13
+ * - Route scoping: the tool is tagged with the route path it was mounted
14
+ * under, so WebMcpProvider can reap it the moment the agent navigates away
15
+ * — even if this component's own unmount hasn't flushed yet.
16
+ * - Deferred until the client: never registers during SSR (no document) or
17
+ * before hydration. React effects already run post-hydration, so simply
18
+ * living in `useEffect` is the deferral — no `hydrationReady` needed.
19
+ * - Stable identity: the tool spec is read through a ref, so re-renders with
20
+ * a new inline `execute` closure don't churn the registration.
21
+ * - `deps` (optional, useEffect-style array): when a value in `deps`
22
+ * changes, the tool's description/inputSchema/annotations/execute are
23
+ * swapped IN PLACE on the still-live registration via updateToolSpec() —
24
+ * never torn down and re-registered. A real unmount (or the route no
25
+ * longer matching) is still the only thing that unregisters. This matters
26
+ * because a naive effect-array re-registration would reopen the
27
+ * WEBMCP_ABORTED bug fixed earlier: an agent call in flight against the
28
+ * old AbortController would be orphaned the instant a dep (e.g. cart
29
+ * quantity) changed mid-call. Swapping in place keeps the same
30
+ * AbortController and receipt across a deps change, so an in-flight call
31
+ * always completes against a live entry.
32
+ *
33
+ * @param {{
34
+ * name: string,
35
+ * description: string,
36
+ * inputSchema: object,
37
+ * annotations?: object,
38
+ * execute: (args: any, ctx: { signal: AbortSignal }) => Promise<any>,
39
+ * }} spec
40
+ * @param {any[]} [deps] omit to behave exactly as before (register once on
41
+ * mount, keyed only by tool name); pass an array to also refresh the spec
42
+ * in place whenever an entry changes.
43
+ *
44
+ * @returns {{
45
+ * status: "idle" | "registered" | "error",
46
+ * receipt: object | null, // registration receipt — NOT an execution result
47
+ * error: import("./errors.js").WebMcpError | null,
48
+ * }}
49
+ */
50
+ export function useTool(spec, deps) {
51
+ const specRef = useRef(spec)
52
+ specRef.current = spec
53
+
54
+ const ownerKeyRef = useRef(null)
55
+ if (ownerKeyRef.current === null) ownerKeyRef.current = newOwnerKey()
56
+
57
+ // The nearest matched route path, provided by WebMcpProvider.
58
+ const routePath = useContext(RouteContext)
59
+ const routePathRef = useRef(routePath)
60
+ routePathRef.current = routePath
61
+
62
+ const [state, setState] = useState({ status: "idle", receipt: null, error: null })
63
+
64
+ // Register ONCE per mounted component (keyed only by tool name). The tool's
65
+ // route scope can change while mounted — e.g. a transient re-match during a
66
+ // navigation — without tearing the registration down and back up, which
67
+ // would orphan the AbortController the agent is mid-call against. Route
68
+ // changes are pushed to the registry in place via updateToolRoute().
69
+ useEffect(() => {
70
+ if (typeof document === "undefined") return undefined
71
+
72
+ const ownerKey = ownerKeyRef.current
73
+ const s = specRef.current
74
+ const liveSpec = {
75
+ name: s.name,
76
+ description: s.description,
77
+ inputSchema: s.inputSchema,
78
+ annotations: s.annotations,
79
+ // Always dispatch to the latest execute + latest route.
80
+ execute: (args, ctx) => specRef.current.execute(args, ctx),
81
+ }
82
+
83
+ const res = registerTool(ownerKey, routePathRef.current || "", liveSpec)
84
+ if (res.ok) {
85
+ setState({ status: "registered", receipt: res.receipt, error: null })
86
+ } else {
87
+ setState({ status: "error", receipt: null, error: res.error })
88
+ // eslint-disable-next-line no-console
89
+ console.warn(`[webmcp] useTool("${s.name}") failed to register:`, res.error)
90
+ }
91
+
92
+ return () => {
93
+ unregisterTool(ownerKey)
94
+ }
95
+ }, [spec.name])
96
+
97
+ // Keep the registry's notion of this tool's route current, in place.
98
+ useEffect(() => {
99
+ if (typeof document === "undefined") return
100
+ updateToolRoute(ownerKeyRef.current, routePath || "")
101
+ }, [routePath])
102
+
103
+ // Optional deps array: refresh description/inputSchema/annotations/execute
104
+ // on the existing registration in place whenever a dep changes. Skipped
105
+ // entirely when the caller doesn't pass `deps` — that's the "register
106
+ // once, execute always reads specRef.current" behaviour from above, which
107
+ // already reflects the latest closure on every call.
108
+ useEffect(() => {
109
+ if (deps === undefined) return
110
+ if (typeof document === "undefined") return
111
+ const s = specRef.current
112
+ updateToolSpec(ownerKeyRef.current, {
113
+ description: s.description,
114
+ inputSchema: s.inputSchema,
115
+ annotations: s.annotations,
116
+ })
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, deps || [])
119
+
120
+ return state
121
+ }
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.1",
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
  },
@@ -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"
@@ -1,8 +1,8 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest"
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2
2
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"
3
3
  import { tmpdir } from "os"
4
4
  import path from "path"
5
- import { runStaticPreflight } from "../../src/scripts/preflight.js"
5
+ import { runStaticPreflight, writeBuildInfo, checkBuildMatchesConfig } from "../../src/scripts/preflight.js"
6
6
  import { ERROR_CODES } from "../../src/errors/registry.js"
7
7
 
8
8
  // runStaticPreflight(appDir) reads config/config.json + package.json from a
@@ -116,3 +116,101 @@ describe("runStaticPreflight", () => {
116
116
  }
117
117
  })
118
118
  })
119
+
120
+ describe("checkBuildMatchesConfig", () => {
121
+ const CONFIG = {
122
+ ...VALID_CONFIG,
123
+ API_URL: "https://api.example.com",
124
+ CLIENT_ENV_VARIABLES: ["API_URL"],
125
+ }
126
+ const SKIPPED = expect.stringContaining("Skipping the build/config check")
127
+
128
+ function buildWith(config: Record<string, unknown>, args = {}) {
129
+ mkdirSync(path.join(appDir, "build"), { recursive: true })
130
+ writeBuildInfo(appDir, config, args)
131
+ }
132
+
133
+ beforeEach(() => {
134
+ vi.spyOn(console, "warn").mockImplementation(() => {})
135
+ })
136
+ afterEach(() => {
137
+ vi.restoreAllMocks()
138
+ })
139
+
140
+ it("returns null when config.json is unchanged since the build", () => {
141
+ writeApp({ config: CONFIG })
142
+ buildWith(CONFIG)
143
+ expect(checkBuildMatchesConfig(appDir)).toBeNull()
144
+ expect(console.warn).not.toHaveBeenCalledWith(SKIPPED)
145
+ })
146
+
147
+ it("ignores config keys that are not inlined into the build", () => {
148
+ writeApp({ config: { ...CONFIG, NODE_SERVER_PORT: 4000 } })
149
+ buildWith(CONFIG)
150
+ expect(checkBuildMatchesConfig(appDir)).toBeNull()
151
+ })
152
+
153
+ it("PREFLIGHT-022 when PUBLIC_STATIC_ASSET_URL changed", () => {
154
+ writeApp({ config: { ...CONFIG, PUBLIC_STATIC_ASSET_URL: "http://192.168.1.5:3000" } })
155
+ buildWith(CONFIG)
156
+ const err = checkBuildMatchesConfig(appDir)
157
+ expect(err?.code).toBe(ERROR_CODES.PREFLIGHT_BUILD_STALE)
158
+ expect(err?.details).toContain(
159
+ 'PUBLIC_STATIC_ASSET_URL: built with "http://localhost:3000", now "http://192.168.1.5:3000"'
160
+ )
161
+ })
162
+
163
+ it("PREFLIGHT-022 when a CLIENT_ENV_VARIABLES value changed", () => {
164
+ writeApp({ config: { ...CONFIG, API_URL: "https://staging.example.com" } })
165
+ buildWith(CONFIG)
166
+ expect(checkBuildMatchesConfig(appDir)?.details).toContain(
167
+ 'API_URL: built with "https://api.example.com", now "https://staging.example.com"'
168
+ )
169
+ })
170
+
171
+ it("PREFLIGHT-022 when a variable is added to CLIENT_ENV_VARIABLES", () => {
172
+ writeApp({ config: { ...CONFIG, CUSTOM_VAR: "x", CLIENT_ENV_VARIABLES: ["API_URL", "CUSTOM_VAR"] } })
173
+ buildWith(CONFIG)
174
+ expect(checkBuildMatchesConfig(appDir)?.details).toContain('CUSTOM_VAR: built with unset, now "x"')
175
+ })
176
+
177
+ it("compares against KEY=value CLI arguments, which win over config.json", () => {
178
+ const args = { PUBLIC_STATIC_ASSET_URL: "http://cdn.example.com" }
179
+ writeApp({ config: CONFIG })
180
+ buildWith(CONFIG, args)
181
+ expect(checkBuildMatchesConfig(appDir, args)).toBeNull()
182
+ expect(checkBuildMatchesConfig(appDir)?.code).toBe(ERROR_CODES.PREFLIGHT_BUILD_STALE)
183
+ })
184
+
185
+ it("treats a CLI argument equal to the config.json object as unchanged", () => {
186
+ const config = { ...CONFIG, OPTIONS: { enabled: true }, CLIENT_ENV_VARIABLES: ["OPTIONS"] }
187
+ writeApp({ config })
188
+ buildWith(config)
189
+ expect(checkBuildMatchesConfig(appDir, { OPTIONS: '{"enabled":true}' })).toBeNull()
190
+ expect(checkBuildMatchesConfig(appDir, { CLIENT_ENV_VARIABLES: '["OPTIONS"]' })).toBeNull()
191
+ })
192
+
193
+ it("warns and passes when the build has no build info file", () => {
194
+ writeApp({ config: CONFIG })
195
+ expect(checkBuildMatchesConfig(appDir)).toBeNull()
196
+ expect(console.warn).toHaveBeenCalledWith(SKIPPED)
197
+ })
198
+
199
+ it.each([["{ not json"], ["null"], ["[]"]])(
200
+ "warns and passes when the build info file is %s",
201
+ (contents) => {
202
+ writeApp({ config: CONFIG })
203
+ mkdirSync(path.join(appDir, "build"), { recursive: true })
204
+ writeFileSync(path.join(appDir, "build", ".catalyst-build.json"), contents)
205
+ expect(checkBuildMatchesConfig(appDir)).toBeNull()
206
+ expect(console.warn).toHaveBeenCalledWith(SKIPPED)
207
+ }
208
+ )
209
+
210
+ it("warns and passes when CLIENT_ENV_VARIABLES cannot be parsed", () => {
211
+ writeApp({ config: CONFIG })
212
+ buildWith(CONFIG)
213
+ expect(checkBuildMatchesConfig(appDir, { CLIENT_ENV_VARIABLES: "not-json" })).toBeNull()
214
+ expect(console.warn).toHaveBeenCalledWith(SKIPPED)
215
+ })
216
+ })