@nxisai/agent-harness 0.4.0 → 0.5.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 (103) hide show
  1. package/README.md +4 -2
  2. package/dist/harness/harness.d.ts.map +1 -1
  3. package/dist/harness/harness.js +31 -9
  4. package/dist/harness/harness.js.map +1 -1
  5. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  6. package/dist/harness/tools/action-tools.js +7 -5
  7. package/dist/harness/tools/action-tools.js.map +1 -1
  8. package/dist/harness/tools/bridge.d.ts.map +1 -1
  9. package/dist/harness/tools/bridge.js +33 -12
  10. package/dist/harness/tools/bridge.js.map +1 -1
  11. package/dist/harness/tools/local-context.d.ts +49 -9
  12. package/dist/harness/tools/local-context.d.ts.map +1 -1
  13. package/dist/harness/tools/local-context.js +377 -22
  14. package/dist/harness/tools/local-context.js.map +1 -1
  15. package/dist/harness/tools/open-page.d.ts +47 -1
  16. package/dist/harness/tools/open-page.d.ts.map +1 -1
  17. package/dist/harness/tools/open-page.js +119 -33
  18. package/dist/harness/tools/open-page.js.map +1 -1
  19. package/dist/harness/tools/retry.d.ts +53 -0
  20. package/dist/harness/tools/retry.d.ts.map +1 -0
  21. package/dist/harness/tools/retry.js +96 -0
  22. package/dist/harness/tools/retry.js.map +1 -0
  23. package/dist/harness/tools/schema.d.ts +18 -0
  24. package/dist/harness/tools/schema.d.ts.map +1 -1
  25. package/dist/harness/tools/schema.js +38 -0
  26. package/dist/harness/tools/schema.js.map +1 -1
  27. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  28. package/dist/harness/tools/site-actions.js +3 -2
  29. package/dist/harness/tools/site-actions.js.map +1 -1
  30. package/dist/harness/tools/site-nav.d.ts +168 -0
  31. package/dist/harness/tools/site-nav.d.ts.map +1 -0
  32. package/dist/harness/tools/site-nav.js +316 -0
  33. package/dist/harness/tools/site-nav.js.map +1 -0
  34. package/dist/harness/tools/site-pages.d.ts +9 -4
  35. package/dist/harness/tools/site-pages.d.ts.map +1 -1
  36. package/dist/harness/tools/site-pages.js +46 -7
  37. package/dist/harness/tools/site-pages.js.map +1 -1
  38. package/dist/harness/tools/source.d.ts +11 -2
  39. package/dist/harness/tools/source.d.ts.map +1 -1
  40. package/dist/harness/tools/source.js +55 -15
  41. package/dist/harness/tools/source.js.map +1 -1
  42. package/dist/harness/tools/webmcp.d.ts +39 -3
  43. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  44. package/dist/harness/tools/webmcp.js +93 -14
  45. package/dist/harness/tools/webmcp.js.map +1 -1
  46. package/dist/index.d.ts +7 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +4 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/types.d.ts +2 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  53. package/dist/ui/agent-fullscreen.js +13 -2
  54. package/dist/ui/agent-fullscreen.js.map +1 -1
  55. package/dist/ui/tailwind.generated.d.ts +1 -1
  56. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  57. package/dist/ui/tailwind.generated.js +1 -1
  58. package/dist/ui/tailwind.generated.js.map +1 -1
  59. package/package.json +2 -1
  60. package/src/a2ui/cards.ts +177 -0
  61. package/src/a2ui/emit.ts +147 -0
  62. package/src/a2ui/form.ts +117 -0
  63. package/src/a2ui/host.ts +73 -0
  64. package/src/a2ui/index.ts +22 -0
  65. package/src/a2ui/stepper.ts +211 -0
  66. package/src/a2ui/theme.ts +134 -0
  67. package/src/a2ui/to-v09.ts +118 -0
  68. package/src/engine.ts +701 -0
  69. package/src/harness/harness.ts +956 -0
  70. package/src/harness/plan.ts +91 -0
  71. package/src/harness/policy.ts +59 -0
  72. package/src/harness/renderer.ts +208 -0
  73. package/src/harness/router/coordinator.ts +220 -0
  74. package/src/harness/router/rules.ts +65 -0
  75. package/src/harness/slots.ts +117 -0
  76. package/src/harness/steps.ts +156 -0
  77. package/src/harness/tools/action-tools.ts +870 -0
  78. package/src/harness/tools/bridge.ts +393 -0
  79. package/src/harness/tools/client.ts +26 -0
  80. package/src/harness/tools/discovery.ts +55 -0
  81. package/src/harness/tools/local-context.ts +480 -0
  82. package/src/harness/tools/open-page.ts +273 -0
  83. package/src/harness/tools/pending-action.ts +232 -0
  84. package/src/harness/tools/resume.ts +134 -0
  85. package/src/harness/tools/retry.ts +117 -0
  86. package/src/harness/tools/schema.ts +245 -0
  87. package/src/harness/tools/site-actions.ts +356 -0
  88. package/src/harness/tools/site-nav.ts +432 -0
  89. package/src/harness/tools/site-pages.ts +288 -0
  90. package/src/harness/tools/source.ts +353 -0
  91. package/src/harness/tools/webmcp.ts +200 -0
  92. package/src/index.ts +193 -0
  93. package/src/providers/capability.ts +167 -0
  94. package/src/providers/proxy.ts +191 -0
  95. package/src/providers/types.ts +150 -0
  96. package/src/types.ts +258 -0
  97. package/src/ui/agent-fullscreen.ts +3984 -0
  98. package/src/ui/agent.tw.css +49 -0
  99. package/src/ui/config.ts +138 -0
  100. package/src/ui/index.ts +32 -0
  101. package/src/ui/journey.ts +57 -0
  102. package/src/ui/tailwind.generated.ts +2 -0
  103. package/src/webmcp-contract.ts +65 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * The site as a graph the agent can walk.
3
+ *
4
+ * A page publishes one breadcrumb trail: the path from the site's root down to
5
+ * itself. Every page publishes its own, so the trails together *are* the
6
+ * hierarchy — inverted, each page gains the sections above it, the pages beside
7
+ * it, and the pages under it. A page's `significantLink` and `relatedLink` add
8
+ * the cross-links a hierarchy cannot express: a listing pointing at its service
9
+ * area, a guide pointing at the form it explains.
10
+ *
11
+ * Link names come free. A link usually points at a page the site also has a
12
+ * record for, so the name shown is that page's own name rather than anchor text
13
+ * nobody extracted. A link to a page with no record keeps its address.
14
+ *
15
+ * Nothing here leaves the origin: a trail level or link on another host is
16
+ * dropped when the graph is built, not when it is walked.
17
+ *
18
+ * @module
19
+ */
20
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
21
+ import type { McpServerInfo, ModelContext, ModelContextTool } from '../../types.js';
22
+ import { queryTypedRecords, samePage } from './site-actions.js';
23
+ import type { SitePage } from './site-pages.js';
24
+
25
+ const SCHEMA_NS = 'https://schema.org/';
26
+ const CACHE_TTL_MS = 10 * 60 * 1000;
27
+ const CACHE_PREFIX = 'nxis:site-trails:v1:';
28
+
29
+ /** One level of a page's trail. The last is the page itself, and names no URL. */
30
+ export interface TrailLevel {
31
+ /** What the site calls this level. */
32
+ name: string;
33
+ /** Absolute URL, on this origin. Absent on the page's own level. */
34
+ url?: string;
35
+ }
36
+
37
+ /** A page's path from the root, root first. */
38
+ export interface SiteTrail {
39
+ /** The page whose trail this is, when it can be told from the record's id. */
40
+ page?: string;
41
+ levels: TrailLevel[];
42
+ }
43
+
44
+ /** Somewhere the agent can go from here, and what to call it. */
45
+ export interface NavLink {
46
+ url: string;
47
+ name: string;
48
+ }
49
+
50
+ /** What a page is surrounded by. */
51
+ export interface PageNav {
52
+ /** The sections above it, root first, not including the page itself. */
53
+ ancestors: NavLink[];
54
+ /** Pages under the same parent, excluding the page itself. */
55
+ siblings: NavLink[];
56
+ /** Pages whose parent is this page. */
57
+ children: NavLink[];
58
+ /** Pages this page points a reader at. */
59
+ links: NavLink[];
60
+ }
61
+
62
+ /** The whole site, ready to answer "where am I and what is near me". */
63
+ export interface SiteNav {
64
+ /** The site's origin, so a graph can be rebuilt from itself. */
65
+ origin: string;
66
+ /** Parent page URL, by page URL. */
67
+ parents: Map<string, string>;
68
+ /** The trail for each page it was published for. */
69
+ trails: Map<string, TrailLevel[]>;
70
+ /** Everything a page points at, by page URL. */
71
+ links: Map<string, string[]>;
72
+ /** Every page known, by URL, for naming. */
73
+ pages: SitePage[];
74
+ }
75
+
76
+ type Obj = Record<string, unknown>;
77
+
78
+ const isObj = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
79
+ const schemaValue = (o: Obj, prop: string): unknown => o[prop] ?? o[SCHEMA_NS + prop];
80
+ const str = (v: unknown): string | undefined =>
81
+ typeof v === 'string' && v.trim() ? v.trim() : undefined;
82
+ const bareType = (t: string): string => t.replace(/^.*[/#]/, '');
83
+
84
+ const types = (o: Obj): string[] =>
85
+ (Array.isArray(o['@type']) ? o['@type'] : [o['@type']])
86
+ .filter((t): t is string => typeof t === 'string')
87
+ .map(bareType);
88
+
89
+ /** A URL resolved against the origin, if it stays on it. */
90
+ function onOrigin(raw: unknown, origin: string): string | undefined {
91
+ const s = str(raw) ?? (isObj(raw) ? str(raw['@id']) ?? str(schemaValue(raw, 'url')) : undefined);
92
+ if (!s) return undefined;
93
+ try {
94
+ const url = new URL(s, origin);
95
+ url.hash = '';
96
+ return url.origin === new URL(origin).origin ? url.href : undefined;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /** The page a trail was published for: its record's id, without the fragment. */
103
+ function pageOfTrail(raw: Obj, origin: string): string | undefined {
104
+ const id = str(raw['@id']);
105
+ if (!id || !id.includes('#')) return undefined;
106
+ return onOrigin(id, origin);
107
+ }
108
+
109
+ /**
110
+ * Read one typed-retrieval record as a trail.
111
+ *
112
+ * @param raw - A `BreadcrumbList` record.
113
+ * @param origin - The site's origin; levels elsewhere are dropped.
114
+ * @returns The trail, or null when the record is not one or carries no levels.
115
+ */
116
+ export function parseTrail(raw: unknown, origin: string): SiteTrail | null {
117
+ if (!isObj(raw) || !types(raw).includes('BreadcrumbList')) return null;
118
+ const rawLevels = schemaValue(raw, 'itemListElement');
119
+ const list = Array.isArray(rawLevels) ? rawLevels : rawLevels ? [rawLevels] : [];
120
+ const levels = list
121
+ .filter(isObj)
122
+ .map((item) => {
123
+ const position = Number(schemaValue(item, 'position') ?? Number.MAX_SAFE_INTEGER);
124
+ const name = str(schemaValue(item, 'name'));
125
+ const url = onOrigin(schemaValue(item, 'item'), origin);
126
+ return { position, name, url };
127
+ })
128
+ .filter((l): l is { position: number; name: string; url: string | undefined } => !!l.name)
129
+ .sort((a, b) => a.position - b.position)
130
+ .map(({ name, url }) => (url ? { name, url } : { name }));
131
+ if (!levels.length) return null;
132
+ const page = pageOfTrail(raw, origin);
133
+ return page ? { page, levels } : { levels };
134
+ }
135
+
136
+ /**
137
+ * The graph: who is above whom, and what points at what.
138
+ *
139
+ * A trail's parent is its last level with a URL that is not the page itself —
140
+ * the section directly above. A page with a one-level trail (itself) has no
141
+ * parent, which is what the root looks like.
142
+ *
143
+ * @param pages - The site's pages, for names.
144
+ * @param trails - Its trails.
145
+ * @param origin - The site's origin.
146
+ */
147
+ export function buildNav(pages: SitePage[], trails: SiteTrail[], origin: string): SiteNav {
148
+ const parents = new Map<string, string>();
149
+ const byPage = new Map<string, TrailLevel[]>();
150
+ const links = new Map<string, string[]>();
151
+
152
+ for (const trail of trails) {
153
+ // A trail with no id to place it still names its page in its last level.
154
+ const page = trail.page ?? trail.levels[trail.levels.length - 1]?.url;
155
+ if (!page) continue;
156
+ byPage.set(page, trail.levels);
157
+ const above = trail.levels.filter((l) => l.url && !samePage(l.url, page));
158
+ const parent = above[above.length - 1]?.url;
159
+ if (parent) parents.set(page, parent);
160
+ }
161
+
162
+ for (const page of pages) {
163
+ const out = (page.links ?? [])
164
+ .map((l) => onOrigin(l, origin))
165
+ .filter((l): l is string => !!l && !samePage(l, page.url));
166
+ if (out.length) links.set(page.url, [...new Set(out)]);
167
+ }
168
+
169
+ return { origin, parents, trails: byPage, links, pages };
170
+ }
171
+
172
+ /**
173
+ * Fold trails into a graph already in someone's hands.
174
+ *
175
+ * The tools are built at connect and hold the graph; the trails may arrive
176
+ * later, because fetching them is a read the site's rate limit counts. Mutating
177
+ * in place is what lets a tool built before the fetch answer after it.
178
+ */
179
+ export function applyTrails(nav: SiteNav, trails: SiteTrail[]): SiteNav {
180
+ const fresh = buildNav(nav.pages, trails, nav.origin);
181
+ nav.parents.clear();
182
+ for (const [page, parent] of fresh.parents) nav.parents.set(page, parent);
183
+ nav.trails.clear();
184
+ for (const [page, levels] of fresh.trails) nav.trails.set(page, levels);
185
+ return nav;
186
+ }
187
+
188
+ /** What a URL is called: the page's own name, else the last word of its path. */
189
+ export function nameOf(url: string, nav: SiteNav): string {
190
+ const known = nav.pages.find((p) => samePage(p.url, url));
191
+ if (known) return known.name;
192
+ const level = [...nav.trails.values()].flat().find((l) => l.url && samePage(l.url, url));
193
+ if (level) return level.name;
194
+ try {
195
+ const slug = new URL(url).pathname.split('/').filter(Boolean).pop() ?? '';
196
+ return slug.replace(/\.\w+$/, '').replace(/[-_]+/g, ' ') || 'home';
197
+ } catch {
198
+ return url;
199
+ }
200
+ }
201
+
202
+ /** The page that a URL is under, if the graph knows. */
203
+ export function parentOf(url: string, nav: SiteNav): string | undefined {
204
+ const key = [...nav.parents.keys()].find((p) => samePage(p, url));
205
+ return key ? nav.parents.get(key) : undefined;
206
+ }
207
+
208
+ /**
209
+ * Where the agent can go from a page: up, sideways, down, and out along its
210
+ * own links.
211
+ *
212
+ * @param href - The page to place. Defaults to the browser's.
213
+ */
214
+ export function navFor(nav: SiteNav, href: string): PageNav {
215
+ const link = (url: string): NavLink => ({ url, name: nameOf(url, nav) });
216
+
217
+ const ancestors: NavLink[] = [];
218
+ let up = parentOf(href, nav);
219
+ // A cycle in published trails would otherwise walk forever.
220
+ while (up && ancestors.length < 8 && !ancestors.some((a) => samePage(a.url, up!))) {
221
+ ancestors.unshift(link(up));
222
+ up = parentOf(up, nav);
223
+ }
224
+
225
+ const parent = parentOf(href, nav);
226
+ const siblings = parent
227
+ ? [...nav.parents.entries()]
228
+ .filter(([page, under]) => samePage(under, parent) && !samePage(page, href))
229
+ .map(([page]) => link(page))
230
+ : [];
231
+
232
+ const children = [...nav.parents.entries()]
233
+ .filter(([, under]) => samePage(under, href))
234
+ .map(([page]) => link(page));
235
+
236
+ const own = [...(nav.links.keys() as Iterable<string>)].find((p) => samePage(p, href));
237
+ const links = (own ? (nav.links.get(own) ?? []) : []).map(link);
238
+
239
+ return { ancestors, siblings, children, links };
240
+ }
241
+
242
+ /** Options for {@link loadSiteTrails}. */
243
+ export interface SiteTrailsOptions {
244
+ /** The origin trails must belong to. Defaults to the page's own. */
245
+ origin?: string;
246
+ /** Where the list is cached between page loads. Defaults to `sessionStorage`; null disables. */
247
+ storage?: Storage | null;
248
+ /** How long a cached list is reused, in milliseconds. Defaults to ten minutes. */
249
+ ttlMs?: number;
250
+ /** The clock, for tests. */
251
+ now?: () => number;
252
+ }
253
+
254
+ function defaultStorage(): Storage | null {
255
+ try {
256
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Every breadcrumb trail on the site.
264
+ *
265
+ * One query, cached in the tab like the page list, because the hierarchy is the
266
+ * same on every page of it.
267
+ *
268
+ * @throws When the query fails. The caller decides whether that matters.
269
+ */
270
+ export async function loadSiteTrails(
271
+ client: Client,
272
+ info: Pick<McpServerInfo, 'endpoint'>,
273
+ opts: SiteTrailsOptions = {},
274
+ ): Promise<SiteTrail[]> {
275
+ const origin = opts.origin ?? location.origin;
276
+ const storage = opts.storage === undefined ? defaultStorage() : opts.storage;
277
+ const now = (opts.now ?? Date.now)();
278
+ const key = CACHE_PREFIX + info.endpoint;
279
+
280
+ try {
281
+ const entry = JSON.parse(storage?.getItem(key) || 'null') as { fetchedAt: number; trails: SiteTrail[] } | null;
282
+ const fresh = entry && now - entry.fetchedAt <= (opts.ttlMs ?? CACHE_TTL_MS) && entry.fetchedAt <= now;
283
+ if (fresh && Array.isArray(entry.trails)) return entry.trails;
284
+ } catch {
285
+ // A corrupt entry is a refetch.
286
+ }
287
+
288
+ const trails = (await queryTypedRecords(client, 'BreadcrumbList'))
289
+ .map((r) => parseTrail(r, origin))
290
+ .filter((t): t is SiteTrail => t !== null);
291
+ try {
292
+ storage?.setItem(key, JSON.stringify({ fetchedAt: now, trails }));
293
+ } catch {
294
+ // Full or blocked storage costs a refetch on the next page, nothing more.
295
+ }
296
+ return trails;
297
+ }
298
+
299
+ /** The tool's registered name. */
300
+ export const PAGE_LINKS_TOOL = 'page_links';
301
+
302
+ /** Where a listing stops, so an answer stays readable. */
303
+ const MAX_LISTED = 10;
304
+
305
+ const list = (label: string, links: NavLink[]): string[] =>
306
+ links.length ? [`${label}: ${links.slice(0, MAX_LISTED).map((l) => l.name).join(', ')}`] : [];
307
+
308
+ /**
309
+ * What the agent can say about where it is.
310
+ *
311
+ * Read rather than navigation: "what else is in this section" is a question,
312
+ * and answering it by opening a page would be answering a different one. The
313
+ * names it returns are the names {@link buildOpenPageTool} accepts, so the
314
+ * model can follow one with the other.
315
+ *
316
+ * Always answers. "Where am I" is answerable from the address bar alone, so a
317
+ * site that publishes no trails and links nothing still gets the page it is on
318
+ * — an agent that cannot say that reads as an agent with no idea where it is.
319
+ */
320
+ export function describeNav(nav: SiteNav, href: string): string {
321
+ const { ancestors, siblings, children, links } = navFor(nav, href);
322
+ const trail = [...nav.trails.entries()].find(([page]) => samePage(page, href))?.[1];
323
+ const known = nav.pages.length;
324
+ const lines = [
325
+ `You are on ${nameOf(href, nav)} (${pathOf(href, nav)})` +
326
+ (known ? `, one of ${known} page${known === 1 ? '' : 's'} on this site.` : '.'),
327
+ ...(trail?.length ? [`Trail: ${trail.map((l) => l.name).join(' › ')}`] : []),
328
+ ...list('Above', ancestors),
329
+ ...list('Also in this section', siblings),
330
+ ...list('Under this page', children),
331
+ ...list('Linked from here', links),
332
+ ];
333
+ if (lines.length > 1) return lines.join('\n');
334
+ const elsewhere = nav.pages.filter((p) => !samePage(p.url, href)).slice(0, MAX_LISTED);
335
+ return elsewhere.length
336
+ ? `${lines[0]} This site publishes no sections, so there is nothing above or beside it. Other pages: ${elsewhere
337
+ .map((p) => p.name)
338
+ .join(', ')}.`
339
+ : `${lines[0]} Nothing else on the site links to or from it.`;
340
+ }
341
+
342
+ /**
343
+ * The address as a visitor reads it, which is what "what page is this" asks for.
344
+ *
345
+ * The path of the page the site actually publishes, when one matches. A query a surface put there
346
+ * — `?surface=window`, `?open=1` — belongs to this tab, not to the site: nothing is indexed under
347
+ * it, and quoting it back invites the model to ask for a page by an address no record carries.
348
+ */
349
+ function pathOf(href: string, nav: SiteNav): string {
350
+ const known = nav.pages.find((p) => samePage(p.url, href));
351
+ try {
352
+ // A published page's own query is part of its address; the tab's is not, and with no record to
353
+ // say otherwise, the path alone is the page.
354
+ if (known) {
355
+ const url = new URL(known.url);
356
+ return url.pathname + url.search;
357
+ }
358
+ return new URL(href).pathname;
359
+ } catch {
360
+ return known?.url ?? href;
361
+ }
362
+ }
363
+
364
+ /** Options for the page-links tool: where the visitor is, and how the graph fills in. */
365
+ export interface PageLinksOptions {
366
+ /** The current page URL. Defaults to the browser's. Read when the tool runs. */
367
+ href?: () => string;
368
+ /**
369
+ * Fetch the site's trails, if they have not been fetched yet.
370
+ *
371
+ * Awaited when the tool runs rather than at connect, because the fetch is a
372
+ * read and a site's rate limit counts reads. Answering "where am I" is what
373
+ * makes that read worth spending.
374
+ */
375
+ loadNav?: () => Promise<SiteNav>;
376
+ }
377
+
378
+ /**
379
+ * Build the tool that reports where this page sits and what it leads to.
380
+ *
381
+ * @param nav - The site's graph.
382
+ */
383
+ export function buildPageLinksTool(nav: SiteNav, opts: PageLinksOptions = {}): ModelContextTool {
384
+ return {
385
+ name: PAGE_LINKS_TOOL,
386
+ description:
387
+ 'Where the visitor is right now and what this page leads to: the page they are on, the sections ' +
388
+ 'above it, the other pages in its section, the pages under it, and the pages it links to. Use for ' +
389
+ '"where am I", "what page is this" and "what else is here", and before offering to take them somewhere.',
390
+ inputSchema: { type: 'object', properties: {} },
391
+ annotations: { readOnlyHint: true },
392
+ execute: async () => {
393
+ const filled = (await opts.loadNav?.().catch(() => nav)) ?? nav;
394
+ return {
395
+ content: [
396
+ {
397
+ type: 'text',
398
+ text: describeNav(filled, opts.href?.() ?? (typeof location === 'undefined' ? '' : location.href)),
399
+ },
400
+ ],
401
+ };
402
+ },
403
+ } as ModelContextTool;
404
+ }
405
+
406
+ /**
407
+ * Register the page-links tool, unless the registry already holds one.
408
+ *
409
+ * Registered whatever the site publishes. It was once gated on the site having
410
+ * trails or links, which withheld the tool from exactly the sites where "where
411
+ * am I" is the only question it could answer.
412
+ *
413
+ * @returns Whether it was registered.
414
+ */
415
+ export async function registerPageLinksTool(
416
+ mc: ModelContext,
417
+ nav: SiteNav,
418
+ opts: PageLinksOptions = {},
419
+ ): Promise<boolean> {
420
+ try {
421
+ if ((await mc.getTools()).some((t) => t.name === PAGE_LINKS_TOOL)) return false;
422
+ } catch {
423
+ // A registry that cannot list may still register.
424
+ }
425
+ try {
426
+ await mc.registerTool(buildPageLinksTool(nav, opts));
427
+ return true;
428
+ } catch (err) {
429
+ console.warn(`[nxis-mcp] could not register ${PAGE_LINKS_TOOL}:`, err);
430
+ return false;
431
+ }
432
+ }