@indemn/cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +9 -4
  3. package/.github/workflows/ci.yml +10 -0
  4. package/.github/workflows/publish.yml +2 -2
  5. package/.mcp.json +1 -4
  6. package/CONTRIBUTING.md +3 -1
  7. package/README.md +69 -176
  8. package/dist/cli/agents.d.ts.map +1 -1
  9. package/dist/cli/agents.js +226 -18
  10. package/dist/cli/agents.js.map +1 -1
  11. package/dist/cli/analytics.d.ts +3 -0
  12. package/dist/cli/analytics.d.ts.map +1 -0
  13. package/dist/cli/analytics.js +133 -0
  14. package/dist/cli/analytics.js.map +1 -0
  15. package/dist/cli/auth.d.ts.map +1 -1
  16. package/dist/cli/auth.js +62 -17
  17. package/dist/cli/auth.js.map +1 -1
  18. package/dist/cli/billing.d.ts +3 -0
  19. package/dist/cli/billing.d.ts.map +1 -0
  20. package/dist/cli/billing.js +23 -0
  21. package/dist/cli/billing.js.map +1 -0
  22. package/dist/cli/campaigns.d.ts +3 -0
  23. package/dist/cli/campaigns.d.ts.map +1 -0
  24. package/dist/cli/campaigns.js +34 -0
  25. package/dist/cli/campaigns.js.map +1 -0
  26. package/dist/cli/chat.d.ts.map +1 -1
  27. package/dist/cli/chat.js +69 -102
  28. package/dist/cli/chat.js.map +1 -1
  29. package/dist/cli/config.d.ts.map +1 -1
  30. package/dist/cli/config.js +71 -27
  31. package/dist/cli/config.js.map +1 -1
  32. package/dist/cli/conversations.d.ts +3 -0
  33. package/dist/cli/conversations.d.ts.map +1 -0
  34. package/dist/cli/conversations.js +129 -0
  35. package/dist/cli/conversations.js.map +1 -0
  36. package/dist/cli/distribution.d.ts +3 -0
  37. package/dist/cli/distribution.d.ts.map +1 -0
  38. package/dist/cli/distribution.js +122 -0
  39. package/dist/cli/distribution.js.map +1 -0
  40. package/dist/cli/eval.d.ts.map +1 -1
  41. package/dist/cli/eval.js +315 -71
  42. package/dist/cli/eval.js.map +1 -1
  43. package/dist/cli/functions.d.ts.map +1 -1
  44. package/dist/cli/functions.js +109 -38
  45. package/dist/cli/functions.js.map +1 -1
  46. package/dist/cli/index.js +63 -54
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/cli/init.d.ts +27 -0
  49. package/dist/cli/init.d.ts.map +1 -0
  50. package/dist/cli/init.js +152 -0
  51. package/dist/cli/init.js.map +1 -0
  52. package/dist/cli/kb.d.ts.map +1 -1
  53. package/dist/cli/kb.js +141 -15
  54. package/dist/cli/kb.js.map +1 -1
  55. package/dist/cli/link.d.ts +21 -0
  56. package/dist/cli/link.d.ts.map +1 -0
  57. package/dist/cli/link.js +112 -0
  58. package/dist/cli/link.js.map +1 -0
  59. package/dist/cli/org.d.ts.map +1 -1
  60. package/dist/cli/org.js +79 -67
  61. package/dist/cli/org.js.map +1 -1
  62. package/dist/cli/platform.d.ts +3 -0
  63. package/dist/cli/platform.d.ts.map +1 -0
  64. package/dist/cli/platform.js +133 -0
  65. package/dist/cli/platform.js.map +1 -0
  66. package/dist/cli/tickets.d.ts +3 -0
  67. package/dist/cli/tickets.d.ts.map +1 -0
  68. package/dist/cli/tickets.js +125 -0
  69. package/dist/cli/tickets.js.map +1 -0
  70. package/dist/cli/utils/output.d.ts +15 -9
  71. package/dist/cli/utils/output.d.ts.map +1 -1
  72. package/dist/cli/utils/output.js +53 -46
  73. package/dist/cli/utils/output.js.map +1 -1
  74. package/dist/cli/voice.d.ts +3 -0
  75. package/dist/cli/voice.d.ts.map +1 -0
  76. package/dist/cli/voice.js +206 -0
  77. package/dist/cli/voice.js.map +1 -0
  78. package/dist/core/diff.d.ts +16 -0
  79. package/dist/core/diff.d.ts.map +1 -0
  80. package/dist/core/diff.js +57 -0
  81. package/dist/core/diff.js.map +1 -0
  82. package/dist/core/org-resolver.d.ts +37 -0
  83. package/dist/core/org-resolver.d.ts.map +1 -0
  84. package/dist/core/org-resolver.js +73 -0
  85. package/dist/core/org-resolver.js.map +1 -0
  86. package/dist/core/runtime.d.ts +36 -0
  87. package/dist/core/runtime.d.ts.map +1 -0
  88. package/dist/core/runtime.js +62 -0
  89. package/dist/core/runtime.js.map +1 -0
  90. package/dist/core/scope.d.ts +33 -0
  91. package/dist/core/scope.d.ts.map +1 -0
  92. package/dist/core/scope.js +37 -0
  93. package/dist/core/scope.js.map +1 -0
  94. package/dist/mcp/registry.d.ts +67 -0
  95. package/dist/mcp/registry.d.ts.map +1 -0
  96. package/dist/mcp/registry.js +195 -0
  97. package/dist/mcp/registry.js.map +1 -0
  98. package/dist/mcp/server.d.ts +9 -0
  99. package/dist/mcp/server.d.ts.map +1 -1
  100. package/dist/mcp/server.js +87 -2
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/tool-meta.d.ts +21 -0
  103. package/dist/mcp/tool-meta.d.ts.map +1 -0
  104. package/dist/mcp/tool-meta.js +203 -0
  105. package/dist/mcp/tool-meta.js.map +1 -0
  106. package/dist/mcp/tools.d.ts +2 -1
  107. package/dist/mcp/tools.d.ts.map +1 -1
  108. package/dist/mcp/tools.js +1462 -215
  109. package/dist/mcp/tools.js.map +1 -1
  110. package/dist/sdk/agent-bundle.d.ts +99 -0
  111. package/dist/sdk/agent-bundle.d.ts.map +1 -0
  112. package/dist/sdk/agent-bundle.js +211 -0
  113. package/dist/sdk/agent-bundle.js.map +1 -0
  114. package/dist/sdk/agents.d.ts +54 -1
  115. package/dist/sdk/agents.d.ts.map +1 -1
  116. package/dist/sdk/agents.js +59 -5
  117. package/dist/sdk/agents.js.map +1 -1
  118. package/dist/sdk/analytics.d.ts +158 -0
  119. package/dist/sdk/analytics.d.ts.map +1 -0
  120. package/dist/sdk/analytics.js +185 -0
  121. package/dist/sdk/analytics.js.map +1 -0
  122. package/dist/sdk/auth.d.ts +34 -12
  123. package/dist/sdk/auth.d.ts.map +1 -1
  124. package/dist/sdk/auth.js +60 -77
  125. package/dist/sdk/auth.js.map +1 -1
  126. package/dist/sdk/billing.d.ts +25 -0
  127. package/dist/sdk/billing.d.ts.map +1 -0
  128. package/dist/sdk/billing.js +61 -0
  129. package/dist/sdk/billing.js.map +1 -0
  130. package/dist/sdk/campaigns.d.ts +35 -0
  131. package/dist/sdk/campaigns.d.ts.map +1 -0
  132. package/dist/sdk/campaigns.js +59 -0
  133. package/dist/sdk/campaigns.js.map +1 -0
  134. package/dist/sdk/chat.d.ts +25 -2
  135. package/dist/sdk/chat.d.ts.map +1 -1
  136. package/dist/sdk/chat.js +37 -10
  137. package/dist/sdk/chat.js.map +1 -1
  138. package/dist/sdk/client.d.ts +93 -5
  139. package/dist/sdk/client.d.ts.map +1 -1
  140. package/dist/sdk/client.js +362 -100
  141. package/dist/sdk/client.js.map +1 -1
  142. package/dist/sdk/config-change.d.ts +74 -0
  143. package/dist/sdk/config-change.d.ts.map +1 -0
  144. package/dist/sdk/config-change.js +117 -0
  145. package/dist/sdk/config-change.js.map +1 -0
  146. package/dist/sdk/config.d.ts.map +1 -1
  147. package/dist/sdk/config.js +2 -1
  148. package/dist/sdk/config.js.map +1 -1
  149. package/dist/sdk/conversations.d.ts +101 -0
  150. package/dist/sdk/conversations.d.ts.map +1 -0
  151. package/dist/sdk/conversations.js +155 -0
  152. package/dist/sdk/conversations.js.map +1 -0
  153. package/dist/sdk/credentials.d.ts +25 -0
  154. package/dist/sdk/credentials.d.ts.map +1 -0
  155. package/dist/sdk/credentials.js +44 -0
  156. package/dist/sdk/credentials.js.map +1 -0
  157. package/dist/sdk/distribution.d.ts +94 -0
  158. package/dist/sdk/distribution.d.ts.map +1 -0
  159. package/dist/sdk/distribution.js +100 -0
  160. package/dist/sdk/distribution.js.map +1 -0
  161. package/dist/sdk/eval.d.ts +81 -0
  162. package/dist/sdk/eval.d.ts.map +1 -1
  163. package/dist/sdk/eval.js +117 -0
  164. package/dist/sdk/eval.js.map +1 -1
  165. package/dist/sdk/functions.d.ts +14 -1
  166. package/dist/sdk/functions.d.ts.map +1 -1
  167. package/dist/sdk/functions.js +48 -5
  168. package/dist/sdk/functions.js.map +1 -1
  169. package/dist/sdk/index.d.ts +2 -0
  170. package/dist/sdk/index.d.ts.map +1 -1
  171. package/dist/sdk/index.js +2 -0
  172. package/dist/sdk/index.js.map +1 -1
  173. package/dist/sdk/jarvis-chat.d.ts +28 -0
  174. package/dist/sdk/jarvis-chat.d.ts.map +1 -0
  175. package/dist/sdk/jarvis-chat.js +117 -0
  176. package/dist/sdk/jarvis-chat.js.map +1 -0
  177. package/dist/sdk/kb.d.ts +69 -1
  178. package/dist/sdk/kb.d.ts.map +1 -1
  179. package/dist/sdk/kb.js +111 -24
  180. package/dist/sdk/kb.js.map +1 -1
  181. package/dist/sdk/orgs.d.ts +13 -0
  182. package/dist/sdk/orgs.d.ts.map +1 -0
  183. package/dist/sdk/orgs.js +59 -0
  184. package/dist/sdk/orgs.js.map +1 -0
  185. package/dist/sdk/platform.d.ts +62 -0
  186. package/dist/sdk/platform.d.ts.map +1 -0
  187. package/dist/sdk/platform.js +95 -0
  188. package/dist/sdk/platform.js.map +1 -0
  189. package/dist/sdk/session.d.ts +25 -0
  190. package/dist/sdk/session.d.ts.map +1 -0
  191. package/dist/sdk/session.js +69 -0
  192. package/dist/sdk/session.js.map +1 -0
  193. package/dist/sdk/skills.d.ts +46 -0
  194. package/dist/sdk/skills.d.ts.map +1 -0
  195. package/dist/sdk/skills.js +203 -0
  196. package/dist/sdk/skills.js.map +1 -0
  197. package/dist/sdk/test-chat.d.ts +52 -0
  198. package/dist/sdk/test-chat.d.ts.map +1 -0
  199. package/dist/sdk/test-chat.js +134 -0
  200. package/dist/sdk/test-chat.js.map +1 -0
  201. package/dist/sdk/tickets.d.ts +98 -0
  202. package/dist/sdk/tickets.d.ts.map +1 -0
  203. package/dist/sdk/tickets.js +91 -0
  204. package/dist/sdk/tickets.js.map +1 -0
  205. package/dist/sdk/types.d.ts +91 -6
  206. package/dist/sdk/types.d.ts.map +1 -1
  207. package/dist/sdk/voice.d.ts +147 -0
  208. package/dist/sdk/voice.d.ts.map +1 -0
  209. package/dist/sdk/voice.js +236 -0
  210. package/dist/sdk/voice.js.map +1 -0
  211. package/package.json +22 -3
  212. package/scripts/check-skills.mjs +25 -0
  213. package/skills/agent-builder/SKILL.md +89 -252
  214. package/skills/evaluations/SKILL.md +23 -234
  215. package/skills/evaluations/references/analysis.md +95 -0
  216. package/skills/evaluations/references/cli-reference.md +28 -0
  217. package/skills/evaluations/references/json-formats.md +100 -0
  218. package/src/cli/agents.ts +202 -22
  219. package/src/cli/analytics.ts +138 -0
  220. package/src/cli/auth.ts +56 -18
  221. package/src/cli/billing.ts +25 -0
  222. package/src/cli/campaigns.ts +33 -0
  223. package/src/cli/chat.ts +66 -115
  224. package/src/cli/config.ts +65 -31
  225. package/src/cli/conversations.ts +133 -0
  226. package/src/cli/distribution.ts +116 -0
  227. package/src/cli/eval.ts +254 -70
  228. package/src/cli/functions.ts +105 -39
  229. package/src/cli/index.ts +58 -55
  230. package/src/cli/init.ts +154 -0
  231. package/src/cli/kb.ts +120 -17
  232. package/src/cli/link.ts +117 -0
  233. package/src/cli/org.ts +81 -75
  234. package/src/cli/platform.ts +128 -0
  235. package/src/cli/tickets.ts +130 -0
  236. package/src/cli/utils/output.ts +51 -42
  237. package/src/cli/voice.ts +204 -0
  238. package/src/core/diff.ts +52 -0
  239. package/src/core/org-resolver.ts +113 -0
  240. package/src/core/runtime.ts +73 -0
  241. package/src/core/scope.ts +64 -0
  242. package/src/mcp/registry.ts +236 -0
  243. package/src/mcp/server.ts +84 -2
  244. package/src/mcp/tool-meta.ts +241 -0
  245. package/src/mcp/tools.ts +1529 -265
  246. package/src/sdk/agent-bundle.ts +245 -0
  247. package/src/sdk/agents.ts +74 -5
  248. package/src/sdk/analytics.ts +220 -0
  249. package/src/sdk/auth.ts +87 -107
  250. package/src/sdk/billing.ts +59 -0
  251. package/src/sdk/campaigns.ts +60 -0
  252. package/src/sdk/chat.ts +44 -10
  253. package/src/sdk/client.ts +373 -102
  254. package/src/sdk/config-change.ts +148 -0
  255. package/src/sdk/config.ts +2 -1
  256. package/src/sdk/conversations.ts +199 -0
  257. package/src/sdk/credentials.ts +53 -0
  258. package/src/sdk/distribution.ts +132 -0
  259. package/src/sdk/eval.ts +140 -0
  260. package/src/sdk/functions.ts +52 -6
  261. package/src/sdk/index.ts +2 -0
  262. package/src/sdk/jarvis-chat.ts +112 -0
  263. package/src/sdk/kb.ts +116 -29
  264. package/src/sdk/orgs.ts +62 -0
  265. package/src/sdk/platform.ts +115 -0
  266. package/src/sdk/session.ts +90 -0
  267. package/src/sdk/skills.ts +188 -0
  268. package/src/sdk/test-chat.ts +151 -0
  269. package/src/sdk/tickets.ts +120 -0
  270. package/src/sdk/types.ts +88 -6
  271. package/src/sdk/voice.ts +262 -0
  272. package/test/cli/entry.test.ts +34 -0
  273. package/test/cli/init.test.ts +66 -0
  274. package/test/cli/link.test.ts +57 -0
  275. package/test/core/org-resolver.test.ts +92 -0
  276. package/test/core/runtime.test.ts +30 -0
  277. package/test/core/scope.test.ts +93 -0
  278. package/test/harness.test.ts +24 -0
  279. package/test/helpers/config-home.ts +22 -0
  280. package/test/helpers/fake-middleware.ts +56 -0
  281. package/test/helpers/fetch-recorder.ts +75 -0
  282. package/test/helpers/jwt.ts +9 -0
  283. package/test/helpers/mcp-pair.ts +38 -0
  284. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
  285. package/test/mcp/askers.test.ts +60 -0
  286. package/test/mcp/elicit-timeout.test.ts +29 -0
  287. package/test/mcp/registry.test.ts +169 -0
  288. package/test/mcp/shaping.test.ts +41 -0
  289. package/test/mcp/tools-snapshot.test.ts +42 -0
  290. package/test/sdk/agents.test.ts +232 -0
  291. package/test/sdk/analytics.test.ts +89 -0
  292. package/test/sdk/auth.test.ts +70 -0
  293. package/test/sdk/billing.test.ts +47 -0
  294. package/test/sdk/campaigns.test.ts +43 -0
  295. package/test/sdk/client.test.ts +80 -0
  296. package/test/sdk/config-change.test.ts +142 -0
  297. package/test/sdk/conversations.test.ts +80 -0
  298. package/test/sdk/credentials.test.ts +33 -0
  299. package/test/sdk/distribution.test.ts +55 -0
  300. package/test/sdk/eval.test.ts +155 -0
  301. package/test/sdk/kb-upload.test.ts +50 -0
  302. package/test/sdk/kb.test.ts +95 -0
  303. package/test/sdk/orgs.test.ts +48 -0
  304. package/test/sdk/platform.test.ts +89 -0
  305. package/test/sdk/renewal.test.ts +115 -0
  306. package/test/sdk/session.test.ts +60 -0
  307. package/test/sdk/skills.test.ts +235 -0
  308. package/test/sdk/test-chat.test.ts +112 -0
  309. package/test/sdk/tickets.test.ts +42 -0
  310. package/test/sdk/voice.test.ts +147 -0
  311. package/vitest.config.ts +11 -0
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { z } from 'zod/v3';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { createRegistry, fitForm } from '../../src/mcp/registry.js';
5
+ import type { ToolMeta } from '../../src/mcp/tool-meta.js';
6
+ import { NeedsInputError } from '../../src/core/runtime.js';
7
+ import { connectPair } from '../helpers/mcp-pair.js';
8
+
9
+ const meta: Record<string, ToolMeta> = {
10
+ wipe: {
11
+ title: 'Wipe thing',
12
+ guard: true,
13
+ describe: (a) => `This will delete thing ${a.id}.`,
14
+ annotations: { destructiveHint: true, readOnlyHint: false },
15
+ },
16
+ look: { title: 'Look at thing', annotations: { readOnlyHint: true } },
17
+ pick: { title: 'Pick org', annotations: { readOnlyHint: true } },
18
+ };
19
+
20
+ function setup(handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: JSON.stringify({ ok: true }) }] }))) {
21
+ return {
22
+ handler,
23
+ register: (server: Parameters<typeof createRegistry>[0]) => {
24
+ const reg = createRegistry(server, { context: () => ({ org_id: 'org1', environment: 'dev' }), meta });
25
+ reg.tool('wipe', 'Delete a thing', { id: z.string() }, handler);
26
+ reg.tool('look', 'Read a thing', {}, handler);
27
+ reg.tool('pick', 'Needs a choice', {}, async () => { throw new NeedsInputError('Which org?', [{ id: 'a', label: 'A' }]); });
28
+ },
29
+ };
30
+ }
31
+
32
+ describe('registry', () => {
33
+ it('refuses a tool with no metadata', () => {
34
+ const reg = createRegistry(new McpServer({ name: 't', version: '0' }), { context: () => ({ environment: 'dev' }), meta });
35
+ expect(() => reg.tool('mystery', 'x', {}, vi.fn())).toThrow(/no entry in tool-meta/);
36
+ });
37
+
38
+ it('wraps results with the org and platform that answered', async () => {
39
+ const { register } = setup();
40
+ const pair = await connectPair(register);
41
+ const res = await pair.call('look');
42
+ expect(res.body).toEqual({ context: { org_id: 'org1', environment: 'dev' }, data: { ok: true } });
43
+ await pair.close();
44
+ });
45
+
46
+ it('turns a needed choice into needs_input instead of an error', async () => {
47
+ const { register } = setup();
48
+ const pair = await connectPair(register);
49
+ const res = await pair.call('pick');
50
+ expect(res.isError).toBe(false);
51
+ expect(res.body).toMatchObject({ needs_input: true, message: 'Which org?', choices: [{ id: 'a', label: 'A' }] });
52
+ expect(res.body.to_proceed).toMatch(/org_id/);
53
+ await pair.close();
54
+ });
55
+
56
+ it('tells an MCP caller to pass org_id, not the CLI flag', async () => {
57
+ const pair = await connectPair((server) => {
58
+ const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
59
+ reg.tool('pick', 'Needs a choice', {}, async () => {
60
+ throw new NeedsInputError('You belong to 2 organizations. Choose one with --org <id>, or pin this folder with `indemn link --org <id>`.', [{ id: 'a', label: 'A' }]);
61
+ });
62
+ });
63
+ const res = await pair.call('pick');
64
+ expect(res.body.message).toBe('You belong to 2 organizations. Call again with org_id set to one of the ids below, or pin this folder with `indemn link --org <id>`.');
65
+ await pair.close();
66
+ });
67
+
68
+ it('returns a dry run and makes no call without confirm', async () => {
69
+ const { register, handler } = setup();
70
+ const pair = await connectPair(register);
71
+ const res = await pair.call('wipe', { id: 't1' });
72
+ expect(res.body).toMatchObject({ dry_run: true, action: 'wipe', would: 'This will delete thing t1. Platform: dev.' });
73
+ expect(handler).not.toHaveBeenCalled();
74
+ await pair.close();
75
+ });
76
+
77
+ it('makes exactly one call with confirm: true', async () => {
78
+ const { register, handler } = setup();
79
+ const pair = await connectPair(register);
80
+ await pair.call('wipe', { id: 't1', confirm: true });
81
+ expect(handler).toHaveBeenCalledTimes(1);
82
+ await pair.close();
83
+ });
84
+
85
+ it('asks the person through elicitation and ignores a self-set confirm', async () => {
86
+ const { register, handler } = setup();
87
+ const pair = await connectPair(register, { elicit: () => ({ action: 'decline' }) });
88
+ const res = await pair.call('wipe', { id: 't1', confirm: true });
89
+ expect(res.body).toMatchObject({ cancelled: true, reason: expect.stringMatching(/^The person pressed Decline: they chose not to make this change\. Nothing was changed\. .*do not call this tool again/) });
90
+ expect(handler).not.toHaveBeenCalled();
91
+ expect(pair.questions[0].message).toBe('This will delete thing t1. Platform: dev. Proceed?');
92
+ await pair.close();
93
+ });
94
+
95
+ it('names things in the approval instead of showing their ids', async () => {
96
+ const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: '{}' }] }));
97
+ const pair = await connectPair((server) => {
98
+ const reg = createRegistry(server, {
99
+ context: () => ({ environment: 'dev' }), meta,
100
+ resolveOrg: async () => ({ id: 'org1' }),
101
+ displayNames: async (args, orgId) => ({ names: { [String(args.id)]: 'Garden party', [String(orgId)]: 'Rudra-Dev' } }),
102
+ });
103
+ reg.tool('wipe', 'Delete a thing', { id: z.string() }, handler);
104
+ }, { elicit: () => ({ action: 'decline' }) });
105
+ await pair.call('wipe', { id: 't1' });
106
+ expect(pair.questions[0].message).toBe('This will delete thing "Garden party". Org: Rudra-Dev. Platform: dev. Proceed?');
107
+ await pair.close();
108
+ });
109
+
110
+ it('asks nothing about a thing that does not exist', async () => {
111
+ const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: '{}' }] }));
112
+ const pair = await connectPair((server) => {
113
+ const reg = createRegistry(server, {
114
+ context: () => ({ environment: 'dev' }), meta,
115
+ displayNames: async () => ({ names: {}, missing: ['skill lc_123'] }),
116
+ });
117
+ reg.tool('wipe', 'Delete a thing', { id: z.string() }, handler);
118
+ }, { elicit: () => ({ action: 'accept', content: { confirm: true } }) });
119
+ const res = await pair.call('wipe', { id: 'lc_123' });
120
+ expect(res.isError).toBe(true);
121
+ expect(String(res.body)).toMatch(/Not found: skill lc_123\. Nothing was changed and nothing was asked/);
122
+ expect(pair.questions).toHaveLength(0);
123
+ expect(handler).not.toHaveBeenCalled();
124
+ await pair.close();
125
+ });
126
+
127
+ it('says why when the host cancels at once (Claude Code in -p mode)', async () => {
128
+ const { register, handler } = setup();
129
+ const pair = await connectPair(register, { elicit: () => ({ action: 'cancel' }) });
130
+ const res = await pair.call('wipe', { id: 't1', confirm: true });
131
+ expect(res.body.reason).toMatch(/cannot show it.*confirm: true does not help/);
132
+ expect(handler).not.toHaveBeenCalled();
133
+ await pair.close();
134
+ });
135
+
136
+ it('keeps the approval form short enough to show its Proceed box', async () => {
137
+ const long = { ...meta, wipe: { ...meta.wipe, describe: () => Array.from({ length: 30 }, (_, i) => `line ${i} ${'x'.repeat(400)}`).join('\n') } };
138
+ const pair = await connectPair((server) => {
139
+ const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta: long });
140
+ reg.tool('wipe', 'Delete a thing', { id: z.string() }, vi.fn(async () => ({ content: [{ type: 'text' as const, text: '{}' }] })));
141
+ }, { elicit: () => ({ action: 'decline' }) });
142
+ await pair.call('wipe', { id: 't1' });
143
+ const message = pair.questions[0].message as string;
144
+ expect(message.split('\n').length).toBeLessThanOrEqual(10);
145
+ expect(message).toMatch(/Shortened to fit/);
146
+ expect(message).toMatch(/Platform: dev\. Proceed\?$/);
147
+ const warning = `This will BUY +13312173972 from Twilio and attach it to agent X. ${'The org pays for it every month, and it cannot be undone from here. '.repeat(3)}`;
148
+ expect(fitForm(warning)).toBe(warning);
149
+ await pair.close();
150
+ });
151
+
152
+ it('proceeds when the person accepts', async () => {
153
+ const { register, handler } = setup();
154
+ const pair = await connectPair(register, { elicit: () => ({ action: 'accept', content: { confirm: true } }) });
155
+ const res = await pair.call('wipe', { id: 't1' });
156
+ expect(handler).toHaveBeenCalledTimes(1);
157
+ expect(res.body.context.approved_by_person).toBe(true);
158
+ await pair.close();
159
+ });
160
+
161
+ it('changes nothing when the approval form cannot be shown, even with a self-set confirm', async () => {
162
+ const { register, handler } = setup();
163
+ const pair = await connectPair(register, { elicit: () => { throw new Error('the form could not open'); } });
164
+ const res = await pair.call('wipe', { id: 't1', confirm: true });
165
+ expect(res.body).toMatchObject({ cancelled: true, reason: expect.stringMatching(/could not be shown, so nothing was changed/) });
166
+ expect(handler).not.toHaveBeenCalled();
167
+ await pair.close();
168
+ });
169
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod/v3';
3
+ import { createRegistry, MAX_RESULT_CHARS } from '../../src/mcp/registry.js';
4
+ import type { ToolMeta } from '../../src/mcp/tool-meta.js';
5
+ import { connectPair } from '../helpers/mcp-pair.js';
6
+
7
+ const meta: Record<string, ToolMeta> = {
8
+ many: { title: 'Many', annotations: { readOnlyHint: true }, paginate: true },
9
+ huge: { title: 'Huge', annotations: { readOnlyHint: true } },
10
+ };
11
+ const ok = (data: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(data) }] });
12
+
13
+ describe('result shaping', () => {
14
+ it('pages list tools and says whether more exist', async () => {
15
+ const pair = await connectPair((server) => {
16
+ const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
17
+ reg.tool('many', 'x', {}, async () => ok(Array.from({ length: 120 }, (_, i) => i)));
18
+ reg.tool('huge', 'x', {}, async () => ok(Array.from({ length: 5000 }, (_, i) => ({ i, pad: 'x'.repeat(100) }))));
19
+ });
20
+ const first = await pair.call('many');
21
+ expect(first.body.data).toMatchObject({ page: 1, limit: 50, total: 120, has_more: true, next: '70 more: call again with page 2' });
22
+ expect(first.body.data.items).toHaveLength(50);
23
+ const last = await pair.call('many', { page: 3 });
24
+ expect(last.body.data).toMatchObject({ page: 3, has_more: false, items: Array.from({ length: 20 }, (_, i) => 100 + i) });
25
+ expect(last.body.data).not.toHaveProperty('next');
26
+
27
+ const big = await pair.call('huge');
28
+ const text = (big.raw.content as Array<{ text: string }>)[0].text;
29
+ expect(text.length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
30
+ expect(big.body.data).toMatchObject({ truncated: true, total: 5000 });
31
+ expect(big.body.data.hint).toMatch(/limit\/page/);
32
+ await pair.close();
33
+ });
34
+
35
+ it('refuses a paged tool that brings its own limit (it would never page)', async () => {
36
+ await connectPair((server) => {
37
+ const reg = createRegistry(server, { context: () => ({ environment: 'dev' }), meta });
38
+ expect(() => reg.tool('many', 'x', { limit: z.number().optional() }, async () => ok([]))).toThrow(/paged by the registry/);
39
+ });
40
+ });
41
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { registerTools } from '../../src/mcp/tools.js';
3
+ import { IndemnClient } from '../../src/sdk/client.js';
4
+ import { connectPair } from '../helpers/mcp-pair.js';
5
+ import { cleanEnv } from '../helpers/config-home.js';
6
+
7
+ describe('MCP tool list', () => {
8
+ it('gives every tool a title and annotations', async () => {
9
+ cleanEnv();
10
+ const client = new IndemnClient({ apiKey: `ind_dev_${'a'.repeat(64)}`, orgId: 'org1', environment: 'dev' });
11
+ const pair = await connectPair((server) => { registerTools(server, client); });
12
+ const { tools } = await pair.client.listTools();
13
+ for (const tool of tools) {
14
+ expect(tool.title, tool.name).toBeTruthy();
15
+ expect(tool.annotations, tool.name).toBeTypeOf('object');
16
+ expect(typeof tool.annotations?.readOnlyHint, tool.name).toBe('boolean');
17
+ }
18
+ expect(tools.map((t) => t.name).sort()).toMatchSnapshot();
19
+ expect(tools).toHaveLength(139); // +numbers // +set_agent_transcriber // +set_org_admin_settings
20
+ const props = (name: string) => Object.keys((tools.find((t) => t.name === name)!.inputSchema as { properties?: object }).properties ?? {});
21
+ for (const name of ['list_agents', 'delete_agent', 'set_prompt', 'list_org_members', 'list_channels', 'chat_with_agent']) {
22
+ expect(props(name), name).toContain('org_id');
23
+ }
24
+ for (const name of ['whoami', 'list_organizations', 'eval_quality_metrics']) {
25
+ expect(props(name), name).not.toContain('org_id');
26
+ }
27
+ // Test items and rubric rules have declared fields (Gemini cannot fill open objects).
28
+ const itemsOf = (name: string, field: string) => ((tools.find((t) => t.name === name)!.inputSchema as { properties: Record<string, { items?: { properties?: object } }> }).properties[field].items?.properties ?? {});
29
+ // ... and no nested objects: Gemini writes constructors for them that Google rejects.
30
+ const nested = (props: Record<string, { type?: string }>) => Object.entries(props).filter(([, p]) => p.type === 'object').map(([k]) => k);
31
+ expect(Object.keys(itemsOf('create_test_set', 'items'))).toEqual(expect.arrayContaining(['type', 'name', 'message', 'success_criteria']));
32
+ expect(nested(itemsOf('create_test_set', 'items') as never)).toEqual([]);
33
+ expect(Object.keys(itemsOf('update_test_set', 'items'))).toEqual(expect.arrayContaining(['type', 'message', 'success_criteria']));
34
+ expect(Object.keys(itemsOf('create_rubric', 'rules'))).toEqual(expect.arrayContaining(['id', 'name', 'severity', 'category', 'description', 'pass_conditions']));
35
+ expect(nested(itemsOf('create_rubric', 'rules') as never)).toEqual([]);
36
+ expect(Object.keys(itemsOf('update_rubric', 'rules'))).toEqual(expect.arrayContaining(['id', 'severity']));
37
+ const skillProps = Object.keys((tools.find((t) => t.name === 'create_function')!.inputSchema as { properties: object }).properties);
38
+ expect(skillProps).toEqual(expect.arrayContaining(['auth_type', 'auth_token']));
39
+ expect(skillProps).not.toContain('authorization');
40
+ await pair.close();
41
+ });
42
+ });
@@ -0,0 +1,232 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
4
+ import { jwtExpiringIn } from '../helpers/jwt.js';
5
+ import { connectPair } from '../helpers/mcp-pair.js';
6
+ import { IndemnClient } from '../../src/sdk/client.js';
7
+ import { AgentInsightsSDK, AgentsSDK } from '../../src/sdk/agents.js';
8
+ import { createAgentFromSkeleton, exportAgent, importAgent, parseModel, redactSecrets } from '../../src/sdk/agent-bundle.js';
9
+ import { registerTools } from '../../src/mcp/tools.js';
10
+ import { setAskers } from '../../src/core/scope.js';
11
+ import { mcpAskers } from '../../src/mcp/server.js';
12
+
13
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
14
+ const AGENT = '111111111111111111111111';
15
+ const NEW = '444444444444444444444444';
16
+ const list = (names: string[]) => on('GET', '/ai-studio/v2/bots', (c) => (c.url.endsWith('/v2/bots') ? { json: names.map((n, i) => ({ _id: `b${i}`, name: n })) } : undefined as never));
17
+
18
+ describe('agents', () => {
19
+ let home: ReturnType<typeof useTempHome>;
20
+ let token: string;
21
+ beforeEach(() => {
22
+ cleanEnv();
23
+ home = useTempHome();
24
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
25
+ token = jwtExpiringIn(5 * 3_600_000);
26
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token }, orgs: [{ id: ORG, name: 'QA A' }] });
27
+ setAskers({});
28
+ });
29
+ afterEach(() => home.cleanup());
30
+
31
+ it('refuses a duplicate name before creating anything', async () => {
32
+ const { calls } = recordFetch([list(['Quote Bot'])]);
33
+ await expect(createAgentFromSkeleton(new IndemnClient(), { name: ' quote bot ' })).rejects.toMatchObject({ exitCode: 2 });
34
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
35
+ });
36
+
37
+ it('creates the skeleton: agent, purpose, prompt and model', async () => {
38
+ const { calls } = recordFetch([
39
+ list([]),
40
+ on('POST', '/ai-studio/bots', { json: { success: true, data: { _id: NEW, name: 'Renewals', channels: ['WEB'] } } }),
41
+ on('PUT', `/ai-studio/bots/${NEW}/configurations`, { json: { success: true, data: {} } }),
42
+ on('PUT', `/ai-studio/bots/${NEW}`, { json: { success: true, data: { _id: NEW } } }),
43
+ on('GET', '/ai-studio/llm-models', { json: { success: true, data: [{ provider: 'openai', models: [{ model_id: 'gpt-4.1', channels: ['CHAT', 'VOICE'], exposed_fields: { temperature: { default: 0 } } }, { model_id: 'o-text', channels: ['CHAT'] }] }] } }),
44
+ ]);
45
+ const { set } = await createAgentFromSkeleton(new IndemnClient(), {
46
+ name: 'Renewals', description: 'Handles renewals', system_prompt: 'You are...', model: parseModel('openai/gpt-4.1'),
47
+ });
48
+ expect(set).toEqual(['name', 'channels', 'description', 'system_prompt', 'model']);
49
+ expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ name: 'Renewals', channels: ['WEB'] });
50
+ expect(calls.find((c) => c.url.endsWith('/configurations'))?.body).toEqual({ system_prompt: 'You are...', llm_config: { provider: 'openai', model: 'gpt-4.1', parameters: { temperature: 0 } } });
51
+ });
52
+
53
+ it('rejects a model without a provider', () => {
54
+ expect(() => parseModel('gpt-4.1')).toThrow(/provider\/model/);
55
+ });
56
+
57
+ it('redacts secrets in exported skills and lists where', () => {
58
+ const { value, redacted } = redactSecrets([{ configurationSchema: { headers: { Authorization: 'Bearer x', 'X-Trace': '1' }, api_key: 'k' } }]);
59
+ expect(value).toEqual([{ configurationSchema: { headers: { Authorization: '<redacted>', 'X-Trace': '1' }, api_key: '<redacted>' } }]);
60
+ expect(redacted).toEqual(['[0].configurationSchema.headers.Authorization', '[0].configurationSchema.api_key']);
61
+ });
62
+
63
+ it('treats webhook addresses and addresses with a key in them as secrets', () => {
64
+ const { value, redacted } = redactSecrets([
65
+ { configurationSchema: { endpoint: 'https://hooks.slack.com/services/T0/B0/xyz', method: 'POST' } },
66
+ { configurationSchema: { endpoint: 'https://n8n.example.com/webhook/1234-abcd' } },
67
+ { configurationSchema: { endpoint: 'https://api.acme.test/quote?api_key=abc' } },
68
+ { configurationSchema: { endpoint: 'https://api.acme.test/quote' } },
69
+ ]);
70
+ expect(redacted).toEqual(['[0].configurationSchema.endpoint', '[1].configurationSchema.endpoint', '[2].configurationSchema.endpoint']);
71
+ expect(value[3].configurationSchema.endpoint).toBe('https://api.acme.test/quote');
72
+ });
73
+
74
+ it('exports and imports an agent with its skills and parameters', async () => {
75
+ recordFetch([
76
+ on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Quote Bot', description: 'Quotes', channels: ['WEB'] }] }),
77
+ on('GET', `/bots/${AGENT}/configurations`, { json: { success: true, data: { ai_config: { system_prompt: 'P' }, first_message: { text: 'Hi' }, llm_config: { provider: 'openai', model: 'gpt-4.1' } } } }),
78
+ on('GET', `/bots/${AGENT}/functions/f1/parameters`, { json: { success: true, data: [{ _id: 'p1', name: 'zip', type: 'string', is_required: true }] } }),
79
+ on('GET', `/bots/${AGENT}/functions`, { json: { success: true, data: [{ _id: 'f1', type: 'rest_api', name: 'quote', configurationSchema: { url: 'https://x', token: 'secret' } }] } }),
80
+ on('GET', `/bots/${AGENT}/mappings`, { json: { success: true, data: [{ id: 'kb1', name: 'FAQ' }] } }),
81
+ ]);
82
+ const bundle = await exportAgent(new IndemnClient(), AGENT);
83
+ expect(bundle).toMatchObject({
84
+ format: 'indemn-agent/1',
85
+ agent: { name: 'Quote Bot', channels: ['WEB'] },
86
+ configuration: { system_prompt: 'P', llm_config: { model: 'gpt-4.1' } },
87
+ skills: [{ name: 'quote', configurationSchema: { url: 'https://x', token: '<redacted>' }, parameters: [{ name: 'zip', type: 'string', is_required: true }] }],
88
+ knowledge_base_ids: ['kb1'],
89
+ redacted: ['[0].configurationSchema.token'],
90
+ });
91
+
92
+ const { calls } = recordFetch([
93
+ list([]),
94
+ on('POST', '/ai-studio/bots/444444444444444444444444/functions/f9/parameters', { json: { success: true, data: [{ _id: 'p9' }] } }),
95
+ on('POST', `/ai-studio/bots/${NEW}/functions`, { json: { success: true, data: { _id: 'f9' } } }),
96
+ on('POST', '/ai-studio/bots', { json: { success: true, data: { _id: NEW, name: 'Quote Bot' } } }),
97
+ on('PUT', '/functions/f9', { json: { success: true, data: { _id: 'f9' } } }),
98
+ on('PUT', '/configurations', { json: { success: true, data: {} } }),
99
+ on('PUT', `/ai-studio/bots/${NEW}`, { json: { success: true, data: {} } }),
100
+ ]);
101
+ const report = await importAgent(new IndemnClient(), bundle);
102
+ expect(report).toMatchObject({ skills_created: 1, skills_failed: [], needs_secrets: ['[0].configurationSchema.token'], knowledge_bases_skipped: ['kb1'] });
103
+ expect(calls.find((c) => c.url.endsWith('/configurations'))?.body).toMatchObject({ first_message: JSON.stringify({ text: 'Hi' }) });
104
+ });
105
+
106
+ it('import reuses the built-in skills a new agent already has', async () => {
107
+ const NEW = '444444444444444444444444';
108
+ const bundle = { format: 'indemn-agent/1', agent: { name: 'Copy', channels: ['WEB'] }, configuration: {}, knowledge_base_ids: [], redacted: [],
109
+ skills: [
110
+ { type: 'faqs-retriever', name: 'FAQs Retriever', configurationSchema: { metadataFilter: {} }, parameters: [{ name: 'query', type: 'string' }] },
111
+ { type: 'human_handoff', name: 'Live Handoff', configurationSchema: { humanHandoffFallback: 'email' }, parameters: [] },
112
+ { type: 'rest_api', name: 'quote', configurationSchema: { endpoint: 'https://x' }, parameters: [] },
113
+ ] } as never;
114
+ const { calls } = recordFetch([
115
+ list([]),
116
+ on('GET', `/ai-studio/bots/${NEW}/functions`, { json: { success: true, data: [
117
+ { _id: 'b1', type: 'faqs-retriever', name: 'FAQs Retriever', systemDeveloped: true },
118
+ { _id: 'b2', type: 'human_handoff', name: 'Live Handoff', systemDeveloped: true },
119
+ ] } }),
120
+ on('POST', `/ai-studio/bots/${NEW}/functions`, { json: { success: true, data: { _id: 'f9' } } }),
121
+ on('POST', '/ai-studio/bots', { json: { success: true, data: { _id: NEW, name: 'Copy' } } }),
122
+ on('PUT', '/functions/', { json: { success: true, data: {} } }),
123
+ ]);
124
+ const report = await importAgent(new IndemnClient(), bundle);
125
+ expect(report).toMatchObject({ skills_created: 1, built_in_skills_reused: ['FAQs Retriever', 'Live Handoff'], skills_failed: [] });
126
+ expect(calls.filter((c) => c.method === 'POST' && c.url.endsWith(`/bots/${NEW}/functions`))).toHaveLength(1);
127
+ expect(calls.find((c) => c.method === 'PUT' && c.url.endsWith('/functions/b2'))?.body).toMatchObject({ configurationSchema: { humanHandoffFallback: 'email' } });
128
+ expect(calls.some((c) => c.url.includes('/functions/b1/parameters') || c.url.includes('/functions/b2/parameters'))).toBe(false);
129
+ });
130
+
131
+ it('MCP list_agents pages', async () => {
132
+ recordFetch([list(['a', 'b', 'c'])]);
133
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
134
+ const res = await pair.call('list_agents', { limit: 2, page: 2 });
135
+ expect(res.body.data).toMatchObject({ page: 2, limit: 2, total: 3, has_more: false, items: [{ _id: 'b2', name: 'c' }] });
136
+ // A link to offer the person instead of the id
137
+ expect(res.body.data.items[0].url).toMatch(/^https:\/\/devcopilot\.indemn\.ai\/#\/organization\/[^/]+\/ai-studio\/bot\/b2\/configurations$/);
138
+ await pair.close();
139
+ });
140
+
141
+ it('MCP list_agents gives what a person needs, with a link, and filters by channel', async () => {
142
+ const channel = (name: string) => ({ _id: `c-${name}`, name, type: name, createdBy: 'u1', updatedAt: '2024-10-30T14:42:17.197Z' });
143
+ recordFetch([on('GET', '/ai-studio/v2/bots', { json: [
144
+ { _id: 'v1', name: 'Petra', description: null, channels: [channel('VOICE')], connected_phone: '+17759428775', updatedAt: '2026-08-19T03:49:01.061Z', intentsEngine: 'none', certifiedTags: [], last_updated_by: { email: 'x@indemn.ai' } },
145
+ { _id: 'w1', name: 'Wedding', channels: [channel('WEB')], updatedAt: '2026-06-01T00:00:00.000Z' },
146
+ ] })]);
147
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
148
+ const res = await pair.call('list_agents', { channel: 'voice' });
149
+ expect(res.body.data.items).toEqual([{
150
+ _id: 'v1', name: 'Petra', channels: ['VOICE'], phone: '+17759428775', updated_at: '2026-08-19T03:49:01.061Z',
151
+ url: expect.stringMatching(/\/ai-studio\/bot\/v1\/configurations$/),
152
+ }]);
153
+ expect((await pair.call('list_agents')).body.data.total).toBe(2);
154
+ await pair.close();
155
+ });
156
+
157
+ it('MCP delete_agent names the agent, warns when it is published, and needs confirm_published without a person', async () => {
158
+ const { calls } = recordFetch([
159
+ on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Quote Bot' }] }),
160
+ on('GET', `/v1-agents/${AGENT}`, { json: { id: AGENT, lifecycle: 'published' } }),
161
+ on('DELETE', `/bots/${AGENT}`, { json: { success: true } }),
162
+ ]);
163
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
164
+ const dry = await pair.call('delete_agent', { agent_id: AGENT });
165
+ expect(dry.body.would).toMatch(/"Quote Bot", which is PUBLISHED/);
166
+ expect((await pair.call('delete_agent', { agent_id: AGENT, confirm: true })).isError).toBe(true);
167
+ expect(calls.some((c) => c.method === 'DELETE')).toBe(false);
168
+ await pair.call('delete_agent', { agent_id: AGENT, confirm: true, confirm_published: true });
169
+ expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(1);
170
+ await pair.close();
171
+ });
172
+
173
+ it('sends channel names the way copilot matches them', async () => {
174
+ const { calls } = recordFetch([on('POST', '/ai-studio/bots', { json: { success: true, data: { _id: 'n1' } } })]);
175
+ await new AgentsSDK(new IndemnClient()).createAgent({ name: 'Caller', channels: ['voice'] });
176
+ expect(calls.find((c) => c.method === 'POST')?.body).toMatchObject({ channels: ['VOICE'] });
177
+ });
178
+
179
+ it('never clones an agent from another org', async () => {
180
+ const { calls } = recordFetch([on('GET', `/v2/bots/${AGENT}`, { status: 404, text: 'not found' })]);
181
+ await expect(new AgentsSDK(new IndemnClient()).cloneAgent(AGENT)).rejects.toThrow();
182
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
183
+ });
184
+
185
+ it('says a deleted agent was not found (copilot answers with an empty list)', async () => {
186
+ recordFetch([on('GET', `/v2/bots/${AGENT}`, { json: [] })]);
187
+ await expect(new AgentsSDK(new IndemnClient()).getAgent(AGENT)).rejects.toThrow(/not found in this organization/);
188
+ });
189
+
190
+ it('changes lifecycle through the V2 platform with the bare session token and the org header', async () => {
191
+ const { calls } = recordFetch([on('PATCH', `/v1-agents/${AGENT}/lifecycle`, { json: { id: AGENT, lifecycle: 'beta' } })]);
192
+ await new AgentInsightsSDK(new IndemnClient()).setLifecycle(AGENT, 'beta');
193
+ expect(calls[0].url).toBe(`https://devplatform.indemn.ai/api/v1/v1-agents/${AGENT}/lifecycle`);
194
+ expect(calls[0].headers.authorization).toBe(`Bearer ${token}`);
195
+ expect(calls[0].headers['x-organization-id']).toBe(ORG);
196
+ expect(calls[0].body).toEqual({ lifecycle: 'beta' });
197
+ });
198
+
199
+ it('shows the V2 platform\'s reason when a non-owner is refused', async () => {
200
+ recordFetch([on('PATCH', '/lifecycle', { status: 403, json: { detail: 'Only the agent owner or organization admin can change lifecycle' } })]);
201
+ await expect(new AgentInsightsSDK(new IndemnClient()).setLifecycle(AGENT, 'published')).rejects.toThrow(/Only the agent owner/);
202
+ });
203
+
204
+ it('MCP create_agent asks for missing basics, or lists them when it cannot ask', async () => {
205
+ recordFetch([
206
+ list([]),
207
+ on('POST', '/ai-studio/bots', { json: { success: true, data: { _id: NEW, name: 'Renewals' } } }),
208
+ on('PUT', '/configurations', { json: { success: true, data: {} } }),
209
+ on('PUT', `/ai-studio/bots/${NEW}`, { json: { success: true, data: {} } }),
210
+ ]);
211
+ const blind = await connectPair((server) => { setAskers(mcpAskers(server)); registerTools(server, new IndemnClient()); });
212
+ const res = await blind.call('create_agent', { name: 'Renewals' });
213
+ expect(res.body).toMatchObject({ needs_input: true, choices: [{ id: 'description' }, { id: 'system_prompt' }] });
214
+ await blind.close();
215
+
216
+ const asking = await connectPair((server) => { setAskers(mcpAskers(server)); registerTools(server, new IndemnClient()); },
217
+ { elicit: () => ({ action: 'accept', content: { description: 'Renewals desk', system_prompt: 'You renew policies.' } }) });
218
+ const made = await asking.call('create_agent', { name: 'Renewals' });
219
+ expect(made.body.data).toMatchObject({ set: ['name', 'channels', 'description', 'system_prompt'], not_set: [] });
220
+ await asking.close();
221
+ });
222
+
223
+ it('MCP delete_agent is a dry run until confirmed', async () => {
224
+ const { calls } = recordFetch([on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Quote Bot' }] })]);
225
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
226
+ const res = await pair.call('delete_agent', { agent_id: AGENT });
227
+ expect(res.body).toMatchObject({ dry_run: true });
228
+ expect(res.body.would).toContain('delete agent "Quote Bot"');
229
+ expect(calls.some((c) => c.method === 'DELETE')).toBe(false);
230
+ await pair.close();
231
+ });
232
+ });
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
4
+ import { jwtExpiringIn } from '../helpers/jwt.js';
5
+ import { IndemnClient } from '../../src/sdk/client.js';
6
+ import { AnalyticsSDK, AuditSDK, OrgSettingsSDK, planAdminSettings, rangeQuery } from '../../src/sdk/analytics.js';
7
+ import { connectPair } from '../helpers/mcp-pair.js';
8
+ import { registerTools } from '../../src/mcp/tools.js';
9
+
10
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
11
+
12
+ describe('analytics, settings and audit', () => {
13
+ let home: ReturnType<typeof useTempHome>;
14
+ beforeEach(() => {
15
+ cleanEnv();
16
+ home = useTempHome();
17
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
18
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
19
+ });
20
+ afterEach(() => home.cleanup());
21
+
22
+ it('defaults to the last 30 days and refuses more than 365', () => {
23
+ expect(rangeQuery({ to: '2026-09-28' })).toEqual({ date_from: '2026-08-29', date_to: '2026-09-28', channel: 'all' });
24
+ expect(() => rangeQuery({ from: '2025-01-01', to: '2026-09-28' })).toThrow(/365 days/);
25
+ });
26
+
27
+ it('asks for one agent\'s metrics and never asks copilot to refresh (which can cost LLM calls)', async () => {
28
+ const { calls } = recordFetch([on('GET', '/analytics/', { json: { success: true, data: {} } })]);
29
+ const sdk = new AnalyticsSDK(new IndemnClient());
30
+ await sdk.get('metrics', { from: '2026-09-01', to: '2026-09-28', agentId: 'b1' });
31
+ await sdk.get('critical-feedback', { from: '2026-09-01', to: '2026-09-28' });
32
+ expect(Object.fromEntries(new URL(calls[0].url).searchParams)).toEqual({ date_from: '2026-09-01', date_to: '2026-09-28', channel: 'all', agent_id: 'b1' });
33
+ expect(calls.every((c) => !c.url.includes('refresh'))).toBe(true);
34
+ });
35
+
36
+ it('validates the timezone before sending', async () => {
37
+ const { calls } = recordFetch([on('PATCH', '/configurations', { json: { timezone: 'Europe/Rome' } })]);
38
+ const sdk = new OrgSettingsSDK(new IndemnClient());
39
+ await expect(sdk.setTimezone('Mars/Base')).rejects.toMatchObject({ exitCode: 2 });
40
+ await sdk.setTimezone('Europe/Rome');
41
+ expect(calls).toHaveLength(1);
42
+ expect(calls[0].body).toEqual({ timezone: 'Europe/Rome' });
43
+ });
44
+
45
+ it('plans an Admin-tab change as the page does: real switches, timers in days, the page\'s words', () => {
46
+ const plan = planAdminSettings({ underwriting_portal: true, underwriting_portal_name: ' Submissions ', tickets: false, ended_to_resolved_minutes: 120, resolved_to_archived_minutes: 0, web_ai_disclosure_text: '', org_id: ORG });
47
+ expect(plan.body).toEqual({ enableUnderwritingPortal: true, enableTickets: false, underwritingPortalName: 'Submissions', endedToResolvedDays: 120 / 1440, resolvedToArchivedDays: null });
48
+ expect(plan.changes).toEqual(['turn on Underwriting Portal (Intake Manager)', 'turn off Tickets', 'name the Intake Manager item "Submissions"', 'set Ended → Resolved to 2 hours', 'set Resolved → Archived back to the default']);
49
+ expect(() => planAdminSettings({ ended_to_resolved_minutes: 5 })).toThrow(/at least 10 minutes, or 0 for the default/);
50
+ expect(() => planAdminSettings({ org_id: ORG, underwriting_portal_name: '' })).toThrow(/at least one Admin setting/);
51
+ expect(() => planAdminSettings({ web_ai_disclosure: true }, { aiDisclosureWeb: '' })).toThrow(/needs its text/);
52
+ expect(planAdminSettings({ web_ai_disclosure: true }, { aiDisclosureWeb: 'You are chatting with an AI.' }).body).toEqual({ showAiDisclosureWeb: true });
53
+ });
54
+
55
+ it('reads the saved disclosure only when a change touches it', async () => {
56
+ const { calls } = recordFetch([on('GET', '/configurations', { json: { showAiDisclosureWeb: false, aiDisclosureWeb: '' } })]);
57
+ const sdk = new OrgSettingsSDK(new IndemnClient());
58
+ await sdk.planAdmin({ tickets: true });
59
+ expect(calls).toHaveLength(0);
60
+ await expect(sdk.planAdmin({ web_ai_disclosure: true })).rejects.toThrow(/needs its text/);
61
+ expect(calls).toHaveLength(1);
62
+ });
63
+
64
+ it('MCP set_org_admin_settings asks in the page\'s words, sends real switches, and asks nothing for a refused value', async () => {
65
+ const { calls } = recordFetch([
66
+ on('PATCH', '/configurations/admin-settings', { json: { success: true, orgConfigs: { enableUnderwritingPortal: true, underwritingPortalName: 'Submissions', enableTickets: false } } }),
67
+ ]);
68
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); }, { elicit: () => ({ action: 'accept', content: { confirm: true } }) });
69
+ const bad = await pair.call('set_org_admin_settings', { ended_to_resolved_minutes: 5 });
70
+ expect(bad.isError).toBe(true);
71
+ expect(String(bad.body)).toMatch(/Ended → Resolved must be at least 10 minutes/);
72
+ expect(pair.questions).toHaveLength(0);
73
+ const ok = await pair.call('set_org_admin_settings', { underwriting_portal: true, underwriting_portal_name: 'Submissions' });
74
+ expect(pair.questions.map((q) => q.message)).toEqual([expect.stringMatching(/^This will turn on Underwriting Portal \(Intake Manager\); name the Intake Manager item "Submissions"\..*Proceed\?$/)]);
75
+ expect(calls.filter((c) => c.method === 'PATCH').map((c) => c.body)).toEqual([{ enableUnderwritingPortal: true, underwritingPortalName: 'Submissions' }]);
76
+ expect(ok.body.data).toEqual({ saved: { enableUnderwritingPortal: true, underwritingPortalName: 'Submissions' } });
77
+ await pair.close();
78
+ });
79
+
80
+ it('filters the full audit history here', async () => {
81
+ recordFetch([on('GET', '/audit-logs', { json: { success: true, data: [
82
+ { action: 'update', resource_type: 'bot', createdAt: '2026-09-27T10:00:00Z' },
83
+ { action: 'delete', resource_type: 'kb', createdAt: '2026-09-20T10:00:00Z' },
84
+ { action: 'update', resource_type: 'kb', createdAt: '2026-09-10T10:00:00Z' },
85
+ ] } })]);
86
+ const rows = await new AuditSDK(new IndemnClient()).list({ since: '2026-09-15', action: 'update' });
87
+ expect(rows).toEqual([expect.objectContaining({ resource_type: 'bot' })]);
88
+ });
89
+ });