catalyst-core 1.0.1 → 1.0.2-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/errors/registry.js +8 -0
  2. package/dist/errors-index.json +7 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.jsx +1 -1
  5. package/dist/scripts/build.js +3 -1
  6. package/dist/scripts/preflight.js +81 -1
  7. package/dist/scripts/serve.js +8 -1
  8. package/dist/server/renderer/document/Body.d.ts +0 -11
  9. package/dist/server/renderer/document/Body.jsx +0 -10
  10. package/dist/server/renderer/document/Head.d.ts +0 -13
  11. package/dist/server/renderer/document/Head.jsx +0 -12
  12. package/dist/vite/vite.config.client.js +1 -1
  13. package/dist/vite/vite.config.js +24 -6
  14. package/dist/web-router/components/MetaTag.jsx +6 -13
  15. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  16. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  17. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  18. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  19. package/dist/web-router/context.d.ts +15 -1
  20. package/dist/web-router/context.jsx +11 -0
  21. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  22. package/dist/webmcp/declarative.js +207 -0
  23. package/dist/webmcp/declarative.test.js +122 -0
  24. package/dist/webmcp/errors.js +59 -0
  25. package/dist/webmcp/errors.test.js +47 -0
  26. package/dist/webmcp/index.js +40 -0
  27. package/dist/webmcp/native.js +37 -0
  28. package/dist/webmcp/native.test.js +41 -0
  29. package/dist/webmcp/pageInfo.js +119 -0
  30. package/dist/webmcp/pageInfo.test.js +85 -0
  31. package/dist/webmcp/registry.dom.test.js +175 -0
  32. package/dist/webmcp/registry.js +540 -0
  33. package/dist/webmcp/registry.test.js +228 -0
  34. package/dist/webmcp/routeContext.js +11 -0
  35. package/dist/webmcp/shim-entry.js +8 -0
  36. package/dist/webmcp/shim.js +149 -0
  37. package/dist/webmcp/useTool.js +121 -0
  38. package/package.json +3 -6
  39. package/test/scripts/preflight.test.ts +100 -2
  40. package/test/server/manifestCache.test.ts +13 -0
  41. package/vitest.config.ts +4 -0
@@ -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
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { flattenHeadElements, pageInfoTool } from "./pageInfo.js"
3
+
4
+ // Plain objects with the stable `.type` / `.props` shape of a React element.
5
+ const el = (type, props = {}) => ({ type, props })
6
+
7
+ describe("flattenHeadElements", () => {
8
+ it("flattens title, description, canonical, keywords, og, twitter and other meta", () => {
9
+ const out = flattenHeadElements([
10
+ el("title", { children: "Home" }),
11
+ [
12
+ el("meta", { name: "description", content: "d" }),
13
+ el("meta", { name: "keywords", content: "k" }),
14
+ el("link", { rel: "canonical", href: "https://x.test/" }),
15
+ el("meta", { property: "og:title", content: "OG" }),
16
+ el("meta", { name: "twitter:card", content: "summary" }),
17
+ el("meta", { httpEquiv: "refresh", content: "5" }),
18
+ ],
19
+ ])
20
+ expect(out).toMatchObject({
21
+ title: "Home",
22
+ description: "d",
23
+ keywords: "k",
24
+ canonical: "https://x.test/",
25
+ og: { title: "OG" },
26
+ twitter: { card: "summary" },
27
+ other: { refresh: "5" },
28
+ })
29
+ })
30
+
31
+ it("joins array/number title children and drops empty groups", () => {
32
+ const out = flattenHeadElements([el("title", { children: ["Docs", " - ", 2] })])
33
+ expect(out.title).toBe("Docs - 2")
34
+ expect(out).not.toHaveProperty("og")
35
+ expect(out).not.toHaveProperty("twitter")
36
+ expect(out).not.toHaveProperty("other")
37
+ })
38
+
39
+ it("ignores non-elements, keyless meta, and a canonical link without href", () => {
40
+ const out = flattenHeadElements([
41
+ null,
42
+ "text",
43
+ el(null),
44
+ el("meta", { content: "orphan" }),
45
+ el("link", { rel: "canonical" }),
46
+ ])
47
+ expect(out.canonical).toBeNull()
48
+ expect(out.title).toBeNull()
49
+ })
50
+
51
+ it("returns null title for missing or non-text children", () => {
52
+ expect(flattenHeadElements([el("title")]).title).toBeNull()
53
+ expect(flattenHeadElements([el("title", { children: { x: 1 } })]).title).toBeNull()
54
+ })
55
+ })
56
+
57
+ describe("pageInfoTool", () => {
58
+ const matchWith = (fn) => [{ route: { component: { setMetaData: fn } } }]
59
+
60
+ it("collects setMetaData from every matched route, passing route data", async () => {
61
+ let seen
62
+ const tool = pageInfoTool(
63
+ () => matchWith((data) => ((seen = data), [el("title", { children: "T" })])),
64
+ () => ({ a: 1 })
65
+ )
66
+ const res = await tool.execute()
67
+ expect(seen).toEqual({ a: 1 })
68
+ expect(res.title).toBe("T")
69
+ expect(res._note).toBeUndefined()
70
+ })
71
+
72
+ it("survives a throwing setMetaData and notes that nothing was reported", async () => {
73
+ const tool = pageInfoTool(() => matchWith(() => { throw new Error("nope") }))
74
+ const res = await tool.execute()
75
+ expect(res.title).toBeNull()
76
+ expect(res._note).toMatch(/no setMetaData/)
77
+ })
78
+
79
+ it("tolerates no matches and no route-data getter", async () => {
80
+ const tool = pageInfoTool(() => null)
81
+ const res = await tool.execute()
82
+ expect(res._note).toBeDefined()
83
+ expect(tool.annotations.readOnlyHint).toBe(true)
84
+ })
85
+ })
@@ -0,0 +1,175 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Registry behaviour that needs a DOM: lifecycle events and the native /
4
+ * shim `document.modelContext` backend contract (receipt shapes, spec
5
+ * re-registration, argument validation).
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
8
+ import {
9
+ registerTool,
10
+ unregisterTool,
11
+ updateToolRoute,
12
+ updateToolSpec,
13
+ invokeTool,
14
+ inspect,
15
+ isNative,
16
+ hasBackend,
17
+ newOwnerKey,
18
+ __resetForTests,
19
+ } from "./registry.js"
20
+
21
+ const makeSpec = (over = {}) => ({
22
+ name: "t",
23
+ description: "d",
24
+ inputSchema: { type: "object", properties: {} },
25
+ execute: async () => "ok",
26
+ ...over,
27
+ })
28
+
29
+ function listen(type) {
30
+ const events = []
31
+ const h = (e) => events.push(e.detail)
32
+ document.addEventListener(type, h)
33
+ return { events, off: () => document.removeEventListener(type, h) }
34
+ }
35
+
36
+ describe("registry with a DOM", () => {
37
+ beforeEach(() => __resetForTests())
38
+ afterEach(() => {
39
+ delete document.modelContext
40
+ __resetForTests()
41
+ })
42
+
43
+ it("emits register / execute / unregister events", async () => {
44
+ const reg = listen("webmcp:register")
45
+ const start = listen("webmcp:execute:start")
46
+ const end = listen("webmcp:execute:end")
47
+ const unreg = listen("webmcp:unregister")
48
+ const k = newOwnerKey()
49
+ registerTool(k, "/r", makeSpec())
50
+ await invokeTool("t", { x: 1 })
51
+ unregisterTool(k)
52
+ expect(reg.events[0]).toMatchObject({ name: "t", routePath: "/r", replaced: false })
53
+ expect(start.events[0]).toMatchObject({ name: "t", args: { x: 1 } })
54
+ expect(end.events[0]).toMatchObject({ name: "t", ok: true, result: "ok" })
55
+ expect(unreg.events[0]).toMatchObject({ name: "t" })
56
+ ;[reg, start, end, unreg].forEach((l) => l.off())
57
+ })
58
+
59
+ it("reports a failed execute and an unknown tool as ok:false end events", async () => {
60
+ const end = listen("webmcp:execute:end")
61
+ registerTool(newOwnerKey(), "", makeSpec({ execute: async () => { throw new Error("bad") } }))
62
+ await expect(invokeTool("t")).rejects.toMatchObject({ code: "WEBMCP_EXECUTE_FAILED" })
63
+ await expect(invokeTool("missing")).rejects.toMatchObject({ code: "WEBMCP_TOOL_NOT_FOUND" })
64
+ expect(end.events.map((e) => e.ok)).toEqual([false, false])
65
+ end.off()
66
+ })
67
+
68
+ it("rejects invalid arguments before executing", async () => {
69
+ const execute = vi.fn(async () => "ran")
70
+ registerTool(
71
+ newOwnerKey(),
72
+ "",
73
+ makeSpec({
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ n: { type: "number", minimum: 2 },
78
+ i: { type: "integer" },
79
+ s: { type: "string" },
80
+ b: { type: "boolean" },
81
+ },
82
+ },
83
+ execute,
84
+ })
85
+ )
86
+ for (const args of [{ n: "x" }, { n: 1 }, { i: 1.5 }, { s: 3 }, { b: "no" }]) {
87
+ await expect(invokeTool("t", args)).rejects.toMatchObject({ code: "WEBMCP_INVALID_ARGS" })
88
+ }
89
+ await invokeTool("t", { n: 2, i: 3, s: "a", b: true })
90
+ expect(execute).toHaveBeenCalledTimes(1)
91
+ })
92
+
93
+ it("uses an installed document.modelContext and normalises its receipts", () => {
94
+ const registered = []
95
+ document.modelContext = {
96
+ registerTool: (s) => (registered.push(s), "string-id"),
97
+ unregisterTool: () => {},
98
+ getTools: () => [],
99
+ }
100
+ expect(isNative()).toBe(true)
101
+ const k = newOwnerKey()
102
+ const res = registerTool(k, "/p", makeSpec())
103
+ expect(res.receipt.id).toBe("string-id")
104
+ updateToolRoute(k, "/q")
105
+ expect(inspect().tools[0].routePath).toBe("/q")
106
+ // the string receipt falls back to api.unregisterTool(name)
107
+ expect(res.receipt.unregister()).toBe(true)
108
+ expect(registered).toHaveLength(1)
109
+ })
110
+
111
+ it("treats a shim-marked backend as non-native and handles object receipts", () => {
112
+ document.modelContext = {
113
+ __isWebMcpShim: true,
114
+ registerTool: () => ({ id: "obj", unregister() { return true } }),
115
+ getTools: () => [],
116
+ }
117
+ expect(isNative()).toBe(false)
118
+ const res = registerTool(newOwnerKey(), "", makeSpec())
119
+ expect(res.receipt.id).toBe("obj")
120
+ })
121
+
122
+ it("surfaces a backend registerTool failure as ok:false", () => {
123
+ document.modelContext = {
124
+ registerTool: () => { throw new Error("backend down") },
125
+ getTools: () => [],
126
+ }
127
+ const res = registerTool(newOwnerKey(), "", makeSpec())
128
+ expect(res.ok).toBe(false)
129
+ expect(res.error.message).toBe("backend down")
130
+ })
131
+
132
+ it("updateToolSpec re-registers only on a real change", () => {
133
+ const calls = []
134
+ document.modelContext = {
135
+ registerTool: (s) => (calls.push(s), { id: `r${calls.length}`, unregister() {} }),
136
+ getTools: () => [],
137
+ }
138
+ const reg = listen("webmcp:register")
139
+ const k = newOwnerKey()
140
+ registerTool(k, "/p", makeSpec())
141
+ updateToolSpec(k, { description: "d", inputSchema: { type: "object", properties: {} }, annotations: {} })
142
+ expect(calls).toHaveLength(1)
143
+ updateToolSpec(k, { description: "new", annotations: { readOnlyHint: true } })
144
+ expect(calls).toHaveLength(2)
145
+ expect(calls[1].description).toBe("new")
146
+ expect(reg.events.at(-1).replaced).toBe(true)
147
+ updateToolSpec("unknown-owner", { description: "x" })
148
+ reg.off()
149
+ })
150
+
151
+ it("aborts in-flight tool signals on unregister", async () => {
152
+ let signal
153
+ const k = newOwnerKey()
154
+ registerTool(k, "", makeSpec({ execute: async (_a, ctx) => { signal = ctx.signal } }))
155
+ await invokeTool("t")
156
+ unregisterTool(k)
157
+ expect(signal.aborted).toBe(true)
158
+ expect(() => unregisterTool(k)).not.toThrow()
159
+ })
160
+ })
161
+
162
+ describe("hasBackend", () => {
163
+ beforeEach(() => {
164
+ __resetForTests()
165
+ delete document.modelContext
166
+ })
167
+
168
+ it("is false with nothing installed, true for a shim (which isNative treats as non-native)", () => {
169
+ expect(hasBackend()).toBe(false)
170
+ document.modelContext = { __isWebMcpShim: true, registerTool: () => ({}) }
171
+ expect(hasBackend()).toBe(true)
172
+ expect(inspect().isNative).toBe(false)
173
+ delete document.modelContext
174
+ })
175
+ })