@jini-ai/mcp 0.4.3 → 0.4.4

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 (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-install/install.js +102 -194
  3. package/dist/bin/serve.d.ts +4 -1
  4. package/dist/bin/serve.js +114 -43
  5. package/dist/client/client.js +71 -58
  6. package/dist/core/config.js +113 -149
  7. package/dist/core/tokens.js +29 -53
  8. package/dist/federation/adapter.http.js +3 -2
  9. package/dist/federation/approvals/call-confirmation.js +5 -70
  10. package/dist/federation/approvals/remembered-approval.d.ts +39 -0
  11. package/dist/federation/approvals/remembered-approval.js +98 -0
  12. package/dist/federation/approvals/revocation-reason.d.ts +9 -0
  13. package/dist/federation/approvals/revocation-reason.js +11 -0
  14. package/dist/federation/approvals/revocation.js +19 -13
  15. package/dist/federation/approvals/scope-option.d.ts +6 -0
  16. package/dist/federation/approvals/scope-option.js +6 -0
  17. package/dist/federation/stdio/adapter.stdio.js +14 -6
  18. package/dist/federation/stdio/default-connect.js +24 -27
  19. package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
  20. package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
  21. package/dist/federation/tool-approvals.js +3 -1
  22. package/dist/federation/trust.d.ts +23 -2
  23. package/dist/federation/trust.js +69 -35
  24. package/dist/server/daemon-client.d.ts +2 -0
  25. package/dist/server/daemon-client.js +86 -69
  26. package/dist/server/tool-arguments.d.ts +46 -0
  27. package/dist/server/tool-arguments.js +111 -0
  28. package/dist/server/tool-protocol.d.ts +62 -7
  29. package/dist/server/tool-protocol.js +30 -40
  30. package/dist/server/tool-server.d.ts +19 -0
  31. package/dist/server/tool-server.js +91 -19
  32. package/dist/server/tools/delegated-tool.d.ts +47 -0
  33. package/dist/server/tools/delegated-tool.js +35 -2
  34. package/dist/server/tools/tool-catalog-tools.js +30 -8
  35. package/dist/tools/ask-choice/index.d.ts +213 -14
  36. package/dist/tools/ask-choice/index.js +90 -46
  37. package/dist/tools/ask-choice/question.d.ts +5 -1
  38. package/dist/tools/ask-choice/question.js +36 -2
  39. package/dist/tools/ask-choice/schema.d.ts +196 -10
  40. package/dist/tools/ask-choice/schema.js +33 -12
  41. package/dist/tools/ask-choice/types.d.ts +8 -0
  42. package/package.json +5 -4
@@ -4,24 +4,40 @@
4
4
  * `search_tools` / `describe_tool` — the discovery half of `ai-control-plane.md` §29 /
5
5
  * `PROP-tool-catalog-discovery-2026-07-26.md` §6.1, proxying `@jini-ai/http-kit`'s
6
6
  * `GET /api/tools/search` / `GET /api/tools/:id` exactly as `run-tools.ts`'s tools proxy
7
- * `runs.ts` — no separate authorization mechanism, no caching, no state.
7
+ * `runs.ts` — no separate authorization mechanism or catalog cache.
8
8
  *
9
9
  * v0 scope: no principal scoping, no versioning, no schema narrowing. Execution is unchanged —
10
- * a caller still runs a discovered id through `execute_delegated_tool`
11
- * (`../tools/delegated-tool.js`), which is the only thing that ever reaches `ToolExecutor`.
10
+ * a caller runs a discovered id through the delegated gateway (`../tools/delegated-tool.js`),
11
+ * either with its legacy wrapper or a concrete definition published after a successful describe.
12
+ * Both reach the same `ToolExecutor`.
12
13
  * Finding a tool id here grants nothing (`PROP` §6.3).
13
14
  */
14
15
  import { getDaemonJson } from '../daemon-client.js';
16
+ import { isRecord } from '../tool-arguments.js';
15
17
  import { daemonCallOptions, requireString } from '../tool-protocol.js';
16
18
  const READ_ANNOTATIONS = {
17
19
  readOnlyHint: true,
18
20
  idempotentHint: true,
19
21
  openWorldHint: false,
20
22
  };
23
+ /** Validate the daemon's identity before publishing; discovery cannot substitute another tool.
24
+ * Unknown risk remains unclassified and therefore cannot claim a read-only annotation.
25
+ * @complexity O(1); no effects. The schema is checked by the publication boundary.
26
+ */
27
+ function describedDescriptor({ id, data }) {
28
+ if (!isRecord(data) || data.id !== id)
29
+ throw new Error('daemon returned a mismatched tool descriptor');
30
+ return {
31
+ id,
32
+ ...(typeof data.description === 'string' ? { description: data.description } : {}),
33
+ ...(typeof data.readOnly === 'boolean' ? { readOnly: data.readOnly } : {}),
34
+ ...(data.inputSchema === undefined ? {} : { inputSchema: data.inputSchema }),
35
+ };
36
+ }
21
37
  /** `search_tools` -> `GET /api/tools/search` (`packages/http/src/tool-catalog.ts`'s `toolCatalogSearchRoute`). */
22
38
  export const searchToolsTool = {
23
39
  name: 'search_tools',
24
- description: 'Search the durable tool catalog. Returns ranked {id, description, source, score} candidates only — no input schemas, so this stays cheap to call broadly. Call describe_tool on the 1-3 candidates that look right before calling execute_delegated_tool. If nothing in the results fits, re-search with a higher limit or different phrasing rather than assuming the tool does not exist.',
40
+ description: 'Search the durable tool catalog. Returns ranked {id, description, source, score, readOnly, sideEffects} candidates only — no input schemas, so this stays cheap to call broadly. Call describe_tool on the 1-3 candidates that look right before calling execute_delegated_tool. If nothing in the results fits, re-search with a higher limit or different phrasing rather than assuming the tool does not exist.',
25
41
  inputSchema: {
26
42
  type: 'object',
27
43
  properties: {
@@ -80,19 +96,25 @@ export const searchToolsTool = {
80
96
  /** `describe_tool` -> `GET /api/tools/:id` (`packages/http/src/tool-catalog.ts`'s `toolCatalogDescribeRoute`). */
81
97
  export const describeToolTool = {
82
98
  name: 'describe_tool',
83
- description: 'Get the full descriptor for one tool id found via search_tools — description and input schema. Paid for only on the candidates that survive search, per the tool catalog\'s staged-discovery design.',
99
+ description: 'Get the full descriptor for one tool id found via search_tools — description, input schema, readOnly and sideEffects. When dynamic publication is enabled, this also exposes that operation as a concrete tool in tools/list; call it with its own arguments. Legacy fallback: use execute_readonly_delegated_tool only when readOnly is true. Supply id (aliases: toolId or name). Paid for only on the candidates that survive search, per the tool catalog\'s staged-discovery design.',
84
100
  inputSchema: {
85
101
  type: 'object',
86
102
  properties: {
87
- id: { type: 'string', description: 'Tool id returned by search_tools. Required.' },
103
+ id: { type: 'string', description: 'Tool id returned by search_tools. Supply id, toolId, or name.' },
104
+ toolId: { type: 'string', description: 'Alias of id, matching the delegated-execute wrappers.' },
105
+ name: { type: 'string', description: 'Alias of id.' },
88
106
  },
89
- required: ['id'],
107
+ anyOf: [{ required: ['id'] }, { required: ['toolId'] }, { required: ['name'] }],
90
108
  additionalProperties: false,
91
109
  },
110
+ argumentNormalization: { aliases: { toolId: 'id', name: 'id' } },
92
111
  annotations: { ...READ_ANNOTATIONS, title: 'Describe a tool' },
93
112
  handler: async ({ args, ctx }) => {
94
113
  const id = requireString({ value: args.id, name: 'id' });
95
- return getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/tools/${encodeURIComponent(id)}` }, daemonCallOptions({ ctx }));
114
+ const data = await getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/tools/${encodeURIComponent(id)}` }, daemonCallOptions({ ctx }));
115
+ if (ctx.publishTool)
116
+ await ctx.publishTool({ descriptor: describedDescriptor({ id, data }) });
117
+ return data;
96
118
  },
97
119
  };
98
120
  /** Both tool-catalog discovery tools, ready to pass as `createMcpToolServer`'s `tools` option. */
@@ -1,9 +1,23 @@
1
- import type { AskChoiceCall, AskChoiceExchangeStore, AskChoiceMessages, AskChoicePendingStore, AskChoicePolicy, AskChoicePresentation } from './types.js';
1
+ import type { AskChoiceCall, AskChoiceExchange, AskChoiceExchangeStore, AskChoiceMessages, AskChoicePendingStore, AskChoicePolicy, AskChoicePresentation, AskChoiceQuestion } from './types.js';
2
2
  export type * from './types.js';
3
3
  export { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
4
4
  export { createAskChoiceAnswerTicketStore } from './pending.js';
5
5
  /** Existing callback wire keys; the tool's identity is supplied by the host. */
6
6
  export declare const ASK_CHOICE_ANSWER_TICKET_PARAM = "__askChoiceAnswerTicket";
7
+ /** Run a trusted host question through the same abort, delivery, validation and cleanup lifecycle
8
+ * as the model-facing tool. The host owns presentation and action policy, never exchange mechanics.
9
+ * @returns Classified human answer, including an offered action when configured.
10
+ * @complexity O(n) offered options and selections.
11
+ */
12
+ export declare function askChoice({ exchange, question, toolId, principalId, presentation, signal, messages }: {
13
+ exchange: AskChoiceExchange;
14
+ question: AskChoiceQuestion;
15
+ toolId: string;
16
+ principalId: string;
17
+ presentation: Pick<AskChoicePresentation, 'render'>;
18
+ signal: AbortSignal;
19
+ messages: AskChoiceMessages;
20
+ }, _options?: {}): Promise<Record<string, unknown>>;
7
21
  /** Creates a choice tool without a CMS, renderer, permission system, clock, or global store.
8
22
  * The host composes this descriptor/handler into its registry and mounts callbacks against the
9
23
  * same exchange and pending stores. Policy and presentation are required, with no product defaults.
@@ -29,15 +43,50 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
29
43
  inputSchema: {
30
44
  readonly type: "object";
31
45
  readonly additionalProperties: false;
32
- readonly required: readonly ["title"];
33
46
  readonly anyOf: readonly [{
34
- readonly required: readonly ["singleSelect"];
47
+ readonly required: readonly ["question", "options"];
35
48
  }, {
36
- readonly required: readonly ["multiSelect"];
49
+ readonly required: readonly ["title", "singleSelect"];
50
+ }, {
51
+ readonly required: readonly ["title", "multiSelect"];
37
52
  }];
38
53
  readonly properties: {
54
+ readonly question: {
55
+ readonly type: "string";
56
+ readonly minLength: 1;
57
+ readonly description: "The question to ask the human.";
58
+ };
59
+ readonly options: {
60
+ readonly type: "array";
61
+ readonly minItems: 1;
62
+ readonly items: {
63
+ readonly type: "object";
64
+ readonly additionalProperties: false;
65
+ readonly required: readonly ["label"];
66
+ readonly properties: {
67
+ readonly label: {
68
+ readonly type: "string";
69
+ readonly minLength: 1;
70
+ };
71
+ readonly value: {
72
+ readonly type: "string";
73
+ readonly minLength: 1;
74
+ readonly description: "Defaults to label when omitted.";
75
+ };
76
+ readonly description: {
77
+ readonly type: "string";
78
+ };
79
+ };
80
+ };
81
+ };
82
+ readonly multiple: {
83
+ readonly type: "boolean";
84
+ readonly description: "Allow several selections. Defaults to false.";
85
+ };
39
86
  readonly title: {
40
87
  readonly type: "string";
88
+ readonly minLength: 1;
89
+ readonly description: "Legacy alias: title with singleSelect or multiSelect.";
41
90
  };
42
91
  readonly description: {
43
92
  readonly type: "string";
@@ -46,25 +95,33 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
46
95
  readonly type: "string";
47
96
  };
48
97
  readonly singleSelect: {
98
+ readonly description: "Legacy nested single-choice alias.";
49
99
  readonly type: "object";
50
100
  readonly additionalProperties: false;
51
101
  readonly required: readonly ["label", "options"];
52
102
  readonly properties: {
53
103
  readonly label: {
54
104
  readonly type: "string";
105
+ readonly minLength: 1;
55
106
  };
56
107
  readonly options: {
57
108
  readonly type: "array";
58
109
  readonly minItems: 1;
59
110
  readonly items: {
111
+ readonly required: readonly ["value", "label"];
60
112
  readonly type: "object";
61
113
  readonly additionalProperties: false;
62
- readonly required: readonly ["value", "label"];
63
114
  readonly properties: {
115
+ readonly label: {
116
+ readonly type: "string";
117
+ readonly minLength: 1;
118
+ };
64
119
  readonly value: {
65
120
  readonly type: "string";
121
+ readonly minLength: 1;
122
+ readonly description: "Defaults to label when omitted.";
66
123
  };
67
- readonly label: {
124
+ readonly description: {
68
125
  readonly type: "string";
69
126
  };
70
127
  };
@@ -73,25 +130,33 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
73
130
  };
74
131
  };
75
132
  readonly multiSelect: {
133
+ readonly description: "Legacy nested multiple-choice alias.";
76
134
  readonly properties: {
77
135
  readonly hint: {
78
136
  readonly type: "string";
79
137
  };
80
138
  readonly label: {
81
139
  readonly type: "string";
140
+ readonly minLength: 1;
82
141
  };
83
142
  readonly options: {
84
143
  readonly type: "array";
85
144
  readonly minItems: 1;
86
145
  readonly items: {
146
+ readonly required: readonly ["value", "label"];
87
147
  readonly type: "object";
88
148
  readonly additionalProperties: false;
89
- readonly required: readonly ["value", "label"];
90
149
  readonly properties: {
150
+ readonly label: {
151
+ readonly type: "string";
152
+ readonly minLength: 1;
153
+ };
91
154
  readonly value: {
92
155
  readonly type: "string";
156
+ readonly minLength: 1;
157
+ readonly description: "Defaults to label when omitted.";
93
158
  };
94
- readonly label: {
159
+ readonly description: {
95
160
  readonly type: "string";
96
161
  };
97
162
  };
@@ -102,19 +167,153 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
102
167
  readonly additionalProperties: false;
103
168
  readonly required: readonly ["label", "options"];
104
169
  };
105
- readonly choice: {
170
+ };
171
+ };
172
+ };
173
+ invocationSchema: {
174
+ readonly anyOf: readonly [{
175
+ readonly required: readonly ["question", "options"];
176
+ }, {
177
+ readonly required: readonly ["title", "singleSelect"];
178
+ }, {
179
+ readonly required: readonly ["title", "multiSelect"];
180
+ }, {
181
+ readonly required: readonly ["choice", "__askChoiceAnswerTicket"];
182
+ }, {
183
+ readonly required: readonly ["selections", "__askChoiceAnswerTicket"];
184
+ }];
185
+ readonly properties: {
186
+ readonly choice: {
187
+ readonly type: "string";
188
+ };
189
+ readonly selections: {
190
+ readonly type: "array";
191
+ readonly items: {
106
192
  readonly type: "string";
107
- readonly description: "Set by the rendered form; never by the model.";
108
193
  };
109
- readonly selections: {
110
- readonly type: "array";
111
- readonly items: {
194
+ };
195
+ readonly __askChoiceAnswerTicket: {
196
+ readonly type: "string";
197
+ };
198
+ readonly question: {
199
+ readonly type: "string";
200
+ readonly minLength: 1;
201
+ readonly description: "The question to ask the human.";
202
+ };
203
+ readonly options: {
204
+ readonly type: "array";
205
+ readonly minItems: 1;
206
+ readonly items: {
207
+ readonly type: "object";
208
+ readonly additionalProperties: false;
209
+ readonly required: readonly ["label"];
210
+ readonly properties: {
211
+ readonly label: {
212
+ readonly type: "string";
213
+ readonly minLength: 1;
214
+ };
215
+ readonly value: {
216
+ readonly type: "string";
217
+ readonly minLength: 1;
218
+ readonly description: "Defaults to label when omitted.";
219
+ };
220
+ readonly description: {
221
+ readonly type: "string";
222
+ };
223
+ };
224
+ };
225
+ };
226
+ readonly multiple: {
227
+ readonly type: "boolean";
228
+ readonly description: "Allow several selections. Defaults to false.";
229
+ };
230
+ readonly title: {
231
+ readonly type: "string";
232
+ readonly minLength: 1;
233
+ readonly description: "Legacy alias: title with singleSelect or multiSelect.";
234
+ };
235
+ readonly description: {
236
+ readonly type: "string";
237
+ };
238
+ readonly submitLabel: {
239
+ readonly type: "string";
240
+ };
241
+ readonly singleSelect: {
242
+ readonly description: "Legacy nested single-choice alias.";
243
+ readonly type: "object";
244
+ readonly additionalProperties: false;
245
+ readonly required: readonly ["label", "options"];
246
+ readonly properties: {
247
+ readonly label: {
112
248
  readonly type: "string";
249
+ readonly minLength: 1;
250
+ };
251
+ readonly options: {
252
+ readonly type: "array";
253
+ readonly minItems: 1;
254
+ readonly items: {
255
+ readonly required: readonly ["value", "label"];
256
+ readonly type: "object";
257
+ readonly additionalProperties: false;
258
+ readonly properties: {
259
+ readonly label: {
260
+ readonly type: "string";
261
+ readonly minLength: 1;
262
+ };
263
+ readonly value: {
264
+ readonly type: "string";
265
+ readonly minLength: 1;
266
+ readonly description: "Defaults to label when omitted.";
267
+ };
268
+ readonly description: {
269
+ readonly type: "string";
270
+ };
271
+ };
272
+ };
273
+ };
274
+ };
275
+ };
276
+ readonly multiSelect: {
277
+ readonly description: "Legacy nested multiple-choice alias.";
278
+ readonly properties: {
279
+ readonly hint: {
280
+ readonly type: "string";
281
+ };
282
+ readonly label: {
283
+ readonly type: "string";
284
+ readonly minLength: 1;
285
+ };
286
+ readonly options: {
287
+ readonly type: "array";
288
+ readonly minItems: 1;
289
+ readonly items: {
290
+ readonly required: readonly ["value", "label"];
291
+ readonly type: "object";
292
+ readonly additionalProperties: false;
293
+ readonly properties: {
294
+ readonly label: {
295
+ readonly type: "string";
296
+ readonly minLength: 1;
297
+ };
298
+ readonly value: {
299
+ readonly type: "string";
300
+ readonly minLength: 1;
301
+ readonly description: "Defaults to label when omitted.";
302
+ };
303
+ readonly description: {
304
+ readonly type: "string";
305
+ };
306
+ };
307
+ };
113
308
  };
114
- readonly description: "Set by the rendered form; never by the model.";
115
309
  };
310
+ readonly type: "object";
311
+ readonly additionalProperties: false;
312
+ readonly required: readonly ["label", "options"];
116
313
  };
117
314
  };
315
+ readonly type: "object";
316
+ readonly additionalProperties: false;
118
317
  };
119
318
  handler({ ctx }: {
120
319
  ctx: AskChoiceCall;
@@ -1,5 +1,5 @@
1
- import { parseQuestion, QuestionShapeError } from './question.js';
2
- import { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
1
+ import { matchesOptions, parseQuestion, QuestionShapeError } from './question.js';
2
+ import { ASK_CHOICE_INPUT_SCHEMA, ASK_CHOICE_INVOCATION_SCHEMA } from './schema.js';
3
3
  export { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
4
4
  export { createAskChoiceAnswerTicketStore } from './pending.js';
5
5
  /** Existing callback wire keys; the tool's identity is supplied by the host. */
@@ -14,7 +14,32 @@ function describeAnswer(params, messages) {
14
14
  return { submitted: false, reason: 'no-answer', note: messages.noAnswer };
15
15
  return { submitted: true, ...(choice === undefined ? {} : { choice }), ...(selections === undefined ? {} : { selections }), note: messages.submitted };
16
16
  }
17
- async function waitForAnswer({ exchange, ui, signal, messages }) {
17
+ // Classifies a delivered form answer: cancel wins over typed text, typed text wins over picks,
18
+ // and picks must stay inside the offered options and actions.
19
+ function classifyReceived({ params, question, messages }) {
20
+ if (params[SURFACE_DISMISSED_PARAM] === true)
21
+ return { submitted: false, reason: 'cancelled', note: messages.cancelled };
22
+ const typed = params[SURFACE_TYPED_ANSWER_PARAM];
23
+ if (typeof typed === 'string' && typed.trim())
24
+ return { submitted: true, freeText: typed.trim(), note: messages.typed };
25
+ const described = describeAnswer(params, messages);
26
+ if (described.submitted !== true)
27
+ return described;
28
+ return withOfferedAction({ described, params, question, messages });
29
+ }
30
+ function withOfferedAction({ described, params, question, messages }) {
31
+ const invalid = { submitted: false, reason: 'invalid-answer', note: messages.noAnswer };
32
+ // Live exchanges need the same offered-option boundary as fallback tickets.
33
+ if (!matchesOptions({ question, params }))
34
+ return invalid;
35
+ if (!question.actions)
36
+ return described;
37
+ const action = params['__choiceAction'];
38
+ if (!question.actions.some(offered => offered.id === action))
39
+ return invalid;
40
+ return { ...described, action };
41
+ }
42
+ async function waitForAnswer({ exchange, ui, signal, messages, question }) {
18
43
  const close = () => exchange.close({});
19
44
  signal.addEventListener('abort', close, { once: true });
20
45
  try {
@@ -26,18 +51,31 @@ async function waitForAnswer({ exchange, ui, signal, messages }) {
26
51
  const answer = await exchange.receive({});
27
52
  if (answer.status !== 'received')
28
53
  return { submitted: false, reason: answer.status, note: messages[answer.status] };
29
- if (answer.params[SURFACE_DISMISSED_PARAM] === true)
30
- return { submitted: false, reason: 'cancelled', note: messages.cancelled };
31
- const typed = answer.params[SURFACE_TYPED_ANSWER_PARAM];
32
- if (typeof typed === 'string' && typed.trim())
33
- return { submitted: true, freeText: typed.trim(), note: messages.typed };
34
- return describeAnswer(answer.params, messages);
54
+ return classifyReceived({ params: answer.params, question, messages });
35
55
  }
36
56
  finally {
37
57
  signal.removeEventListener('abort', close);
38
58
  close();
39
59
  }
40
60
  }
61
+ /** Run a trusted host question through the same abort, delivery, validation and cleanup lifecycle
62
+ * as the model-facing tool. The host owns presentation and action policy, never exchange mechanics.
63
+ * @returns Classified human answer, including an offered action when configured.
64
+ * @complexity O(n) offered options and selections.
65
+ */
66
+ export async function askChoice({ exchange, question, toolId, principalId, presentation, signal, messages }, _options = {}) {
67
+ let ui;
68
+ try {
69
+ ui = presentation.render({ toolName: toolId, principalId, question,
70
+ baseParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id },
71
+ }, { cancelParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id, [SURFACE_DISMISSED_PARAM]: true } });
72
+ }
73
+ catch (error) {
74
+ exchange.close({});
75
+ throw error;
76
+ }
77
+ return waitForAnswer({ exchange, ui, signal, messages, question });
78
+ }
41
79
  /** Creates a choice tool without a CMS, renderer, permission system, clock, or global store.
42
80
  * The host composes this descriptor/handler into its registry and mounts callbacks against the
43
81
  * same exchange and pending stores. Policy and presentation are required, with no product defaults.
@@ -49,50 +87,56 @@ export function createAskChoiceTool({ toolId, description, permission, policy, p
49
87
  };
50
88
  return {
51
89
  descriptor,
90
+ invocationSchema: ASK_CHOICE_INVOCATION_SCHEMA,
52
91
  async handler({ ctx }) {
53
92
  await policy.authorize({ ctx, toolId });
54
93
  const input = typeof ctx.input === 'object' && ctx.input !== null ? ctx.input : {};
55
- const isAnswer = typeof input['title'] !== 'string' && (typeof input['choice'] === 'string' || Array.isArray(input['selections']));
56
- if (isAnswer) {
57
- const rawTicket = input[ASK_CHOICE_ANSWER_TICKET_PARAM];
58
- const ticket = typeof rawTicket === 'string' ? rawTicket : undefined;
59
- if (!pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: input })) {
60
- throw policy.inputError({ message: messages.forgedAnswer });
61
- }
62
- return describeAnswer(input, messages);
63
- }
64
- let question;
65
- try {
66
- question = parseQuestion({ input, toolId });
67
- }
68
- catch (error) {
69
- if (!(error instanceof QuestionShapeError))
70
- throw error;
71
- throw policy.shapeError({ message: error.message, toolId, inputSchema: ASK_CHOICE_INPUT_SCHEMA });
72
- }
94
+ if (isAnswerCall(input))
95
+ return redeemAnswer({ input, ctx, pendingQuestions, policy, messages });
96
+ const question = parseOrRefuse({ input, toolId, policy });
73
97
  if (ctx.signal.aborted)
74
98
  return { submitted: false, reason: 'abandoned', note: messages.abandoned };
75
99
  const exchange = ctx.emitSurface ? surfaceExchanges.open({ toolId, principalId: ctx.principalId, emit: ctx.emitSurface }) : undefined;
76
- const ticket = exchange ? undefined : pendingQuestions.mint({ principalId: ctx.principalId, question });
77
- const baseParams = exchange ? { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id } : { [ASK_CHOICE_ANSWER_TICKET_PARAM]: ticket };
78
- let ui;
79
- try {
80
- ui = presentation.render({
81
- toolName: toolId, principalId: ctx.principalId, question, baseParams,
82
- }, { ...(exchange ? { cancelParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id, [SURFACE_DISMISSED_PARAM]: true } } : {}),
83
- });
84
- if (!exchange)
85
- return presentation.buildResult({ modelText: messages.pending, ui });
86
- }
87
- catch (error) {
88
- exchange?.close({});
89
- // A form that was not delivered must not leave a redeemable fallback ticket behind.
90
- if (ticket !== undefined)
91
- pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: {} });
92
- throw error;
93
- }
94
- return waitForAnswer({ exchange, ui, signal: ctx.signal, messages });
100
+ if (exchange)
101
+ return askChoice({ exchange, question, toolId, principalId: ctx.principalId, presentation, signal: ctx.signal, messages });
102
+ return presentFallback({ question, toolId, principalId: ctx.principalId, pendingQuestions, presentation, messages });
95
103
  },
96
104
  };
97
105
  }
106
+ // A second call carrying a pick (and no question title) is an answer to a fallback form.
107
+ function isAnswerCall(input) {
108
+ return typeof input['title'] !== 'string' && typeof input['question'] !== 'string' && (typeof input['choice'] === 'string' || Array.isArray(input['selections']));
109
+ }
110
+ // The ticket is the only proof the answer came from a rendered form: redeem is fail-closed.
111
+ function redeemAnswer({ input, ctx, pendingQuestions, policy, messages }) {
112
+ const rawTicket = input[ASK_CHOICE_ANSWER_TICKET_PARAM];
113
+ const ticket = typeof rawTicket === 'string' ? rawTicket : undefined;
114
+ if (!pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: input })) {
115
+ throw policy.inputError({ message: messages.forgedAnswer });
116
+ }
117
+ return describeAnswer(input, messages);
118
+ }
119
+ function parseOrRefuse({ input, toolId, policy }) {
120
+ try {
121
+ return parseQuestion({ input, toolId });
122
+ }
123
+ catch (error) {
124
+ if (!(error instanceof QuestionShapeError))
125
+ throw error;
126
+ throw policy.shapeError({ message: error.message, toolId, inputSchema: ASK_CHOICE_INPUT_SCHEMA });
127
+ }
128
+ }
129
+ // No emit seam: return the form itself, bound to a freshly minted single-use ticket.
130
+ function presentFallback({ question, toolId, principalId, pendingQuestions, presentation, messages }) {
131
+ const ticket = pendingQuestions.mint({ principalId, question });
132
+ try {
133
+ const ui = presentation.render({ toolName: toolId, principalId, question, baseParams: { [ASK_CHOICE_ANSWER_TICKET_PARAM]: ticket } });
134
+ return presentation.buildResult({ modelText: messages.pending, ui });
135
+ }
136
+ catch (error) {
137
+ // A form that was not delivered must not leave a redeemable fallback ticket behind.
138
+ pendingQuestions.redeem({ ticket, principalId, params: {} });
139
+ throw error;
140
+ }
141
+ }
98
142
  //# sourceMappingURL=index.js.map
@@ -5,10 +5,14 @@ export declare class QuestionShapeError extends Error {
5
5
  message: string;
6
6
  }, options?: ErrorOptions);
7
7
  }
8
+ /** Normalize the flat model question into the existing UI/answer contract.
9
+ * @throws QuestionShapeError for malformed questions or ambiguous mixed forms.
10
+ * @complexity O(n) in offered options; no effects.
11
+ */
8
12
  export declare function parseQuestion({ input, toolId }: {
9
13
  input: Record<string, unknown>;
10
14
  toolId: string;
11
- }): AskChoiceQuestion;
15
+ }, _options?: {}): AskChoiceQuestion;
12
16
  export declare function matchesOptions({ question, params }: {
13
17
  question: AskChoiceQuestion;
14
18
  params: Record<string, unknown>;
@@ -21,11 +21,45 @@ function readSelect(raw, field, toolId) {
21
21
  if (typeof value !== 'string' || !value || typeof label !== 'string' || !label) {
22
22
  throw new QuestionShapeError({ message: `${toolId}: every '${field}' option requires a string 'value' and 'label'.` });
23
23
  }
24
- return { value, label };
24
+ return { value, label, ...(typeof option['description'] === 'string' ? { description: option['description'] } : {}) };
25
25
  });
26
26
  return { label: spec['label'], options, ...(typeof spec['hint'] === 'string' ? { hint: spec['hint'] } : {}) };
27
27
  }
28
- export function parseQuestion({ input, toolId }) {
28
+ function normalizeFlatQuestion({ input, toolId }, _options = {}) {
29
+ const question = input['question'];
30
+ if (typeof question !== 'string' || !question.trim())
31
+ throw new QuestionShapeError({ message: `${toolId}: 'question' is required.` });
32
+ if (input['multiple'] !== undefined && typeof input['multiple'] !== 'boolean') {
33
+ throw new QuestionShapeError({ message: `${toolId}: 'multiple' must be a boolean.` });
34
+ }
35
+ const options = normalizeFlatOptions({ rawOptions: input['options'] });
36
+ const select = { label: question, options };
37
+ return { title: question, ...(typeof input['description'] === 'string' ? { description: input['description'] } : {}),
38
+ ...(typeof input['submitLabel'] === 'string' ? { submitLabel: input['submitLabel'] } : {}),
39
+ [input['multiple'] === true ? 'multiSelect' : 'singleSelect']: select };
40
+ }
41
+ function normalizeFlatOptions({ rawOptions }, _options = {}) {
42
+ return Array.isArray(rawOptions) ? rawOptions.map(entry => {
43
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
44
+ return entry;
45
+ const option = entry;
46
+ return { ...option, value: option['value'] === undefined ? option['label'] : option['value'] };
47
+ }) : rawOptions;
48
+ }
49
+ function normalizeQuestion({ input, toolId }, _options = {}) {
50
+ if (input['question'] === undefined && input['options'] === undefined && input['multiple'] === undefined)
51
+ return input;
52
+ if (input['title'] !== undefined || input['singleSelect'] !== undefined || input['multiSelect'] !== undefined) {
53
+ throw new QuestionShapeError({ message: `${toolId}: use {question, options, multiple?} or the legacy nested selects; do not mix them.` });
54
+ }
55
+ return normalizeFlatQuestion({ input, toolId });
56
+ }
57
+ /** Normalize the flat model question into the existing UI/answer contract.
58
+ * @throws QuestionShapeError for malformed questions or ambiguous mixed forms.
59
+ * @complexity O(n) in offered options; no effects.
60
+ */
61
+ export function parseQuestion({ input, toolId }, _options = {}) {
62
+ input = normalizeQuestion({ input, toolId });
29
63
  const title = input['title'];
30
64
  if (typeof title !== 'string' || !title)
31
65
  throw new QuestionShapeError({ message: `${toolId}: 'title' is required.` });