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
|
@@ -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
|
+
}
|