@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,393 @@
1
+ /**
2
+ * Registers a remote MCP server's tools as WebMCP tools on the page.
3
+ *
4
+ * Each registered tool proxies to the connected client's `tools/call` and maps
5
+ * the result back to a WebMCP response, so the agent sees one unified toolset
6
+ * and treats remote and on-page tools alike.
7
+ *
8
+ * Remote schemas are often deeply nested, which small on-device models fill
9
+ * unreliably. Known read tools are therefore registered with a flattened schema
10
+ * that is expanded back to the server's shape before the call. Tools without a
11
+ * mapping keep their native schema.
12
+ *
13
+ * @module
14
+ */
15
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
16
+ import { isRateLimited, retryAfterMs, withRetry } from './retry.js';
17
+ import type {
18
+ JsonSchema,
19
+ McpServerInfo,
20
+ ModelContext,
21
+ ModelContextContent,
22
+ } from '../../types.js';
23
+
24
+ interface RemoteTool {
25
+ name: string;
26
+ /** MCP's display name, when the server gives one. */
27
+ title?: string;
28
+ description?: string;
29
+ inputSchema?: JsonSchema;
30
+ annotations?: Record<string, unknown>;
31
+ }
32
+
33
+ /** A model-friendly override for a known remote tool: the flat schema shown to
34
+ * the agent, plus a mapper expanding those arguments to the server's shape.
35
+ */
36
+ interface ToolOverride {
37
+ description: string;
38
+ inputSchema: JsonSchema;
39
+ mapArgs: (input: Record<string, unknown>) => Record<string, unknown>;
40
+ /** Known-safe reads. Asserted here because we define these surfaces. */
41
+ readOnly: true;
42
+ }
43
+
44
+ const isSet = (v: unknown): boolean => v != null && String(v).trim() !== '';
45
+
46
+ /** Simplified surfaces for the public typed-retrieval tools. */
47
+ const TOOL_OVERRIDES: Record<string, ToolOverride> = {
48
+ nxis_list_types: {
49
+ description:
50
+ "Lists the schema.org types present in this site's data (with counts). Call this first to discover what to query.",
51
+ inputSchema: { type: 'object', properties: {} },
52
+ mapArgs: () => ({}),
53
+ readOnly: true,
54
+ },
55
+
56
+ nxis_get: {
57
+ description:
58
+ 'Fetches one entity by its JSON-LD @id (as returned by nxis_query). Returns the full record.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ id: { type: 'string', description: 'The JSON-LD @id of the entity to fetch.' },
63
+ },
64
+ required: ['id'],
65
+ },
66
+ mapArgs: (i) => ({ id: i.id }),
67
+ readOnly: true,
68
+ },
69
+
70
+ nxis_query: {
71
+ description:
72
+ "Searches the site's typed records by schema.org type, with optional natural-language text " +
73
+ '(use it for locations or keywords), numeric or exact filters on any field, and sorting. ' +
74
+ 'Call nxis_list_types first to pick a valid type.',
75
+ inputSchema: {
76
+ type: 'object',
77
+ properties: {
78
+ schema_type: {
79
+ type: 'string',
80
+ description: "schema.org type to search, e.g. 'Electrician', 'LocalBusiness', 'Product'.",
81
+ },
82
+ text: {
83
+ type: 'string',
84
+ // Deliberately generic: this text reaches the model on every site
85
+ // that installs the package, so a concrete example from one
86
+ // deployment would bias the rest.
87
+ description: 'Optional natural-language query — keywords and/or a location.',
88
+ },
89
+ filters: {
90
+ type: 'object',
91
+ // The server takes an array of {path, operator, value} objects,
92
+ // three nested decisions per constraint. A flat object with the
93
+ // comparison written into the value is a shape a small model can
94
+ // reliably produce; it is expanded before the call.
95
+ description:
96
+ 'Optional exact or numeric filters, as {field path: value}. ' +
97
+ 'Prefix a number with >=, >, <= or < to compare, e.g. ' +
98
+ '{"aggregateRating.ratingValue": ">=4.5"}. A plain value matches exactly, ' +
99
+ 'and a list matches any of its entries.',
100
+ },
101
+ sort: {
102
+ type: 'string',
103
+ description:
104
+ 'Optional field path to sort by. Prefix with "-" for highest first, e.g. ' +
105
+ '"-aggregateRating.ratingValue".',
106
+ },
107
+ limit: { type: 'integer', description: 'Maximum results to return (1-100).', default: 10 },
108
+ },
109
+ required: ['schema_type'],
110
+ },
111
+ mapArgs: (i) => mapQueryArgs(i),
112
+ readOnly: true,
113
+ },
114
+ };
115
+
116
+ /** Comparison prefixes the model may write into a filter value. */
117
+ const COMPARISONS: [string, string][] = [
118
+ ['>=', 'GREATER_THAN_OR_EQUAL'],
119
+ ['<=', 'LESS_THAN_OR_EQUAL'],
120
+ ['>', 'GREATER_THAN'],
121
+ ['<', 'LESS_THAN'],
122
+ ];
123
+
124
+ /**
125
+ * Convert one `{path: value}` entry into the server's filter object.
126
+ *
127
+ * Numeric values are coerced, since the comparison operators are numeric on the
128
+ * server while a model writes them inside a string.
129
+ */
130
+ export function toFieldFilter(path: string, value: unknown): Record<string, unknown> {
131
+ if (Array.isArray(value)) return { path, operator: 'IN', value };
132
+ if (typeof value === 'string') {
133
+ const trimmed = value.trim();
134
+ for (const [prefix, operator] of COMPARISONS) {
135
+ if (trimmed.startsWith(prefix)) {
136
+ const rest = trimmed.slice(prefix.length).trim();
137
+ const num = Number(rest);
138
+ return { path, operator, value: Number.isFinite(num) ? num : rest };
139
+ }
140
+ }
141
+ const num = Number(trimmed);
142
+ // An equality written as "4.5" means the number: a string would not match
143
+ // a numeric field.
144
+ return { path, operator: 'EQUALS', value: trimmed !== '' && Number.isFinite(num) ? num : trimmed };
145
+ }
146
+ return { path, operator: 'EQUALS', value };
147
+ }
148
+
149
+ /**
150
+ * Parse a filter object however the model expressed it.
151
+ *
152
+ * Asked for `{"path": "value"}`, models produce a real object, a JSON string,
153
+ * or a JavaScript-like string with unquoted keys. All three mean the same
154
+ * thing, and rejecting any of them would silently drop the constraint and
155
+ * return unfiltered results.
156
+ *
157
+ * @param raw - The value the model supplied.
158
+ * @returns The parsed object, or null when there is nothing to read.
159
+ */
160
+ export function parseLooseObject(input: unknown): Record<string, unknown> | null {
161
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
162
+ return input as Record<string, unknown>;
163
+ }
164
+ if (typeof input !== 'string') return null;
165
+ const text = input.trim();
166
+ if (!text) return null;
167
+
168
+ // `path >= value`, written with no braces at all.
169
+ const bare = text.match(/^([\w.\-]+)\s*(>=|<=|>|<|=)\s*(.+)$/);
170
+ if (bare && !text.startsWith('{')) {
171
+ const [, path, op, value] = bare;
172
+ return { [path]: op === '=' ? value.trim() : `${op}${value.trim()}` };
173
+ }
174
+ if (!text.startsWith('{')) return null;
175
+
176
+ const attempts = [
177
+ text,
178
+ // Quote bare keys, and normalise single quotes to double.
179
+ text.replace(/([{,]\s*)([A-Za-z_][\w.\-]*)\s*:/g, '$1"$2":').replace(/'/g, '"'),
180
+ ];
181
+ // Last resort: quote bare word values too, as in `{brand: Rheem}`.
182
+ attempts.push(attempts[1].replace(/:\s*([A-Za-z_][\w .\-]*?)\s*([,}])/g, ': "$1"$2'));
183
+
184
+ for (const attempt of attempts) {
185
+ try {
186
+ const parsed = JSON.parse(attempt) as unknown;
187
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
188
+ return parsed as Record<string, unknown>;
189
+ }
190
+ } catch {
191
+ /* try the next shape */
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Flatten a nested filter object into the dot-paths the schema asks for.
199
+ *
200
+ * Models mirror the shape of the data they saw in the last response, which is
201
+ * nested, so `{aggregateRating: {ratingValue: ">=4.5"}}` becomes
202
+ * `{"aggregateRating.ratingValue": ">=4.5"}`.
203
+ */
204
+ export function flattenPaths(
205
+ obj: Record<string, unknown>,
206
+ prefix = '',
207
+ out: Record<string, unknown> = {},
208
+ ): Record<string, unknown> {
209
+ for (const [key, value] of Object.entries(obj)) {
210
+ const path = prefix ? `${prefix}.${key}` : key;
211
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
212
+ flattenPaths(value as Record<string, unknown>, path, out);
213
+ } else {
214
+ out[path] = value;
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /** The model's flat arguments, as the server's nested query. */
221
+ export function mapQueryArgs(i: Record<string, unknown>): Record<string, unknown> {
222
+ const filter: Record<string, unknown> = { schema_type: i.schema_type };
223
+
224
+ const filters = parseLooseObject(i.filters);
225
+ if (filters) {
226
+ const fieldFilters = Object.entries(flattenPaths(filters))
227
+ .filter(([path, value]) => path && value !== undefined && value !== null && value !== '')
228
+ .map(([path, value]) => toFieldFilter(path, value));
229
+ if (fieldFilters.length) filter.field_filters = fieldFilters;
230
+ }
231
+
232
+ if (isSet(i.sort)) {
233
+ const raw = String(i.sort).trim();
234
+ const descending = raw.startsWith('-');
235
+ filter.sort_option = {
236
+ property: descending ? raw.slice(1) : raw,
237
+ order: descending ? 'DESCENDING' : 'ASCENDING',
238
+ };
239
+ }
240
+
241
+ const args: Record<string, unknown> = { filter };
242
+ if (isSet(i.text)) args.text = i.text;
243
+ if (i.limit != null) args.limit = i.limit;
244
+ return args;
245
+ }
246
+
247
+ /**
248
+ * Whether a tool may run without a confirmation step.
249
+ *
250
+ * Fails closed: a tool whose server declares no `readOnlyHint` is treated as a
251
+ * write. The harness runs reads as soon as their arguments are satisfied, so
252
+ * defaulting an unannotated tool to read-only would fire it unattended. Being
253
+ * wrong this way costs one extra confirmation; being wrong the other way sends
254
+ * data on the user's behalf.
255
+ */
256
+ function isReadOnly(tool: RemoteTool, override?: ToolOverride): boolean {
257
+ if (override) return override.readOnly;
258
+ const declared = (tool.annotations as { readOnlyHint?: unknown } | undefined)?.readOnlyHint;
259
+ return declared === true;
260
+ }
261
+
262
+ /**
263
+ * List the server's tools, falling back to the tools its card advertises when
264
+ * `tools/list` is unavailable.
265
+ */
266
+ async function listRemoteTools(client: Client, info: McpServerInfo): Promise<RemoteTool[]> {
267
+ try {
268
+ const res = await client.listTools();
269
+ return res.tools as unknown as RemoteTool[];
270
+ } catch (err) {
271
+ console.warn('[nxis-mcp] tools/list failed; falling back to card publicTools', err);
272
+ return info.publicTools.map((name) => ({ name }));
273
+ }
274
+ }
275
+
276
+ /** The names a registry already holds, or none when it cannot say. */
277
+ async function registeredNames(mc: ModelContext): Promise<Set<string>> {
278
+ try {
279
+ return new Set((await mc.getTools()).map((t) => t.name));
280
+ } catch {
281
+ return new Set();
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Tools excluded from the browser agent's toolset by default.
287
+ *
288
+ * `nxis_action` performs a site's write actions server-side, addressed by
289
+ * JSON-LD `@id` and authenticated with an API key. That suits an external MCP
290
+ * client, which has a key and no page, but duplicates the page's own action
291
+ * tools, which are generated from the same Schema.org actions and can show the
292
+ * user what is about to be submitted. Bridging both would offer two routes to
293
+ * the same outcome, and add the excluded tool's schema to every prompt.
294
+ *
295
+ * Override with {@link BridgeOptions.accept}.
296
+ */
297
+ const SERVER_ONLY_TOOLS = new Set(['nxis_action']);
298
+
299
+ /** Options for {@link registerRemoteTools}. */
300
+ export interface BridgeOptions {
301
+ /**
302
+ * Choose which of the server's tools reach the page.
303
+ *
304
+ * Rarely needed: the default excludes only {@link SERVER_ONLY_TOOLS}.
305
+ *
306
+ * @param name - The remote tool's name.
307
+ * @returns True to register it.
308
+ */
309
+ accept?(name: string): boolean;
310
+ }
311
+
312
+ /**
313
+ * Register every accepted remote tool into a model context.
314
+ *
315
+ * Each registered tool proxies execution to `client`, translating arguments
316
+ * from the flattened schema and the result back into a WebMCP response.
317
+ *
318
+ * @param client - The connected MCP client.
319
+ * @param mc - The registry to register into.
320
+ * @param info - The server's connection details.
321
+ * @param options - A tool filter.
322
+ * @returns The names of the tools registered.
323
+ */
324
+ export async function registerRemoteTools(
325
+ client: Client,
326
+ mc: ModelContext,
327
+ info: McpServerInfo,
328
+ opts: BridgeOptions = {},
329
+ ): Promise<string[]> {
330
+ const accept = opts.accept ?? ((name: string) => !SERVER_ONLY_TOOLS.has(name));
331
+ const tools = (await listRemoteTools(client, info)).filter((t) => accept(t.name));
332
+ // A registry rejects a name it already holds (WebMCP: InvalidStateError), and older polyfills
333
+ // replaced it. Skipping names already present behaves the same on every one.
334
+ const present = await registeredNames(mc);
335
+ const registered: string[] = [];
336
+
337
+ for (const tool of tools) {
338
+ if (present.has(tool.name)) {
339
+ console.warn(`[nxis-mcp] a tool named ${tool.name} is already registered; keeping it`);
340
+ continue;
341
+ }
342
+ const override = TOOL_OVERRIDES[tool.name];
343
+
344
+ const registration = {
345
+ name: tool.name,
346
+ // A registry refuses an empty description (WebMCP: InvalidStateError), and one tool the
347
+ // server left undescribed should still be callable.
348
+ description: override?.description || tool.description || tool.title || tool.name,
349
+ // Prefer the simplified schema; otherwise pass the native one through.
350
+ inputSchema: override?.inputSchema ?? tool.inputSchema ?? { type: 'object' },
351
+ // The harness reads this to decide whether to run the tool outright, so
352
+ // it must not be optimistic. See isReadOnly.
353
+ annotations: { ...(tool.annotations ?? {}), readOnlyHint: isReadOnly(tool, override) },
354
+ execute: async (input: Record<string, unknown>) => {
355
+ const args = override ? override.mapArgs(input ?? {}) : (input ?? {});
356
+ // A read over the site's rate limit is the server asking us to wait, not a failure to
357
+ // report: answering "the site has no data" would be false, and retrying at once makes it
358
+ // worse. Waited out once or twice, then said plainly.
359
+ try {
360
+ const result = await withRetry(() => client.callTool({ name: tool.name, arguments: args }));
361
+ return {
362
+ content: (result.content as ModelContextContent[] | undefined) ?? [],
363
+ isError: result.isError as boolean | undefined,
364
+ structuredContent: result.structuredContent as Record<string, unknown> | undefined,
365
+ };
366
+ } catch (err) {
367
+ if (!isRateLimited(err)) throw err;
368
+ const seconds = Math.ceil((retryAfterMs(err) ?? 60_000) / 1000);
369
+ return {
370
+ content: [
371
+ {
372
+ type: 'text',
373
+ text: `This site's records are rate limited right now. They should answer again in about ${seconds} seconds.`,
374
+ },
375
+ ],
376
+ isError: true,
377
+ };
378
+ }
379
+ },
380
+ };
381
+ // A registry rejects (the spec), throws or returns nothing (older ones). Awaiting covers all
382
+ // three, and one tool the registry refuses should not cost the rest.
383
+ try {
384
+ await mc.registerTool(registration);
385
+ } catch (err) {
386
+ console.warn(`[nxis-mcp] could not register ${tool.name}:`, err);
387
+ continue;
388
+ }
389
+ registered.push(tool.name);
390
+ }
391
+
392
+ return registered;
393
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Connects an MCP client to a discovered server over Streamable HTTP.
3
+ *
4
+ * @module
5
+ */
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
8
+ import type { McpServerInfo } from '../../types.js';
9
+
10
+ /** Open an initialized MCP session to `info.endpoint`. Pass `apiKey` only for
11
+ * private tools. Public typed retrieval needs no credential. */
12
+ export async function connectMcp(info: McpServerInfo, apiKey?: string): Promise<Client> {
13
+ const headers: Record<string, string> = {};
14
+ if (apiKey && info.authHeaderName) headers[info.authHeaderName] = apiKey;
15
+
16
+ const transport = new StreamableHTTPClientTransport(new URL(info.endpoint), {
17
+ requestInit: { headers },
18
+ });
19
+
20
+ const client = new Client(
21
+ { name: 'nxisai-browser-agent', version: '1.0.0' },
22
+ { capabilities: {} },
23
+ );
24
+ await client.connect(transport);
25
+ return client;
26
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Resolves a site's MCP server from its `.well-known` catalogs.
3
+ *
4
+ * Reads `/.well-known/ai-catalog.json` (falling back to `mcp/catalog.json`,
5
+ * the MCP-specific catalog NxisAI publishes beside it),
6
+ * follows an entry's URL to the server card, and extracts the Streamable HTTP
7
+ * endpoint, its auth header, and the tools available without a key.
8
+ *
9
+ * @module
10
+ */
11
+ import type { CatalogEntry, CatalogFile, McpServerInfo, ServerCard } from '../../types.js';
12
+
13
+ async function fetchJson<T>(url: string): Promise<T> {
14
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
15
+ if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
16
+ return (await res.json()) as T;
17
+ }
18
+
19
+ /**
20
+ * The catalog entry for the MCP server card.
21
+ *
22
+ * A catalog can list more than the MCP server: an A2A Agent Card for a
23
+ * handoff target, say. An entry typed as an MCP server card wins; an entry
24
+ * typed as anything else is never taken for one; an untyped entry is taken
25
+ * only when nothing is typed, as older catalogs have no types at all.
26
+ *
27
+ * @returns The entry, or undefined when the catalog lists no MCP server.
28
+ */
29
+ export function mcpCatalogEntry(catalog: CatalogFile): CatalogEntry | undefined {
30
+ const entries = (catalog.entries ?? []).filter((e) => !!e.url);
31
+ const typeOf = (e: CatalogEntry) => (e.type ?? e.mediaType ?? '').toLowerCase();
32
+ const typed = entries.find((e) => /\bmcp\b|mcp-server/.test(typeOf(e)));
33
+ if (typed) return typed;
34
+ return entries.find((e) => !typeOf(e));
35
+ }
36
+
37
+ /** Find the server-card URL by reading the well-known catalogs in order. */
38
+ async function resolveServerCardUrl(): Promise<string> {
39
+ const catalogs = ['/.well-known/ai-catalog.json', '/.well-known/mcp/catalog.json'];
40
+ for (const path of catalogs) {
41
+ try {
42
+ const catalog = await fetchJson<CatalogFile>(path);
43
+ const entry = mcpCatalogEntry(catalog);
44
+ if (entry?.url) return entry.url;
45
+ } catch {
46
+ /* try the next catalog */
47
+ }
48
+ }
49
+ throw new Error('No MCP server-card URL found in .well-known catalogs');
50
+ }
51
+
52
+ /** Discover the remote MCP server: card URL → endpoint, auth, public tools. */
53
+ export async function discoverMcpServer(): Promise<McpServerInfo> {
54
+ const cardUrl = await resolveServerCardUrl();
55
+ const card = await fetchJson<ServerCard>(cardUrl);
56
+
57
+ const remote = card.remotes?.find((r) => r.type === 'streamable-http') ?? card.remotes?.[0];
58
+ if (!remote?.url) throw new Error(`Server card at ${cardUrl} has no remote endpoint URL`);
59
+
60
+ const authHeader = remote.headers?.[0];
61
+ const meta = card._meta ?? {};
62
+ const publicTools = (meta['ai.nxis/publicTools'] as string[] | undefined) ?? [];
63
+
64
+ return {
65
+ endpoint: remote.url,
66
+ authHeaderName: authHeader?.name,
67
+ authRequired: authHeader?.isRequired ?? false,
68
+ publicTools,
69
+ protocolVersions: remote.supportedProtocolVersions ?? [],
70
+ cardUrl,
71
+ title: card.title,
72
+ };
73
+ }