@meantjs/tools 0.0.0-stage → 0.1.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,459 @@
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
+ //
30
+ // The input words come from the format library (`Formatter.describe()`), so a form hint and a tool schema read
31
+ // one grammar. Arm C keeps its own frozen words (D-015, D-030, D-031).
32
+
33
+ import { MAX_VEC_ITEMS, inputReader, inputWithoutRule, nestedRole, readAs } from "./input.mjs";
34
+
35
+ export const COMMIT_TOOL = "commit";
36
+ export const prepareToolName = (method) => `prepare_${method}`;
37
+ const byName = ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0);
38
+
39
+ /** The Candid id of a label: `#n` is n, a name is its idl hash (profile spec 3.2). */
40
+ export function labelId(label) {
41
+ if (/^#(?:0|[1-9][0-9]*)$/.test(label)) return Number(label.slice(1));
42
+ let h = 0;
43
+ for (const b of new TextEncoder().encode(label)) h = (h * 223 + b) >>> 0;
44
+ return h;
45
+ }
46
+
47
+ /**
48
+ * The label ids of a relpath into a one-element tuple, or null if it is not a plain label path there. `(0).Ok`,
49
+ * `.Ok`, `Ok` and `#17724` are one position (profile spec 3.2, rule 9), so positions are compared by these ids, never
50
+ * by spelling.
51
+ */
52
+ export function relpathIds(rel) {
53
+ const m = /^(?:\(0\))?\.?((?:[A-Za-z_][A-Za-z0-9_]*|#[0-9]+)(?:\.(?:[A-Za-z_][A-Za-z0-9_]*|#[0-9]+))*)$/.exec(rel ?? "");
54
+ return m ? m[1].split(".").map(labelId) : null;
55
+ }
56
+
57
+ /**
58
+ * The methods of one or more resolved forms, merged by name and sorted. A method two forms resolve differently is an
59
+ * error: merging layers is the resolver's job (profile spec section 8).
60
+ * @param {object | object[]} resolved candid-profile-resolved v0 documents
61
+ * @returns {[string, any][]}
62
+ */
63
+ export function resolvedMethods(resolved) {
64
+ const docs = Array.isArray(resolved) ? resolved : [resolved];
65
+ if (docs.length === 0) throw new TypeError("resolved: give at least one resolved form");
66
+ const methods = new Map();
67
+ for (const doc of docs) {
68
+ if (doc === null || typeof doc !== "object" || doc.format !== "candid-profile-resolved" || doc.format_version !== 0) {
69
+ throw new TypeError("resolved: not a candid-profile-resolved v0 document");
70
+ }
71
+ for (const [name, method] of Object.entries(doc.methods ?? {})) {
72
+ const seen = methods.get(name);
73
+ if (seen === undefined) methods.set(name, method);
74
+ 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`);
75
+ }
76
+ }
77
+ return [...methods.entries()].sort(byName);
78
+ }
79
+
80
+ /** @returns {{ role: "param" | "auto" | "stamp" | "hidden" | "none" } | { skip: string }} */
81
+ function classifyField(name, node, stampId) {
82
+ const tags = node.tags ?? {};
83
+ const optional = node.optional === true;
84
+ const def = tags.default?.args;
85
+ if (def !== undefined && def.value !== "null") return { skip: `${name}: a default value other than null has no rule` };
86
+ if (stampId !== undefined && labelId(name) === stampId) {
87
+ // a required stamp is allowed (the resolver's W_STAMP_REQUIRED): the layer always fills it
88
+ if (!tags.timestamp || node.candid !== "nat64" || tags.timestamp.args?.unit !== "ns") return { skip: `${name}: the idempotency stamp must be a nat64 timestamp in ns` };
89
+ return { role: "stamp" };
90
+ }
91
+ if (def?.mode === "auto") return optional ? { role: "auto" } : { skip: `${name}: an auto default needs an optional position` };
92
+ if (tags.ui?.args?.hidden === true) return optional ? { role: "hidden" } : { skip: `${name}: a hidden position must be optional` };
93
+ // a null or reserved position has one value, so it is filled, never asked for
94
+ if (!readAs(node) && (node.candid === "null" || node.candid === "reserved") && !node.recursive) return { role: "none" };
95
+ const why = inputWithoutRule(node, name);
96
+ return why ? { skip: why } : { role: "param" };
97
+ }
98
+
99
+ /**
100
+ * Why an outcome class key cannot be read by `classifyReply`, or undefined. It walks variant cases and record fields,
101
+ * and at a record follows the one field the keys name there; so a key it could not follow, or two keys that read
102
+ * different fields of one record (a reply could match both, at the same depth), skip the method rather than let a
103
+ * reply fall to the default class or to whichever key comes first.
104
+ */
105
+ function outcomeKeyWithoutRule(classes, result) {
106
+ const fieldAt = new Map(); // the path to a record -> the first key through it, and the field that key reads there
107
+ for (const key of Object.keys(classes ?? {})) {
108
+ const ids = relpathIds(key);
109
+ if (ids === null) return `the outcome key ${key} is not a path of cases and fields`;
110
+ let node = result;
111
+ for (const [i, id] of ids.entries()) {
112
+ if (node?.candid === "variant") {
113
+ const hit = Object.entries(node.cases ?? {}).find(([name]) => labelId(name) === id);
114
+ if (hit === undefined) return `the outcome key ${key} names no case of the result`;
115
+ node = hit[1];
116
+ } else if (node?.candid === "record") {
117
+ const hit = Object.entries(node.fields ?? {}).find(([name]) => labelId(name) === id);
118
+ if (hit === undefined) return `the outcome key ${key} names no field of the result`;
119
+ if (i === ids.length - 1) return `the outcome key ${key} ends on a record field; an outcome key selects a case`;
120
+ const at = ids.slice(0, i).join(".");
121
+ const first = fieldAt.get(at);
122
+ if (first === undefined) fieldAt.set(at, { key, id });
123
+ 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`;
124
+ node = hit[1];
125
+ } else return `the outcome key ${key} passes through a ${node?.candid ?? "missing"} position; only variant cases and record fields have a rule`;
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ /** @returns {object | { skip: string }} */
132
+ function planMethod(name, m) {
133
+ const tags = m.tags ?? {};
134
+ const effect = tags.effect?.args;
135
+ // a read-only update (D-038) is a read for consumers, and must be sent as an update so that its answer is certified
136
+ const replicated = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
137
+ // a composite query is a read too (arm C has none to meet: the ICRC-1/2 packs declare only queries and updates)
138
+ const read = m.mode === "query" || m.mode === "composite_query" || replicated;
139
+ const args = Array.isArray(m.args) ? m.args : [];
140
+ const plan = {
141
+ name: read ? name : prepareToolName(name),
142
+ kind: read ? "read" : "prepare",
143
+ method: name,
144
+ mode: m.mode,
145
+ /** a read sent as an update call */
146
+ replicated,
147
+ effect: effect ?? (read ? { kind: "read", irreversible: false } : { kind: "write", irreversible: true }),
148
+ describe: tags.describe?.args,
149
+ intent: tags.intent?.args,
150
+ idempotency: tags.idempotency?.args,
151
+ outcome: tags.outcome?.args,
152
+ /** the method's single argument, or undefined (none, or more than one) */
153
+ argNode: args.length === 1 ? args[0] : undefined,
154
+ /** every argument, in order */
155
+ argNodes: args,
156
+ /** the inputs, in resolved-form order: { name, node, optional, path } */
157
+ params: [],
158
+ /** the argument record's field the stamp relpath resolves to */
159
+ stampField: undefined,
160
+ /** the record fields (or, with more than one argument, the arguments), inputs and filled ones: { name, node, role, optional } */
161
+ fields: [],
162
+ rootParam: false,
163
+ /** the single argument is null or reserved: no input, and null is sent */
164
+ rootNull: false,
165
+ results: m.results ?? [],
166
+ };
167
+ plan.title = plan.intent?.title ?? plan.describe?.label ?? name;
168
+ if (read && plan.results.length > 1) return { skip: "a query with more than one result has no rule (only the first would be shown)" };
169
+ // the stamp would be an input, or left unset: the stamp rules are for writes
170
+ if (replicated && plan.idempotency) return { skip: "a read-only update with an idempotency stamp has no rule" };
171
+ if (!read) {
172
+ for (const need of ["intent", "outcome"]) if (!tags[need]) return { skip: `an update method needs the ${need} tag` };
173
+ if (plan.idempotency && plan.idempotency.mode !== "replay_same_payload") return { skip: `idempotency mode ${plan.idempotency.mode} has no rule` };
174
+ if (!plan.idempotency && [...Object.values(plan.outcome.classes ?? {}), plan.outcome.default].includes("retry_new")) {
175
+ return { skip: "the outcome class retry_new renews an idempotency stamp, and this method has none" };
176
+ }
177
+ // With keys, one result whose cases they select; with `default` alone, every reply is that class (spec 5.3, D-049)
178
+ if (Object.keys(plan.outcome.classes ?? {}).length > 0) {
179
+ if (plan.results.length !== 1) return { skip: "outcome keys need a method with one result" };
180
+ const unread = outcomeKeyWithoutRule(plan.outcome.classes, plan.results[0]);
181
+ if (unread) return { skip: unread };
182
+ } else if (plan.outcome.default === undefined) return { skip: "the outcome has neither keys nor a default" };
183
+ }
184
+ if (args.length > 1) {
185
+ // each argument is one input, named by its place; a record among them has no rule (its fields could collide)
186
+ if (plan.idempotency) return { skip: "an idempotency stamp in a method with more than one argument has no rule" };
187
+ for (const [i, node] of args.entries()) {
188
+ const c = classifyField(`(${i})`, node);
189
+ if (c.skip) return c;
190
+ const optional = node.optional === true;
191
+ plan.fields.push({ name: `arg${i}`, node, role: c.role, optional });
192
+ if (c.role === "param") plan.params.push({ name: `arg${i}`, node, optional, path: `$args[${i}]` });
193
+ }
194
+ } else if (args.length === 1) {
195
+ const root = args[0];
196
+ const tag = readAs(root);
197
+ if (tag) {
198
+ const why = inputWithoutRule(root, "(0)"); // a text pattern the layer does not run
199
+ if (why) return { skip: why };
200
+ // the stamp is filled by the tool layer, so it must be a field beside the inputs, never the whole argument
201
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
202
+ plan.rootParam = true;
203
+ plan.params.push({ name: tag, node: root, optional: root.optional === true, path: "$args[0]" });
204
+ } else if (root.candid === "record") {
205
+ let stampId;
206
+ if (plan.idempotency) {
207
+ const ids = relpathIds(plan.idempotency.stamp);
208
+ if (ids === null || ids.length !== 1) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
209
+ stampId = ids[0];
210
+ }
211
+ for (const [fname, fnode] of Object.entries(root.fields ?? {})) {
212
+ // the canister selector of every tool (toolDefinitions, the tool layer) owns this input name
213
+ if (fname === "canister_id") return { skip: "an argument field named canister_id would collide with the canister selector input" };
214
+ const c = classifyField(fname, fnode, stampId);
215
+ if (c.skip) return c;
216
+ if (c.role === "stamp") plan.stampField = fname;
217
+ const optional = fnode.optional === true;
218
+ plan.fields.push({ name: fname, node: fnode, role: c.role, optional });
219
+ if (c.role === "param") plan.params.push({ name: fname, node: fnode, optional, path: `$args[0].${fname}` });
220
+ }
221
+ if (plan.idempotency && !plan.fields.some((f) => f.role === "stamp")) return { skip: `the stamp field ${plan.idempotency.stamp} is not an argument field` };
222
+ } else if (root.candid === "null" || root.candid === "reserved") {
223
+ // one value only, so nothing to ask for: the tool takes no input and sends null
224
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
225
+ plan.rootNull = true;
226
+ } else {
227
+ // a variant or a vec (or a record a value tag does not cover) is one JSON input
228
+ const why = inputWithoutRule(root, "(0)");
229
+ if (why) return { skip: why };
230
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
231
+ plan.rootParam = true;
232
+ plan.params.push({ name: "arg0", node: root, optional: root.optional === true, path: "$args[0]" });
233
+ }
234
+ }
235
+ return plan;
236
+ }
237
+
238
+ /**
239
+ * @param {object | object[]} resolved
240
+ * @returns {{ plans: Map<string, any>, skipped: { method: string, reason: string }[] }} plans keyed by tool name, sorted
241
+ */
242
+ export function buildToolPlans(resolved) {
243
+ const plans = new Map();
244
+ const skipped = [];
245
+ const byTool = new Map();
246
+ for (const [name, m] of resolvedMethods(resolved)) {
247
+ const p = planMethod(name, m);
248
+ if (p.skip) skipped.push({ method: name, reason: p.skip });
249
+ else byTool.set(p.name, [...(byTool.get(p.name) ?? []), p]);
250
+ }
251
+ // Two methods whose tools share a name (a query named prepare_x beside an update x), or one named like the commit
252
+ // tool: neither is dropped silently, and neither is preferred, so all of them are skipped.
253
+ for (const [tool, ps] of byTool) {
254
+ if (tool === COMMIT_TOOL) for (const p of ps) skipped.push({ method: p.method, reason: `its tool name ${tool} is the commit tool's` });
255
+ 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((q) => q.method).join(", ")}'s` });
256
+ else plans.set(tool, ps[0]);
257
+ }
258
+ return { plans: new Map([...plans.entries()].sort(byName)), skipped };
259
+ }
260
+
261
+ /**
262
+ * The names sets the resolved forms declare (spec 5.3, D-045): set -> { method, mode, replicated, args, results },
263
+ * where `args` are the `names` tag's (items, name, value, show, kind) and `results` the method's result nodes.
264
+ */
265
+ export function namesSets(resolved) {
266
+ const sets = new Map();
267
+ for (const [name, m] of resolvedMethods(resolved)) {
268
+ const args = m.tags?.names?.args;
269
+ if (!args) continue;
270
+ const effect = m.tags.effect?.args;
271
+ const replicated = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
272
+ sets.set(args.set, { method: name, mode: m.mode, replicated, args, results: m.results ?? [] });
273
+ }
274
+ return sets;
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Definitions
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /** Words for an input whose tag arguments differ between the canisters (or are not known): the grammar without them. */
282
+ const GENERIC = {
283
+ 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.",
284
+ 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".',
285
+ duration: 'ISO 8601 duration with weeks, days, hours, minutes and seconds only, for example "PT24H" or "P7D".',
286
+ account: 'ICRC-1 account in text form: a principal for its default subaccount, or "principal-checksum.subaccounthex".',
287
+ subaccount: 'Subaccount as "#n" (the number n below 2^64) or as exactly 64 hex characters.',
288
+ bytes: "Bytes, in the encoding the position's profile names.",
289
+ principal: 'A principal (a canister or user id) in its textual form, for example "ryjl3-tyaaa-aaaaa-aaaba-cai".',
290
+ integer: "A whole number in decimal digits, written as it is: no unit, separators, exponent or leading zeros.",
291
+ text: "Text, exactly as given.",
292
+ bool: 'Yes or no, written "true" or "false".',
293
+ };
294
+
295
+ /** The description of one input across the canisters: the formatter's own when every canister agrees, else generic. */
296
+ function grammarOf(node, refsList) {
297
+ const seen = new Map();
298
+ for (const refs of refsList) {
299
+ let d;
300
+ try {
301
+ d = inputReader(node, refs).describe();
302
+ } catch {
303
+ return { summary: GENERIC[readAs(node)] };
304
+ }
305
+ seen.set(JSON.stringify(d), d);
306
+ }
307
+ return seen.size === 1 ? [...seen.values()][0] : { summary: GENERIC[readAs(node)] };
308
+ }
309
+
310
+ /** The schema of one input: a string for a position `readAs` names, else the JSON shape of a record, variant or vec. */
311
+ function inputProperty(node, optional, refsList, sets) {
312
+ const d = node.tags?.describe?.args;
313
+ const parts = [];
314
+ if (d?.label) parts.push(`${d.label}.`);
315
+ if (readAs(node)) {
316
+ const grammar = grammarOf(node, refsList);
317
+ const named = node.tags?.named?.args;
318
+ parts.push(grammar.summary);
319
+ // a name is no value of the grammar, so a named input has no pattern
320
+ 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.`);
321
+ if (d?.help) parts.push(d.help);
322
+ if (optional) parts.push("Optional: leave it out for none.");
323
+ return { type: "string", description: parts.join(" "), ...(grammar.pattern === undefined || named ? {} : { pattern: grammar.pattern }) };
324
+ }
325
+ let shape;
326
+ if (node.candid === "record") {
327
+ const entries = [];
328
+ const required = [];
329
+ for (const [name, f] of Object.entries(node.fields ?? {})) {
330
+ if (nestedRole(name, f).role !== "param" || f.candid === "null" || f.candid === "reserved") continue;
331
+ entries.push([name, inputProperty(f, f.optional === true, refsList, sets)]);
332
+ if (f.optional !== true) required.push(name);
333
+ }
334
+ parts.push("A JSON object.");
335
+ shape = { type: "object", properties: Object.fromEntries(entries), required, additionalProperties: false };
336
+ } else if (node.candid === "variant") {
337
+ const oneOf = Object.entries(node.cases ?? {}).map(([name, c]) => {
338
+ const takes = !(c.candid === "null" || c.candid === "reserved") && !readAsNothing(c);
339
+ return {
340
+ type: "object",
341
+ properties: { tag: { const: name }, ...(takes ? { value: inputProperty(c, c.optional === true, refsList, sets) } : {}) },
342
+ required: takes && c.optional !== true ? ["tag", "value"] : ["tag"],
343
+ additionalProperties: false,
344
+ };
345
+ });
346
+ 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.`);
347
+ shape = { oneOf };
348
+ } else {
349
+ // an element cannot be left out without moving the others, so a none element is a JSON null
350
+ const el = node.element;
351
+ const none = readAsNothing(el);
352
+ const items = none ? { type: "null" } : inputProperty(el, false, refsList, sets);
353
+ 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" : ""}.`);
354
+ shape = { type: "array", items: el.optional === true && !none ? { anyOf: [items, { type: "null" }] } : items, maxItems: MAX_VEC_ITEMS };
355
+ }
356
+ if (d?.help) parts.push(d.help);
357
+ if (optional) parts.push("Optional: leave it out for none.");
358
+ return { ...shape, description: parts.join(" ") };
359
+ }
360
+
361
+ const readAsNothing = (node) => !readAs(node) && (node.candid === "null" || node.candid === "reserved");
362
+
363
+ const paramProperty = (param, refsList, sets) => inputProperty(param.node, param.optional, refsList, sets);
364
+
365
+ /** @param {{ id: string, label?: string }[]} canisters */
366
+ const canisterProperty = (canisters) => ({
367
+ type: "string",
368
+ enum: canisters.map((c) => c.id),
369
+ description: `Canister id of the canister to call: ${canisters.map((c) => (c.label ? `${c.id} (${c.label})` : c.id)).join(", ")}.`,
370
+ });
371
+
372
+ function inputSchema(plan, canisters, refsList, sets) {
373
+ // entries, not assignments: a field named `__proto__` must become an own property
374
+ const entries = [];
375
+ const required = [];
376
+ if (canisters.length > 0) {
377
+ entries.push(["canister_id", canisterProperty(canisters)]);
378
+ required.push("canister_id");
379
+ }
380
+ for (const p of plan.params) {
381
+ entries.push([p.name, paramProperty(p, refsList, sets)]);
382
+ if (!p.optional) required.push(p.name);
383
+ }
384
+ return { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: Object.fromEntries(entries), required, additionalProperties: false };
385
+ }
386
+
387
+ function filledByToolLayer(plan) {
388
+ const parts = [];
389
+ for (const f of plan.fields) {
390
+ if (f.role === "stamp") parts.push(`${f.name} (the idempotency stamp, set once from the canister's clock and reused for every retry)`);
391
+ else if (f.role !== "param") parts.push(`${f.name} (left empty)`);
392
+ }
393
+ return parts.join("; ");
394
+ }
395
+
396
+ function definitionOf(plan, canisters, refsList, sets) {
397
+ const schema = inputSchema(plan, canisters, refsList, sets);
398
+ if (plan.kind === "read") {
399
+ const label = plan.describe?.label;
400
+ const what = plan.replicated ? `Read-only call of \`${plan.method}\`, sent as an update call so that the answer is certified; it changes nothing.` : `Read-only query of \`${plan.method}\`.`;
401
+ return {
402
+ name: plan.name,
403
+ description: `${label ? `${label}. ` : ""}${what} The answer gives each value formatted for people and raw (base units for amounts, the tagged unit for times).`,
404
+ inputSchema: schema,
405
+ annotations: { destructiveHint: false, openWorldHint: true, readOnlyHint: true, title: label ?? plan.method },
406
+ };
407
+ }
408
+ const e = plan.effect;
409
+ const filled = filledByToolLayer(plan);
410
+ return {
411
+ name: plan.name,
412
+ description:
413
+ `Prepare the call "${plan.title}" (\`${plan.method}\`, effect: ${e.kind}${e.irreversible ? ", irreversible" : ""}). Nothing is sent yet. ` +
414
+ "Inputs are in human units and are checked exactly (nothing is rounded). " +
415
+ (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). ") +
416
+ (filled ? `The tool layer fills in: ${filled}. ` : "") +
417
+ "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.",
418
+ inputSchema: schema,
419
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true, title: plan.title },
420
+ };
421
+ }
422
+
423
+ const commitDefinition = () => ({
424
+ name: COMMIT_TOOL,
425
+ description:
426
+ "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. " +
427
+ "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); " +
428
+ "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); " +
429
+ "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).",
430
+ inputSchema: {
431
+ $schema: "https://json-schema.org/draft/2020-12/schema",
432
+ type: "object",
433
+ properties: { preview_id: { type: "string", description: "The preview_id returned by a prepare_* tool." } },
434
+ required: ["preview_id"],
435
+ additionalProperties: false,
436
+ },
437
+ annotations: { destructiveHint: true, openWorldHint: true, readOnlyHint: false, title: "Commit a prepared call" },
438
+ });
439
+
440
+ /**
441
+ * MCP tool definitions (`name`, `description`, `inputSchema`, `annotations`) from any resolved form, sorted by name.
442
+ * A fresh copy on every call.
443
+ *
444
+ * @param {object | object[]} resolved one or more candid-profile-resolved v0 documents
445
+ * @param {{ canisters?: { id: string, label?: string, refs?: Record<string, unknown> }[] }} [options]
446
+ * `canisters`: the canisters the tools may call. Given, every tool takes a required `canister_id` (one of their ids),
447
+ * and each input's words carry the tag arguments the canisters' `refs` resolve (decimals, symbol) when they agree.
448
+ */
449
+ export function toolDefinitions(resolved, { canisters = [] } = {}) {
450
+ if (!Array.isArray(canisters)) throw new TypeError("toolDefinitions: `canisters` must be an array");
451
+ for (const c of canisters) if (typeof c?.id !== "string" || c.id === "") throw new TypeError("toolDefinitions: every canister needs an `id`");
452
+ const refsList = canisters.length > 0 ? canisters.map((c) => c.refs ?? {}) : [{}];
453
+ const { plans } = buildToolPlans(resolved);
454
+ const sets = namesSets(resolved);
455
+ const defs = [...plans.values()].map((p) => definitionOf(p, canisters, refsList, sets));
456
+ if ([...plans.values()].some((p) => p.kind === "prepare")) defs.push(commitDefinition());
457
+ defs.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
458
+ return JSON.parse(JSON.stringify(defs));
459
+ }