@indemn/cli 1.7.1 → 1.7.3

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/mcp/registry.d.ts.map +1 -1
  4. package/dist/mcp/registry.js +6 -4
  5. package/dist/mcp/registry.js.map +1 -1
  6. package/dist/mcp/tool-meta.d.ts.map +1 -1
  7. package/dist/mcp/tool-meta.js +35 -9
  8. package/dist/mcp/tool-meta.js.map +1 -1
  9. package/dist/mcp/tools.d.ts.map +1 -1
  10. package/dist/mcp/tools.js +235 -49
  11. package/dist/mcp/tools.js.map +1 -1
  12. package/dist/report/styles.d.ts +1 -0
  13. package/dist/report/styles.d.ts.map +1 -1
  14. package/dist/report/styles.js +5 -1
  15. package/dist/report/styles.js.map +1 -1
  16. package/dist/sdk/client.d.ts.map +1 -1
  17. package/dist/sdk/client.js +11 -1
  18. package/dist/sdk/client.js.map +1 -1
  19. package/dist/sdk/config-change.d.ts +4 -0
  20. package/dist/sdk/config-change.d.ts.map +1 -1
  21. package/dist/sdk/config-change.js +27 -5
  22. package/dist/sdk/config-change.js.map +1 -1
  23. package/dist/sdk/kb.d.ts +13 -0
  24. package/dist/sdk/kb.d.ts.map +1 -1
  25. package/dist/sdk/kb.js +48 -6
  26. package/dist/sdk/kb.js.map +1 -1
  27. package/dist/sdk/voice.js +5 -5
  28. package/dist/sdk/voice.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/mcp/registry.ts +9 -4
  31. package/src/mcp/tool-meta.ts +35 -9
  32. package/src/mcp/tools.ts +238 -49
  33. package/src/report/styles.ts +5 -1
  34. package/src/sdk/client.ts +5 -1
  35. package/src/sdk/config-change.ts +27 -5
  36. package/src/sdk/kb.ts +53 -10
  37. package/src/sdk/voice.ts +5 -5
  38. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +1 -0
  39. package/test/mcp/darwin-fixes.test.ts +249 -0
  40. package/test/mcp/tools-snapshot.test.ts +1 -1
package/src/mcp/tools.ts CHANGED
@@ -54,9 +54,12 @@ function withAuthorization<T extends AuthFields & { configuration?: Record<strin
54
54
  return { ...args, configuration: { ...(args.configuration ?? {}), authorization } };
55
55
  }
56
56
 
57
- // Helper: format a successful MCP tool result
57
+ /** The CI gate's threshold when the caller gives none (evaluations repo scripts/eval_gate.py uses the same). */
58
+ const DEFAULT_MIN_PASS_RATE = 0.8;
59
+
60
+ // Helper: format a successful MCP tool result (the registry adds the context and formats it)
58
61
  function success(data: unknown) {
59
- return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
62
+ return { content: [{ type: 'text' as const, text: JSON.stringify(data) }] };
60
63
  }
61
64
 
62
65
  // Helper: format an error MCP tool result
@@ -130,6 +133,20 @@ const parseJsonText = (v: unknown) => {
130
133
  try { return JSON.parse(v); } catch { return v; }
131
134
  };
132
135
  const jsonObject = () => z.preprocess(parseJsonText, z.record(z.any()));
136
+ // A list a model may also write as JSON text.
137
+ const jsonArray = <T extends z.ZodTypeAny>(item: T) => z.preprocess(parseJsonText, z.array(item));
138
+
139
+ // What the knowledge base import accepts: typed, so a wrong shape is refused here instead of being
140
+ // "imported" as nothing (copilot answers success for any body).
141
+ const kbImportSchema = z.preprocess(parseJsonText, z.object({
142
+ type: z.enum(['file']).describe('Kind of import; files from the organization\'s file store'),
143
+ sources: z.array(z.object({
144
+ name: z.string().optional().describe('File name shown in the knowledge base'),
145
+ file_type: z.string().optional().describe('Extension, e.g. pdf, txt, docx (taken from the name when missing)'),
146
+ url: z.string().describe('The file\'s link in the file store'),
147
+ }).passthrough()).min(1),
148
+ is_qna_import: z.boolean().optional().describe('The file holds question/answer pairs'),
149
+ }).passthrough());
133
150
 
134
151
  // A skill for bulk_create_skills with flat fields (the configuration is built as create_function builds it);
135
152
  // a proposal from generate_skills_from_doc keeps its own configurationSchema.
@@ -170,12 +187,26 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
170
187
  },
171
188
  (err) => { if (what && notFound.test(err instanceof Error ? err.message : String(err))) missing.push(`${what} ${id}`); })
172
189
  : undefined;
190
+ const agentId = typeof args.agent_id === 'string' ? args.agent_id : undefined;
191
+ const label = (name: unknown) => ({ name: typeof name === 'string' && name ? name : undefined });
173
192
  await Promise.all([
174
193
  add(args.agent_id ?? args.bot_id, 'agent', (id) => agents.getAgent(id)),
175
- typeof args.agent_id === 'string' ? add(args.function_id, 'skill', (id) => skillOf(String(args.agent_id), id)) : undefined,
194
+ agentId ? add(args.function_id, 'skill', (id) => skillOf(agentId, id)) : undefined,
176
195
  add(args.kb_id, 'knowledge base', (id) => kb.getKB(id)),
196
+ ...((Array.isArray(args.kb_ids) ? args.kb_ids : []) as unknown[]).map((id) => add(id, 'knowledge base', (x) => kb.getKB(x))),
177
197
  add(args.test_set_id, 'test set', (id) => testSets.getTestSet(id)),
178
198
  add(args.rubric_id, 'rubric', (id) => rubrics.getRubric(id)),
199
+ // The rest name things the cards used to show as raw ids.
200
+ // Q&A entries come from the entry list; files and pages only from the knowledge base's sources.
201
+ typeof args.kb_id === 'string' ? add(args.source_id, null, (id) => kb.findDataSource(String(args.kb_id), id).then((d) => d
202
+ ? label(d.question ?? d.name ?? d.source_url)
203
+ : kb.scrapeStatus(String(args.kb_id)).then((all) => { const s = all.find((x) => x.id === id); return label(s?.name ?? s?.source); }))) : undefined,
204
+ add(args.ticket_id, null, (id) => tickets.get(id).then((t) => { const x = t as { ticket_ref?: string; title?: string }; return label([x.ticket_ref, x.title].filter(Boolean).join(' ')); })),
205
+ add(args.rule_id, null, (id) => tickets.listRules({ includeTrashed: true }).then((rules) => label((rules as Array<{ _id?: string; id?: string; name?: string }>).find((r) => (r._id ?? r.id) === id)?.name))),
206
+ add(args.distribution_id, null, (id) => distribution.list().then((d) => { const p = (d.paths ?? []).find((x) => x.id === id); return label(p ? `${p.path_name ?? ''} (${p.host_name ?? '*'}${p.path_expression ?? ''})`.trim() : undefined); })),
207
+ add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label((ts as Array<{ _id?: string; name?: string }>).find((t) => t._id === id)?.name))),
208
+ add(args.owner_id, null, (id) => orgs.listMembers().then((ms) => { const m = ms.find((x) => x.user_id === id); return label(m?.name ?? m?.email); })),
209
+ agentId && typeof args.function_id === 'string' ? add(args.parameter_id, null, (id) => skillOf(agentId, String(args.function_id)).then((s) => functions.listParameters(agentId, s._id)).then((ps) => label(ps.find((p) => p._id === id)?.name))) : undefined,
179
210
  add(orgId, null, (id) => client.copilotGet<{ organization?: { name?: string } }>(`/organization/${id}`).then((r) => r.organization)),
180
211
  ]);
181
212
  return { names, missing };
@@ -231,6 +262,18 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
231
262
  const config = new ConfigSDK(client);
232
263
  const functions = new FunctionsSDK(client);
233
264
  // A skill of an agent by its id.
265
+ /** An export result with a signed download_url: the services hand out unsigned file-store links (403 in a browser). */
266
+ const withDownloadLink = async <T extends object>(result: T, stored?: string) => {
267
+ if (!stored || typeof stored !== 'string') return result;
268
+ const signed = /X-Amz-Signature=/.test(stored) ? stored : await kb.signedLink(stored);
269
+ return { ...result, download_url: signed ?? stored, ...(signed ? {} : { note: 'The link could not be signed; it may need signing in to the dashboard.' }) };
270
+ };
271
+ /** A report file written by this process, put in the organization's file store so the person can download it. */
272
+ const deliver = async (result: { file_path: string; file_name: string; file_type: string }, upload?: boolean) => {
273
+ if (!upload) return result;
274
+ const { signedUrl, url } = await kb.storeFile(result.file_path);
275
+ return { ...result, download_url: signedUrl ?? url };
276
+ };
234
277
  const skillOf = async (agentId: string, id: string) => {
235
278
  const skill = (await functions.listFunctions(agentId)).find((f) => f._id === id);
236
279
  if (!skill) throw new Error(`Skill ${id} is not on agent ${agentId}.`);
@@ -259,11 +302,15 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
259
302
  reg.tool(
260
303
  'list_agents',
261
304
  'List the agents (bots) of the organization: id, name, channels. Start here to find an agent id.',
262
- { channel: z.string().optional().describe('Only agents on this channel, e.g. web, voice, email') },
305
+ {
306
+ channel: z.string().optional().describe('Only agents on this channel, e.g. web, voice, email'),
307
+ search: z.string().optional().describe('Only agents whose name contains this text'),
308
+ },
263
309
  async (args) => {
264
310
  try {
265
311
  const pages = await orgPages(client);
266
312
  const wanted = args.channel?.trim().toUpperCase();
313
+ const needle = args.search?.trim().toLowerCase();
267
314
  // What a person needs, not the stored record (a page of full records ran to 73k characters).
268
315
  const rows = (await agents.listAgents()).map((a) => ({
269
316
  _id: a._id,
@@ -275,7 +322,8 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
275
322
  updated_at: a.updatedAt,
276
323
  url: `${pages}/ai-studio/bot/${a._id}/configurations`,
277
324
  }));
278
- return success(wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows);
325
+ const byChannel = wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows;
326
+ return success(needle ? byChannel.filter((a) => a.name.toLowerCase().includes(needle)) : byChannel);
279
327
  } catch (err) { return error(err); }
280
328
  },
281
329
  );
@@ -392,7 +440,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
392
440
  preview: async (a) => {
393
441
  const agent = await agents.getAgent(String(a.agent_id));
394
442
  const lifecycle = await lifecycleOf(String(a.agent_id));
395
- return `This will delete agent "${agent.name}"${mayBeLive(lifecycle) ? `, which ${liveText(lifecycle)}` : ''}. It cannot be restored from the CLI.`;
443
+ return `This will delete agent "${agent.name}"${mayBeLive(lifecycle) ? `, which ${liveText(lifecycle)}` : ''}. It cannot be restored.`;
396
444
  },
397
445
  },
398
446
  );
@@ -450,7 +498,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
450
498
  },
451
499
  async (args) => {
452
500
  try {
453
- return success(await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels }));
501
+ const copy = await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels });
502
+ // copilot's clone answers { new_agent_id, new_agent_name, ... } and leaves the description empty:
503
+ // carry the original's over.
504
+ const copyId = copy._id ?? (copy as unknown as { new_agent_id?: string }).new_agent_id;
505
+ const source = await agents.getAgent(args.agent_id);
506
+ if (source.description && !copy.description && copyId) {
507
+ const described = await agents.updateAgent(copyId, { description: source.description }).then(() => true).catch(() => false);
508
+ return success({ ...copy, ...(described ? { description: source.description } : {}) });
509
+ }
510
+ return success(copy);
454
511
  } catch (err) { return error(err); }
455
512
  },
456
513
  );
@@ -468,7 +525,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
468
525
  'import_agent',
469
526
  'Create a new agent in this org from a bundle made by export_agent (possibly from another org or platform). Lists skills that failed and secrets to fill in.',
470
527
  {
471
- bundle: z.record(z.unknown()).describe('The bundle returned by export_agent'),
528
+ bundle: jsonObject().describe('The bundle returned by export_agent (an object, or JSON text)'),
472
529
  name: z.string().optional().describe('Name for the new agent (default: the bundle name)'),
473
530
  link_knowledge_bases: z.boolean().optional().describe('Link the same knowledge base ids if they exist in this org'),
474
531
  allow_duplicate: z.boolean().optional(),
@@ -515,16 +572,18 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
515
572
  system_prompt: a.system_prompt as string | undefined,
516
573
  greeting: a.first_message as string | undefined,
517
574
  model: a.model ? modelArg(String(a.model)) : undefined,
575
+ parameters: a.temperature !== undefined ? { temperature: a.temperature } : undefined,
518
576
  });
519
577
 
520
578
  reg.tool(
521
579
  'set_config',
522
- 'Change an agent\'s prompt, greeting and/or model. The confirmation shows the prompt diff and whether the agent is published; published agents need confirm_published too. The result says what was saved (read back from the platform).',
580
+ 'Change an agent\'s prompt, greeting, model and/or temperature. The confirmation shows the prompt diff and whether the agent is published; published agents need confirm_published too. The result says what was saved (read back from the platform).',
523
581
  {
524
582
  agent_id: z.string().describe('The agent ID'),
525
583
  system_prompt: z.string().optional().describe('New system prompt (whole text)'),
526
584
  first_message: z.string().optional().describe('New greeting shown to users'),
527
585
  model: z.string().optional().describe('provider/model, e.g. openai/gpt-4.1 (see list_llm_models)'),
586
+ temperature: z.number().min(0).max(2).optional().describe('Model temperature (0 = most predictable); only for models that expose it'),
528
587
  confirm_published: z.boolean().optional().describe('Required with confirm when the agent is published and the user explicitly agreed'),
529
588
  },
530
589
  async (args, meta) => {
@@ -592,10 +651,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
592
651
  ...skillAuthFields,
593
652
  configuration: jsonObject().optional().describe('Other configurationSchema fields (an object, or JSON text); the fields above win'),
594
653
  transfer_to: z.string().optional().describe('transfer_call: phone number (E.164, e.g. +15551234567) or SIP address (sip:...) calls are transferred to'),
654
+ parameters: jsonArray(z.object({
655
+ name: z.string(), type: z.string().describe('string, number or boolean'),
656
+ description: z.string().optional(), required: z.boolean().optional(),
657
+ allowed_values: z.array(z.string()).optional().describe('Fixed choices the customer picks from'),
658
+ })).optional().describe('What the skill collects from the customer before it runs ({{name}} placeholders in url/payload)'),
595
659
  },
596
660
  async (args) => {
597
661
  try {
598
662
  const { agent_id, type, name, description } = args;
663
+ const parameters = (args.parameters ?? []) as Array<{ name: string; type: string; description?: string; required?: boolean; allowed_values?: string[] }>;
599
664
  const configurationSchema = skillConfiguration(type, withAuthorization(args));
600
665
  // copilot sends a REST answer straight to the customer unless craftResponse is on; with nothing to say
601
666
  // how to present it, let the agent phrase it.
@@ -613,9 +678,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
613
678
  if (same) throw new Error(`The agent already has a skill named "${name}" (function_id ${same._id}). Change it with update_function instead of creating another.`);
614
679
  // copilot turns configurationSchema into the executable form only on update: create, then configure.
615
680
  const created = await functions.createFunction(agent_id, { type, name, description });
616
- if (!configurationSchema) return success(created);
681
+ // Parameters are their own records: a skill created with parameters used to lose them silently.
682
+ const addParameters = async () => {
683
+ const saved = [];
684
+ for (const { allowed_values, ...p } of parameters) saved.push(await functions.addParameter(agent_id, created._id, { ...p, allowedValues: allowed_values }));
685
+ return saved;
686
+ };
687
+ if (!configurationSchema) return success(parameters.length ? { ...created, parameters: await addParameters() } : created);
617
688
  try {
618
- return success(await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema }));
689
+ const configured = await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema });
690
+ return success(parameters.length ? { ...configured, parameters: await addParameters() } : configured);
619
691
  } catch (err) {
620
692
  // Leave no half-made skill behind: remove the empty one, then say why nothing was saved.
621
693
  const why = err instanceof Error ? err.message : String(err);
@@ -707,12 +779,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
707
779
 
708
780
  reg.tool(
709
781
  'export_functions',
710
- 'Export every skill of an agent as a JSON file (to copy them or keep a backup before import_functions).',
782
+ 'Export every skill of an agent as a JSON file (to copy them or keep a backup before import_functions); download_url is the link to give the person.',
711
783
  { agent_id: z.string().describe('The agent ID') },
712
784
  async (args) => {
713
785
  try {
714
- const result = await functions.exportFunctions(args.agent_id);
715
- return success(result);
786
+ const result = await functions.exportFunctions(args.agent_id) as { fileUrl?: string; signedUrl?: string; url?: string };
787
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl ?? result.url));
716
788
  } catch (err) { return error(err); }
717
789
  },
718
790
  );
@@ -807,6 +879,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
807
879
  return success(result);
808
880
  } catch (err) { return error(err); }
809
881
  },
882
+ liveChange('a skill parameter', ['function_id']),
810
883
  );
811
884
 
812
885
  reg.tool(
@@ -829,6 +902,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
829
902
  return success(result);
830
903
  } catch (err) { return error(err); }
831
904
  },
905
+ liveChange('a skill parameter', ['function_id', 'parameter_id']),
832
906
  );
833
907
 
834
908
  reg.tool(
@@ -950,11 +1024,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
950
1024
 
951
1025
  reg.tool(
952
1026
  'list_kb_data',
953
- 'The question/answer pairs typed into a knowledge base (website and file sources are listed by get_knowledge_base).',
1027
+ 'The question/answer pairs typed into a knowledge base (website and file sources are listed by get_knowledge_base). Each entry: id, question, answer, whether it is trained. full: true adds the stored chunks.',
954
1028
  {
955
1029
  kb_id: z.string().describe('The knowledge base ID'),
956
1030
  page: z.number().optional().describe('Page number'),
957
1031
  limit: z.number().optional().describe('Items per page'),
1032
+ full: z.boolean().optional().describe('Include the chunks and statistics of every entry'),
958
1033
  },
959
1034
  async (args) => {
960
1035
  try {
@@ -962,7 +1037,15 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
962
1037
  if (args.page !== undefined) query.page = String(args.page);
963
1038
  if (args.limit !== undefined) query.limit = String(args.limit);
964
1039
  const result = await kb.listDataSources(args.kb_id, Object.keys(query).length ? query : undefined);
965
- return success(result);
1040
+ if (args.full) return success(result);
1041
+ // The stored record repeats every chunk twice (chunks, chunks_text): a page of it is tens of kilobytes.
1042
+ const entries = (result.dataSources ?? []).map((d) => ({
1043
+ _id: d._id, type: d.type, question: d.question ?? d.name, answer: d.answer,
1044
+ ...(d.source_url ? { source_url: d.source_url } : {}),
1045
+ trained: (d as { trained?: boolean }).trained, ...(d.status ? { status: d.status } : {}),
1046
+ ...((d as { error_message?: string }).error_message ? { error: (d as { error_message?: string }).error_message } : {}),
1047
+ }));
1048
+ return success({ ...result, dataSources: entries });
966
1049
  } catch (err) { return error(err); }
967
1050
  },
968
1051
  );
@@ -1144,30 +1227,37 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1144
1227
 
1145
1228
  reg.tool(
1146
1229
  'export_kb',
1147
- 'Export the question/answer pairs of a knowledge base as a CSV or XLSX download link.',
1230
+ 'Export the question/answer pairs of a knowledge base as a CSV or XLSX file; download_url is the link to give the person (valid for a while).',
1148
1231
  {
1149
1232
  kb_id: z.string().describe('The knowledge base ID'),
1150
- format: z.string().optional().describe('Export format (e.g. "json", "csv")'),
1233
+ format: z.string().optional().describe('Export format: csv (default) or xlsx'),
1151
1234
  },
1152
1235
  async (args) => {
1153
1236
  try {
1154
- const result = await kb.exportKB(args.kb_id, args.format || 'json');
1155
- return success(result);
1237
+ const result = await kb.exportKB(args.kb_id, args.format || 'csv') as { fileUrl?: string; signedUrl?: string };
1238
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl));
1156
1239
  } catch (err) { return error(err); }
1157
1240
  },
1158
1241
  );
1159
1242
 
1160
1243
  reg.tool(
1161
1244
  'import_kb',
1162
- 'Import websites or files into a knowledge base; processed in the background (see kb_scrape_status).',
1245
+ 'Import files (already in the organization\'s file store) into a knowledge base; processed in the background (see kb_scrape_status). For a website use scrape_url.',
1163
1246
  {
1164
1247
  kb_id: z.string().describe('The knowledge base ID'),
1165
- data: z.record(z.unknown()).describe('Data to import'),
1248
+ data: kbImportSchema.describe('{ type: "file", sources: [{ name, file_type, url }] } (an object, or JSON text)'),
1166
1249
  },
1167
1250
  async (args) => {
1168
1251
  try {
1169
- const result = await kb.importKB(args.kb_id, args.data);
1170
- return success(result);
1252
+ const data = args.data as { type: string; sources: Array<{ name?: string; url: string; file_type?: string }> };
1253
+ // The import service tells the file's kind from file_type: fill it from the name or the link.
1254
+ const sources = data.sources.map((s) => ({
1255
+ ...s,
1256
+ name: s.name ?? s.url.split('?')[0].split('/').pop() ?? 'file',
1257
+ file_type: (s.file_type ?? (s.name ?? s.url.split('?')[0]).split('.').pop() ?? '').toLowerCase(),
1258
+ }));
1259
+ const result = await kb.importKB(args.kb_id, { ...data, sources });
1260
+ return success({ ...(result as object), imported: sources.map((s) => s.name) });
1171
1261
  } catch (err) { return error(err); }
1172
1262
  },
1173
1263
  );
@@ -1220,7 +1310,8 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1220
1310
  return success({
1221
1311
  conversation_id: conversation.request_id, test: conversation.isTestMode === true,
1222
1312
  url: conversationUrl(client, projectId, conversation.request_id),
1223
- messages: messages.map((m) => ({ role: m.role, text: m.text, at: m.createdAt })),
1313
+ // System lines carry the dashboard's template ("{{userLabel}} has ended the call"): say it in words.
1314
+ messages: messages.map((m) => ({ role: m.role, text: (m.text ?? '').replace(/\{\{\s*userLabel\s*\}\}/g, 'The customer'), at: m.createdAt })),
1224
1315
  });
1225
1316
  } catch (err) { return error(err); }
1226
1317
  },
@@ -1322,10 +1413,18 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1322
1413
 
1323
1414
  reg.tool(
1324
1415
  'list_agent_numbers',
1325
- 'Phone numbers attached to an agent, or (without agent_id) every number the org owns.',
1416
+ 'Phone numbers attached to an agent, or (without agent_id) every number the org owns: the ones attached to an agent (with the agent) and the free ones.',
1326
1417
  { agent_id: z.string().optional() },
1327
1418
  async (args) => {
1328
- try { return success(args.agent_id ? await voice.agentNumbers(args.agent_id) : await voice.orgNumbers()); } catch (err) { return error(err); }
1419
+ try {
1420
+ if (args.agent_id) return success(await voice.agentNumbers(args.agent_id));
1421
+ // copilot's org list holds only unattached ("reserve") numbers; the attached ones sit on the agents.
1422
+ const [free, all] = await Promise.all([voice.orgNumbers(), agents.listAgents()]);
1423
+ const attached = all
1424
+ .filter((a) => (a as { connected_phone?: string }).connected_phone)
1425
+ .map((a) => ({ phone_number: (a as { connected_phone?: string }).connected_phone, attached: true, agent_id: a._id, agent_name: a.name }));
1426
+ return success([...attached, ...free.map((n) => ({ phone_number: n.phoneNumber, attached: false, name: n.friendlyName, type: n.type }))]);
1427
+ } catch (err) { return error(err); }
1329
1428
  },
1330
1429
  );
1331
1430
 
@@ -1374,7 +1473,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1374
1473
  async (args, meta) => {
1375
1474
  try {
1376
1475
  // A purchase needs the person, not the model: confirm: true from a host without approval forms is not enough.
1377
- if (!meta.confirmedByPerson) throw new Error('Buying a number needs a person to approve it in an approval form, and this host cannot show one. Nothing was bought. Buy it in the dashboard, or with `indemn agents numbers buy` in a terminal.');
1476
+ if (!meta.confirmedByPerson) throw new Error('Buying a number needs a person to approve it in an approval form, and this host cannot show one. Nothing was bought. Buy it in the dashboard instead.');
1378
1477
  return success(await voice.buyNumber(args.agent_id, args.phone_number, { country: args.country, tollFree: args.toll_free }));
1379
1478
  } catch (err) { return error(err); }
1380
1479
  },
@@ -1512,19 +1611,64 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1512
1611
  try { return success(await orgSettings.setTimezone(args.timezone)); } catch (err) { return error(err); }
1513
1612
  });
1514
1613
 
1614
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
1615
+ const clock = (hhmm: string) => {
1616
+ const [h, m] = hhmm.split(':').map(Number);
1617
+ const hour = ((h + 11) % 12) + 1;
1618
+ return `${hour}${m ? `:${String(m).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`;
1619
+ };
1620
+ /** What agents say about the hours, built from the hours themselves so it never contradicts them. */
1621
+ const hoursMessage = (days: Array<{ day: number; slots: Array<{ start: string; end: string }> }>) => {
1622
+ const open = days.filter((d) => d.slots.length).sort((a, b) => a.day - b.day);
1623
+ if (!open.length) return 'We are currently closed.';
1624
+ const parts = open.map((d) => `${DAY_NAMES[d.day]} ${d.slots.map((s) => `${clock(s.start)} to ${clock(s.end)}`).join(' and ')}`);
1625
+ return `We're open ${parts.join('; ')}.`;
1626
+ };
1627
+
1515
1628
  reg.tool(
1516
1629
  'set_business_hours',
1517
- 'Replace the org business hours. days: 0 Sunday to 6; an empty slots list closes that day.',
1518
- { enabled: z.boolean(), hours: z.array(z.object({ day: z.number().int().min(0).max(6), slots: z.array(z.object({ start: z.string().describe('HH:MM'), end: z.string().describe('HH:MM') })) })).optional(), out_of_hours_message: z.string().optional() },
1630
+ 'Replace the org business hours: every day not listed is closed. days: 0 Sunday to 6. enabled turns enforcement on or off (unchanged when omitted). The out-of-hours message is rebuilt from the hours unless given.',
1631
+ {
1632
+ enabled: z.boolean().optional().describe('Enforce the hours (agents say the out-of-hours message outside them); unchanged when omitted'),
1633
+ hours: jsonArray(z.object({ day: z.number().int().min(0).max(6), slots: z.array(z.object({ start: z.string().describe('HH:MM'), end: z.string().describe('HH:MM') })) })).optional(),
1634
+ out_of_hours_message: z.string().optional(),
1635
+ },
1519
1636
  async (args) => {
1520
1637
  try {
1521
- return success(await orgSettings.setBusinessHours({ businessHoursEnabled: args.enabled, businessHours: args.hours, ...(args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}) }));
1638
+ const current = (await orgSettings.get()) as { businessHoursConfig?: { businessHoursEnabled?: boolean } };
1639
+ const enabled = args.enabled ?? Boolean(current.businessHoursConfig?.businessHoursEnabled);
1640
+ const given = (args.hours ?? []) as Array<{ day: number; slots: Array<{ start: string; end: string }> }>;
1641
+ // Replace means replace: a day the person did not mention is closed.
1642
+ const days = args.hours ? DAY_NAMES.map((_, day) => ({ day, slots: given.filter((d) => d.day === day).flatMap((d) => d.slots) })) : undefined;
1643
+ return success(await orgSettings.setBusinessHours({
1644
+ businessHoursEnabled: enabled,
1645
+ ...(days ? { businessHours: days, businessHoursUtterance: args.out_of_hours_message ?? hoursMessage(days) } : {}),
1646
+ ...(!days && args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}),
1647
+ }));
1522
1648
  } catch (err) { return error(err); }
1523
1649
  },
1524
- );
1650
+ { preview: async (a) => {
1651
+ const hours = (a.hours ?? []) as Array<{ day: number; slots: Array<{ start: string; end: string }> }>;
1652
+ const what = a.hours ? hoursMessage(DAY_NAMES.map((_, day) => ({ day, slots: hours.filter((d) => d.day === day).flatMap((d) => d.slots) }))) : 'the hours unchanged';
1653
+ return `This will replace the org business hours: ${what}${a.enabled === undefined ? '' : a.enabled ? ' Enforced: agents say the out-of-hours message outside them.' : ' Not enforced.'}`;
1654
+ } },
1655
+ );
1656
+
1657
+ reg.tool('add_holiday', 'Close the org on a date.', {
1658
+ date: z.string().describe('YYYY-MM-DD'), name: z.string(),
1659
+ message: z.string().optional().describe('What agents say that day (default: closed on account of the holiday)'),
1660
+ type: z.string().optional().describe('public or custom (default custom)'),
1661
+ }, async (args) => {
1662
+ try {
1663
+ return success(await orgSettings.addHolidays([{
1664
+ name: args.name, date: args.date, type: args.type ?? 'custom',
1665
+ holidayFallbackUtterance: args.message ?? `Our offices are currently closed on account of ${args.name}.`,
1666
+ }]));
1667
+ } catch (err) { return error(err); }
1668
+ });
1525
1669
 
1526
- reg.tool('add_holiday', 'Close the org on a date.', { date: z.string().describe('YYYY-MM-DD'), name: z.string(), message: z.string().optional().describe('What agents say that day') }, async (args) => {
1527
- try { return success(await orgSettings.addHolidays([{ name: args.name, date: args.date, ...(args.message ? { holidayFallbackUtterance: args.message } : {}) }])); } catch (err) { return error(err); }
1670
+ reg.tool('remove_holiday', 'Open the org again on a date that was a holiday.', { date: z.string().describe('YYYY-MM-DD, as get_org_settings lists it') }, async (args) => {
1671
+ try { return success(await orgSettings.removeHolidays([args.date])); } catch (err) { return error(err); }
1528
1672
  });
1529
1673
 
1530
1674
  reg.tool(
@@ -1562,7 +1706,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1562
1706
  'Who changed what in the org, newest first, filtered here (the server has no filters).',
1563
1707
  { since: z.string().optional(), action: z.string().optional(), resource_type: z.string().optional(), user_id: z.string().optional(), limit: z.number().int().min(1).max(500).optional() },
1564
1708
  async (args) => {
1565
- try { return success(await new AuditSDK(client).list({ since: args.since, action: args.action, resourceType: args.resource_type, user: args.user_id, limit: args.limit })); } catch (err) { return error(err); }
1709
+ try {
1710
+ const entries = await new AuditSDK(client).list({ since: args.since, action: args.action, resourceType: args.resource_type, user: args.user_id, limit: args.limit });
1711
+ // copilot's log carries the person's id only: name them from the member list (one read).
1712
+ const members = entries.some((e) => e.id_user) ? await orgs.listMembers().catch(() => []) : [];
1713
+ const byId = new Map(members.map((m) => [m.user_id, m]));
1714
+ return success(entries.map((e) => {
1715
+ const m = byId.get(String(e.id_user ?? ''));
1716
+ return m ? { ...e, user_name: m.name, user_email: m.email } : e;
1717
+ }));
1718
+ } catch (err) { return error(err); }
1566
1719
  },
1567
1720
  );
1568
1721
 
@@ -1604,11 +1757,13 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1604
1757
 
1605
1758
  reg.tool(
1606
1759
  'update_ticket',
1607
- 'Change a ticket: fields, assignee (user id, or unassign: true), or close it with resolve: true (and an optional note).',
1760
+ 'Change a ticket: fields, a note (added to its notes, or the closing note with resolve), assignee (user id, or unassign: true), or close it with resolve: true.',
1608
1761
  {
1609
- ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(), notes: z.string().optional(),
1762
+ ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(),
1763
+ notes: z.string().optional().describe('Replace the whole notes text'),
1610
1764
  status: z.enum(['not_started', 'in_progress']).optional(), priority: ticketPriority.optional(),
1611
- assignee_user_id: z.string().optional(), unassign: z.boolean().optional(), resolve: z.boolean().optional(), note: z.string().optional(),
1765
+ assignee_user_id: z.string().optional(), unassign: z.boolean().optional(), resolve: z.boolean().optional(),
1766
+ note: z.string().optional().describe('A note to add: appended to the ticket\'s notes, or the closing note when resolving'),
1612
1767
  link_ticket_id: z.string().optional().describe('Link to another ticket'),
1613
1768
  },
1614
1769
  async (args) => {
@@ -1616,6 +1771,11 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1616
1771
  const out: Record<string, unknown> = {};
1617
1772
  const changes: Record<string, unknown> = {};
1618
1773
  for (const k of ['title', 'description', 'notes', 'status', 'priority'] as const) if (args[k] !== undefined) changes[k] = args[k];
1774
+ // A note outside resolve used to be dropped silently: it goes onto the ticket's notes.
1775
+ if (args.note && !args.resolve) {
1776
+ const existing = String(((await tickets.get(args.ticket_id)) as { notes?: unknown }).notes ?? '').trim();
1777
+ changes.notes = args.notes !== undefined ? `${args.notes}\n${args.note}`.trim() : existing ? `${existing}\n${args.note}` : args.note;
1778
+ }
1619
1779
  if (Object.keys(changes).length) out.updated = await tickets.update(args.ticket_id, changes);
1620
1780
  if (args.assignee_user_id || args.unassign) out.assigned = await tickets.assign(args.ticket_id, args.unassign ? null : { type: 'user', target: args.assignee_user_id! });
1621
1781
  if (args.link_ticket_id) out.linked = await tickets.link(args.ticket_id, args.link_ticket_id);
@@ -1718,7 +1878,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1718
1878
  reg.tool(
1719
1879
  'platform_save',
1720
1880
  'Create (no id) or change (id) a V2 agent, template, connector binding or scenario. Components and connectors are read-only shared catalogs.',
1721
- { resource: z.enum(['agents', 'templates', 'connector-bindings', 'scenarios']), id: z.string().optional(), body: z.record(z.unknown()) },
1881
+ { resource: z.enum(['agents', 'templates', 'connector-bindings', 'scenarios']), id: z.string().optional(), body: jsonObject().describe('The item to save (an object, or JSON text)') },
1722
1882
  async (args) => {
1723
1883
  try { return success(args.id ? await platform.update(args.resource, args.id, args.body) : await platform.create(args.resource, args.body)); } catch (err) { return error(err); }
1724
1884
  },
@@ -1883,7 +2043,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1883
2043
  'Judge a finished run like the CI gate: pass rate = passed/total, optional p95 limit, failed runs fail by default.',
1884
2044
  {
1885
2045
  run_id: z.string(),
1886
- min_pass_rate: z.number().min(0).max(1).optional(),
2046
+ min_pass_rate: z.number().min(0).max(1).optional().describe('Pass rate the run must reach (default 0.8)'),
1887
2047
  max_p95_ms: z.number().optional(),
1888
2048
  error_policy: z.enum(['block', 'warn']).optional(),
1889
2049
  fail_on_error: z.boolean().optional(),
@@ -1892,7 +2052,9 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1892
2052
  try {
1893
2053
  const run = await evals.getRunInOrg(args.run_id);
1894
2054
  if (run.status !== 'completed' && run.status !== 'failed') return success({ finished: false, status: run.status });
1895
- return success({ finished: true, ...gateVerdict(run, { minPassRate: args.min_pass_rate, maxP95Ms: args.max_p95_ms, errorPolicy: args.error_policy, failOnError: args.fail_on_error }) });
2055
+ // Without a threshold any finished run "passed" (a 1-of-3 run was reported as a pass): judge against 80%.
2056
+ const minPassRate = args.min_pass_rate ?? DEFAULT_MIN_PASS_RATE;
2057
+ return success({ finished: true, min_pass_rate: minPassRate, ...gateVerdict(run, { minPassRate, maxP95Ms: args.max_p95_ms, errorPolicy: args.error_policy, failOnError: args.fail_on_error }) });
1896
2058
  } catch (err) { return error(err); }
1897
2059
  },
1898
2060
  );
@@ -2006,20 +2168,42 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2006
2168
  },
2007
2169
  );
2008
2170
 
2171
+ // get_conversation says "agent"; the evaluations service says "bot". Both are accepted.
2172
+ const transcriptTurn = z.object({ sender: z.enum(['user', 'bot', 'agent', 'system']), text: z.string() });
2173
+ const transcriptForService = (turns: Array<{ sender: string; text: string }>) => turns
2174
+ .filter((t) => t.sender !== 'system')
2175
+ .map((t) => ({ sender: (t.sender === 'user' ? 'user' : 'bot') as 'user' | 'bot', text: t.text }));
2176
+ const transcriptPreconditions = async (a: Record<string, unknown>) => {
2177
+ const agentId = String(a.agent_id);
2178
+ await evals.assertAgentInOrg(agentId);
2179
+ const configs = await evals.listEvaluatorConfigs(agentId) as { items?: unknown[] } | unknown[];
2180
+ const count = Array.isArray(configs) ? configs.length : (configs.items ?? []).length;
2181
+ if (!count) throw new Error('This agent has no evaluator yet, so a transcript cannot be graded. Create a rubric and run an evaluation first (run_evaluation stores the evaluator).');
2182
+ if (JSON.stringify(a.baseline_transcript) === JSON.stringify(a.replay_transcript)) {
2183
+ throw new Error('The baseline and the replay are the same conversation; grading compares a replay with its baseline. Give the replayed conversation as replay_transcript.');
2184
+ }
2185
+ };
2186
+
2009
2187
  reg.tool(
2010
2188
  'evaluate_transcript',
2011
- 'Grade a replayed conversation against a baseline with the agent\'s stored evaluator (paid model calls).',
2189
+ 'Grade a replayed conversation against a baseline with the agent\'s stored evaluator (paid model calls). The agent needs an evaluator (a finished run), and the replay must differ from the baseline.',
2012
2190
  {
2013
2191
  agent_id: z.string(),
2014
- baseline_transcript: z.array(z.object({ sender: z.enum(['user', 'bot']), text: z.string() })),
2015
- replay_transcript: z.array(z.object({ sender: z.enum(['user', 'bot']), text: z.string() })),
2192
+ baseline_transcript: jsonArray(transcriptTurn),
2193
+ replay_transcript: jsonArray(transcriptTurn),
2016
2194
  },
2017
2195
  async (args) => {
2018
2196
  try {
2019
- await evals.assertAgentInOrg(args.agent_id);
2020
- return success(await evals.evaluateTranscript(args));
2197
+ await transcriptPreconditions(args);
2198
+ return success(await evals.evaluateTranscript({
2199
+ agent_id: args.agent_id,
2200
+ baseline_transcript: transcriptForService(args.baseline_transcript),
2201
+ replay_transcript: transcriptForService(args.replay_transcript),
2202
+ }));
2021
2203
  } catch (err) { return error(err); }
2022
2204
  },
2205
+ // Checked before the approval card: a card for something that cannot run wastes the person's answer.
2206
+ { preview: async (a) => { await transcriptPreconditions(a); return `This will grade a replayed transcript for agent ${a.agent_id} with paid model calls.`; } },
2023
2207
  );
2024
2208
 
2025
2209
  reg.tool(
@@ -2236,17 +2420,20 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2236
2420
  // Export tools
2237
2421
  // ---------------------------------------------------------------------------
2238
2422
 
2423
+ const uploadArg = z.boolean().optional().describe('Put the file in the organization\'s file store and return download_url (use it when the person cannot reach this machine\'s files)');
2424
+
2239
2425
  reg.tool(
2240
2426
  'export_eval_markdown',
2241
2427
  'Export evaluation results as a structured markdown file with conversations, scores, and criteria',
2242
2428
  {
2243
2429
  run_id: z.string().describe('The evaluation run ID'),
2244
2430
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2431
+ upload: uploadArg,
2245
2432
  },
2246
2433
  async (args) => {
2247
2434
  try {
2248
2435
  const result = await exportEvalMarkdown(args.run_id, args.output_path, client);
2249
- return success(result);
2436
+ return success(await deliver(result, args.upload));
2250
2437
  } catch (err) { return error(err); }
2251
2438
  },
2252
2439
  );
@@ -2257,11 +2444,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2257
2444
  {
2258
2445
  run_id: z.string().describe('The evaluation run ID'),
2259
2446
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2447
+ upload: uploadArg,
2260
2448
  },
2261
2449
  async (args) => {
2262
2450
  try {
2263
2451
  const result = await exportEvalReport(args.run_id, args.output_path, client);
2264
- return success(result);
2452
+ return success(await deliver(result, args.upload));
2265
2453
  } catch (err) { return error(err); }
2266
2454
  },
2267
2455
  );
@@ -2272,11 +2460,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2272
2460
  {
2273
2461
  agent_id: z.string().describe('The agent ID'),
2274
2462
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2463
+ upload: uploadArg,
2275
2464
  },
2276
2465
  async (args) => {
2277
2466
  try {
2278
2467
  const result = await exportAgentCard(args.agent_id, args.output_path, client);
2279
- return success(result);
2468
+ return success(await deliver(result, args.upload));
2280
2469
  } catch (err) { return error(err); }
2281
2470
  },
2282
2471
  );
@@ -2,9 +2,13 @@
2
2
  import { Font, StyleSheet } from '@react-pdf/renderer';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
+ import { existsSync } from 'fs';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const assetsDir = join(__dirname, 'assets');
8
+ // Fonts and logo: copied next to the built code by `npm run build`; the published package also carries
9
+ // src/report/assets, so an install whose build step skipped the copy still finds them.
10
+ const candidates = [join(__dirname, 'assets'), join(__dirname, '..', '..', 'src', 'report', 'assets')];
11
+ export const assetsDir = candidates.find((dir) => existsSync(join(dir, 'fonts', 'Barlow-Regular.ttf'))) ?? candidates[0];
8
12
 
9
13
  // Register Barlow font family from bundled files
10
14
  Font.register({
package/src/sdk/client.ts CHANGED
@@ -483,7 +483,11 @@ export class IndemnClient {
483
483
  throw new SessionExpiredError('Authentication failed. Run `indemn login` to re-authenticate.');
484
484
  }
485
485
  if (res.status === 403) {
486
- throw new Error('Access denied. You may not have permission for this operation.');
486
+ // Keep the server's reason: "Tickets are not enabled for this organization." is not a permission problem.
487
+ const text = await res.text().catch(() => '');
488
+ let reason = '';
489
+ try { const b = JSON.parse(text) as { message?: unknown; error?: unknown; msg?: unknown }; reason = String(b.message ?? b.error ?? b.msg ?? ''); } catch { reason = ''; }
490
+ throw new Error(reason ? `Not allowed: ${reason}` : 'Access denied. You may not have permission for this operation.');
487
491
  }
488
492
  if (res.status === 404) {
489
493
  // Keep the server's reason: voice-service answers 404 with specific messages (no phone number, ...).