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
@@ -4,6 +4,17 @@ import { createContext } from "react";
4
4
  * Internal context shared among the router components. `matchedRoutes` is the
5
5
  * match chain for the current location; the two callbacks are supplied by
6
6
  * RouterDataProvider and consumed by useCurrentRouteData.
7
+ *
8
+ * `location`/`params`/`navigate` are the same values RouterDataProvider
9
+ * already reads via react-router's own hooks (useLocation/useParams/
10
+ * useNavigate) — republished here so a `catalyst-core/*` SUBPATH package
11
+ * (loaded through a separate module resolution than the app's own
12
+ * react-router import under Vite's dev SSR — `react-router` is deliberately
13
+ * excluded from vite.config.js's `dedupe`, see that file's comment) can read
14
+ * router state through ONE shared React context (identity guaranteed by
15
+ * `react`, which IS deduped) instead of calling react-router's hooks itself
16
+ * and risking a second, unlinked `react-router` module instance whose
17
+ * internal context a subpath's own hook calls can't see.
7
18
  */
8
19
 
9
20
  /**
@@ -0,0 +1,234 @@
1
+ import React, { useEffect, useMemo, useRef } from "react"
2
+ // Relative import, not the "catalyst-core" package specifier: under Vite's
3
+ // dev SSR, re-entering through the package specifier from INSIDE
4
+ // node_modules/catalyst-core/dist/webmcp/ can resolve to a separate module
5
+ // record than the app's own `import ... from "catalyst-core"` — even though
6
+ // both point at the identical file on disk — which defeats the whole point
7
+ // of routing state through a shared React context (see the file-level
8
+ // comment below). A relative path from this file straight to the main
9
+ // entry's compiled output guarantees the SAME module record both places.
10
+ import { useRouterState } from "../index.jsx"
11
+ import { RouteContext } from "./routeContext.js"
12
+ import { hasBackend, reapUnmatched, registerTool, unregisterTool, inspect, newOwnerKey } from "./registry.js"
13
+ import { declarativeToolFor, frameworkTools } from "./declarative.js"
14
+ import { pageInfoTool } from "./pageInfo.js"
15
+
16
+ /**
17
+ * WebMcpProvider — framework glue for WebMCP.
18
+ *
19
+ * Mounted once, inside RouterDataProvider (so it sees the same route match
20
+ * chain) and wrapping the app. Two jobs:
21
+ *
22
+ * 1. Publishes the current innermost route path pattern via <RouteContext>
23
+ * so every useTool() beneath it tags its registration with a route.
24
+ *
25
+ * 2. On every navigation, hands the registry the set of still-matched route
26
+ * patterns. Any route-scoped tool whose route is no longer matched is
27
+ * aborted + unregistered *now* — leaving a route aborts its tools.
28
+ *
29
+ * Route state (location/params/navigate/the matched-route chain) comes from
30
+ * catalyst-core's `useRouterState()`, NOT from react-router's own hooks
31
+ * called directly in this file. That is deliberate: this module ships as
32
+ * the `catalyst-core/webmcp` SUBPATH, a separate module-resolution root from
33
+ * the app's own react-router import under Vite's dev SSR (react-router is
34
+ * deliberately excluded from vite.config.js's `dedupe`). Calling
35
+ * `useLocation()` etc. here risks landing on a second, unlinked
36
+ * react-router module instance whose RouterContext this component can't
37
+ * see — `useRouterState()` sidesteps that by reading through a plain React
38
+ * context RouterDataProvider already populates, and `react` context
39
+ * identity (unlike react-router's) IS guaranteed single-instance.
40
+ *
41
+ * @param {object} props
42
+ * @param {React.ReactNode} props.children
43
+ * @param {any[]} props.routes the app's OWN route table (whatever shape it
44
+ * builds `preparedRoutes()`/`getRoutes()` from), in its natural nested
45
+ * form — WebMcpProvider flattens it itself. Required: unlike the earlier
46
+ * POC, core cannot reach into an app's own routing module, so the caller
47
+ * passes its routes explicitly.
48
+ * @param {(flatRoute: any) => boolean} [props.filterNavigable] optional
49
+ * predicate deciding which flattened, non-parameterised routes are offered
50
+ * through the `navigate` framework tool's enum. Default: every such route.
51
+ * An app with many generated pages (e.g. one route per doc page) should
52
+ * exclude those here — they're reachable through their own search/open
53
+ * tools instead of bloating `navigate`'s enum to the whole site map.
54
+ */
55
+ export function WebMcpProvider({ children, routes, filterNavigable }) {
56
+ const { location, params, navigate, matchedRoutes: matches } = useRouterState()
57
+
58
+ const currentRoutePath = useMemo(() => {
59
+ for (let i = matches.length - 1; i >= 0; i--) {
60
+ const patt = patternOf(matches[i])
61
+ if (patt) return patt
62
+ }
63
+ return ""
64
+ }, [matches])
65
+
66
+ const matchedPatterns = useMemo(() => {
67
+ const set = new Set()
68
+ for (const m of matches) {
69
+ const patt = patternOf(m)
70
+ if (patt) set.add(patt)
71
+ }
72
+ return set
73
+ }, [matches])
74
+
75
+ // A stable string key so effects only fire when the matched SET changes.
76
+ const patternsKey = useMemo(() => Array.from(matchedPatterns).sort().join("|"), [matchedPatterns])
77
+
78
+ const firstRun = useRef(true)
79
+
80
+ useEffect(() => {
81
+ if (typeof document === "undefined") return
82
+ if (hasBackend()) return
83
+ // Neither a native document.modelContext nor an app-installed shim
84
+ // is present — every registerTool() call below still "succeeds"
85
+ // against the in-memory fallback (registry.js's safety net), but no
86
+ // agent can see any of it. This is silent otherwise, and the
87
+ // resulting "nothing is broken, nothing works" state is exactly the
88
+ // kind of thing worth a loud one-time warning for.
89
+ // eslint-disable-next-line no-console
90
+ console.warn(
91
+ "[webmcp] No document.modelContext found (native or shim) — WebMCP tools are registering into an " +
92
+ "in-memory fallback that no agent can see. Install a shim (e.g. catalyst-core/webmcp/shim's " +
93
+ "installShim()) before this provider mounts if you need WebMCP to work in a browser without " +
94
+ "native support."
95
+ )
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ }, [])
98
+
99
+ // Keep live route facts + the matched-route chain in a ref so the framework
100
+ // tools (registered once, below) always report the *current* page without
101
+ // re-registering.
102
+ const routeFactsRef = useRef({ pathname: location.pathname, pattern: currentRoutePath, params, matches })
103
+ routeFactsRef.current = { pathname: location.pathname, pattern: currentRoutePath, params, matches }
104
+
105
+ const flatRoutes = useMemo(() => flattenRoutes(routes), [routes])
106
+
107
+ // Register the DECLARATIVE read tools + the framework tools ONCE, as
108
+ // app-lifetime tools (routePath ""), so an agent can navigate from anywhere
109
+ // to anywhere. Imperative useTool() action tools stay route-scoped.
110
+ useEffect(() => {
111
+ if (typeof document === "undefined") return undefined
112
+ const owners = []
113
+
114
+ const getCurrent = () => ({
115
+ pathname: routeFactsRef.current.pathname,
116
+ pattern: routeFactsRef.current.pattern,
117
+ params: routeFactsRef.current.params,
118
+ tools: inspect().tools.map((t) => ({ name: t.name, routePath: t.routePath })),
119
+ })
120
+
121
+ // Declarative, one per route that declares route.tool.
122
+ for (const route of flatRoutes) {
123
+ const spec = declarativeToolFor(route, navigate)
124
+ if (!spec) continue
125
+ const key = newOwnerKey()
126
+ const res = registerTool(key, "", spec)
127
+ if (res.ok) owners.push(key)
128
+ else
129
+ // eslint-disable-next-line no-console
130
+ console.warn(`[webmcp] declarative tool "${spec.name}" failed to register:`, res.error)
131
+ }
132
+
133
+ // Framework: navigate + get_current_route + get_page_info.
134
+ const navigableRoutes = filterNavigable ? flatRoutes.filter(filterNavigable) : flatRoutes
135
+ const fw = [
136
+ ...frameworkTools(navigableRoutes, navigate, getCurrent, flatRoutes),
137
+ pageInfoTool(
138
+ () => routeFactsRef.current.matches,
139
+ () => ({})
140
+ ),
141
+ ]
142
+ for (const spec of fw) {
143
+ const key = newOwnerKey()
144
+ const res = registerTool(key, "", spec)
145
+ if (res.ok) owners.push(key)
146
+ else
147
+ // eslint-disable-next-line no-console
148
+ console.warn(
149
+ `[webmcp] framework tool "${spec.name}" failed to register — a route tool may already use that name:`,
150
+ res.error
151
+ )
152
+ }
153
+
154
+ return () => owners.forEach(unregisterTool)
155
+ // navigate identity is stable for the life of the router; flatRoutes
156
+ // is memoised on the routes prop, which an app should pass a stable
157
+ // reference for (the same module-level array every render).
158
+ // eslint-disable-next-line react-hooks/exhaustive-deps
159
+ }, [flatRoutes])
160
+
161
+ useEffect(() => {
162
+ if (firstRun.current) {
163
+ firstRun.current = false
164
+ return
165
+ }
166
+ const reaped = reapUnmatched(matchedPatterns)
167
+ if (reaped.length) {
168
+ // eslint-disable-next-line no-console
169
+ console.info(
170
+ `[webmcp] navigated to ${location.pathname} — reaped ${reaped.length} stale tool(s): ${reaped.join(", ")}`
171
+ )
172
+ }
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ }, [patternsKey, location.pathname])
175
+
176
+ return <RouteContext.Provider value={currentRoutePath}>{children}</RouteContext.Provider>
177
+ }
178
+
179
+ /**
180
+ * Flatten a nested route table into the flat array declarative.js's
181
+ * declarativeToolFor()/frameworkTools() expect: one entry per NAVIGABLE
182
+ * route, each carrying an absolute `.path` and (if present) its own
183
+ * `.tool` config.
184
+ *
185
+ * Rules:
186
+ * - A route with `children` is a layout wrapper — recurse into its
187
+ * children, joining relative child paths against the parent's absolute
188
+ * path. Its own entry is dropped UNLESS it itself has a `path` (a layout
189
+ * can also be directly navigable, e.g. nested `/parent` + `/parent/:id`).
190
+ * - A route with `index: true` and no `path` is the default child of its
191
+ * parent — it has no path of its own to register a tool under, so it is
192
+ * skipped. (Its parent's own path, if any, already covers "/".)
193
+ * - A route with neither `path` nor `children` nor `index` contributes
194
+ * nothing (matches ServerRouter's own tolerance for pathless wrapper
195
+ * routes, e.g. an outer <RouterDataProvider> element with no path).
196
+ */
197
+ export function flattenRoutes(routes, parentPath = "") {
198
+ if (!Array.isArray(routes)) return []
199
+ const out = []
200
+ for (const route of routes) {
201
+ if (route.index || typeof route.path !== "string") {
202
+ if (Array.isArray(route.children)) {
203
+ out.push(...flattenRoutes(route.children, parentPath))
204
+ }
205
+ continue
206
+ }
207
+ const absolutePath = route.path.startsWith("/")
208
+ ? route.path
209
+ : `${parentPath.replace(/\/$/, "")}/${route.path}`
210
+ out.push({ ...route, path: absolutePath })
211
+ if (Array.isArray(route.children)) {
212
+ out.push(...flattenRoutes(route.children, absolutePath))
213
+ }
214
+ }
215
+ return out
216
+ }
217
+
218
+ /**
219
+ * A RouteMatch has `.route.path` (the pattern segment as react-router sees
220
+ * it — usually just the leaf segment, not the full absolute path) and
221
+ * `.pathname` (the full matched URL). We want the full pattern, so this
222
+ * joins every matched segment's `.route.path` up the chain rather than
223
+ * trusting either alone.
224
+ *
225
+ * A *pathless* layout route (no `.route.path`) contributes NO scope — it is
226
+ * not a navigable destination. Falling back to `.pathname` instead would pin
227
+ * whatever page happened to be current into the matched-set, and stop that
228
+ * route's tools from ever being reaped.
229
+ */
230
+ function patternOf(match) {
231
+ if (!match || !match.route || typeof match.route.path !== "string" || !match.route.path) return ""
232
+ const p = match.route.path
233
+ return p.startsWith("/") ? p : `/${p}`
234
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Declarative WebMCP tools — the §4a layer of discussion #466.
3
+ *
4
+ * A route declares its own tool config directly in routes/index.js, next to
5
+ * `path`/`component` — NOT as a static on the page component:
6
+ *
7
+ * { path: "/products", component: ProductList, tool: {
8
+ * description: "Browse the product catalogue, optionally filtered.",
9
+ * searchParams: {
10
+ * category: { type: "string", enum: ["footwear","electronics","apparel"], description: "..." },
11
+ * maxPrice: { type: "number", description: "upper price bound in INR" },
12
+ * },
13
+ * annotations: { readOnlyHint: true },
14
+ * } }
15
+ *
16
+ * `route.tool` over a `Component.tool` static: routing config belongs with
17
+ * the route, and — unlike a component static — it survives lazy-loading. A
18
+ * component rendered through Split (packages/catalyst-core/src/web-router/
19
+ * components/Split.tsx) only has its statics copied onto the lazy wrapper
20
+ * for the keys Split's own copyRouteStatics() lists (clientFetcher/
21
+ * serverFetcher/setMetaData); a `tool` static would silently vanish behind
22
+ * Split. `route.tool` needs no such patch: getRoutes() returns the route
23
+ * table directly, before any Split wrapping happens. No app has ever shipped
24
+ * the component-static form, so there is no fallback to carry here.
25
+ *
26
+ * From that + the route's path pattern, the framework derives a full WebMCP
27
+ * tool with zero extra wiring:
28
+ *
29
+ * - name from the path ("/products" -> "products",
30
+ * "/product/:id" -> "product")
31
+ * - inputSchema route :params (required) + declared searchParams (optional)
32
+ * - execute navigate to the filled path + query string, then report the
33
+ * new location. (This POC's pages fetch via Redux, not
34
+ * serverFetcher/clientFetcher, and plain {path,component}
35
+ * routes never have their fetchers invoked by Catalyst — so
36
+ * "navigate + refetch" degrades cleanly to "navigate".)
37
+ *
38
+ * Lifetime: declarative READ tools are registered ONCE, for the life of the
39
+ * app — NOT scoped to a matched route. That is the whole point: an agent
40
+ * sitting on /cart must still be able to call `products({category:"footwear"})`
41
+ * to get there. (Only imperative ACTION tools — useTool — are route-scoped,
42
+ * because acting on a page you've left is what you want to prevent.)
43
+ * This resolves open question #4 in the discussion for the POC.
44
+ */
45
+
46
+ import { WebMcpError, WEBMCP_ERROR_CODES } from "./errors.js"
47
+
48
+ /** "/product/:id" -> "product" ; "/products" -> "products" ; "/" -> "home" */
49
+ export function toolNameFromPath(pathPattern) {
50
+ const segs = String(pathPattern)
51
+ .split("/")
52
+ .filter(Boolean)
53
+ .filter((s) => !s.startsWith(":"))
54
+ if (segs.length === 0) return "home"
55
+ return segs.join("_")
56
+ }
57
+
58
+ /** Pull ":id", ":slug" etc. out of a path pattern. */
59
+ export function paramNames(pathPattern) {
60
+ return String(pathPattern)
61
+ .split("/")
62
+ .filter((s) => s.startsWith(":"))
63
+ .map((s) => s.slice(1))
64
+ }
65
+
66
+ /** Fill "/product/:id" + { id: "trail-runner-x" } -> "/product/trail-runner-x". */
67
+ export function fillPath(pathPattern, params = {}) {
68
+ return String(pathPattern).replace(/:([A-Za-z0-9_]+)/g, (_, k) => {
69
+ if (params[k] == null || params[k] === "") {
70
+ throw new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
71
+ message: `missing required path param "${k}" for ${pathPattern}`,
72
+ })
73
+ }
74
+ return encodeURIComponent(params[k])
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Build the WebMCP inputSchema for a declarative tool: path params are required
80
+ * strings, declared searchParams are optional and typed as given.
81
+ */
82
+ export function buildInputSchema(pathPattern, toolStatic) {
83
+ const properties = {}
84
+ const required = []
85
+ for (const p of paramNames(pathPattern)) {
86
+ properties[p] = { type: "string", description: `path parameter "${p}"` }
87
+ required.push(p)
88
+ }
89
+ const sp = (toolStatic && toolStatic.searchParams) || {}
90
+ for (const [key, def] of Object.entries(sp)) {
91
+ properties[key] = { ...def }
92
+ }
93
+ const schema = { type: "object", properties }
94
+ if (required.length) schema.required = required
95
+ return schema
96
+ }
97
+
98
+ /**
99
+ * Turn one route (with a `.tool` config) into a registrable WebMCP tool
100
+ * spec. `navigate` is react-router's navigate fn.
101
+ */
102
+ export function declarativeToolFor(route, navigate) {
103
+ const toolStatic = route && route.tool
104
+ if (!toolStatic) return null
105
+
106
+ const pattern = route.path.startsWith("/") ? route.path : `/${route.path}`
107
+ const name = toolNameFromPath(pattern)
108
+ const params = paramNames(pattern)
109
+ const searchKeys = Object.keys((toolStatic && toolStatic.searchParams) || {})
110
+
111
+ return {
112
+ name,
113
+ description: toolStatic.description || `Open ${pattern}`,
114
+ inputSchema: buildInputSchema(pattern, toolStatic),
115
+ annotations: { readOnlyHint: true, ...(toolStatic.annotations || {}) },
116
+ execute: async (args = {}) => {
117
+ const pathParams = {}
118
+ for (const p of params) pathParams[p] = args[p]
119
+ const path = fillPath(pattern, pathParams)
120
+
121
+ const qs = new URLSearchParams()
122
+ for (const k of searchKeys) {
123
+ if (args[k] != null && args[k] !== "") qs.set(k, String(args[k]))
124
+ }
125
+ const query = qs.toString()
126
+ const to = query ? `${path}?${query}` : path
127
+
128
+ navigate(to)
129
+ return {
130
+ navigated: true,
131
+ path: to,
132
+ note:
133
+ "Navigation done. The set of available tools changes with the route — " +
134
+ "call get_current_route to see what's now callable before the next step.",
135
+ }
136
+ },
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The two fixed framework tools from §4c.
142
+ *
143
+ * @param routes flat route table `navigate`'s enum is built from — already
144
+ * run through the app's `filterNavigable`, if any (see
145
+ * WebMcpProvider). Parameterised routes ("/product/:id")
146
+ * are reachable through their own declarative tool, so
147
+ * only static paths are ever offered here.
148
+ * @param navigate react-router navigate fn
149
+ * @param getCurrent () => ({ pathname, params, patterns, tools })
150
+ * @param allRoutes the FULL flat route table, before filterNavigable —
151
+ * optional, used only to give a more useful error when an
152
+ * agent asks for a path that's a real route but was
153
+ * deliberately excluded from `navigate` (e.g. one of many
154
+ * generated content pages, reachable through the app's
155
+ * own search/read tools instead).
156
+ */
157
+ export function frameworkTools(routes, navigate, getCurrent, allRoutes) {
158
+ // Closed set of navigable destinations. Parameterised routes ("/product/:id")
159
+ // are reachable through their own declarative tool, so we list only the
160
+ // static paths here.
161
+ const staticPaths = routes
162
+ .map((r) => (r.path.startsWith("/") ? r.path : `/${r.path}`))
163
+ .filter((p) => !p.includes(":"))
164
+
165
+ const allPaths = allRoutes
166
+ ? allRoutes.map((r) => (r.path.startsWith("/") ? r.path : `/${r.path}`))
167
+ : staticPaths
168
+
169
+ return [
170
+ {
171
+ name: "navigate",
172
+ description: "Go to one of this app's pages.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ path: { type: "string", enum: staticPaths, description: "destination route" },
177
+ },
178
+ required: ["path"],
179
+ },
180
+ annotations: { readOnlyHint: true },
181
+ execute: async ({ path } = {}) => {
182
+ if (!staticPaths.includes(path)) {
183
+ // A real route, just not one `navigate` offers (e.g. deliberately
184
+ // excluded via filterNavigable) — say so, rather than implying
185
+ // the path doesn't exist at all. get_current_route's own tool
186
+ // list is the generic way to discover what IS callable right now.
187
+ const isKnownButExcluded = allPaths.includes(path)
188
+ throw new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
189
+ message: isKnownButExcluded
190
+ ? `"${path}" exists but isn't a navigate() destination. Call get_current_route to see ` +
191
+ "what other tools are callable right now — this app likely exposes it through one of those instead."
192
+ : `"${path}" is not a navigable page. One of: ${staticPaths.join(", ")}`,
193
+ })
194
+ }
195
+ navigate(path)
196
+ return { navigated: true, path }
197
+ },
198
+ },
199
+ {
200
+ name: "get_current_route",
201
+ description: "Report the current page, its URL params, and which tools are callable right now.",
202
+ inputSchema: { type: "object", properties: {} },
203
+ annotations: { readOnlyHint: true },
204
+ execute: async () => getCurrent(),
205
+ },
206
+ ]
207
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import {
3
+ toolNameFromPath,
4
+ paramNames,
5
+ fillPath,
6
+ buildInputSchema,
7
+ declarativeToolFor,
8
+ frameworkTools,
9
+ } from "./declarative.js"
10
+ import { flattenHeadElements } from "./pageInfo.js"
11
+ import React from "react"
12
+
13
+ describe("declarative helpers", () => {
14
+ it("derives tool names from path patterns", () => {
15
+ expect(toolNameFromPath("/")).toBe("home")
16
+ expect(toolNameFromPath("/products")).toBe("products")
17
+ expect(toolNameFromPath("/product/:id")).toBe("product")
18
+ expect(toolNameFromPath("/orders/:oid/items")).toBe("orders_items")
19
+ })
20
+
21
+ it("extracts and fills path params", () => {
22
+ expect(paramNames("/product/:id")).toEqual(["id"])
23
+ expect(fillPath("/product/:id", { id: "trail-runner-x" })).toBe("/product/trail-runner-x")
24
+ expect(() => fillPath("/product/:id", {})).toThrow(/missing required path param/)
25
+ expect(() => fillPath("/product/:id", {})).toThrow(expect.objectContaining({ code: "WEBMCP_INVALID_ARGS" }))
26
+ })
27
+
28
+ it("builds an inputSchema: params required, searchParams optional", () => {
29
+ const s = buildInputSchema("/product/:id", {
30
+ searchParams: { variant: { type: "string" } },
31
+ })
32
+ expect(s.required).toEqual(["id"])
33
+ expect(s.properties.id.type).toBe("string")
34
+ expect(s.properties.variant.type).toBe("string")
35
+ expect(s.required).not.toContain("variant")
36
+ })
37
+
38
+ it("declarativeToolFor returns null when the route has no .tool config", () => {
39
+ expect(declarativeToolFor({ path: "/x", component: () => null }, vi.fn())).toBeNull()
40
+ })
41
+
42
+ it("declarativeToolFor reads route.tool", async () => {
43
+ const navigate = vi.fn()
44
+ const route = {
45
+ path: "/products",
46
+ component: () => null,
47
+ tool: {
48
+ description: "list",
49
+ searchParams: { category: { type: "string" }, maxPrice: { type: "number" } },
50
+ },
51
+ }
52
+ const spec = declarativeToolFor(route, navigate)
53
+ expect(spec.name).toBe("products")
54
+ expect(spec.annotations.readOnlyHint).toBe(true)
55
+
56
+ const res = await spec.execute({ category: "footwear", maxPrice: 3000 })
57
+ expect(navigate).toHaveBeenCalledWith("/products?category=footwear&maxPrice=3000")
58
+ expect(res.navigated).toBe(true)
59
+ })
60
+
61
+ it("frameworkTools: navigate enum excludes parameterised routes; rejects unknown", async () => {
62
+ const navigate = vi.fn()
63
+ const routes = [
64
+ { path: "/" },
65
+ { path: "/products" },
66
+ { path: "/product/:id" },
67
+ { path: "/cart" },
68
+ ]
69
+ const [nav] = frameworkTools(routes, navigate, () => ({}))
70
+ expect(nav.inputSchema.properties.path.enum).toEqual(["/", "/products", "/cart"])
71
+
72
+ await expect(nav.execute({ path: "/product/:id" })).rejects.toThrow(/not a navigable page/)
73
+ await nav.execute({ path: "/cart" })
74
+ expect(navigate).toHaveBeenCalledWith("/cart")
75
+ })
76
+
77
+ it("frameworkTools: navigate gives a distinct, actionable error for a route excluded via filterNavigable", async () => {
78
+ const navigate = vi.fn()
79
+ const navigableRoutes = [{ path: "/" }, { path: "/errors" }]
80
+ const allRoutes = [{ path: "/" }, { path: "/errors" }, { path: "/content/foo" }, { path: "/content/bar" }]
81
+ const [nav] = frameworkTools(navigableRoutes, navigate, () => ({}), allRoutes)
82
+
83
+ // A genuinely unknown path still gets the generic "not a navigable page" message.
84
+ await expect(nav.execute({ path: "/nope" })).rejects.toMatchObject({ code: "WEBMCP_INVALID_ARGS" })
85
+ await expect(nav.execute({ path: "/nope" })).rejects.toThrow(/not a navigable page/)
86
+
87
+ // A real route that was deliberately excluded gets a DIFFERENT, more
88
+ // useful message — pointing the agent at get_current_route instead of
89
+ // implying the page doesn't exist.
90
+ await expect(nav.execute({ path: "/content/foo" })).rejects.toMatchObject({ code: "WEBMCP_INVALID_ARGS" })
91
+ await expect(nav.execute({ path: "/content/foo" })).rejects.toThrow(/exists but isn't a navigate\(\) destination/)
92
+ expect(navigate).not.toHaveBeenCalled()
93
+ })
94
+ })
95
+
96
+ describe("pageInfo.flattenHeadElements", () => {
97
+ it("flattens title / description / og / twitter", () => {
98
+ const els = [
99
+ [
100
+ React.createElement("title", { key: "t" }, "My Page"),
101
+ React.createElement("meta", { key: "d", name: "description", content: "about my page" }),
102
+ React.createElement("meta", { key: "o", property: "og:title", content: "OG My Page" }),
103
+ React.createElement("meta", { key: "w", name: "twitter:card", content: "summary" }),
104
+ React.createElement("meta", { key: "k", name: "keywords", content: "a,b,c" }),
105
+ React.createElement("link", { key: "c", rel: "canonical", href: "https://x.test/" }),
106
+ ],
107
+ ]
108
+ const info = flattenHeadElements(els)
109
+ expect(info.title).toBe("My Page")
110
+ expect(info.description).toBe("about my page")
111
+ expect(info.og.title).toBe("OG My Page")
112
+ expect(info.twitter.card).toBe("summary")
113
+ expect(info.keywords).toBe("a,b,c")
114
+ expect(info.canonical).toBe("https://x.test/")
115
+ })
116
+
117
+ it("returns nulls for a page with no head elements", () => {
118
+ const info = flattenHeadElements([])
119
+ expect(info.title).toBeNull()
120
+ expect(info.description).toBeNull()
121
+ })
122
+ })
@@ -0,0 +1,59 @@
1
+ /**
2
+ * WebMcpError — the error type for catalyst-core's WebMCP integration.
3
+ *
4
+ * Shaped deliberately like catalyst-core's `CatalystError` (same `.code`,
5
+ * `.details`, `.suggestedAction`, `.cause` fields) so a later integration can
6
+ * swap these ad-hoc codes for registry codes from `catalyst-core/errors`
7
+ * without changing any call sites. The framework's own error registry
8
+ * (PREFLIGHT_* / RUNTIME_WEB_* / AI_* …) has no code that means "a page
9
+ * tool's execute() threw" or "the agent called a tool that isn't registered
10
+ * on this route", so this module defines its own small set. That mapping
11
+ * (folding these into the shared registry) is a follow-up, not part of this
12
+ * migration — src/errors/registry.js has its own contract tests (#411) that
13
+ * this module deliberately does not touch.
14
+ */
15
+
16
+ export const WEBMCP_ERROR_CODES = Object.freeze({
17
+ /** Agent invoked a tool name that is not currently registered. */
18
+ TOOL_NOT_FOUND: "WEBMCP_TOOL_NOT_FOUND",
19
+ /** Agent-supplied arguments failed a (shallow) inputSchema check. */
20
+ INVALID_ARGS: "WEBMCP_INVALID_ARGS",
21
+ /** The tool's own execute() threw or rejected. Original error is `.cause`. */
22
+ EXECUTE_FAILED: "WEBMCP_EXECUTE_FAILED",
23
+ /** The tool's route was left (or component unmounted) before execute() finished. */
24
+ ABORTED: "WEBMCP_ABORTED",
25
+ /** registerTool was called with a name that is already live. */
26
+ DUPLICATE_TOOL: "WEBMCP_DUPLICATE_TOOL",
27
+ })
28
+
29
+ export class WebMcpError extends Error {
30
+ constructor(code, { message, details, suggestedAction, cause } = {}) {
31
+ super(message || code)
32
+ this.name = "WebMcpError"
33
+ this.code = code
34
+ this.details = details
35
+ this.suggestedAction = suggestedAction
36
+ if (cause !== undefined) this.cause = cause
37
+ }
38
+
39
+ /** Plain object suitable for returning to an agent / rendering in a dev panel. */
40
+ toResult() {
41
+ return {
42
+ ok: false,
43
+ code: this.code,
44
+ message: this.message,
45
+ details: this.details,
46
+ suggestedAction: this.suggestedAction,
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Wrap whatever a tool's execute() threw. If it already is a WebMcpError we
53
+ * keep it; otherwise it becomes EXECUTE_FAILED with the original as `cause`.
54
+ */
55
+ export function toWebMcpError(err, fallbackCode = WEBMCP_ERROR_CODES.EXECUTE_FAILED) {
56
+ if (err instanceof WebMcpError) return err
57
+ const message = err && err.message ? err.message : String(err)
58
+ return new WebMcpError(fallbackCode, { message, cause: err })
59
+ }