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.
- 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/scripts/build.js +3 -1
- package/dist/scripts/preflight.js +81 -1
- package/dist/scripts/serve.js +8 -1
- 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/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +24 -6
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- 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 +121 -0
- package/package.json +3 -6
- package/test/scripts/preflight.test.ts +100 -2
- package/test/server/manifestCache.test.ts +13 -0
- 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
|
+
})
|