catalyst-core 1.0.1 → 1.0.2-canary.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/catalyst.js +105 -1
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +50 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- 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/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +61 -94
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +335 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +314 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +115 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +65 -9
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- 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/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +19 -1
- package/dist/vite/vite.config.js +96 -19
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +20 -14
- package/dist/web-router/components/MetaTag.test.jsx +108 -32
- 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 +124 -0
- package/package.json +4 -7
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/preflight.test.ts +100 -2
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/manifestCache.test.ts +13 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/vitest.config.ts +4 -0
- package/dist/scripts/checkVersion.js +0 -3
- package/dist/scripts/cleanCache.js +0 -30
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { WebMcpError, WEBMCP_ERROR_CODES, toWebMcpError } from "./errors.js"
|
|
3
|
+
|
|
4
|
+
describe("WebMcpError", () => {
|
|
5
|
+
it("falls back to the code as message and keeps an optional cause", () => {
|
|
6
|
+
const cause = new Error("root")
|
|
7
|
+
const e = new WebMcpError(WEBMCP_ERROR_CODES.EXECUTE_FAILED, { cause })
|
|
8
|
+
expect(e.message).toBe(WEBMCP_ERROR_CODES.EXECUTE_FAILED)
|
|
9
|
+
expect(e.cause).toBe(cause)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("toResult() is a plain agent-facing object", () => {
|
|
13
|
+
const e = new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
|
|
14
|
+
message: "bad",
|
|
15
|
+
details: "d",
|
|
16
|
+
suggestedAction: "retry",
|
|
17
|
+
})
|
|
18
|
+
expect(e.toResult()).toEqual({
|
|
19
|
+
ok: false,
|
|
20
|
+
code: WEBMCP_ERROR_CODES.INVALID_ARGS,
|
|
21
|
+
message: "bad",
|
|
22
|
+
details: "d",
|
|
23
|
+
suggestedAction: "retry",
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("toWebMcpError", () => {
|
|
29
|
+
it("passes a WebMcpError through untouched", () => {
|
|
30
|
+
const e = new WebMcpError(WEBMCP_ERROR_CODES.ABORTED)
|
|
31
|
+
expect(toWebMcpError(e)).toBe(e)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("wraps an Error as EXECUTE_FAILED with the original as cause", () => {
|
|
35
|
+
const orig = new Error("boom")
|
|
36
|
+
const w = toWebMcpError(orig)
|
|
37
|
+
expect(w.code).toBe(WEBMCP_ERROR_CODES.EXECUTE_FAILED)
|
|
38
|
+
expect(w.message).toBe("boom")
|
|
39
|
+
expect(w.cause).toBe(orig)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("stringifies non-Error throwables and honours a fallback code", () => {
|
|
43
|
+
const w = toWebMcpError("plain string", WEBMCP_ERROR_CODES.INVALID_ARGS)
|
|
44
|
+
expect(w.message).toBe("plain string")
|
|
45
|
+
expect(w.code).toBe(WEBMCP_ERROR_CODES.INVALID_ARGS)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalyst-core/webmcp — WebMCP integration for Catalyst apps.
|
|
3
|
+
*
|
|
4
|
+
* Public exports:
|
|
5
|
+
*
|
|
6
|
+
* WebMcpProvider — framework glue. Mount once, inside RouterDataProvider,
|
|
7
|
+
* wrapping the app. Takes the app's OWN route table as `routes` (core
|
|
8
|
+
* cannot reach into an app's routing module the way the framework's own
|
|
9
|
+
* route preparation can). Publishes route scope to useTool() and reaps
|
|
10
|
+
* stale route-scoped tools on navigation.
|
|
11
|
+
*
|
|
12
|
+
* useTool(spec, deps?) — register an imperative action tool for the
|
|
13
|
+
* lifetime of the calling component, or (with a `deps` array) refresh
|
|
14
|
+
* its description/inputSchema/annotations in place whenever a dep
|
|
15
|
+
* changes, without tearing the registration down. Returns
|
|
16
|
+
* { status, receipt, error } — the receipt is a registration handle,
|
|
17
|
+
* deliberately not an execution result.
|
|
18
|
+
*
|
|
19
|
+
* WebMcpError / WEBMCP_ERROR_CODES — the typed error a tool's execute()
|
|
20
|
+
* should throw for a recoverable failure (bad args, not found, …) so an
|
|
21
|
+
* agent gets a `.code` to act on instead of an opaque message.
|
|
22
|
+
*
|
|
23
|
+
* A DOM shim for browsers without native `document.modelContext` support is
|
|
24
|
+
* a SEPARATE opt-in subpath: `catalyst-core/webmcp/shim`. It is never
|
|
25
|
+
* imported here — an app calls its `installShim()` itself, once, before
|
|
26
|
+
* WebMcpProvider mounts, if it needs WebMCP to work without native support.
|
|
27
|
+
* Likewise a dev panel (a way to exercise tools by hand without a real
|
|
28
|
+
* agent) is an app-level concern, not shipped from core: subscribe to the
|
|
29
|
+
* `webmcp:register` / `webmcp:unregister` / `webmcp:execute:start` /
|
|
30
|
+
* `webmcp:execute:end` events this module's registry dispatches on
|
|
31
|
+
* `document`, and call `inspect()`/`invokeTool()` — both exported here for
|
|
32
|
+
* exactly that purpose.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export { WebMcpProvider, flattenRoutes } from "./WebMcpProvider.jsx"
|
|
36
|
+
export { useTool } from "./useTool.js"
|
|
37
|
+
export { WebMcpError, WEBMCP_ERROR_CODES } from "./errors.js"
|
|
38
|
+
export { inspect, invokeTool, isNative } from "./registry.js"
|
|
39
|
+
export { declarativeToolFor, frameworkTools, toolNameFromPath, paramNames, fillPath, buildInputSchema } from "./declarative.js"
|
|
40
|
+
export { pageInfoTool, flattenHeadElements } from "./pageInfo.js"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native `document.modelContext` detection — the part of WebMCP integration
|
|
3
|
+
* that is genuinely framework-owned: there is one `document.modelContext`
|
|
4
|
+
* per document, so there is one place that should decide whether a native
|
|
5
|
+
* implementation is present.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: native WebMCP is intentionally UNSUPPORTED for now — no agent can
|
|
8
|
+
* drive a native `document.modelContext` yet, so Catalyst targets the opt-in
|
|
9
|
+
* shim (`catalyst-core/webmcp/shim`). The registry's teardown (receipt
|
|
10
|
+
* `unregister()` / `unregisterTool`) follows the shim's contract, not the
|
|
11
|
+
* spec's `registerTool(tool, { signal })` abort-to-unregister model, so
|
|
12
|
+
* revisit registry.js before relying on a native backend.
|
|
13
|
+
*
|
|
14
|
+
* This module does NOT install anything. It only looks. Installing a
|
|
15
|
+
* fallback (a DOM shim for browsers with no native WebMCP support yet, or an
|
|
16
|
+
* in-memory stand-in for SSR/tests) is a separate concern — see
|
|
17
|
+
* `catalyst-core/webmcp/shim` for the opt-in DOM shim, and registry.js's own
|
|
18
|
+
* `createFallbackBackend()` for the SSR/test safety net. Neither of those
|
|
19
|
+
* runs unless something explicitly calls it; importing this module alone
|
|
20
|
+
* never touches `document`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Locate a native modelContext, checking both documented mount points. */
|
|
24
|
+
export function nativeModelContext() {
|
|
25
|
+
if (typeof document !== "undefined" && document.modelContext && !document.modelContext.__isWebMcpShim) {
|
|
26
|
+
return document.modelContext
|
|
27
|
+
}
|
|
28
|
+
if (typeof navigator !== "undefined" && navigator.modelContext && !navigator.modelContext.__isWebMcpShim) {
|
|
29
|
+
return navigator.modelContext
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether a real (non-shim) `document.modelContext` is present right now. */
|
|
35
|
+
export function hasNative() {
|
|
36
|
+
return !!nativeModelContext()
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest"
|
|
3
|
+
import { nativeModelContext, hasNative } from "./native.js"
|
|
4
|
+
import { RouteContext } from "./routeContext.js"
|
|
5
|
+
|
|
6
|
+
describe("native modelContext detection", () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
delete document.modelContext
|
|
9
|
+
delete navigator.modelContext
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("finds nothing when no modelContext is installed", () => {
|
|
13
|
+
expect(nativeModelContext()).toBeNull()
|
|
14
|
+
expect(hasNative()).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("finds a native document.modelContext", () => {
|
|
18
|
+
const api = { registerTool() {} }
|
|
19
|
+
document.modelContext = api
|
|
20
|
+
expect(nativeModelContext()).toBe(api)
|
|
21
|
+
expect(hasNative()).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("falls back to navigator.modelContext", () => {
|
|
25
|
+
const api = { registerTool() {} }
|
|
26
|
+
navigator.modelContext = api
|
|
27
|
+
expect(nativeModelContext()).toBe(api)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("does not count a shim as native, on either mount point", () => {
|
|
31
|
+
document.modelContext = { __isWebMcpShim: true }
|
|
32
|
+
navigator.modelContext = { __isWebMcpShim: true }
|
|
33
|
+
expect(nativeModelContext()).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe("RouteContext", () => {
|
|
38
|
+
it("defaults to the empty (app-lifetime) route path", () => {
|
|
39
|
+
expect(RouteContext._currentValue).toBe("")
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* get_page_info — a framework WebMCP tool that describes the current page to an
|
|
3
|
+
* agent, built ENTIRELY from metadata the app already writes for SEO.
|
|
4
|
+
*
|
|
5
|
+
* Catalyst pages define `Component.setMetaData(routeData)` returning an array of
|
|
6
|
+
* JSX <head> elements (<title>, <meta name/property ... content ...>). MetaTag
|
|
7
|
+
* renders exactly this into the document head. We read the SAME static, call it,
|
|
8
|
+
* and flatten the elements into a plain object an agent can consume:
|
|
9
|
+
*
|
|
10
|
+
* { title, description, canonical, og: {...}, keywords, raw: [...] }
|
|
11
|
+
*
|
|
12
|
+
* No new authoring surface: if the page has good meta tags, the agent gets a
|
|
13
|
+
* good page description for free. This is the read-side complement to
|
|
14
|
+
* get_current_route ("where am I") — "what is this page and what's it for".
|
|
15
|
+
*
|
|
16
|
+
* Phase-3 note for discussion #466: the next layer up is the browser's
|
|
17
|
+
* accessibility tree (roles, landmarks, labelled controls) — a structured map
|
|
18
|
+
* of the page the platform already computes for screen readers. WebMCP could
|
|
19
|
+
* surface that too instead of leaving agents to DOM-scrape.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pull `{ type, props }` out of a React element without depending on React
|
|
24
|
+
* internals beyond the stable `.type` / `.props` shape.
|
|
25
|
+
*/
|
|
26
|
+
function readEl(el) {
|
|
27
|
+
if (!el || typeof el !== "object") return null
|
|
28
|
+
const type = typeof el.type === "string" ? el.type : null
|
|
29
|
+
const props = el.props || {}
|
|
30
|
+
return { type, props }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Flatten an array of head JSX elements (possibly nested arrays) into a
|
|
35
|
+
* plain, agent-friendly object.
|
|
36
|
+
*/
|
|
37
|
+
export function flattenHeadElements(elements) {
|
|
38
|
+
const out = { title: null, description: null, canonical: null, keywords: null, og: {}, twitter: {}, other: {} }
|
|
39
|
+
const walk = (node) => {
|
|
40
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
41
|
+
const el = readEl(node)
|
|
42
|
+
if (!el || !el.type) return
|
|
43
|
+
const { type, props } = el
|
|
44
|
+
|
|
45
|
+
if (type === "title") {
|
|
46
|
+
out.title = childText(props.children)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (type === "link" && (props.rel === "canonical")) {
|
|
50
|
+
out.canonical = props.href || null
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (type === "meta") {
|
|
54
|
+
const key = props.name || props.property || props["http-equiv"] || props.httpEquiv
|
|
55
|
+
const content = props.content
|
|
56
|
+
if (!key) return
|
|
57
|
+
if (key === "description") out.description = content
|
|
58
|
+
else if (key === "keywords") out.keywords = content
|
|
59
|
+
else if (String(key).startsWith("og:")) out.og[String(key).slice(3)] = content
|
|
60
|
+
else if (String(key).startsWith("twitter:")) out.twitter[String(key).slice(8)] = content
|
|
61
|
+
else out.other[key] = content
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
walk(elements)
|
|
65
|
+
// drop empties for a cleaner payload
|
|
66
|
+
if (!Object.keys(out.og).length) delete out.og
|
|
67
|
+
if (!Object.keys(out.twitter).length) delete out.twitter
|
|
68
|
+
if (!Object.keys(out.other).length) delete out.other
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function childText(children) {
|
|
73
|
+
if (children == null) return null
|
|
74
|
+
if (typeof children === "string" || typeof children === "number") return String(children)
|
|
75
|
+
if (Array.isArray(children)) return children.map(childText).filter(Boolean).join("")
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the get_page_info tool.
|
|
81
|
+
*
|
|
82
|
+
* @param getMatched () => RouteMatch[] (the current matched-route chain)
|
|
83
|
+
* @param getRouteData () => object (RouterContext data, or {} — POC pages
|
|
84
|
+
* use Redux so this is usually empty)
|
|
85
|
+
*/
|
|
86
|
+
export function pageInfoTool(getMatched, getRouteData) {
|
|
87
|
+
return {
|
|
88
|
+
name: "get_page_info",
|
|
89
|
+
description:
|
|
90
|
+
"Describe the current page: its title, description, and social/SEO metadata — " +
|
|
91
|
+
"the same information the page publishes in its <head>. Use this to understand " +
|
|
92
|
+
"what a page is about before acting on it.",
|
|
93
|
+
inputSchema: { type: "object", properties: {} },
|
|
94
|
+
annotations: { readOnlyHint: true },
|
|
95
|
+
execute: async () => {
|
|
96
|
+
const matched = getMatched() || []
|
|
97
|
+
const routeData = (getRouteData && getRouteData()) || {}
|
|
98
|
+
const collected = []
|
|
99
|
+
for (const m of matched) {
|
|
100
|
+
const fn = m && m.route && m.route.component && m.route.component.setMetaData
|
|
101
|
+
if (typeof fn === "function") {
|
|
102
|
+
try {
|
|
103
|
+
collected.push(fn(routeData))
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// a page's setMetaData threw — report it, don't crash the tool
|
|
106
|
+
collected.push([])
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const info = flattenHeadElements(collected)
|
|
111
|
+
const hasAny = info.title || info.description || info.canonical
|
|
112
|
+
return {
|
|
113
|
+
...info,
|
|
114
|
+
_source: "Component.setMetaData (same as the page <head>)",
|
|
115
|
+
_note: hasAny ? undefined : "This page defines no setMetaData; nothing to report.",
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|