@meantjs/tools 0.0.0-stage → 0.2.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/src/plan.mjs ADDED
@@ -0,0 +1,530 @@
1
+ // Tool plans and tool definitions from any resolved form (#16, items 1 and 5).
2
+ //
3
+ // One tool per query method (named as the method), one `prepare_<method>` per update method, and one shared
4
+ // `commit`, as arm C does (packages/mcp-adapters/src/arm-c/spec.mjs). The rules for what a position becomes are
5
+ // arm C's (profile spec 5.3, `default`, `ui`, `idempotency`):
6
+ // - a position with a value tag and no hiding rule is a string input in human units;
7
+ // - so is an untagged `principal` position, read as its textual form (input.mjs, `inputReader`): it has exactly one,
8
+ // checksummed, so it needs no profile meaning to be read exactly;
9
+ // - so is an untagged integer position with a `describe`, read as decimal digits with no conversion: the profile
10
+ // says what the number is (an action id, a page size), and the Candid type bounds it;
11
+ // - an `auto` default (value null), the idempotency stamp and `ui.hidden` positions are not inputs; the tool layer
12
+ // fills them (null, the stamp from the canister's clock);
13
+ // - a `suggest` default stays an input.
14
+ // Unlike arm C, a method this file has no rule for is skipped with a reason rather than thrown on: an app's resolved
15
+ // form may hold methods no agent should call, and `buildToolPlans` reports them. Beyond arm C (its packs meet none of
16
+ // these; proposal w9-b3wallet-gaps.md, B2):
17
+ // - a read-only update (`effect` read with `replicated: true`, D-038) is a read tool, sent as an update call;
18
+ // - an update method without `idempotency` gets a prepare tool too; the tool layer never sends it again after an
19
+ // unknown outcome (spec 5.3: without the tag, never retried automatically);
20
+ // - outcome keys may pass through record fields (`Ok.state.Failed`), so long as no two keys read different fields
21
+ // of one record (one reply could match both);
22
+ // - a method with more than one argument takes each as an input of its own, `arg0`, `arg1` and so on.
23
+ // And beyond those (D-050, proposal w6-adoption.md items 2 and 3):
24
+ // - a `text` position reads the text as given, within its `text` tag; a `bool` position reads "true" or "false";
25
+ // - a record, variant or vec position with no value tag is one input given as JSON, whose leaves are read by these
26
+ // rules (input.mjs, `inputWithoutRule`); a single argument that is a variant or a vec is the input `arg0`;
27
+ // - outcome keys may start with a record field (`result.Ok` over `record { result : opt variant { Ok; Err } }`);
28
+ // - an outcome with `default` alone classes every reply of a method whose result has no variant to select.
29
+ // And one tool per case (B2 rule 3 of proposal w9-b3wallet-gaps.md, D-053): a method whose single argument is a
30
+ // variant with an `intent` or `effect` on any of its cases gets a tool for each case, `<method>_<Case>` for a read and
31
+ // `prepare_<method>_<Case>` otherwise. Its inputs are the case's payload, read as a single argument is; its title is
32
+ // the case's intent, else its `describe`, else `<method> <Case>`; its effect is the case's, else the method's; and the
33
+ // method's `idempotency` and `outcome` apply. A method with an idempotency stamp has no case tools yet (the stamp would
34
+ // sit inside a case), and a tagged variant deeper in the argument stays one JSON input.
35
+ //
36
+ // The input words come from the format library (`Formatter.describe()`), so a form hint and a tool schema read
37
+ // one grammar. Arm C keeps its own frozen words (D-015, D-030, D-031).
38
+
39
+ import { MAX_VEC_ITEMS, inputReader, inputWithoutRule, nestedRole, readAs } from "./input.mjs";
40
+
41
+ export const COMMIT_TOOL = "commit";
42
+ export const prepareToolName = (method) => `prepare_${method}`;
43
+ /** The tool of one case of a method's variant argument (D-053): `<method>_<Case>` for a read, else `prepare_<method>_<Case>`. */
44
+ export const caseToolName = (method, label, read) => (read ? `${method}_${label}` : prepareToolName(`${method}_${label}`));
45
+ const byName = ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0);
46
+
47
+ /** The Candid id of a label: `#n` is n, a name is its idl hash (profile spec 3.2). */
48
+ export function labelId(label) {
49
+ if (/^#(?:0|[1-9][0-9]*)$/.test(label)) return Number(label.slice(1));
50
+ let h = 0;
51
+ for (const b of new TextEncoder().encode(label)) h = (h * 223 + b) >>> 0;
52
+ return h;
53
+ }
54
+
55
+ /**
56
+ * The label ids of a relpath into a one-element tuple, or null if it is not a plain label path there. `(0).Ok`,
57
+ * `.Ok`, `Ok` and `#17724` are one position (profile spec 3.2, rule 9), so positions are compared by these ids, never
58
+ * by spelling.
59
+ */
60
+ export function relpathIds(rel) {
61
+ const m = /^(?:\(0\))?\.?((?:[A-Za-z_][A-Za-z0-9_]*|#[0-9]+)(?:\.(?:[A-Za-z_][A-Za-z0-9_]*|#[0-9]+))*)$/.exec(rel ?? "");
62
+ return m ? m[1].split(".").map(labelId) : null;
63
+ }
64
+
65
+ /**
66
+ * The methods of one or more resolved forms, merged by name and sorted. A method two forms resolve differently is an
67
+ * error: merging layers is the resolver's job (profile spec section 8).
68
+ * @param {object | object[]} resolved candid-profile-resolved v0 documents
69
+ * @returns {[string, any][]}
70
+ */
71
+ export function resolvedMethods(resolved) {
72
+ const docs = Array.isArray(resolved) ? resolved : [resolved];
73
+ if (docs.length === 0) throw new TypeError("resolved: give at least one resolved form");
74
+ const methods = new Map();
75
+ for (const doc of docs) {
76
+ if (doc === null || typeof doc !== "object" || doc.format !== "candid-profile-resolved" || doc.format_version !== 0) {
77
+ throw new TypeError("resolved: not a candid-profile-resolved v0 document");
78
+ }
79
+ // An EVM contract's form (profile spec section 15) has ABI positions, nonces and receipts: its tools are phase 2.
80
+ if (doc.chain !== undefined) throw new TypeError(`resolved: a resolved form of chain ${JSON.stringify(doc.chain)} has no tools yet; this layer calls Internet Computer canisters`);
81
+ for (const [name, method] of Object.entries(doc.methods ?? {})) {
82
+ const seen = methods.get(name);
83
+ if (seen === undefined) methods.set(name, method);
84
+ else if (JSON.stringify(seen) !== JSON.stringify(method)) throw new TypeError(`resolved: ${name} is resolved differently by two forms; merge them with the resolver first`);
85
+ }
86
+ }
87
+ return [...methods.entries()].sort(byName);
88
+ }
89
+
90
+ /** @returns {{ role: "param" | "auto" | "stamp" | "hidden" | "none" } | { skip: string }} */
91
+ function classifyField(name, node, stampId) {
92
+ const tags = node.tags ?? {};
93
+ const optional = node.optional === true;
94
+ const def = tags.default?.args;
95
+ if (def !== undefined && def.value !== "null") return { skip: `${name}: a default value other than null has no rule` };
96
+ if (stampId !== undefined && labelId(name) === stampId) {
97
+ // a required stamp is allowed (the resolver's W_STAMP_REQUIRED): the layer always fills it
98
+ if (!tags.timestamp || node.candid !== "nat64" || tags.timestamp.args?.unit !== "ns") return { skip: `${name}: the idempotency stamp must be a nat64 timestamp in ns` };
99
+ return { role: "stamp" };
100
+ }
101
+ if (def?.mode === "auto") return optional ? { role: "auto" } : { skip: `${name}: an auto default needs an optional position` };
102
+ if (tags.ui?.args?.hidden === true) return optional ? { role: "hidden" } : { skip: `${name}: a hidden position must be optional` };
103
+ // a null or reserved position has one value, so it is filled, never asked for
104
+ if (!readAs(node) && (node.candid === "null" || node.candid === "reserved") && !node.recursive) return { role: "none" };
105
+ const why = inputWithoutRule(node, name);
106
+ return why ? { skip: why } : { role: "param" };
107
+ }
108
+
109
+ /**
110
+ * Why an outcome class key cannot be read by `classifyReply`, or undefined. It walks variant cases and record fields,
111
+ * and at a record follows the one field the keys name there; so a key it could not follow, or two keys that read
112
+ * different fields of one record (a reply could match both, at the same depth), skip the method rather than let a
113
+ * reply fall to the default class or to whichever key comes first.
114
+ */
115
+ function outcomeKeyWithoutRule(classes, result) {
116
+ const fieldAt = new Map(); // the path to a record -> the first key through it, and the field that key reads there
117
+ for (const key of Object.keys(classes ?? {})) {
118
+ const ids = relpathIds(key);
119
+ if (ids === null) return `the outcome key ${key} is not a path of cases and fields`;
120
+ let node = result;
121
+ for (const [i, id] of ids.entries()) {
122
+ if (node?.candid === "variant") {
123
+ const hit = Object.entries(node.cases ?? {}).find(([name]) => labelId(name) === id);
124
+ if (hit === undefined) return `the outcome key ${key} names no case of the result`;
125
+ node = hit[1];
126
+ } else if (node?.candid === "record") {
127
+ const hit = Object.entries(node.fields ?? {}).find(([name]) => labelId(name) === id);
128
+ if (hit === undefined) return `the outcome key ${key} names no field of the result`;
129
+ if (i === ids.length - 1) return `the outcome key ${key} ends on a record field; an outcome key selects a case`;
130
+ const at = ids.slice(0, i).join(".");
131
+ const first = fieldAt.get(at);
132
+ if (first === undefined) fieldAt.set(at, { key, id });
133
+ else if (first.id !== id) return `the outcome keys ${first.key} and ${key} read different fields of one record, so one reply could match both`;
134
+ node = hit[1];
135
+ } else return `the outcome key ${key} passes through a ${node?.candid ?? "missing"} position; only variant cases and record fields have a rule`;
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
142
+ const hasCaseTag = (node) => isObject(node?.tags) && (isObject(node.tags.intent) || isObject(node.tags.effect));
143
+
144
+ /**
145
+ * The cases of a method's single variant argument when any of them has an `intent` or an `effect` (D-053), else
146
+ * undefined: such a method gets one tool per case.
147
+ */
148
+ function taggedCases(m) {
149
+ const root = Array.isArray(m.args) && m.args.length === 1 ? m.args[0] : undefined;
150
+ if (root?.candid !== "variant" || readAs(root)) return undefined;
151
+ const cases = Object.entries(root.cases ?? {});
152
+ return cases.some(([, node]) => hasCaseTag(node)) ? cases : undefined;
153
+ }
154
+
155
+ /**
156
+ * A method's tools: one, or one per case of its variant argument (D-053).
157
+ * @returns {{ plans: object[], skipped: string[] }} the plans, and why each tool it would have is not made
158
+ */
159
+ function planTools(name, m) {
160
+ const cases = taggedCases(m);
161
+ if (cases === undefined) {
162
+ const p = planMethod(name, m);
163
+ return p.skip ? { plans: [], skipped: [p.skip] } : { plans: [p], skipped: [] };
164
+ }
165
+ if (m.tags?.idempotency) return { plans: [], skipped: ["tools per case of a method with an idempotency stamp have no rule yet (the stamp would sit inside a case)"] };
166
+ const plans = [];
167
+ const skipped = [];
168
+ for (const kase of cases) {
169
+ const p = /^[A-Za-z_][A-Za-z0-9_]*$/.test(kase[0]) ? planMethod(name, m, kase) : { skip: "a case without a name has no tool name" };
170
+ if (p.skip) skipped.push(`case ${kase[0]}: ${p.skip}`);
171
+ else plans.push(p);
172
+ }
173
+ return { plans, skipped };
174
+ }
175
+
176
+ /**
177
+ * @param {[string, object]} [kase] for a tool per case (D-053): the case's label and node in the variant argument
178
+ * @returns {object | { skip: string }}
179
+ */
180
+ function planMethod(name, m, kase) {
181
+ const tags = m.tags ?? {};
182
+ const own = kase === undefined ? tags : isObject(kase[1].tags) ? kase[1].tags : {}; // the case's tags, for a case tool
183
+ // the call's effect and intent: the case's, else the method's (spec 5.2 and 5.3)
184
+ const effect = own.effect?.args ?? tags.effect?.args;
185
+ const intent = own.intent?.args ?? tags.intent?.args;
186
+ // a read-only update (D-038) is a read for consumers, and must be sent as an update so that its answer is certified
187
+ const replicated = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
188
+ // a composite query is a read too (arm C has none to meet: the ICRC-1/2 packs declare only queries and updates)
189
+ const read = m.mode === "query" || m.mode === "composite_query" || replicated;
190
+ const args = Array.isArray(m.args) ? m.args : [];
191
+ const plan = {
192
+ name: kase !== undefined ? caseToolName(name, kase[0], read) : read ? name : prepareToolName(name),
193
+ kind: read ? "read" : "prepare",
194
+ method: name,
195
+ /** for a tool per case (D-053): the case of the variant argument it sends, and that case's node */
196
+ case: kase?.[0],
197
+ caseNode: kase?.[1],
198
+ mode: m.mode,
199
+ /** a read sent as an update call */
200
+ replicated,
201
+ effect: effect ?? (read ? { kind: "read", irreversible: false } : { kind: "write", irreversible: true }),
202
+ describe: own.describe?.args,
203
+ intent,
204
+ idempotency: tags.idempotency?.args,
205
+ outcome: tags.outcome?.args,
206
+ /** the method's single argument, or undefined (none, or more than one) */
207
+ argNode: args.length === 1 ? args[0] : undefined,
208
+ /** every argument, in order */
209
+ argNodes: args,
210
+ /** the inputs, in resolved-form order: { name, node, optional, path } */
211
+ params: [],
212
+ /** the argument record's field the stamp relpath resolves to */
213
+ stampField: undefined,
214
+ /** the record fields (or, with more than one argument, the arguments), inputs and filled ones: { name, node, role, optional } */
215
+ fields: [],
216
+ rootParam: false,
217
+ /** the single argument is null or reserved: no input, and null is sent */
218
+ rootNull: false,
219
+ results: m.results ?? [],
220
+ };
221
+ plan.title = kase !== undefined ? (own.intent?.args?.title ?? plan.describe?.label ?? `${name} ${kase[0]}`) : (plan.intent?.title ?? plan.describe?.label ?? name);
222
+ if (read && plan.results.length > 1) return { skip: "a query with more than one result has no rule (only the first would be shown)" };
223
+ // the stamp would be an input, or left unset: the stamp rules are for writes
224
+ if (replicated && plan.idempotency) return { skip: "a read-only update with an idempotency stamp has no rule" };
225
+ if (!read) {
226
+ if (!intent) return { skip: "an update method needs the intent tag" };
227
+ if (!tags.outcome) return { skip: "an update method needs the outcome tag" };
228
+ if (plan.idempotency && plan.idempotency.mode !== "replay_same_payload") return { skip: `idempotency mode ${plan.idempotency.mode} has no rule` };
229
+ if (!plan.idempotency && [...Object.values(plan.outcome.classes ?? {}), plan.outcome.default].includes("retry_new")) {
230
+ return { skip: "the outcome class retry_new renews an idempotency stamp, and this method has none" };
231
+ }
232
+ // With keys, one result whose cases they select; with `default` alone, every reply is that class (spec 5.3, D-049)
233
+ if (Object.keys(plan.outcome.classes ?? {}).length > 0) {
234
+ if (plan.results.length !== 1) return { skip: "outcome keys need a method with one result" };
235
+ const unread = outcomeKeyWithoutRule(plan.outcome.classes, plan.results[0]);
236
+ if (unread) return { skip: unread };
237
+ } else if (plan.outcome.default === undefined) return { skip: "the outcome has neither keys nor a default" };
238
+ }
239
+ if (args.length > 1) {
240
+ // each argument is one input, named by its place; a record among them has no rule (its fields could collide)
241
+ if (plan.idempotency) return { skip: "an idempotency stamp in a method with more than one argument has no rule" };
242
+ for (const [i, node] of args.entries()) {
243
+ const c = classifyField(`(${i})`, node);
244
+ if (c.skip) return c;
245
+ const optional = node.optional === true;
246
+ plan.fields.push({ name: `arg${i}`, node, role: c.role, optional });
247
+ if (c.role === "param") plan.params.push({ name: `arg${i}`, node, optional, path: `$args[${i}]` });
248
+ }
249
+ } else if (args.length === 1) {
250
+ // a case tool reads its case's payload as a single argument is read (D-053)
251
+ const why = kase !== undefined ? planArgument(plan, kase[1], `(0).${kase[0]}`, `$args[0].${kase[0]}`) : planArgument(plan, args[0], "(0)", "$args[0]");
252
+ if (why) return why;
253
+ }
254
+ return plan;
255
+ }
256
+
257
+ /**
258
+ * The inputs of a single argument, or of a case's payload: a position `readAs` names is one input named by its tag, a
259
+ * record's fields are inputs of their own (the stamp and filled ones aside), null takes none, and any other position
260
+ * is one JSON input. `at` names the position in skip reasons, `path` in input issues.
261
+ * @returns {{ skip: string } | undefined}
262
+ */
263
+ function planArgument(plan, root, at, path) {
264
+ const tag = readAs(root);
265
+ if (tag) {
266
+ const why = inputWithoutRule(root, at); // a text pattern the layer does not run
267
+ if (why) return { skip: why };
268
+ // the stamp is filled by the tool layer, so it must be a field beside the inputs, never the whole argument
269
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
270
+ plan.rootParam = true;
271
+ plan.params.push({ name: tag, node: root, optional: root.optional === true, path });
272
+ } else if (root.candid === "record") {
273
+ let stampId;
274
+ if (plan.idempotency) {
275
+ const ids = relpathIds(plan.idempotency.stamp);
276
+ if (ids === null || ids.length !== 1) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
277
+ stampId = ids[0];
278
+ }
279
+ for (const [fname, fnode] of Object.entries(root.fields ?? {})) {
280
+ // the canister selector of every tool (toolDefinitions, the tool layer) owns this input name
281
+ if (fname === "canister_id") return { skip: "an argument field named canister_id would collide with the canister selector input" };
282
+ const c = classifyField(fname, fnode, stampId);
283
+ if (c.skip) return c;
284
+ if (c.role === "stamp") plan.stampField = fname;
285
+ const optional = fnode.optional === true;
286
+ plan.fields.push({ name: fname, node: fnode, role: c.role, optional });
287
+ if (c.role === "param") plan.params.push({ name: fname, node: fnode, optional, path: `${path}.${fname}` });
288
+ }
289
+ if (plan.idempotency && !plan.fields.some((f) => f.role === "stamp")) return { skip: `the stamp field ${plan.idempotency.stamp} is not an argument field` };
290
+ } else if (root.candid === "null" || root.candid === "reserved") {
291
+ // one value only, so nothing to ask for: the tool takes no input and sends null
292
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
293
+ plan.rootNull = true;
294
+ } else {
295
+ // a variant or a vec (or a record a value tag does not cover) is one JSON input
296
+ const why = inputWithoutRule(root, at);
297
+ if (why) return { skip: why };
298
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
299
+ plan.rootParam = true;
300
+ plan.params.push({ name: "arg0", node: root, optional: root.optional === true, path });
301
+ }
302
+ return undefined;
303
+ }
304
+
305
+ /** What a tool calls, for messages: the method, or the method and the case it sends. */
306
+ const toolOf = (p) => (p.case === undefined ? p.method : `${p.method}'s case ${p.case}`);
307
+
308
+ /**
309
+ * @param {object | object[]} resolved
310
+ * @returns {{ plans: Map<string, any>, skipped: { method: string, reason: string }[] }} plans keyed by tool name, sorted
311
+ */
312
+ export function buildToolPlans(resolved) {
313
+ const plans = new Map();
314
+ const skipped = [];
315
+ const byTool = new Map();
316
+ for (const [name, m] of resolvedMethods(resolved)) {
317
+ const tools = planTools(name, m);
318
+ for (const reason of tools.skipped) skipped.push({ method: name, reason });
319
+ for (const p of tools.plans) byTool.set(p.name, [...(byTool.get(p.name) ?? []), p]);
320
+ }
321
+ // Two methods whose tools share a name (a query named prepare_x beside an update x), or one named like the commit
322
+ // tool: neither is dropped silently, and neither is preferred, so all of them are skipped.
323
+ for (const [tool, ps] of byTool) {
324
+ if (tool === COMMIT_TOOL) for (const p of ps) skipped.push({ method: p.method, reason: `its tool name ${tool} is the commit tool's` });
325
+ else if (ps.length > 1) for (const p of ps) skipped.push({ method: p.method, reason: `its tool name ${tool} is also ${ps.filter((q) => q !== p).map(toolOf).join(", ")}'s` });
326
+ else plans.set(tool, ps[0]);
327
+ }
328
+ return { plans: new Map([...plans.entries()].sort(byName)), skipped };
329
+ }
330
+
331
+ /**
332
+ * The names sets the resolved forms declare (spec 5.3, D-045): set -> { method, mode, replicated, args, results },
333
+ * where `args` are the `names` tag's (items, name, value, show, kind) and `results` the method's result nodes.
334
+ */
335
+ export function namesSets(resolved) {
336
+ const sets = new Map();
337
+ for (const [name, m] of resolvedMethods(resolved)) {
338
+ const args = m.tags?.names?.args;
339
+ if (!args) continue;
340
+ const effect = m.tags.effect?.args;
341
+ const replicated = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
342
+ sets.set(args.set, { method: name, mode: m.mode, replicated, args, results: m.results ?? [] });
343
+ }
344
+ return sets;
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Definitions
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /** Words for an input whose tag arguments differ between the canisters (or are not known): the grammar without them. */
352
+ const GENERIC = {
353
+ amount: "Amount in the token's own units as a decimal number, optionally followed by the token's symbol. Never rounded: more digits after the point than the token has is an error.",
354
+ timestamp: 'Time in RFC 3339 with an explicit zone, for example "2026-12-25T10:00:00Z", or relative to the current time as "+" and an ISO 8601 duration with weeks, days, hours, minutes and seconds only, for example "+PT24H".',
355
+ duration: 'ISO 8601 duration with weeks, days, hours, minutes and seconds only, for example "PT24H" or "P7D".',
356
+ account: 'ICRC-1 account in text form: a principal for its default subaccount, or "principal-checksum.subaccounthex".',
357
+ subaccount: 'Subaccount as "#n" (the number n below 2^64) or as exactly 64 hex characters.',
358
+ bytes: "Bytes, in the encoding the position's profile names.",
359
+ principal: 'A principal (a canister or user id) in its textual form, for example "ryjl3-tyaaa-aaaaa-aaaba-cai".',
360
+ integer: "A whole number in decimal digits, written as it is: no unit, separators, exponent or leading zeros.",
361
+ text: "Text, exactly as given.",
362
+ bool: 'Yes or no, written "true" or "false".',
363
+ };
364
+
365
+ /** The description of one input across the canisters: the formatter's own when every canister agrees, else generic. */
366
+ function grammarOf(node, refsList) {
367
+ const seen = new Map();
368
+ for (const refs of refsList) {
369
+ let d;
370
+ try {
371
+ d = inputReader(node, refs).describe();
372
+ } catch {
373
+ return { summary: GENERIC[readAs(node)] };
374
+ }
375
+ seen.set(JSON.stringify(d), d);
376
+ }
377
+ return seen.size === 1 ? [...seen.values()][0] : { summary: GENERIC[readAs(node)] };
378
+ }
379
+
380
+ /** The schema of one input: a string for a position `readAs` names, else the JSON shape of a record, variant or vec. */
381
+ function inputProperty(node, optional, refsList, sets) {
382
+ const d = node.tags?.describe?.args;
383
+ const parts = [];
384
+ if (d?.label) parts.push(`${d.label}.`);
385
+ if (readAs(node)) {
386
+ const grammar = grammarOf(node, refsList);
387
+ const named = node.tags?.named?.args;
388
+ parts.push(grammar.summary);
389
+ // a name is no value of the grammar, so a named input has no pattern
390
+ if (named) parts.push(`Or the name of one of the canister's ${named.set} entries, as ${sets.get(named.set)?.method ?? "its names method"} lists them, matched with case and spacing aside. A name more than one entry has is refused: ask the person which.`);
391
+ if (d?.help) parts.push(d.help);
392
+ if (optional) parts.push("Optional: leave it out for none.");
393
+ return { type: "string", description: parts.join(" "), ...(grammar.pattern === undefined || named ? {} : { pattern: grammar.pattern }) };
394
+ }
395
+ let shape;
396
+ if (node.candid === "record") {
397
+ const entries = [];
398
+ const required = [];
399
+ for (const [name, f] of Object.entries(node.fields ?? {})) {
400
+ if (nestedRole(name, f).role !== "param" || f.candid === "null" || f.candid === "reserved") continue;
401
+ entries.push([name, inputProperty(f, f.optional === true, refsList, sets)]);
402
+ if (f.optional !== true) required.push(name);
403
+ }
404
+ parts.push("A JSON object.");
405
+ shape = { type: "object", properties: Object.fromEntries(entries), required, additionalProperties: false };
406
+ } else if (node.candid === "variant") {
407
+ const oneOf = Object.entries(node.cases ?? {}).map(([name, c]) => {
408
+ const takes = !(c.candid === "null" || c.candid === "reserved") && !readAsNothing(c);
409
+ return {
410
+ type: "object",
411
+ properties: { tag: { const: name }, ...(takes ? { value: inputProperty(c, c.optional === true, refsList, sets) } : {}) },
412
+ required: takes && c.optional !== true ? ["tag", "value"] : ["tag"],
413
+ additionalProperties: false,
414
+ };
415
+ });
416
+ parts.push(`One of ${oneOf.length} cases, as a JSON object {"tag": the case, "value": its value}; a case without a value takes the tag alone.`);
417
+ shape = { oneOf };
418
+ } else {
419
+ // an element cannot be left out without moving the others, so a none element is a JSON null
420
+ const el = node.element;
421
+ const none = readAsNothing(el);
422
+ const items = none ? { type: "null" } : inputProperty(el, false, refsList, sets);
423
+ parts.push(`A JSON array of at most ${MAX_VEC_ITEMS} elements${none ? ", each null" : el.optional === true ? "; null for an element that is none" : ""}.`);
424
+ shape = { type: "array", items: el.optional === true && !none ? { anyOf: [items, { type: "null" }] } : items, maxItems: MAX_VEC_ITEMS };
425
+ }
426
+ if (d?.help) parts.push(d.help);
427
+ if (optional) parts.push("Optional: leave it out for none.");
428
+ return { ...shape, description: parts.join(" ") };
429
+ }
430
+
431
+ const readAsNothing = (node) => !readAs(node) && (node.candid === "null" || node.candid === "reserved");
432
+
433
+ const paramProperty = (param, refsList, sets) => inputProperty(param.node, param.optional, refsList, sets);
434
+
435
+ /** @param {{ id: string, label?: string }[]} canisters */
436
+ const canisterProperty = (canisters) => ({
437
+ type: "string",
438
+ enum: canisters.map((c) => c.id),
439
+ description: `Canister id of the canister to call: ${canisters.map((c) => (c.label ? `${c.id} (${c.label})` : c.id)).join(", ")}.`,
440
+ });
441
+
442
+ function inputSchema(plan, canisters, refsList, sets) {
443
+ // entries, not assignments: a field named `__proto__` must become an own property
444
+ const entries = [];
445
+ const required = [];
446
+ if (canisters.length > 0) {
447
+ entries.push(["canister_id", canisterProperty(canisters)]);
448
+ required.push("canister_id");
449
+ }
450
+ for (const p of plan.params) {
451
+ entries.push([p.name, paramProperty(p, refsList, sets)]);
452
+ if (!p.optional) required.push(p.name);
453
+ }
454
+ return { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: Object.fromEntries(entries), required, additionalProperties: false };
455
+ }
456
+
457
+ function filledByToolLayer(plan) {
458
+ const parts = [];
459
+ for (const f of plan.fields) {
460
+ if (f.role === "stamp") parts.push(`${f.name} (the idempotency stamp, set once from the canister's clock and reused for every retry)`);
461
+ else if (f.role !== "param") parts.push(`${f.name} (left empty)`);
462
+ }
463
+ return parts.join("; ");
464
+ }
465
+
466
+ function definitionOf(plan, canisters, refsList, sets) {
467
+ const schema = inputSchema(plan, canisters, refsList, sets);
468
+ if (plan.kind === "read") {
469
+ const label = plan.describe?.label;
470
+ const call = `\`${plan.method}\`${plan.case === undefined ? "" : ` with the case \`${plan.case}\``}`;
471
+ const what = plan.replicated ? `Read-only call of ${call}, sent as an update call so that the answer is certified; it changes nothing.` : `Read-only query of ${call}.`;
472
+ return {
473
+ name: plan.name,
474
+ description: `${label ? `${label}. ` : ""}${what} The answer gives each value formatted for people and raw (base units for amounts, the tagged unit for times).`,
475
+ inputSchema: schema,
476
+ annotations: { destructiveHint: false, openWorldHint: true, readOnlyHint: true, title: label ?? (plan.case === undefined ? plan.method : plan.title) },
477
+ };
478
+ }
479
+ const e = plan.effect;
480
+ const filled = filledByToolLayer(plan);
481
+ return {
482
+ name: plan.name,
483
+ description:
484
+ `Prepare the call "${plan.title}" (\`${plan.method}\`${plan.case === undefined ? "" : ` with the case \`${plan.case}\``}, effect: ${e.kind}${e.irreversible ? ", irreversible" : ""}). Nothing is sent yet. ` +
485
+ "Inputs are in human units and are checked exactly (nothing is rounded). " +
486
+ (plan.idempotency ? "" : "This call has no idempotency stamp, so the canister cannot tell a repeat from a new call: once an attempt's outcome is unknown, it is never sent again (commit answers reconcile). ") +
487
+ (filled ? `The tool layer fills in: ${filled}. ` : "") +
488
+ "Returns a preview_id and the exact arguments; commit sends it after the person confirms. Preparing the same call again returns the same preview_id.",
489
+ inputSchema: schema,
490
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true, title: plan.title },
491
+ };
492
+ }
493
+
494
+ const commitDefinition = () => ({
495
+ name: COMMIT_TOOL,
496
+ description:
497
+ "Send a call prepared by a prepare_* tool, exactly as previewed, once the person confirms it: the same bytes every time, so repeating commit is safe. " +
498
+ "The result names an outcome: success; already_done (treat it as success); declined (the person said no: nothing was sent, do not commit it again without a new instruction); " +
499
+ "retry_same (temporary: commit the same preview_id again later); retry_new (a new preview is returned, to confirm and commit); fix_input (correct the input and prepare again); " +
500
+ "fail (do not retry); unknown (commit the same preview_id again; do not prepare a new call); reconcile (an earlier attempt may have executed: tell the person).",
501
+ inputSchema: {
502
+ $schema: "https://json-schema.org/draft/2020-12/schema",
503
+ type: "object",
504
+ properties: { preview_id: { type: "string", description: "The preview_id returned by a prepare_* tool." } },
505
+ required: ["preview_id"],
506
+ additionalProperties: false,
507
+ },
508
+ annotations: { destructiveHint: true, openWorldHint: true, readOnlyHint: false, title: "Commit a prepared call" },
509
+ });
510
+
511
+ /**
512
+ * MCP tool definitions (`name`, `description`, `inputSchema`, `annotations`) from any resolved form, sorted by name.
513
+ * A fresh copy on every call.
514
+ *
515
+ * @param {object | object[]} resolved one or more candid-profile-resolved v0 documents
516
+ * @param {{ canisters?: { id: string, label?: string, refs?: Record<string, unknown> }[] }} [options]
517
+ * `canisters`: the canisters the tools may call. Given, every tool takes a required `canister_id` (one of their ids),
518
+ * and each input's words carry the tag arguments the canisters' `refs` resolve (decimals, symbol) when they agree.
519
+ */
520
+ export function toolDefinitions(resolved, { canisters = [] } = {}) {
521
+ if (!Array.isArray(canisters)) throw new TypeError("toolDefinitions: `canisters` must be an array");
522
+ for (const c of canisters) if (typeof c?.id !== "string" || c.id === "") throw new TypeError("toolDefinitions: every canister needs an `id`");
523
+ const refsList = canisters.length > 0 ? canisters.map((c) => c.refs ?? {}) : [{}];
524
+ const { plans } = buildToolPlans(resolved);
525
+ const sets = namesSets(resolved);
526
+ const defs = [...plans.values()].map((p) => definitionOf(p, canisters, refsList, sets));
527
+ if ([...plans.values()].some((p) => p.kind === "prepare")) defs.push(commitDefinition());
528
+ defs.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
529
+ return JSON.parse(JSON.stringify(defs));
530
+ }