catalyst-core 1.0.1 → 1.0.2-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/scripts/build.js +3 -1
- package/dist/scripts/preflight.js +81 -1
- package/dist/scripts/serve.js +8 -1
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +24 -6
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +121 -0
- package/package.json +3 -6
- package/test/scripts/preflight.test.ts +100 -2
- package/test/server/manifestCache.test.ts +13 -0
- package/vitest.config.ts +4 -0
|
@@ -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
|
+
})
|