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.
Files changed (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. 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
+ }