@nxisai/agent-harness 0.4.1 → 0.6.0

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 (137) hide show
  1. package/README.md +25 -2
  2. package/dist/handoff/a2a.d.ts +152 -0
  3. package/dist/handoff/a2a.d.ts.map +1 -0
  4. package/dist/handoff/a2a.js +227 -0
  5. package/dist/handoff/a2a.js.map +1 -0
  6. package/dist/handoff/controller.d.ts +181 -0
  7. package/dist/handoff/controller.d.ts.map +1 -0
  8. package/dist/handoff/controller.js +543 -0
  9. package/dist/handoff/controller.js.map +1 -0
  10. package/dist/handoff/envelope.d.ts +130 -0
  11. package/dist/handoff/envelope.d.ts.map +1 -0
  12. package/dist/handoff/envelope.js +178 -0
  13. package/dist/handoff/envelope.js.map +1 -0
  14. package/dist/handoff/index.d.ts +16 -0
  15. package/dist/handoff/index.d.ts.map +1 -0
  16. package/dist/handoff/index.js +12 -0
  17. package/dist/handoff/index.js.map +1 -0
  18. package/dist/handoff/tools.d.ts +36 -0
  19. package/dist/handoff/tools.d.ts.map +1 -0
  20. package/dist/handoff/tools.js +148 -0
  21. package/dist/handoff/tools.js.map +1 -0
  22. package/dist/harness/harness.d.ts +4 -2
  23. package/dist/harness/harness.d.ts.map +1 -1
  24. package/dist/harness/harness.js +61 -16
  25. package/dist/harness/harness.js.map +1 -1
  26. package/dist/harness/router/coordinator.d.ts +13 -0
  27. package/dist/harness/router/coordinator.d.ts.map +1 -1
  28. package/dist/harness/router/coordinator.js +7 -0
  29. package/dist/harness/router/coordinator.js.map +1 -1
  30. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  31. package/dist/harness/tools/action-tools.js +7 -5
  32. package/dist/harness/tools/action-tools.js.map +1 -1
  33. package/dist/harness/tools/bridge.d.ts.map +1 -1
  34. package/dist/harness/tools/bridge.js +33 -12
  35. package/dist/harness/tools/bridge.js.map +1 -1
  36. package/dist/harness/tools/discovery.d.ts +12 -1
  37. package/dist/harness/tools/discovery.d.ts.map +1 -1
  38. package/dist/harness/tools/discovery.js +19 -1
  39. package/dist/harness/tools/discovery.js.map +1 -1
  40. package/dist/harness/tools/local-context.d.ts +49 -9
  41. package/dist/harness/tools/local-context.d.ts.map +1 -1
  42. package/dist/harness/tools/local-context.js +377 -22
  43. package/dist/harness/tools/local-context.js.map +1 -1
  44. package/dist/harness/tools/open-page.d.ts +16 -0
  45. package/dist/harness/tools/open-page.d.ts.map +1 -1
  46. package/dist/harness/tools/open-page.js +45 -9
  47. package/dist/harness/tools/open-page.js.map +1 -1
  48. package/dist/harness/tools/retry.d.ts +53 -0
  49. package/dist/harness/tools/retry.d.ts.map +1 -0
  50. package/dist/harness/tools/retry.js +96 -0
  51. package/dist/harness/tools/retry.js.map +1 -0
  52. package/dist/harness/tools/schema.d.ts +18 -0
  53. package/dist/harness/tools/schema.d.ts.map +1 -1
  54. package/dist/harness/tools/schema.js +38 -0
  55. package/dist/harness/tools/schema.js.map +1 -1
  56. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  57. package/dist/harness/tools/site-actions.js +3 -2
  58. package/dist/harness/tools/site-actions.js.map +1 -1
  59. package/dist/harness/tools/site-nav.d.ts +168 -0
  60. package/dist/harness/tools/site-nav.d.ts.map +1 -0
  61. package/dist/harness/tools/site-nav.js +316 -0
  62. package/dist/harness/tools/site-nav.js.map +1 -0
  63. package/dist/harness/tools/site-pages.d.ts +4 -0
  64. package/dist/harness/tools/site-pages.d.ts.map +1 -1
  65. package/dist/harness/tools/site-pages.js +19 -3
  66. package/dist/harness/tools/site-pages.js.map +1 -1
  67. package/dist/harness/tools/source.d.ts +9 -0
  68. package/dist/harness/tools/source.d.ts.map +1 -1
  69. package/dist/harness/tools/source.js +54 -14
  70. package/dist/harness/tools/source.js.map +1 -1
  71. package/dist/harness/tools/webmcp.d.ts +39 -3
  72. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  73. package/dist/harness/tools/webmcp.js +93 -14
  74. package/dist/harness/tools/webmcp.js.map +1 -1
  75. package/dist/index.d.ts +6 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +5 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/types.d.ts +2 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  82. package/dist/ui/agent-fullscreen.js +276 -2
  83. package/dist/ui/agent-fullscreen.js.map +1 -1
  84. package/dist/ui/tailwind.generated.d.ts +1 -1
  85. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  86. package/dist/ui/tailwind.generated.js +1 -1
  87. package/dist/ui/tailwind.generated.js.map +1 -1
  88. package/package.json +2 -1
  89. package/src/a2ui/cards.ts +177 -0
  90. package/src/a2ui/emit.ts +147 -0
  91. package/src/a2ui/form.ts +117 -0
  92. package/src/a2ui/host.ts +73 -0
  93. package/src/a2ui/index.ts +22 -0
  94. package/src/a2ui/stepper.ts +211 -0
  95. package/src/a2ui/theme.ts +134 -0
  96. package/src/a2ui/to-v09.ts +118 -0
  97. package/src/engine.ts +701 -0
  98. package/src/handoff/a2a.ts +326 -0
  99. package/src/handoff/controller.ts +661 -0
  100. package/src/handoff/envelope.ts +216 -0
  101. package/src/handoff/index.ts +53 -0
  102. package/src/handoff/tools.ts +178 -0
  103. package/src/harness/harness.ts +981 -0
  104. package/src/harness/plan.ts +91 -0
  105. package/src/harness/policy.ts +59 -0
  106. package/src/harness/renderer.ts +208 -0
  107. package/src/harness/router/coordinator.ts +241 -0
  108. package/src/harness/router/rules.ts +65 -0
  109. package/src/harness/slots.ts +117 -0
  110. package/src/harness/steps.ts +156 -0
  111. package/src/harness/tools/action-tools.ts +870 -0
  112. package/src/harness/tools/bridge.ts +393 -0
  113. package/src/harness/tools/client.ts +26 -0
  114. package/src/harness/tools/discovery.ts +73 -0
  115. package/src/harness/tools/local-context.ts +480 -0
  116. package/src/harness/tools/open-page.ts +273 -0
  117. package/src/harness/tools/pending-action.ts +232 -0
  118. package/src/harness/tools/resume.ts +134 -0
  119. package/src/harness/tools/retry.ts +117 -0
  120. package/src/harness/tools/schema.ts +245 -0
  121. package/src/harness/tools/site-actions.ts +356 -0
  122. package/src/harness/tools/site-nav.ts +432 -0
  123. package/src/harness/tools/site-pages.ts +288 -0
  124. package/src/harness/tools/source.ts +353 -0
  125. package/src/harness/tools/webmcp.ts +200 -0
  126. package/src/index.ts +196 -0
  127. package/src/providers/capability.ts +167 -0
  128. package/src/providers/proxy.ts +191 -0
  129. package/src/providers/types.ts +150 -0
  130. package/src/types.ts +258 -0
  131. package/src/ui/agent-fullscreen.ts +4262 -0
  132. package/src/ui/agent.tw.css +49 -0
  133. package/src/ui/config.ts +138 -0
  134. package/src/ui/index.ts +32 -0
  135. package/src/ui/journey.ts +57 -0
  136. package/src/ui/tailwind.generated.ts +2 -0
  137. package/src/webmcp-contract.ts +65 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The site's pages, and which of them a record belongs to.
3
+ *
4
+ * A result the agent finds is a record — a plumber, a product — and the
5
+ * visitor usually wants the page it came from. The record rarely says: a
6
+ * business's `url` is its website, often the site root. Typed retrieval also
7
+ * holds the site's `WebPage` records, and every Action names the page its form
8
+ * is on, so between them most records can be traced back to a page.
9
+ *
10
+ * Only pages on this origin are ever returned: whatever a record claims, the
11
+ * agent does not send a visitor to another site.
12
+ *
13
+ * @module
14
+ */
15
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
16
+ import type { McpServerInfo } from '../../types.js';
17
+ import type { NxisAction } from '../../webmcp-contract.js';
18
+ import { queryTypedRecords, samePage } from './site-actions.js';
19
+
20
+ const SCHEMA_NS = 'https://schema.org/';
21
+ const CACHE_TTL_MS = 10 * 60 * 1000;
22
+ const CACHE_PREFIX = 'nxis:site-pages:v1:';
23
+
24
+ /** A page on the site, as the agent can name it. */
25
+ export interface SitePage {
26
+ /** Absolute URL, on this origin. */
27
+ url: string;
28
+ /** What the page is called, for the visitor and the model. */
29
+ name: string;
30
+ /** The `@id`s of the records the page is about, when its record says. */
31
+ about?: string[];
32
+ /** Pages this one points a reader at: its `significantLink` and `relatedLink`. */
33
+ links?: string[];
34
+ /** The `@id` of the breadcrumb trail extracted from this page, when it has one. */
35
+ breadcrumb?: string;
36
+ }
37
+
38
+ type Obj = Record<string, unknown>;
39
+
40
+ const isObj = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
41
+ const schemaValue = (o: Obj, prop: string): unknown => o[prop] ?? o[SCHEMA_NS + prop];
42
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
43
+ const bareType = (t: string): string => t.replace(/^.*[/#]/, '');
44
+ const types = (o: Obj): string[] =>
45
+ (Array.isArray(o['@type']) ? o['@type'] : [o['@type']]).filter((t): t is string => typeof t === 'string').map(bareType);
46
+
47
+ /** A name compared the way a person would: case, spacing and punctuation aside. */
48
+ const normal = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
49
+
50
+ /**
51
+ * A URL resolved against the origin, if it stays on it.
52
+ *
53
+ * Crawled pages often record a path (`/listings/x.html`) rather than a URL.
54
+ */
55
+ function onOrigin(raw: unknown, origin: string): string | null {
56
+ const s = str(raw);
57
+ if (!s) return null;
58
+ try {
59
+ const url = new URL(s, origin);
60
+ return url.origin === new URL(origin).origin ? url.href : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Every `@id` (or URL) a reference property points at. */
67
+ function refs(v: unknown): string[] {
68
+ const list = Array.isArray(v) ? v : [v];
69
+ return list
70
+ .map((r) => (isObj(r) ? str(r['@id']) ?? str(schemaValue(r, 'url')) : str(r)))
71
+ .filter((r): r is string => !!r);
72
+ }
73
+
74
+ /**
75
+ * Read one typed-retrieval record as a page.
76
+ *
77
+ * @param raw - A record as returned by `nxis_query`.
78
+ * @param origin - The site's origin; pages elsewhere are refused.
79
+ * @returns The page, or null when the record is not a page, has no URL on
80
+ * this origin, or has no name.
81
+ */
82
+ export function parsePage(raw: unknown, origin: string): SitePage | null {
83
+ if (!isObj(raw) || !types(raw).some((t) => t.endsWith('Page'))) return null;
84
+ const url = onOrigin(schemaValue(raw, 'url'), origin);
85
+ const name = str(schemaValue(raw, 'name')) ?? str(schemaValue(raw, 'headline'));
86
+ if (!url || !name) return null;
87
+ const about = [...refs(schemaValue(raw, 'mainEntity')), ...refs(schemaValue(raw, 'about'))];
88
+ // What the page points at, which is how an agent follows a page's own links rather than only
89
+ // the hierarchy above it. Kept as published; the platform stores them absolute.
90
+ const links = [...new Set([...refs(schemaValue(raw, 'significantLink')), ...refs(schemaValue(raw, 'relatedLink'))])];
91
+ const breadcrumb = refs(schemaValue(raw, 'breadcrumb'))[0];
92
+ return {
93
+ url,
94
+ name,
95
+ ...(about.length ? { about } : {}),
96
+ ...(links.length ? { links } : {}),
97
+ ...(breadcrumb ? { breadcrumb } : {}),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * The pages the site's actions live on, named for what each is about.
103
+ *
104
+ * An action whose field is fixed to one value — the business a quote goes
105
+ * to — names its page better than the page's path does.
106
+ */
107
+ export function pagesFromActions(actions: NxisAction[], origin: string): SitePage[] {
108
+ const pages: SitePage[] = [];
109
+ for (const action of actions) {
110
+ const url = onOrigin(action.target['nxis:pageUrl'], origin);
111
+ if (!url) continue;
112
+ const named = (action.input ?? []).find((i) => i.defaultValue && !i['nxis:valueOption']);
113
+ const name = named?.defaultValue ?? slugName(url);
114
+ if (name) pages.push({ url, name });
115
+ }
116
+ return pages;
117
+ }
118
+
119
+ /** `/listings/detroit-metro-plumbing.html` → `detroit metro plumbing`. */
120
+ function slugName(url: string): string {
121
+ const slug = new URL(url).pathname.split('/').filter(Boolean).pop() ?? '';
122
+ return slug.replace(/\.\w+$/, '').replace(/[-_]+/g, ' ') || 'home';
123
+ }
124
+
125
+ /**
126
+ * Pages from any number of sources, each page once.
127
+ *
128
+ * The first source to name a page wins its name, so pass the page records
129
+ * before anything inferred.
130
+ */
131
+ export function mergePages(...lists: SitePage[][]): SitePage[] {
132
+ const out: SitePage[] = [];
133
+ for (const page of lists.flat()) {
134
+ const same = out.find((p) => samePage(p.url, page.url));
135
+ if (!same) out.push({ ...page });
136
+ else {
137
+ if (page.about?.length) same.about = [...new Set([...(same.about ?? []), ...page.about])];
138
+ if (page.links?.length) same.links = [...new Set([...(same.links ?? []), ...page.links])];
139
+ if (page.breadcrumb && !same.breadcrumb) same.breadcrumb = page.breadcrumb;
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * The page a record came from, when it can be told.
147
+ *
148
+ * In order of how much each is to be trusted: the record naming its page, its
149
+ * id when that is formed from a page (`page.html#organization`), the record
150
+ * being a page, the record's own URL when it is a page of this site rather than
151
+ * its root, a page that says it is about the record, and last a page with the
152
+ * record's exact name.
153
+ *
154
+ * @param entity - The record, compact or IRI-keyed.
155
+ * @param pages - The site's pages.
156
+ * @param origin - The site's origin.
157
+ * @returns The page, or null when none can be told apart.
158
+ */
159
+ export function pageForEntity(entity: Obj, pages: SitePage[], origin: string): SitePage | null {
160
+ if (!isObj(entity)) return null;
161
+ const name = str(schemaValue(entity, 'name')) ?? str(schemaValue(entity, 'headline'));
162
+ const known = (url: string): SitePage =>
163
+ pages.find((p) => samePage(p.url, url)) ?? { url, name: name ?? slugName(url) };
164
+
165
+ for (const ref of refs(schemaValue(entity, 'mainEntityOfPage'))) {
166
+ const url = onOrigin(ref, origin);
167
+ if (url) return known(url);
168
+ }
169
+
170
+ // An id built from its page (`page.html#organization`) names the page: everything before the
171
+ // fragment. Only with a fragment and below the root: a site-wide id (`/#organization/...`) names
172
+ // no page, and the hash-suffixed ids stored before (`page.html/23400b...`) are not pages either.
173
+ const idPage = pageOfId(str(entity['@id']), origin);
174
+ if (idPage) return known(idPage);
175
+
176
+ const own = onOrigin(schemaValue(entity, 'url'), origin);
177
+ if (own && (types(entity).some((t) => t.endsWith('Page')) || new URL(own).pathname !== '/')) {
178
+ return known(own);
179
+ }
180
+
181
+ const id = str(entity['@id']);
182
+ if (id) {
183
+ const about = pages.filter((p) => p.about?.includes(id));
184
+ if (about.length === 1) return about[0];
185
+ }
186
+
187
+ if (name) {
188
+ const named = pages.filter((p) => normal(p.name) === normal(name));
189
+ if (named.length === 1) return named[0];
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * The page an id formed from it names, or null.
196
+ *
197
+ * nxis-sdn-api forms a page entity's id as the page URL plus a fragment, so the page is the id with
198
+ * the fragment removed.
199
+ */
200
+ function pageOfId(id: string | undefined, origin: string): string | null {
201
+ if (!id || !id.includes('#')) return null;
202
+ const url = onOrigin(id, origin);
203
+ if (!url) return null;
204
+ const page = new URL(url);
205
+ page.hash = '';
206
+ return page.pathname === '/' ? null : page.href;
207
+ }
208
+
209
+ /**
210
+ * The page a visitor or model means, by name or by address.
211
+ *
212
+ * @param wanted - A page name, a URL, or a path.
213
+ * @param pages - The site's pages.
214
+ * @param origin - The site's origin.
215
+ * @returns The page, or null when nothing matches or several do equally.
216
+ */
217
+ export function findPage(wanted: string, pages: SitePage[], origin: string): SitePage | null {
218
+ const w = wanted.trim();
219
+ if (!w) return null;
220
+ if (/^(https?:\/\/|\/)/i.test(w)) {
221
+ const url = onOrigin(w, origin);
222
+ return url ? (pages.find((p) => samePage(p.url, url)) ?? null) : null;
223
+ }
224
+ const n = normal(w);
225
+ const exact = pages.filter((p) => normal(p.name) === n);
226
+ if (exact.length === 1) return exact[0];
227
+ const partial = pages.filter((p) => normal(p.name).includes(n) || n.includes(normal(p.name)));
228
+ return partial.length === 1 ? partial[0] : null;
229
+ }
230
+
231
+ /** Options for {@link loadSitePages}. */
232
+ export interface SitePagesOptions {
233
+ /** The origin pages must belong to. Defaults to the page's own. */
234
+ origin?: string;
235
+ /** Where the list is cached between page loads. Defaults to `sessionStorage`; null disables. */
236
+ storage?: Storage | null;
237
+ /** How long a cached list is reused, in milliseconds. Defaults to ten minutes. */
238
+ ttlMs?: number;
239
+ /** The clock, for tests. */
240
+ now?: () => number;
241
+ }
242
+
243
+ function defaultStorage(): Storage | null {
244
+ try {
245
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Every page record on the site, on this origin.
253
+ *
254
+ * Cached in the tab like the site's actions, so moving between pages costs
255
+ * one request per session.
256
+ *
257
+ * @throws When the query fails. The caller decides whether that matters.
258
+ */
259
+ export async function loadSitePages(
260
+ client: Client,
261
+ info: Pick<McpServerInfo, 'endpoint'>,
262
+ opts: SitePagesOptions = {},
263
+ ): Promise<SitePage[]> {
264
+ const origin = opts.origin ?? location.origin;
265
+ const storage = opts.storage === undefined ? defaultStorage() : opts.storage;
266
+ const now = (opts.now ?? Date.now)();
267
+ const key = CACHE_PREFIX + info.endpoint;
268
+
269
+ try {
270
+ const entry = JSON.parse(storage?.getItem(key) || 'null') as { fetchedAt: number; pages: SitePage[] } | null;
271
+ const fresh = entry && now - entry.fetchedAt <= (opts.ttlMs ?? CACHE_TTL_MS) && entry.fetchedAt <= now;
272
+ if (fresh && Array.isArray(entry.pages)) return entry.pages;
273
+ } catch {
274
+ // A corrupt entry is a refetch.
275
+ }
276
+
277
+ const pages = mergePages(
278
+ (await queryTypedRecords(client, 'Page'))
279
+ .map((r) => parsePage(r, origin))
280
+ .filter((p): p is SitePage => p !== null),
281
+ );
282
+ try {
283
+ storage?.setItem(key, JSON.stringify({ fetchedAt: now, pages }));
284
+ } catch {
285
+ // Full or blocked storage costs a refetch on the next page, nothing more.
286
+ }
287
+ return pages;
288
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Assembles the page's toolset from both of its sources.
3
+ *
4
+ * Tools come from two places: those registered on the page itself, and those
5
+ * from a remote MCP server discovered through `.well-known` and bridged in.
6
+ * Both end up in `document.modelContext`, so the harness discovers them with a
7
+ * single call and treats them alike, distinguishing only read from write via
8
+ * each tool's annotations.
9
+ *
10
+ * {@link connectTools} is awaited rather than run as a load-time side effect,
11
+ * so a caller knows when the toolset is complete.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import { registerRemoteTools, type BridgeOptions } from './bridge.js';
17
+ import { ensureModelContext } from './local-context.js';
18
+ import { connectMcp } from './client.js';
19
+ import { discoverMcpServer } from './discovery.js';
20
+ import { loadSiteActions, type SiteActionsOptions } from './site-actions.js';
21
+ import { registerSiteActionTools } from './action-tools.js';
22
+ import { loadSitePages, mergePages, pagesFromActions, type SitePage } from './site-pages.js';
23
+ import { registerOpenPageTool, OPEN_PAGE_TOOL, type OpenPageOptions } from './open-page.js';
24
+ import {
25
+ applyTrails,
26
+ buildNav,
27
+ loadSiteTrails,
28
+ registerPageLinksTool,
29
+ PAGE_LINKS_TOOL,
30
+ type SiteNav,
31
+ } from './site-nav.js';
32
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
33
+ import type { McpServerInfo, ModelContext } from '../../types.js';
34
+ import type { NxisAction } from '../../webmcp-contract.js';
35
+
36
+ const READY_TIMEOUT_MS = 20000;
37
+ const POLL_INTERVAL_MS = 200;
38
+
39
+ /** The outcome of {@link connectTools}. */
40
+ export interface ConnectResult {
41
+ /** The registry the tools were registered into, or null if none was found. */
42
+ modelContext: ModelContext | null;
43
+ /** Remote tool names bridged in. Empty when discovery failed or was skipped. */
44
+ remoteTools: string[];
45
+ /**
46
+ * Every Action on the site, from any page, that belongs to this origin.
47
+ *
48
+ * Empty when discovery failed, the query failed, or it was turned off with
49
+ * {@link ConnectOptions.siteActions}.
50
+ */
51
+ siteActions: NxisAction[];
52
+ /**
53
+ * The tools registered for {@link siteActions}.
54
+ *
55
+ * Fewer than the actions: those that share a name and a set of fields become
56
+ * one tool that chooses between them.
57
+ */
58
+ siteTools: string[];
59
+ /**
60
+ * The site's pages the agent can open and link results to: its page
61
+ * records, and the pages its actions live on.
62
+ */
63
+ sitePages: SitePage[];
64
+ /**
65
+ * The site's graph: which pages sit under which, and what points at what,
66
+ * from its breadcrumb trails and its pages' links. Empty when the site
67
+ * publishes no trails and its pages link nothing.
68
+ */
69
+ siteNav?: SiteNav;
70
+ /** Why remote tools or site actions are absent, when they are. Never fatal. */
71
+ warning?: string;
72
+ }
73
+
74
+ /** The page's model context, from either of the two places it may appear. */
75
+ function getModelContext(): ModelContext | null {
76
+ return document.modelContext ?? navigator.modelContext ?? null;
77
+ }
78
+
79
+ /**
80
+ * Wait for a WebMCP provider to appear on the page.
81
+ *
82
+ * @param timeout - How long to wait, in milliseconds.
83
+ * @returns The model context, or null if none appears in time.
84
+ */
85
+ export function waitForModelContext(timeout = READY_TIMEOUT_MS): Promise<ModelContext | null> {
86
+ const immediate = getModelContext();
87
+ if (immediate) return Promise.resolve(immediate);
88
+ return new Promise((resolve) => {
89
+ const started = Date.now();
90
+ const iv = setInterval(() => {
91
+ const mc = getModelContext();
92
+ if (mc) {
93
+ clearInterval(iv);
94
+ resolve(mc);
95
+ } else if (Date.now() - started > timeout) {
96
+ clearInterval(iv);
97
+ resolve(null);
98
+ }
99
+ }, POLL_INTERVAL_MS);
100
+ });
101
+ }
102
+
103
+ /** Options for {@link connectTools}. */
104
+ export interface ConnectOptions {
105
+ /** How long to wait for a WebMCP provider before falling back. */
106
+ timeout?: number;
107
+ /**
108
+ * Register into a local registry when the page has no WebMCP provider.
109
+ *
110
+ * Defaults to true, so a working MCP server still yields tools on a page with
111
+ * no WebMCP implementation. Pass false to require a real provider.
112
+ */
113
+ fallback?: boolean;
114
+ /** Override which of the server's tools are bridged in. */
115
+ accept?: BridgeOptions['accept'];
116
+ /**
117
+ * Fetch the site's Actions from every page, not only this one.
118
+ *
119
+ * Defaults to true. Pass false to skip the query, or an options object to
120
+ * override the origin check or the cache.
121
+ */
122
+ siteActions?: boolean | SiteActionsOptions;
123
+ /**
124
+ * Register `open_page`, so the agent can take the visitor to a page.
125
+ *
126
+ * Defaults to true when site actions are fetched. Pass false to leave it
127
+ * out, or options to supply the conversation to continue on arrival.
128
+ */
129
+ openPage?: boolean | OpenPageOptions;
130
+ }
131
+
132
+ /**
133
+ * Wait for a tool registry, then bridge in a remote MCP server's tools.
134
+ *
135
+ * Every remote failure is caught and reported as
136
+ * {@link ConnectResult.warning}: discovery, CORS and network problems leave the
137
+ * page's own tools working, and a site with no MCP server is an ordinary case
138
+ * rather than an error.
139
+ *
140
+ * Connects each registry once. A second call for the same registry, such as a
141
+ * page script and the element both connecting, returns the first call's result
142
+ * rather than registering the same tools again, which a native `modelContext`
143
+ * rejects as a duplicate.
144
+ *
145
+ * @param apiKey - Key for the remote server's private tools. Public tools need
146
+ * none, and the element never supplies one: a key readable in the page is a
147
+ * published key. Pass one only where it is short-lived and scoped, such as a
148
+ * token your backend mints per session.
149
+ * @param opts - Timeout, local-registry fallback, and a tool filter. Only the
150
+ * first call's options apply to a registry.
151
+ * @returns The registry, the remote tools bridged in, and any warning.
152
+ */
153
+ export async function connectTools(
154
+ apiKey?: string,
155
+ opts: ConnectOptions = {},
156
+ ): Promise<ConnectResult> {
157
+ const waited = await waitForModelContext(opts.timeout ?? READY_TIMEOUT_MS);
158
+ const mc = waited ?? (opts.fallback === false ? null : ensureModelContext());
159
+ if (!mc) {
160
+ return {
161
+ modelContext: null,
162
+ remoteTools: [],
163
+ siteActions: [],
164
+ siteTools: [],
165
+ sitePages: [],
166
+ warning: 'WebMCP (document.modelContext) unavailable — no tools registered.',
167
+ };
168
+ }
169
+
170
+ let connection = connections.get(mc);
171
+ if (!connection) {
172
+ connection = connectRegistry(mc, apiKey, opts);
173
+ connections.set(mc, connection);
174
+ }
175
+ return connection;
176
+ }
177
+
178
+ /** One connection per registry, shared by every caller. */
179
+ const connections = new WeakMap<ModelContext, Promise<ConnectResult>>();
180
+
181
+ async function connectRegistry(
182
+ mc: ModelContext,
183
+ apiKey: string | undefined,
184
+ opts: ConnectOptions,
185
+ ): Promise<ConnectResult> {
186
+ let info: McpServerInfo;
187
+ let client: Client;
188
+ let remoteTools: string[];
189
+ try {
190
+ info = await discoverMcpServer();
191
+ client = await connectMcp(info, apiKey);
192
+ remoteTools = await registerRemoteTools(client, mc, info, { accept: opts.accept });
193
+ } catch (err) {
194
+ // No server, so no page list — but the address bar still says where the visitor is, and
195
+ // "home" and "back" need nothing else. An unreachable corpus is not a reason to lose those.
196
+ if (opts.openPage !== false) await registerNavigationOnly(mc, opts.openPage);
197
+ return {
198
+ modelContext: mc,
199
+ remoteTools: [],
200
+ siteActions: [],
201
+ siteTools: [],
202
+ sitePages: [],
203
+ warning: `remote tool bootstrap failed — on-page tools still work: ${errorText(err)}`,
204
+ };
205
+ }
206
+
207
+ if (opts.siteActions === false) {
208
+ return { modelContext: mc, remoteTools, siteActions: [], siteTools: [], sitePages: [] };
209
+ }
210
+
211
+ const siteOpts = typeof opts.siteActions === 'object' ? opts.siteActions : {};
212
+
213
+ // Each read stands on its own. They used to share one `try`, so a corpus the server was rate
214
+ // limiting took the navigation tools down with it: the agent lost the tool that says which page
215
+ // it is on — a question needing no read at all — because a different query had failed.
216
+ let siteActions: NxisAction[] = [];
217
+ let siteTools: string[] = [];
218
+ let warning: string | undefined;
219
+ try {
220
+ siteActions = await loadSiteActions(client, info, siteOpts);
221
+ siteTools = await registerSiteActionTools(mc, siteActions);
222
+ } catch (err) {
223
+ warning = `site actions unavailable — this page's own actions still work: ${errorText(err)}`;
224
+ console.warn(`[nxis-mcp] ${warning}`);
225
+ }
226
+
227
+ const site = await connectPages(mc, client, info, siteActions, siteOpts, opts.openPage);
228
+ return { modelContext: mc, remoteTools, siteActions, siteTools, sitePages: site.pages, siteNav: site.nav, warning };
229
+ }
230
+
231
+ /**
232
+ * The site's pages, and the tool that opens them.
233
+ *
234
+ * A failed page query is not worth a warning of its own: the pages the
235
+ * actions live on still make a usable list.
236
+ */
237
+ async function connectPages(
238
+ mc: ModelContext,
239
+ client: Client,
240
+ info: McpServerInfo,
241
+ actions: NxisAction[],
242
+ siteOpts: SiteActionsOptions,
243
+ openPage: ConnectOptions['openPage'],
244
+ ): Promise<{ pages: SitePage[]; nav: SiteNav }> {
245
+ const origin = siteOpts.origin ?? location.origin;
246
+ let records: SitePage[] = [];
247
+ try {
248
+ records = await loadSitePages(client, info, siteOpts);
249
+ } catch (err) {
250
+ console.warn(`[nxis-mcp] page records unavailable, using action pages only: ${errorText(err)}`);
251
+ }
252
+ const pages = mergePages(records, pagesFromActions(actions, origin));
253
+ if (!pages.length) {
254
+ // Registered anyway: "where am I", "home" and "back" are answerable from the address bar, and a
255
+ // page list that failed to load is the moment a visitor most needs them.
256
+ console.info('[nxis-mcp] no page records; navigation is by address');
257
+ }
258
+
259
+ // The pages and what they link, now; the hierarchy on demand. Connecting already spends two
260
+ // reads (actions, pages), and a site's rate limit counts them — the public plan allows three a
261
+ // minute, so a third here would leave the visitor's first question waiting on the limit. The
262
+ // trails are fetched the first time navigation needs them, and folded into this same graph.
263
+ const nav = buildNav(pages, [], origin);
264
+ let trails: Promise<SiteNav> | null = null;
265
+ const loadNav = (): Promise<SiteNav> =>
266
+ (trails ??= loadSiteTrails(client, info, siteOpts)
267
+ .then((found) => {
268
+ console.info(`[nxis-mcp] ${found.length} breadcrumb trail(s)`);
269
+ return applyTrails(nav, found);
270
+ })
271
+ .catch((err) => {
272
+ // Asked again next time: a rate limit now is not a site without trails.
273
+ trails = null;
274
+ console.warn(`[nxis-mcp] breadcrumb trails unavailable, navigation is by name: ${errorText(err)}`);
275
+ return nav;
276
+ }));
277
+
278
+ if (openPage !== false) {
279
+ const registered = await registerOpenPageTool(mc, pages, {
280
+ origin,
281
+ nav,
282
+ loadNav,
283
+ ...(typeof openPage === 'object' ? openPage : {}),
284
+ });
285
+ if (registered) console.info(`[nxis-mcp] ${OPEN_PAGE_TOOL}: ${pages.length} page(s)`);
286
+ const links = await registerPageLinksTool(mc, nav, { loadNav });
287
+ if (links) console.info(`[nxis-mcp] ${PAGE_LINKS_TOOL}: where the visitor is, and what leads where`);
288
+ }
289
+ return { pages, nav };
290
+ }
291
+
292
+ /** The navigation tools with nothing but the page's own address to go on. */
293
+ async function registerNavigationOnly(mc: ModelContext, openPage: ConnectOptions['openPage']): Promise<void> {
294
+ const origin = typeof location === 'undefined' ? '' : location.origin;
295
+ const nav = buildNav([], [], origin);
296
+ await registerOpenPageTool(mc, [], { origin, nav, ...(typeof openPage === 'object' ? openPage : {}) });
297
+ await registerPageLinksTool(mc, nav);
298
+ }
299
+
300
+ const errorText = (err: unknown): string => String((err as Error)?.message || err);
301
+
302
+ export { discoverMcpServer, connectMcp, registerRemoteTools, loadSiteActions };
303
+ export {
304
+ registerSiteActionTools,
305
+ buildSiteActionTools,
306
+ withoutShadowedTools,
307
+ handoffFor,
308
+ findPageTool,
309
+ awaitPageTool,
310
+ pageToolRef,
311
+ siteToolName,
312
+ selectSiteTools,
313
+ } from './action-tools.js';
314
+ export { resumePendingAction } from './resume.js';
315
+ export {
316
+ loadSitePages,
317
+ parsePage,
318
+ pagesFromActions,
319
+ mergePages,
320
+ pageForEntity,
321
+ findPage,
322
+ } from './site-pages.js';
323
+ export type { SitePage, SitePagesOptions } from './site-pages.js';
324
+ export { buildOpenPageTool, registerOpenPageTool, resolveDestination, visitFor, OPEN_PAGE_TOOL } from './open-page.js';
325
+ export {
326
+ applyTrails,
327
+ buildNav,
328
+ buildPageLinksTool,
329
+ describeNav,
330
+ loadSiteTrails,
331
+ nameOf,
332
+ navFor,
333
+ parentOf,
334
+ parseTrail,
335
+ registerPageLinksTool,
336
+ PAGE_LINKS_TOOL,
337
+ } from './site-nav.js';
338
+ export type { NavLink, PageNav, SiteNav, SiteTrail, SiteTrailsOptions, TrailLevel } from './site-nav.js';
339
+ export type { OpenPageOptions, Destination } from './open-page.js';
340
+ export type { ResumeOutcome, ResumeOptions } from './resume.js';
341
+ export type { SiteActionTool, Handoff, PageToolRef } from './action-tools.js';
342
+ export {
343
+ savePendingAction,
344
+ takePendingAction,
345
+ clearPendingAction,
346
+ savePendingVisit,
347
+ takePendingVisit,
348
+ setConversationSource,
349
+ } from './pending-action.js';
350
+ export type { PendingAction, PendingVisit } from './pending-action.js';
351
+ export { parseAction, samePage, sameActionPage } from './site-actions.js';
352
+ export type { SiteActionsOptions } from './site-actions.js';
353
+ export { ensureModelContext, LocalModelContext } from './local-context.js';