@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.
- package/README.md +25 -2
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +148 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +61 -16
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +7 -5
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +33 -12
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.js.map +1 -1
- package/dist/harness/tools/local-context.d.ts +49 -9
- package/dist/harness/tools/local-context.d.ts.map +1 -1
- package/dist/harness/tools/local-context.js +377 -22
- package/dist/harness/tools/local-context.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +16 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -1
- package/dist/harness/tools/open-page.js +45 -9
- package/dist/harness/tools/open-page.js.map +1 -1
- package/dist/harness/tools/retry.d.ts +53 -0
- package/dist/harness/tools/retry.d.ts.map +1 -0
- package/dist/harness/tools/retry.js +96 -0
- package/dist/harness/tools/retry.js.map +1 -0
- package/dist/harness/tools/schema.d.ts +18 -0
- package/dist/harness/tools/schema.d.ts.map +1 -1
- package/dist/harness/tools/schema.js +38 -0
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +3 -2
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-nav.d.ts +168 -0
- package/dist/harness/tools/site-nav.d.ts.map +1 -0
- package/dist/harness/tools/site-nav.js +316 -0
- package/dist/harness/tools/site-nav.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +4 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -1
- package/dist/harness/tools/site-pages.js +19 -3
- package/dist/harness/tools/site-pages.js.map +1 -1
- package/dist/harness/tools/source.d.ts +9 -0
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +54 -14
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/harness/tools/webmcp.d.ts +39 -3
- package/dist/harness/tools/webmcp.d.ts.map +1 -1
- package/dist/harness/tools/webmcp.js +93 -14
- package/dist/harness/tools/webmcp.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +276 -2
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/cards.ts +177 -0
- package/src/a2ui/emit.ts +147 -0
- package/src/a2ui/form.ts +117 -0
- package/src/a2ui/host.ts +73 -0
- package/src/a2ui/index.ts +22 -0
- package/src/a2ui/stepper.ts +211 -0
- package/src/a2ui/theme.ts +134 -0
- package/src/a2ui/to-v09.ts +118 -0
- package/src/engine.ts +701 -0
- package/src/handoff/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +178 -0
- package/src/harness/harness.ts +981 -0
- package/src/harness/plan.ts +91 -0
- package/src/harness/policy.ts +59 -0
- package/src/harness/renderer.ts +208 -0
- package/src/harness/router/coordinator.ts +241 -0
- package/src/harness/router/rules.ts +65 -0
- package/src/harness/slots.ts +117 -0
- package/src/harness/steps.ts +156 -0
- package/src/harness/tools/action-tools.ts +870 -0
- package/src/harness/tools/bridge.ts +393 -0
- package/src/harness/tools/client.ts +26 -0
- package/src/harness/tools/discovery.ts +73 -0
- package/src/harness/tools/local-context.ts +480 -0
- package/src/harness/tools/open-page.ts +273 -0
- package/src/harness/tools/pending-action.ts +232 -0
- package/src/harness/tools/resume.ts +134 -0
- package/src/harness/tools/retry.ts +117 -0
- package/src/harness/tools/schema.ts +245 -0
- package/src/harness/tools/site-actions.ts +356 -0
- package/src/harness/tools/site-nav.ts +432 -0
- package/src/harness/tools/site-pages.ts +288 -0
- package/src/harness/tools/source.ts +353 -0
- package/src/harness/tools/webmcp.ts +200 -0
- package/src/index.ts +196 -0
- package/src/providers/capability.ts +167 -0
- package/src/providers/proxy.ts +191 -0
- package/src/providers/types.ts +150 -0
- package/src/types.ts +258 -0
- package/src/ui/agent-fullscreen.ts +4262 -0
- package/src/ui/agent.tw.css +49 -0
- package/src/ui/config.ts +138 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/journey.ts +57 -0
- package/src/ui/tailwind.generated.ts +2 -0
- package/src/webmcp-contract.ts +65 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches every Schema.org Action on the site through typed retrieval.
|
|
3
|
+
*
|
|
4
|
+
* The page's own script registers tools only for the actions in the current
|
|
5
|
+
* page's JSON-LD. The site's MCP server holds them all, so an agent opened on
|
|
6
|
+
* any page can learn what the rest of the site offers. This module fetches and
|
|
7
|
+
* normalises that list; turning it into tools is a separate step.
|
|
8
|
+
*
|
|
9
|
+
* Records arrive with expanded IRI keys (`https://schema.org/name`) where a
|
|
10
|
+
* page carries compact ones (`name`), so both spellings are read, as the
|
|
11
|
+
* page's script does.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
16
|
+
import type { McpServerInfo } from '../../types.js';
|
|
17
|
+
import { withRetry } from './retry.js';
|
|
18
|
+
import type {
|
|
19
|
+
NxisAction,
|
|
20
|
+
NxisEntryPoint,
|
|
21
|
+
NxisPropertyValueSpecification,
|
|
22
|
+
NxisValueOption,
|
|
23
|
+
NxisValueType,
|
|
24
|
+
} from '../../webmcp-contract.js';
|
|
25
|
+
|
|
26
|
+
const SCHEMA_NS = 'https://schema.org/';
|
|
27
|
+
const NXIS_NS = 'https://nxis.ai/ns#';
|
|
28
|
+
|
|
29
|
+
/** The server caps a page of results at this many. */
|
|
30
|
+
const PAGE_LIMIT = 100;
|
|
31
|
+
/** A guard against a server that keeps returning page tokens. */
|
|
32
|
+
const MAX_PAGES = 10;
|
|
33
|
+
/** How long a fetched list is reused across page loads in the same tab. */
|
|
34
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
35
|
+
const CACHE_PREFIX = 'nxis:site-actions:v1:';
|
|
36
|
+
|
|
37
|
+
const VALUE_TYPES: readonly NxisValueType[] = ['string', 'number', 'integer', 'boolean', 'date', 'file'];
|
|
38
|
+
|
|
39
|
+
type Obj = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
const isObj = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
42
|
+
|
|
43
|
+
/** A schema.org property, by compact term or expanded IRI. */
|
|
44
|
+
const schemaValue = (o: Obj, prop: string): unknown => o[prop] ?? o[SCHEMA_NS + prop];
|
|
45
|
+
/** An nxis extension property, by compact term or expanded IRI. */
|
|
46
|
+
const nxisValue = (o: Obj, prop: string): unknown => o[`nxis:${prop}`] ?? o[NXIS_NS + prop];
|
|
47
|
+
|
|
48
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
|
|
49
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
|
|
50
|
+
|
|
51
|
+
/** `https://schema.org/InteractAction` → `InteractAction`. */
|
|
52
|
+
const bareType = (t: string): string => t.replace(/^.*[/#]/, '');
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read one typed-retrieval record as an action.
|
|
56
|
+
*
|
|
57
|
+
* @param raw - A record as returned by `nxis_query`.
|
|
58
|
+
* @returns The action with bare types and compact keys, or null when the record
|
|
59
|
+
* is not an Action or lacks an `@id`, a name, or a target with an HTTP method.
|
|
60
|
+
*/
|
|
61
|
+
export function parseAction(raw: unknown): NxisAction | null {
|
|
62
|
+
if (!isObj(raw)) return null;
|
|
63
|
+
const rawType = str(raw['@type']);
|
|
64
|
+
if (!rawType) return null;
|
|
65
|
+
const type = bareType(rawType);
|
|
66
|
+
if (!type.endsWith('Action')) return null;
|
|
67
|
+
|
|
68
|
+
const id = str(raw['@id']);
|
|
69
|
+
const name = str(schemaValue(raw, 'name'));
|
|
70
|
+
const target = parseEntryPoint(schemaValue(raw, 'target'));
|
|
71
|
+
if (!id || !name || !target) return null;
|
|
72
|
+
|
|
73
|
+
// Inputs live under `nxis:input`: schema.org has no `input` on Action.
|
|
74
|
+
const rawInput = nxisValue(raw, 'input');
|
|
75
|
+
const input = (Array.isArray(rawInput) ? rawInput : [])
|
|
76
|
+
.map(parseInput)
|
|
77
|
+
.filter((i): i is NxisPropertyValueSpecification => i !== null);
|
|
78
|
+
|
|
79
|
+
const action: NxisAction = { '@type': type, '@id': id, name, target };
|
|
80
|
+
const description = str(schemaValue(raw, 'description'));
|
|
81
|
+
if (description) action.description = description;
|
|
82
|
+
if (input.length) action.input = input;
|
|
83
|
+
const extractedAt = str(nxisValue(raw, 'extractedAt'));
|
|
84
|
+
if (extractedAt) action['nxis:extractedAt'] = extractedAt;
|
|
85
|
+
return action;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseEntryPoint(raw: unknown): NxisEntryPoint | null {
|
|
89
|
+
if (!isObj(raw)) return null;
|
|
90
|
+
const httpMethod = str(schemaValue(raw, 'httpMethod'))?.toUpperCase();
|
|
91
|
+
if (!httpMethod) return null;
|
|
92
|
+
|
|
93
|
+
const target: NxisEntryPoint = { '@type': 'EntryPoint', httpMethod };
|
|
94
|
+
const urlTemplate = str(schemaValue(raw, 'urlTemplate'));
|
|
95
|
+
if (urlTemplate) target.urlTemplate = urlTemplate;
|
|
96
|
+
const encodingType = str(schemaValue(raw, 'encodingType'));
|
|
97
|
+
if (encodingType) target.encodingType = encodingType;
|
|
98
|
+
const kind = nxisValue(raw, 'targetKind');
|
|
99
|
+
if (kind === 'selfPage' || kind === 'clientSide') target['nxis:targetKind'] = kind;
|
|
100
|
+
const pageUrl = str(nxisValue(raw, 'pageUrl'));
|
|
101
|
+
if (pageUrl) target['nxis:pageUrl'] = pageUrl;
|
|
102
|
+
if (nxisValue(raw, 'jsHandled') === true) target['nxis:jsHandled'] = true;
|
|
103
|
+
return target;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseInput(raw: unknown): NxisPropertyValueSpecification | null {
|
|
107
|
+
if (!isObj(raw)) return null;
|
|
108
|
+
const valueName = str(schemaValue(raw, 'valueName'));
|
|
109
|
+
if (!valueName) return null;
|
|
110
|
+
|
|
111
|
+
const declared = str(nxisValue(raw, 'valueType')) as NxisValueType | undefined;
|
|
112
|
+
const spec: NxisPropertyValueSpecification = {
|
|
113
|
+
'@type': 'PropertyValueSpecification',
|
|
114
|
+
valueName,
|
|
115
|
+
'nxis:valueType': declared && VALUE_TYPES.includes(declared) ? declared : 'string',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const description = str(schemaValue(raw, 'description'));
|
|
119
|
+
if (description) spec.description = description;
|
|
120
|
+
if (schemaValue(raw, 'valueRequired') === true) spec.valueRequired = true;
|
|
121
|
+
const defaultValue = str(schemaValue(raw, 'defaultValue'));
|
|
122
|
+
if (defaultValue !== undefined) spec.defaultValue = defaultValue;
|
|
123
|
+
for (const key of ['valueMinLength', 'valueMaxLength', 'minValue', 'maxValue', 'stepValue'] as const) {
|
|
124
|
+
const v = num(schemaValue(raw, key));
|
|
125
|
+
if (v !== undefined) spec[key] = v;
|
|
126
|
+
}
|
|
127
|
+
const valuePattern = str(schemaValue(raw, 'valuePattern'));
|
|
128
|
+
if (valuePattern) spec.valuePattern = valuePattern;
|
|
129
|
+
if (schemaValue(raw, 'multipleValues') === true) spec.multipleValues = true;
|
|
130
|
+
if (schemaValue(raw, 'readonlyValue') === true) spec.readonlyValue = true;
|
|
131
|
+
|
|
132
|
+
const rawOptions = nxisValue(raw, 'valueOption');
|
|
133
|
+
const options: NxisValueOption[] = [];
|
|
134
|
+
for (const o of Array.isArray(rawOptions) ? rawOptions : []) {
|
|
135
|
+
if (!isObj(o)) continue;
|
|
136
|
+
const value = str(schemaValue(o, 'value'));
|
|
137
|
+
if (value === undefined) continue;
|
|
138
|
+
const label = str(schemaValue(o, 'name'));
|
|
139
|
+
options.push(label ? { '@type': 'nxis:ValueOption', value, name: label } : { '@type': 'nxis:ValueOption', value });
|
|
140
|
+
}
|
|
141
|
+
if (options.length) spec['nxis:valueOption'] = options;
|
|
142
|
+
return spec;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether two URLs name the same page, ignoring the query and fragment.
|
|
147
|
+
*
|
|
148
|
+
* A form is the same form whether it was reached with tracking parameters or
|
|
149
|
+
* without.
|
|
150
|
+
*/
|
|
151
|
+
export function samePage(a: string | undefined, b: string | undefined): boolean {
|
|
152
|
+
if (!a || !b) return false;
|
|
153
|
+
try {
|
|
154
|
+
const x = new URL(a, b);
|
|
155
|
+
const y = new URL(b, a);
|
|
156
|
+
return x.origin === y.origin && x.pathname === y.pathname;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether an action's form is on the page currently open.
|
|
164
|
+
*
|
|
165
|
+
* @param action - The action to place.
|
|
166
|
+
* @param href - The current page. Defaults to the browser's.
|
|
167
|
+
*/
|
|
168
|
+
export function sameActionPage(action: NxisAction, href?: string): boolean {
|
|
169
|
+
const here = href ?? (typeof location === 'undefined' ? undefined : location.href);
|
|
170
|
+
return samePage(action.target['nxis:pageUrl'], here);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether an action can be carried out from this site.
|
|
175
|
+
*
|
|
176
|
+
* Actions run by filling the form on their own page, so the harness may later
|
|
177
|
+
* navigate to `nxis:pageUrl`. A record naming another origin, whether stale or
|
|
178
|
+
* tampered with, must never send the visitor there.
|
|
179
|
+
*/
|
|
180
|
+
function onOrigin(action: NxisAction, origin: string): boolean {
|
|
181
|
+
const pageUrl = action.target['nxis:pageUrl'];
|
|
182
|
+
if (!pageUrl) return false;
|
|
183
|
+
try {
|
|
184
|
+
return new URL(pageUrl, origin).origin === origin;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The records in a tool result, whether structured or JSON text.
|
|
192
|
+
*
|
|
193
|
+
* Typed loosely because the SDK's result type also admits the legacy
|
|
194
|
+
* `{ toolResult }` shape, which carries neither.
|
|
195
|
+
*/
|
|
196
|
+
function readResults(result: Obj): { results: unknown[]; nextPageToken?: string; total?: number } {
|
|
197
|
+
const text = (Array.isArray(result.content) ? result.content : [])
|
|
198
|
+
.map((c) => (isObj(c) && typeof c.text === 'string' ? c.text : ''))
|
|
199
|
+
.join('');
|
|
200
|
+
if (result.isError) throw new Error(text || 'nxis_query failed');
|
|
201
|
+
let body: unknown = result.structuredContent;
|
|
202
|
+
if (!isObj(body)) {
|
|
203
|
+
try {
|
|
204
|
+
body = JSON.parse(text);
|
|
205
|
+
} catch {
|
|
206
|
+
throw new Error('nxis_query returned a response that is not JSON');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const b = body as Obj;
|
|
210
|
+
return {
|
|
211
|
+
results: Array.isArray(b.results) ? b.results : [],
|
|
212
|
+
nextPageToken: str(b.next_page_token) || undefined,
|
|
213
|
+
total: num(b.total),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Query every record whose `@type` contains `fragment`.
|
|
219
|
+
*
|
|
220
|
+
* `schema_type: "Action"` does not yet expand to subtypes on the server, so
|
|
221
|
+
* this matches on `@type` instead and the caller filters the results. When the
|
|
222
|
+
* server supports the type hierarchy, only this function needs to change.
|
|
223
|
+
*
|
|
224
|
+
* @param client - A connected client for the site's MCP server.
|
|
225
|
+
* @param fragment - Part of the type name: `Action`, `Page`.
|
|
226
|
+
* @returns Every matching record, across the server's result pages.
|
|
227
|
+
*/
|
|
228
|
+
export async function queryTypedRecords(client: Client, fragment: string): Promise<unknown[]> {
|
|
229
|
+
const records: unknown[] = [];
|
|
230
|
+
let pageToken: string | undefined;
|
|
231
|
+
let total: number | undefined;
|
|
232
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
233
|
+
const result = await withRetry(() => client.callTool({
|
|
234
|
+
name: 'nxis_query',
|
|
235
|
+
arguments: {
|
|
236
|
+
filter: {
|
|
237
|
+
schema_type: '',
|
|
238
|
+
field_filters: [{ path: '@type', operator: 'CONTAINS', value: fragment }],
|
|
239
|
+
},
|
|
240
|
+
limit: PAGE_LIMIT,
|
|
241
|
+
...(pageToken ? { page_token: pageToken } : {}),
|
|
242
|
+
},
|
|
243
|
+
}));
|
|
244
|
+
const read = readResults(result);
|
|
245
|
+
records.push(...read.results);
|
|
246
|
+
total = read.total ?? total;
|
|
247
|
+
pageToken = read.nextPageToken;
|
|
248
|
+
if (!pageToken) break;
|
|
249
|
+
}
|
|
250
|
+
if (total !== undefined && records.length < total) {
|
|
251
|
+
console.warn(
|
|
252
|
+
`[nxis-mcp] site ${fragment} records truncated: fetched ${records.length} of ${total} — ` +
|
|
253
|
+
'the server returned no further pages',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return records;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The tab's session storage, or null where it is unavailable or blocked. */
|
|
260
|
+
function defaultStorage(): Storage | null {
|
|
261
|
+
try {
|
|
262
|
+
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface CacheEntry {
|
|
269
|
+
fetchedAt: number;
|
|
270
|
+
actions: NxisAction[];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readCache(storage: Storage | null, key: string, now: number, ttl: number): NxisAction[] | null {
|
|
274
|
+
if (!storage) return null;
|
|
275
|
+
try {
|
|
276
|
+
const entry = JSON.parse(storage.getItem(key) || 'null') as CacheEntry | null;
|
|
277
|
+
if (!entry || !Array.isArray(entry.actions) || typeof entry.fetchedAt !== 'number') return null;
|
|
278
|
+
if (now - entry.fetchedAt > ttl || entry.fetchedAt > now) return null;
|
|
279
|
+
return entry.actions;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function writeCache(storage: Storage | null, key: string, entry: CacheEntry): void {
|
|
286
|
+
try {
|
|
287
|
+
storage?.setItem(key, JSON.stringify(entry));
|
|
288
|
+
} catch {
|
|
289
|
+
// Full or blocked storage costs a refetch on the next page, nothing more.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Options for {@link loadSiteActions}. */
|
|
294
|
+
export interface SiteActionsOptions {
|
|
295
|
+
/**
|
|
296
|
+
* The origin actions must belong to. Defaults to the page's own.
|
|
297
|
+
*
|
|
298
|
+
* An action whose page is on any other origin is dropped.
|
|
299
|
+
*/
|
|
300
|
+
origin?: string;
|
|
301
|
+
/** Where the list is cached between page loads. Defaults to `sessionStorage`; null disables. */
|
|
302
|
+
storage?: Storage | null;
|
|
303
|
+
/** How long a cached list is reused, in milliseconds. Defaults to ten minutes. */
|
|
304
|
+
ttlMs?: number;
|
|
305
|
+
/** The clock, for tests. */
|
|
306
|
+
now?: () => number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Every Action on the site that can be carried out from this origin.
|
|
311
|
+
*
|
|
312
|
+
* Reuses a list fetched in the same tab within the last ten minutes, so moving
|
|
313
|
+
* between pages costs one request per session rather than one per page.
|
|
314
|
+
*
|
|
315
|
+
* @param client - A connected client for the site's MCP server. The query is a
|
|
316
|
+
* public read and needs no key.
|
|
317
|
+
* @param info - The server's details; its endpoint keys the cache.
|
|
318
|
+
* @param opts - Origin, cache and clock overrides.
|
|
319
|
+
* @returns The actions, each once, in the order the server returned them.
|
|
320
|
+
* @throws When the query itself fails. The caller decides whether that matters.
|
|
321
|
+
*/
|
|
322
|
+
export async function loadSiteActions(
|
|
323
|
+
client: Client,
|
|
324
|
+
info: Pick<McpServerInfo, 'endpoint'>,
|
|
325
|
+
opts: SiteActionsOptions = {},
|
|
326
|
+
): Promise<NxisAction[]> {
|
|
327
|
+
const origin = opts.origin ?? location.origin;
|
|
328
|
+
const storage = opts.storage === undefined ? defaultStorage() : opts.storage;
|
|
329
|
+
const now = (opts.now ?? Date.now)();
|
|
330
|
+
const key = CACHE_PREFIX + info.endpoint;
|
|
331
|
+
|
|
332
|
+
const cached = readCache(storage, key, now, opts.ttlMs ?? CACHE_TTL_MS);
|
|
333
|
+
if (cached) return cached;
|
|
334
|
+
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const actions: NxisAction[] = [];
|
|
337
|
+
let offOrigin = 0;
|
|
338
|
+
for (const record of await queryTypedRecords(client, 'Action')) {
|
|
339
|
+
const action = parseAction(record);
|
|
340
|
+
if (!action || seen.has(action['@id'])) continue;
|
|
341
|
+
seen.add(action['@id']);
|
|
342
|
+
if (!onOrigin(action, origin)) {
|
|
343
|
+
offOrigin++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
actions.push(action);
|
|
347
|
+
}
|
|
348
|
+
if (offOrigin) {
|
|
349
|
+
console.warn(
|
|
350
|
+
`[nxis-mcp] ignored ${offOrigin} site action(s) whose page is not on ${origin}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
writeCache(storage, key, { fetchedAt: now, actions });
|
|
355
|
+
return actions;
|
|
356
|
+
}
|