@indemn/cli 1.7.0 → 1.7.2

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 (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CONTRIBUTING.md +2 -2
  4. package/dist/mcp/registry.d.ts.map +1 -1
  5. package/dist/mcp/registry.js +6 -4
  6. package/dist/mcp/registry.js.map +1 -1
  7. package/dist/mcp/tool-meta.d.ts.map +1 -1
  8. package/dist/mcp/tool-meta.js +35 -9
  9. package/dist/mcp/tool-meta.js.map +1 -1
  10. package/dist/mcp/tools.d.ts.map +1 -1
  11. package/dist/mcp/tools.js +232 -49
  12. package/dist/mcp/tools.js.map +1 -1
  13. package/dist/report/styles.d.ts +1 -0
  14. package/dist/report/styles.d.ts.map +1 -1
  15. package/dist/report/styles.js +5 -1
  16. package/dist/report/styles.js.map +1 -1
  17. package/dist/sdk/auth.js +2 -2
  18. package/dist/sdk/auth.js.map +1 -1
  19. package/dist/sdk/client.d.ts.map +1 -1
  20. package/dist/sdk/client.js +11 -1
  21. package/dist/sdk/client.js.map +1 -1
  22. package/dist/sdk/config-change.d.ts +4 -0
  23. package/dist/sdk/config-change.d.ts.map +1 -1
  24. package/dist/sdk/config-change.js +27 -5
  25. package/dist/sdk/config-change.js.map +1 -1
  26. package/dist/sdk/kb.d.ts +12 -0
  27. package/dist/sdk/kb.d.ts.map +1 -1
  28. package/dist/sdk/kb.js +45 -5
  29. package/dist/sdk/kb.js.map +1 -1
  30. package/dist/sdk/voice.js +5 -5
  31. package/dist/sdk/voice.js.map +1 -1
  32. package/package.json +4 -6
  33. package/src/mcp/registry.ts +9 -4
  34. package/src/mcp/tool-meta.ts +35 -9
  35. package/src/mcp/tools.ts +235 -49
  36. package/src/report/styles.ts +5 -1
  37. package/src/sdk/auth.ts +2 -2
  38. package/src/sdk/client.ts +5 -1
  39. package/src/sdk/config-change.ts +27 -5
  40. package/src/sdk/kb.ts +49 -8
  41. package/src/sdk/voice.ts +5 -5
  42. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +1 -0
  43. package/test/mcp/darwin-fixes.test.ts +231 -0
  44. 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,23 @@ 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
+ typeof args.kb_id === 'string' ? add(args.source_id, null, (id) => kb.findDataSource(String(args.kb_id), id).then((d) => label(d?.question ?? d?.name ?? d?.source_url))) : undefined,
201
+ 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(' ')); })),
202
+ 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))),
203
+ 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); })),
204
+ add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label((ts as Array<{ _id?: string; name?: string }>).find((t) => t._id === id)?.name))),
205
+ 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); })),
206
+ 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
207
  add(orgId, null, (id) => client.copilotGet<{ organization?: { name?: string } }>(`/organization/${id}`).then((r) => r.organization)),
180
208
  ]);
181
209
  return { names, missing };
@@ -231,6 +259,18 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
231
259
  const config = new ConfigSDK(client);
232
260
  const functions = new FunctionsSDK(client);
233
261
  // A skill of an agent by its id.
262
+ /** An export result with a signed download_url: the services hand out unsigned file-store links (403 in a browser). */
263
+ const withDownloadLink = async <T extends object>(result: T, stored?: string) => {
264
+ if (!stored || typeof stored !== 'string') return result;
265
+ const signed = /X-Amz-Signature=/.test(stored) ? stored : await kb.signedLink(stored);
266
+ return { ...result, download_url: signed ?? stored, ...(signed ? {} : { note: 'The link could not be signed; it may need signing in to the dashboard.' }) };
267
+ };
268
+ /** A report file written by this process, put in the organization's file store so the person can download it. */
269
+ const deliver = async (result: { file_path: string; file_name: string; file_type: string }, upload?: boolean) => {
270
+ if (!upload) return result;
271
+ const { signedUrl, url } = await kb.storeFile(result.file_path);
272
+ return { ...result, download_url: signedUrl ?? url };
273
+ };
234
274
  const skillOf = async (agentId: string, id: string) => {
235
275
  const skill = (await functions.listFunctions(agentId)).find((f) => f._id === id);
236
276
  if (!skill) throw new Error(`Skill ${id} is not on agent ${agentId}.`);
@@ -259,11 +299,15 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
259
299
  reg.tool(
260
300
  'list_agents',
261
301
  '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') },
302
+ {
303
+ channel: z.string().optional().describe('Only agents on this channel, e.g. web, voice, email'),
304
+ search: z.string().optional().describe('Only agents whose name contains this text'),
305
+ },
263
306
  async (args) => {
264
307
  try {
265
308
  const pages = await orgPages(client);
266
309
  const wanted = args.channel?.trim().toUpperCase();
310
+ const needle = args.search?.trim().toLowerCase();
267
311
  // What a person needs, not the stored record (a page of full records ran to 73k characters).
268
312
  const rows = (await agents.listAgents()).map((a) => ({
269
313
  _id: a._id,
@@ -275,7 +319,8 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
275
319
  updated_at: a.updatedAt,
276
320
  url: `${pages}/ai-studio/bot/${a._id}/configurations`,
277
321
  }));
278
- return success(wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows);
322
+ const byChannel = wanted ? rows.filter((a) => a.channels.some((c) => c.toUpperCase() === wanted)) : rows;
323
+ return success(needle ? byChannel.filter((a) => a.name.toLowerCase().includes(needle)) : byChannel);
279
324
  } catch (err) { return error(err); }
280
325
  },
281
326
  );
@@ -392,7 +437,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
392
437
  preview: async (a) => {
393
438
  const agent = await agents.getAgent(String(a.agent_id));
394
439
  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.`;
440
+ return `This will delete agent "${agent.name}"${mayBeLive(lifecycle) ? `, which ${liveText(lifecycle)}` : ''}. It cannot be restored.`;
396
441
  },
397
442
  },
398
443
  );
@@ -450,7 +495,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
450
495
  },
451
496
  async (args) => {
452
497
  try {
453
- return success(await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels }));
498
+ const copy = await agents.cloneAgent(args.agent_id, { name: args.name, channels: args.channels });
499
+ // copilot's clone answers { new_agent_id, new_agent_name, ... } and leaves the description empty:
500
+ // carry the original's over.
501
+ const copyId = copy._id ?? (copy as unknown as { new_agent_id?: string }).new_agent_id;
502
+ const source = await agents.getAgent(args.agent_id);
503
+ if (source.description && !copy.description && copyId) {
504
+ const described = await agents.updateAgent(copyId, { description: source.description }).then(() => true).catch(() => false);
505
+ return success({ ...copy, ...(described ? { description: source.description } : {}) });
506
+ }
507
+ return success(copy);
454
508
  } catch (err) { return error(err); }
455
509
  },
456
510
  );
@@ -468,7 +522,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
468
522
  'import_agent',
469
523
  '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
524
  {
471
- bundle: z.record(z.unknown()).describe('The bundle returned by export_agent'),
525
+ bundle: jsonObject().describe('The bundle returned by export_agent (an object, or JSON text)'),
472
526
  name: z.string().optional().describe('Name for the new agent (default: the bundle name)'),
473
527
  link_knowledge_bases: z.boolean().optional().describe('Link the same knowledge base ids if they exist in this org'),
474
528
  allow_duplicate: z.boolean().optional(),
@@ -515,16 +569,18 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
515
569
  system_prompt: a.system_prompt as string | undefined,
516
570
  greeting: a.first_message as string | undefined,
517
571
  model: a.model ? modelArg(String(a.model)) : undefined,
572
+ parameters: a.temperature !== undefined ? { temperature: a.temperature } : undefined,
518
573
  });
519
574
 
520
575
  reg.tool(
521
576
  '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).',
577
+ '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
578
  {
524
579
  agent_id: z.string().describe('The agent ID'),
525
580
  system_prompt: z.string().optional().describe('New system prompt (whole text)'),
526
581
  first_message: z.string().optional().describe('New greeting shown to users'),
527
582
  model: z.string().optional().describe('provider/model, e.g. openai/gpt-4.1 (see list_llm_models)'),
583
+ temperature: z.number().min(0).max(2).optional().describe('Model temperature (0 = most predictable); only for models that expose it'),
528
584
  confirm_published: z.boolean().optional().describe('Required with confirm when the agent is published and the user explicitly agreed'),
529
585
  },
530
586
  async (args, meta) => {
@@ -592,10 +648,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
592
648
  ...skillAuthFields,
593
649
  configuration: jsonObject().optional().describe('Other configurationSchema fields (an object, or JSON text); the fields above win'),
594
650
  transfer_to: z.string().optional().describe('transfer_call: phone number (E.164, e.g. +15551234567) or SIP address (sip:...) calls are transferred to'),
651
+ parameters: jsonArray(z.object({
652
+ name: z.string(), type: z.string().describe('string, number or boolean'),
653
+ description: z.string().optional(), required: z.boolean().optional(),
654
+ allowed_values: z.array(z.string()).optional().describe('Fixed choices the customer picks from'),
655
+ })).optional().describe('What the skill collects from the customer before it runs ({{name}} placeholders in url/payload)'),
595
656
  },
596
657
  async (args) => {
597
658
  try {
598
659
  const { agent_id, type, name, description } = args;
660
+ const parameters = (args.parameters ?? []) as Array<{ name: string; type: string; description?: string; required?: boolean; allowed_values?: string[] }>;
599
661
  const configurationSchema = skillConfiguration(type, withAuthorization(args));
600
662
  // copilot sends a REST answer straight to the customer unless craftResponse is on; with nothing to say
601
663
  // how to present it, let the agent phrase it.
@@ -613,9 +675,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
613
675
  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
676
  // copilot turns configurationSchema into the executable form only on update: create, then configure.
615
677
  const created = await functions.createFunction(agent_id, { type, name, description });
616
- if (!configurationSchema) return success(created);
678
+ // Parameters are their own records: a skill created with parameters used to lose them silently.
679
+ const addParameters = async () => {
680
+ const saved = [];
681
+ for (const { allowed_values, ...p } of parameters) saved.push(await functions.addParameter(agent_id, created._id, { ...p, allowedValues: allowed_values }));
682
+ return saved;
683
+ };
684
+ if (!configurationSchema) return success(parameters.length ? { ...created, parameters: await addParameters() } : created);
617
685
  try {
618
- return success(await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema }));
686
+ const configured = await functions.updateFunction(agent_id, created._id, { type, description: description ?? '', configurationSchema });
687
+ return success(parameters.length ? { ...configured, parameters: await addParameters() } : configured);
619
688
  } catch (err) {
620
689
  // Leave no half-made skill behind: remove the empty one, then say why nothing was saved.
621
690
  const why = err instanceof Error ? err.message : String(err);
@@ -707,12 +776,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
707
776
 
708
777
  reg.tool(
709
778
  'export_functions',
710
- 'Export every skill of an agent as a JSON file (to copy them or keep a backup before import_functions).',
779
+ '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
780
  { agent_id: z.string().describe('The agent ID') },
712
781
  async (args) => {
713
782
  try {
714
- const result = await functions.exportFunctions(args.agent_id);
715
- return success(result);
783
+ const result = await functions.exportFunctions(args.agent_id) as { fileUrl?: string; signedUrl?: string; url?: string };
784
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl ?? result.url));
716
785
  } catch (err) { return error(err); }
717
786
  },
718
787
  );
@@ -807,6 +876,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
807
876
  return success(result);
808
877
  } catch (err) { return error(err); }
809
878
  },
879
+ liveChange('a skill parameter', ['function_id']),
810
880
  );
811
881
 
812
882
  reg.tool(
@@ -829,6 +899,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
829
899
  return success(result);
830
900
  } catch (err) { return error(err); }
831
901
  },
902
+ liveChange('a skill parameter', ['function_id', 'parameter_id']),
832
903
  );
833
904
 
834
905
  reg.tool(
@@ -950,11 +1021,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
950
1021
 
951
1022
  reg.tool(
952
1023
  'list_kb_data',
953
- 'The question/answer pairs typed into a knowledge base (website and file sources are listed by get_knowledge_base).',
1024
+ '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
1025
  {
955
1026
  kb_id: z.string().describe('The knowledge base ID'),
956
1027
  page: z.number().optional().describe('Page number'),
957
1028
  limit: z.number().optional().describe('Items per page'),
1029
+ full: z.boolean().optional().describe('Include the chunks and statistics of every entry'),
958
1030
  },
959
1031
  async (args) => {
960
1032
  try {
@@ -962,7 +1034,15 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
962
1034
  if (args.page !== undefined) query.page = String(args.page);
963
1035
  if (args.limit !== undefined) query.limit = String(args.limit);
964
1036
  const result = await kb.listDataSources(args.kb_id, Object.keys(query).length ? query : undefined);
965
- return success(result);
1037
+ if (args.full) return success(result);
1038
+ // The stored record repeats every chunk twice (chunks, chunks_text): a page of it is tens of kilobytes.
1039
+ const entries = (result.dataSources ?? []).map((d) => ({
1040
+ _id: d._id, type: d.type, question: d.question ?? d.name, answer: d.answer,
1041
+ ...(d.source_url ? { source_url: d.source_url } : {}),
1042
+ trained: (d as { trained?: boolean }).trained, ...(d.status ? { status: d.status } : {}),
1043
+ ...((d as { error_message?: string }).error_message ? { error: (d as { error_message?: string }).error_message } : {}),
1044
+ }));
1045
+ return success({ ...result, dataSources: entries });
966
1046
  } catch (err) { return error(err); }
967
1047
  },
968
1048
  );
@@ -1144,30 +1224,37 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1144
1224
 
1145
1225
  reg.tool(
1146
1226
  'export_kb',
1147
- 'Export the question/answer pairs of a knowledge base as a CSV or XLSX download link.',
1227
+ '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
1228
  {
1149
1229
  kb_id: z.string().describe('The knowledge base ID'),
1150
- format: z.string().optional().describe('Export format (e.g. "json", "csv")'),
1230
+ format: z.string().optional().describe('Export format: csv (default) or xlsx'),
1151
1231
  },
1152
1232
  async (args) => {
1153
1233
  try {
1154
- const result = await kb.exportKB(args.kb_id, args.format || 'json');
1155
- return success(result);
1234
+ const result = await kb.exportKB(args.kb_id, args.format || 'csv') as { fileUrl?: string; signedUrl?: string };
1235
+ return success(await withDownloadLink(result, result.signedUrl ?? result.fileUrl));
1156
1236
  } catch (err) { return error(err); }
1157
1237
  },
1158
1238
  );
1159
1239
 
1160
1240
  reg.tool(
1161
1241
  'import_kb',
1162
- 'Import websites or files into a knowledge base; processed in the background (see kb_scrape_status).',
1242
+ '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
1243
  {
1164
1244
  kb_id: z.string().describe('The knowledge base ID'),
1165
- data: z.record(z.unknown()).describe('Data to import'),
1245
+ data: kbImportSchema.describe('{ type: "file", sources: [{ name, file_type, url }] } (an object, or JSON text)'),
1166
1246
  },
1167
1247
  async (args) => {
1168
1248
  try {
1169
- const result = await kb.importKB(args.kb_id, args.data);
1170
- return success(result);
1249
+ const data = args.data as { type: string; sources: Array<{ name?: string; url: string; file_type?: string }> };
1250
+ // The import service tells the file's kind from file_type: fill it from the name or the link.
1251
+ const sources = data.sources.map((s) => ({
1252
+ ...s,
1253
+ name: s.name ?? s.url.split('?')[0].split('/').pop() ?? 'file',
1254
+ file_type: (s.file_type ?? (s.name ?? s.url.split('?')[0]).split('.').pop() ?? '').toLowerCase(),
1255
+ }));
1256
+ const result = await kb.importKB(args.kb_id, { ...data, sources });
1257
+ return success({ ...(result as object), imported: sources.map((s) => s.name) });
1171
1258
  } catch (err) { return error(err); }
1172
1259
  },
1173
1260
  );
@@ -1220,7 +1307,8 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1220
1307
  return success({
1221
1308
  conversation_id: conversation.request_id, test: conversation.isTestMode === true,
1222
1309
  url: conversationUrl(client, projectId, conversation.request_id),
1223
- messages: messages.map((m) => ({ role: m.role, text: m.text, at: m.createdAt })),
1310
+ // System lines carry the dashboard's template ("{{userLabel}} has ended the call"): say it in words.
1311
+ messages: messages.map((m) => ({ role: m.role, text: (m.text ?? '').replace(/\{\{\s*userLabel\s*\}\}/g, 'The customer'), at: m.createdAt })),
1224
1312
  });
1225
1313
  } catch (err) { return error(err); }
1226
1314
  },
@@ -1322,10 +1410,18 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1322
1410
 
1323
1411
  reg.tool(
1324
1412
  'list_agent_numbers',
1325
- 'Phone numbers attached to an agent, or (without agent_id) every number the org owns.',
1413
+ '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
1414
  { agent_id: z.string().optional() },
1327
1415
  async (args) => {
1328
- try { return success(args.agent_id ? await voice.agentNumbers(args.agent_id) : await voice.orgNumbers()); } catch (err) { return error(err); }
1416
+ try {
1417
+ if (args.agent_id) return success(await voice.agentNumbers(args.agent_id));
1418
+ // copilot's org list holds only unattached ("reserve") numbers; the attached ones sit on the agents.
1419
+ const [free, all] = await Promise.all([voice.orgNumbers(), agents.listAgents()]);
1420
+ const attached = all
1421
+ .filter((a) => (a as { connected_phone?: string }).connected_phone)
1422
+ .map((a) => ({ phone_number: (a as { connected_phone?: string }).connected_phone, attached: true, agent_id: a._id, agent_name: a.name }));
1423
+ return success([...attached, ...free.map((n) => ({ phone_number: n.phoneNumber, attached: false, name: n.friendlyName, type: n.type }))]);
1424
+ } catch (err) { return error(err); }
1329
1425
  },
1330
1426
  );
1331
1427
 
@@ -1374,7 +1470,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1374
1470
  async (args, meta) => {
1375
1471
  try {
1376
1472
  // 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.');
1473
+ 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
1474
  return success(await voice.buyNumber(args.agent_id, args.phone_number, { country: args.country, tollFree: args.toll_free }));
1379
1475
  } catch (err) { return error(err); }
1380
1476
  },
@@ -1512,19 +1608,64 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1512
1608
  try { return success(await orgSettings.setTimezone(args.timezone)); } catch (err) { return error(err); }
1513
1609
  });
1514
1610
 
1611
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
1612
+ const clock = (hhmm: string) => {
1613
+ const [h, m] = hhmm.split(':').map(Number);
1614
+ const hour = ((h + 11) % 12) + 1;
1615
+ return `${hour}${m ? `:${String(m).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`;
1616
+ };
1617
+ /** What agents say about the hours, built from the hours themselves so it never contradicts them. */
1618
+ const hoursMessage = (days: Array<{ day: number; slots: Array<{ start: string; end: string }> }>) => {
1619
+ const open = days.filter((d) => d.slots.length).sort((a, b) => a.day - b.day);
1620
+ if (!open.length) return 'We are currently closed.';
1621
+ const parts = open.map((d) => `${DAY_NAMES[d.day]} ${d.slots.map((s) => `${clock(s.start)} to ${clock(s.end)}`).join(' and ')}`);
1622
+ return `We're open ${parts.join('; ')}.`;
1623
+ };
1624
+
1515
1625
  reg.tool(
1516
1626
  '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() },
1627
+ '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.',
1628
+ {
1629
+ enabled: z.boolean().optional().describe('Enforce the hours (agents say the out-of-hours message outside them); unchanged when omitted'),
1630
+ 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(),
1631
+ out_of_hours_message: z.string().optional(),
1632
+ },
1519
1633
  async (args) => {
1520
1634
  try {
1521
- return success(await orgSettings.setBusinessHours({ businessHoursEnabled: args.enabled, businessHours: args.hours, ...(args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}) }));
1635
+ const current = (await orgSettings.get()) as { businessHoursConfig?: { businessHoursEnabled?: boolean } };
1636
+ const enabled = args.enabled ?? Boolean(current.businessHoursConfig?.businessHoursEnabled);
1637
+ const given = (args.hours ?? []) as Array<{ day: number; slots: Array<{ start: string; end: string }> }>;
1638
+ // Replace means replace: a day the person did not mention is closed.
1639
+ const days = args.hours ? DAY_NAMES.map((_, day) => ({ day, slots: given.filter((d) => d.day === day).flatMap((d) => d.slots) })) : undefined;
1640
+ return success(await orgSettings.setBusinessHours({
1641
+ businessHoursEnabled: enabled,
1642
+ ...(days ? { businessHours: days, businessHoursUtterance: args.out_of_hours_message ?? hoursMessage(days) } : {}),
1643
+ ...(!days && args.out_of_hours_message ? { businessHoursUtterance: args.out_of_hours_message } : {}),
1644
+ }));
1522
1645
  } catch (err) { return error(err); }
1523
1646
  },
1524
- );
1647
+ { preview: async (a) => {
1648
+ const hours = (a.hours ?? []) as Array<{ day: number; slots: Array<{ start: string; end: string }> }>;
1649
+ const what = a.hours ? hoursMessage(DAY_NAMES.map((_, day) => ({ day, slots: hours.filter((d) => d.day === day).flatMap((d) => d.slots) }))) : 'the hours unchanged';
1650
+ 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.'}`;
1651
+ } },
1652
+ );
1653
+
1654
+ reg.tool('add_holiday', 'Close the org on a date.', {
1655
+ date: z.string().describe('YYYY-MM-DD'), name: z.string(),
1656
+ message: z.string().optional().describe('What agents say that day (default: closed on account of the holiday)'),
1657
+ type: z.string().optional().describe('public or custom (default custom)'),
1658
+ }, async (args) => {
1659
+ try {
1660
+ return success(await orgSettings.addHolidays([{
1661
+ name: args.name, date: args.date, type: args.type ?? 'custom',
1662
+ holidayFallbackUtterance: args.message ?? `Our offices are currently closed on account of ${args.name}.`,
1663
+ }]));
1664
+ } catch (err) { return error(err); }
1665
+ });
1525
1666
 
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); }
1667
+ 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) => {
1668
+ try { return success(await orgSettings.removeHolidays([args.date])); } catch (err) { return error(err); }
1528
1669
  });
1529
1670
 
1530
1671
  reg.tool(
@@ -1562,7 +1703,16 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1562
1703
  'Who changed what in the org, newest first, filtered here (the server has no filters).',
1563
1704
  { 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
1705
  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); }
1706
+ try {
1707
+ const entries = await new AuditSDK(client).list({ since: args.since, action: args.action, resourceType: args.resource_type, user: args.user_id, limit: args.limit });
1708
+ // copilot's log carries the person's id only: name them from the member list (one read).
1709
+ const members = entries.some((e) => e.id_user) ? await orgs.listMembers().catch(() => []) : [];
1710
+ const byId = new Map(members.map((m) => [m.user_id, m]));
1711
+ return success(entries.map((e) => {
1712
+ const m = byId.get(String(e.id_user ?? ''));
1713
+ return m ? { ...e, user_name: m.name, user_email: m.email } : e;
1714
+ }));
1715
+ } catch (err) { return error(err); }
1566
1716
  },
1567
1717
  );
1568
1718
 
@@ -1604,11 +1754,13 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1604
1754
 
1605
1755
  reg.tool(
1606
1756
  'update_ticket',
1607
- 'Change a ticket: fields, assignee (user id, or unassign: true), or close it with resolve: true (and an optional note).',
1757
+ '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
1758
  {
1609
- ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(), notes: z.string().optional(),
1759
+ ticket_id: z.string(), title: z.string().optional(), description: z.string().optional(),
1760
+ notes: z.string().optional().describe('Replace the whole notes text'),
1610
1761
  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(),
1762
+ assignee_user_id: z.string().optional(), unassign: z.boolean().optional(), resolve: z.boolean().optional(),
1763
+ note: z.string().optional().describe('A note to add: appended to the ticket\'s notes, or the closing note when resolving'),
1612
1764
  link_ticket_id: z.string().optional().describe('Link to another ticket'),
1613
1765
  },
1614
1766
  async (args) => {
@@ -1616,6 +1768,11 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1616
1768
  const out: Record<string, unknown> = {};
1617
1769
  const changes: Record<string, unknown> = {};
1618
1770
  for (const k of ['title', 'description', 'notes', 'status', 'priority'] as const) if (args[k] !== undefined) changes[k] = args[k];
1771
+ // A note outside resolve used to be dropped silently: it goes onto the ticket's notes.
1772
+ if (args.note && !args.resolve) {
1773
+ const existing = String(((await tickets.get(args.ticket_id)) as { notes?: unknown }).notes ?? '').trim();
1774
+ changes.notes = args.notes !== undefined ? `${args.notes}\n${args.note}`.trim() : existing ? `${existing}\n${args.note}` : args.note;
1775
+ }
1619
1776
  if (Object.keys(changes).length) out.updated = await tickets.update(args.ticket_id, changes);
1620
1777
  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
1778
  if (args.link_ticket_id) out.linked = await tickets.link(args.ticket_id, args.link_ticket_id);
@@ -1718,7 +1875,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1718
1875
  reg.tool(
1719
1876
  'platform_save',
1720
1877
  '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()) },
1878
+ { 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
1879
  async (args) => {
1723
1880
  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
1881
  },
@@ -1883,7 +2040,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1883
2040
  'Judge a finished run like the CI gate: pass rate = passed/total, optional p95 limit, failed runs fail by default.',
1884
2041
  {
1885
2042
  run_id: z.string(),
1886
- min_pass_rate: z.number().min(0).max(1).optional(),
2043
+ min_pass_rate: z.number().min(0).max(1).optional().describe('Pass rate the run must reach (default 0.8)'),
1887
2044
  max_p95_ms: z.number().optional(),
1888
2045
  error_policy: z.enum(['block', 'warn']).optional(),
1889
2046
  fail_on_error: z.boolean().optional(),
@@ -1892,7 +2049,9 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1892
2049
  try {
1893
2050
  const run = await evals.getRunInOrg(args.run_id);
1894
2051
  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 }) });
2052
+ // Without a threshold any finished run "passed" (a 1-of-3 run was reported as a pass): judge against 80%.
2053
+ const minPassRate = args.min_pass_rate ?? DEFAULT_MIN_PASS_RATE;
2054
+ 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
2055
  } catch (err) { return error(err); }
1897
2056
  },
1898
2057
  );
@@ -2006,20 +2165,42 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2006
2165
  },
2007
2166
  );
2008
2167
 
2168
+ // get_conversation says "agent"; the evaluations service says "bot". Both are accepted.
2169
+ const transcriptTurn = z.object({ sender: z.enum(['user', 'bot', 'agent', 'system']), text: z.string() });
2170
+ const transcriptForService = (turns: Array<{ sender: string; text: string }>) => turns
2171
+ .filter((t) => t.sender !== 'system')
2172
+ .map((t) => ({ sender: (t.sender === 'user' ? 'user' : 'bot') as 'user' | 'bot', text: t.text }));
2173
+ const transcriptPreconditions = async (a: Record<string, unknown>) => {
2174
+ const agentId = String(a.agent_id);
2175
+ await evals.assertAgentInOrg(agentId);
2176
+ const configs = await evals.listEvaluatorConfigs(agentId) as { items?: unknown[] } | unknown[];
2177
+ const count = Array.isArray(configs) ? configs.length : (configs.items ?? []).length;
2178
+ 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).');
2179
+ if (JSON.stringify(a.baseline_transcript) === JSON.stringify(a.replay_transcript)) {
2180
+ 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.');
2181
+ }
2182
+ };
2183
+
2009
2184
  reg.tool(
2010
2185
  'evaluate_transcript',
2011
- 'Grade a replayed conversation against a baseline with the agent\'s stored evaluator (paid model calls).',
2186
+ '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
2187
  {
2013
2188
  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() })),
2189
+ baseline_transcript: jsonArray(transcriptTurn),
2190
+ replay_transcript: jsonArray(transcriptTurn),
2016
2191
  },
2017
2192
  async (args) => {
2018
2193
  try {
2019
- await evals.assertAgentInOrg(args.agent_id);
2020
- return success(await evals.evaluateTranscript(args));
2194
+ await transcriptPreconditions(args);
2195
+ return success(await evals.evaluateTranscript({
2196
+ agent_id: args.agent_id,
2197
+ baseline_transcript: transcriptForService(args.baseline_transcript),
2198
+ replay_transcript: transcriptForService(args.replay_transcript),
2199
+ }));
2021
2200
  } catch (err) { return error(err); }
2022
2201
  },
2202
+ // Checked before the approval card: a card for something that cannot run wastes the person's answer.
2203
+ { preview: async (a) => { await transcriptPreconditions(a); return `This will grade a replayed transcript for agent ${a.agent_id} with paid model calls.`; } },
2023
2204
  );
2024
2205
 
2025
2206
  reg.tool(
@@ -2236,17 +2417,20 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2236
2417
  // Export tools
2237
2418
  // ---------------------------------------------------------------------------
2238
2419
 
2420
+ 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)');
2421
+
2239
2422
  reg.tool(
2240
2423
  'export_eval_markdown',
2241
2424
  'Export evaluation results as a structured markdown file with conversations, scores, and criteria',
2242
2425
  {
2243
2426
  run_id: z.string().describe('The evaluation run ID'),
2244
2427
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2428
+ upload: uploadArg,
2245
2429
  },
2246
2430
  async (args) => {
2247
2431
  try {
2248
2432
  const result = await exportEvalMarkdown(args.run_id, args.output_path, client);
2249
- return success(result);
2433
+ return success(await deliver(result, args.upload));
2250
2434
  } catch (err) { return error(err); }
2251
2435
  },
2252
2436
  );
@@ -2257,11 +2441,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2257
2441
  {
2258
2442
  run_id: z.string().describe('The evaluation run ID'),
2259
2443
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2444
+ upload: uploadArg,
2260
2445
  },
2261
2446
  async (args) => {
2262
2447
  try {
2263
2448
  const result = await exportEvalReport(args.run_id, args.output_path, client);
2264
- return success(result);
2449
+ return success(await deliver(result, args.upload));
2265
2450
  } catch (err) { return error(err); }
2266
2451
  },
2267
2452
  );
@@ -2272,11 +2457,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
2272
2457
  {
2273
2458
  agent_id: z.string().describe('The agent ID'),
2274
2459
  output_path: z.string().optional().describe('Output file path (defaults to current directory)'),
2460
+ upload: uploadArg,
2275
2461
  },
2276
2462
  async (args) => {
2277
2463
  try {
2278
2464
  const result = await exportAgentCard(args.agent_id, args.output_path, client);
2279
- return success(result);
2465
+ return success(await deliver(result, args.upload));
2280
2466
  } catch (err) { return error(err); }
2281
2467
  },
2282
2468
  );
@@ -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/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { initializeApp, getApp } from 'firebase/app';
2
- import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
1
+ import { initializeApp, getApp } from '@firebase/app';
2
+ import { getAuth, signInWithEmailAndPassword } from '@firebase/auth';
3
3
  import { DEFAULT_HOSTS, IndemnClient } from './client.js';
4
4
  import { exchangeIdToken, toStoredSession } from './session.js';
5
5
  import type { Credential } from './credentials.js';
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, ...).