@indemn/cli 1.6.0 → 1.7.1

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 +62 -79
  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 +21 -4
  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 +89 -109
  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,73 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ /** Process exit codes. Scripts and agents branch on these. */
4
+ export const EXIT = {
5
+ OK: 0,
6
+ ERROR: 1,
7
+ /** A choice is needed (for example which org) or the command was used wrongly. */
8
+ NEEDS_INPUT: 2,
9
+ /** Not logged in, or the session expired and could not be renewed. */
10
+ AUTH: 3,
11
+ } as const;
12
+
13
+ export class CliError extends Error {
14
+ constructor(
15
+ message: string,
16
+ public readonly exitCode: number = EXIT.ERROR,
17
+ public readonly details?: unknown,
18
+ ) {
19
+ super(message);
20
+ this.name = 'CliError';
21
+ }
22
+ }
23
+
24
+ export interface Choice {
25
+ id: string;
26
+ label: string;
27
+ }
28
+
29
+ export class NeedsInputError extends CliError {
30
+ constructor(message: string, public readonly choices: Choice[] = []) {
31
+ super(message, EXIT.NEEDS_INPUT, { choices });
32
+ this.name = 'NeedsInputError';
33
+ }
34
+ }
35
+
36
+ export class SessionExpiredError extends CliError {
37
+ constructor(message = 'Session expired. Run `indemn login`, then retry.') {
38
+ super(message, EXIT.AUTH);
39
+ this.name = 'SessionExpiredError';
40
+ }
41
+ }
42
+
43
+ /**
44
+ * True when nobody can answer a prompt: an AI agent (Claude Code sets
45
+ * CLAUDECODE=1), CI, an explicit opt-out, or stdin that is not a terminal.
46
+ */
47
+ export function isNonInteractive(
48
+ env: NodeJS.ProcessEnv = process.env,
49
+ stdinIsTTY: boolean = Boolean(process.stdin.isTTY),
50
+ ): boolean {
51
+ if (env.CLAUDECODE === '1') return true;
52
+ if (env.CI && env.CI !== 'false' && env.CI !== '0') return true;
53
+ if (env.INDEMN_NON_INTERACTIVE === '1') return true;
54
+ return !stdinIsTTY;
55
+ }
56
+
57
+ export function isDebug(env: NodeJS.ProcessEnv = process.env): boolean {
58
+ return env.INDEMN_DEBUG === '1';
59
+ }
60
+
61
+ let cachedVersion: string | undefined;
62
+
63
+ /** The package version, read from package.json (src/core and dist/core are both two levels down). */
64
+ export function cliVersion(): string {
65
+ if (!cachedVersion) {
66
+ const require = createRequire(import.meta.url);
67
+ cachedVersion = (require('../../package.json') as { version: string }).version;
68
+ }
69
+ return cachedVersion;
70
+ }
71
+
72
+ /** How long a question to a person (through an MCP host) may stay open. The MCP default of 60 s is too short to read a diff. */
73
+ export const PERSON_TIMEOUT_MS = 10 * 60_000;
@@ -0,0 +1,64 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { Environment } from '../sdk/types.js';
3
+ import type { OrgChoice, OrgSource } from './org-resolver.js';
4
+
5
+ /**
6
+ * The org one MCP tool call acts on. Each call runs inside its own scope, so
7
+ * two concurrent calls for different orgs never see each other's org.
8
+ */
9
+ const orgScope = new AsyncLocalStorage<{ orgId: string }>();
10
+
11
+ export function runWithOrg<T>(orgId: string | undefined, fn: () => Promise<T>): Promise<T> {
12
+ return orgId ? orgScope.run({ orgId }, fn) : fn();
13
+ }
14
+
15
+ export function scopedOrg(): string | undefined {
16
+ return orgScope.getStore()?.orgId;
17
+ }
18
+
19
+ /** The terminal `--org` flag, process-wide for one CLI command. */
20
+ let orgFlag: string | undefined;
21
+ export function setOrgFlag(flag?: string): void {
22
+ orgFlag = flag || undefined;
23
+ }
24
+ export function getOrgFlag(): string | undefined {
25
+ return orgFlag;
26
+ }
27
+
28
+ /** How to ask the person something: a terminal prompt, an MCP elicitation, or nothing. */
29
+ export interface FormField {
30
+ key: string;
31
+ title: string;
32
+ description?: string;
33
+ required?: boolean;
34
+ }
35
+
36
+ export interface Askers {
37
+ chooseOrg?: (choices: OrgChoice[]) => Promise<string | undefined>;
38
+ mfaCode?: () => Promise<string>;
39
+ /** Ask for several text fields at once; undefined when declined or nobody can answer. */
40
+ form?: (message: string, fields: FormField[]) => Promise<Record<string, string> | undefined>;
41
+ }
42
+ let askers: Askers = {};
43
+ export function setAskers(a: Askers): void {
44
+ askers = a;
45
+ }
46
+ export function getAskers(): Askers {
47
+ return askers;
48
+ }
49
+
50
+ export interface ResolvedOrgEvent {
51
+ id: string;
52
+ name?: string;
53
+ source: OrgSource;
54
+ environment: Environment;
55
+ }
56
+ type Listener = (org: ResolvedOrgEvent) => void;
57
+ const listeners: Listener[] = [];
58
+ /** Called once per client when its org is decided; the CLI prints its banner here. */
59
+ export function onOrgResolved(fn: Listener): void {
60
+ listeners.push(fn);
61
+ }
62
+ export function emitOrgResolved(org: ResolvedOrgEvent): void {
63
+ for (const fn of listeners) fn(org);
64
+ }
@@ -0,0 +1,236 @@
1
+ import { z } from 'zod/v3';
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
+ import type { Environment } from '../sdk/types.js';
5
+ import { NeedsInputError, PERSON_TIMEOUT_MS } from '../core/runtime.js';
6
+ import { runWithOrg } from '../core/scope.js';
7
+ import { TOOL_META, type ToolMeta } from './tool-meta.js';
8
+
9
+ /** Where a tool result came from. Every successful result carries it. */
10
+ export interface ResultContext {
11
+ org_id?: string;
12
+ org_name?: string;
13
+ environment: Environment;
14
+ /** The person approved this call in the host's approval form (the model cannot see that form). */
15
+ approved_by_person?: boolean;
16
+ }
17
+
18
+ type Shape = Record<string, z.ZodTypeAny>;
19
+ type Args = Record<string, unknown>;
20
+ /** `confirmedByPerson` is true when the host asked the person and they agreed. */
21
+ type Handler = (args: any, meta: { confirmedByPerson: boolean }) => Promise<CallToolResult>;
22
+
23
+ function text(data: unknown, isError = false): CallToolResult {
24
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], ...(isError ? { isError: true } : {}) };
25
+ }
26
+
27
+ /**
28
+ * Handlers return `success(data)`, a JSON text block. Rewrap it as
29
+ * `{ context, data }` so the caller always sees which org and platform
30
+ * answered. Error results and non-JSON text pass through untouched.
31
+ */
32
+ /** About 20k tokens: Claude Code refuses tool results over 25k tokens. */
33
+ export const MAX_RESULT_CHARS = 80_000;
34
+
35
+ /** Keep a result inside the size limit: trim a list and say so, or cut other text with a note. */
36
+ function fit(body: { context: ResultContext; data: unknown }): CallToolResult {
37
+ const full = JSON.stringify(body, null, 2);
38
+ if (full.length <= MAX_RESULT_CHARS) return text(body);
39
+ const hint = 'Result was too large. Ask for less: use limit/page, a filter, or response_format "concise".';
40
+ const list = Array.isArray(body.data) ? body.data : (body.data as { items?: unknown[] } | null)?.items;
41
+ if (Array.isArray(list)) {
42
+ const shaped = (keep: number) => ({ context: body.context, data: { items: list.slice(0, keep), returned: keep, total: list.length, truncated: true, hint } });
43
+ let keep = list.length;
44
+ while (keep > 0 && JSON.stringify(shaped(keep), null, 2).length > MAX_RESULT_CHARS) keep = Math.floor(keep * 0.7);
45
+ return text(shaped(keep));
46
+ }
47
+ return { content: [{ type: 'text', text: `${full.slice(0, MAX_RESULT_CHARS)}\n... [truncated: ${full.length - MAX_RESULT_CHARS} more characters. ${hint}]` }] };
48
+ }
49
+
50
+ function withContext(result: CallToolResult, context: ResultContext, page?: { limit: number; page: number }): CallToolResult {
51
+ if (result.isError || result.content.length !== 1 || result.content[0].type !== 'text') return result;
52
+ let data: unknown;
53
+ try {
54
+ data = JSON.parse(result.content[0].text);
55
+ } catch {
56
+ return result;
57
+ }
58
+ // A question back to the caller is not data from the platform: keep its shape.
59
+ if (data && typeof data === 'object' && (data as { needs_input?: unknown }).needs_input === true) return result;
60
+ if (page && Array.isArray(data)) {
61
+ const all = data;
62
+ const end = (page.page - 1) * page.limit + page.limit;
63
+ const hasMore = end < all.length;
64
+ // Spelled out: a bare has_more is easy for a model to skip, then it answers from one page.
65
+ const next = hasMore ? { next: `${all.length - end} more: call again with page ${page.page + 1}` } : {};
66
+ data = { items: all.slice(end - page.limit, end), page: page.page, limit: page.limit, total: all.length, has_more: hasMore, ...next };
67
+ }
68
+ return fit({ context, data });
69
+ }
70
+
71
+ /** Why a person's answer stopped the change, so the agent tells them instead of retrying. */
72
+ const NOT_APPROVED: Record<string, string> = {
73
+ accept: 'Proceed was not ticked. Nothing was changed.',
74
+ // Said as a choice, not a failure: a model reading "declined" as an error calls the tool again.
75
+ decline: 'The person pressed Decline: they chose not to make this change. Nothing was changed. Tell them so and do not call this tool again unless they ask for it again.',
76
+ cancel: 'The question was dismissed, or this host cannot show it (Claude Code in -p mode cancels at once). Nothing was changed; calling again with confirm: true does not help. Approve in an interactive session, or use the indemn CLI with --yes.',
77
+ };
78
+
79
+ /**
80
+ * Ask before a destructive call. With elicitation the host asks the person
81
+ * directly and a `confirm` argument is ignored, so an agent cannot confirm
82
+ * for itself. Without elicitation `confirm: true` proceeds; anything else
83
+ * returns a dry run and the call is not made.
84
+ */
85
+ /** Approval forms show a few short lines: a long prompt diff would push the Proceed box off the screen. */
86
+ export function fitForm(text: string, maxLines = 8, maxLine = 140): string {
87
+ const lines = text.split('\n');
88
+ // A short message stays whole; only long, many-line text (a prompt diff) is shortened.
89
+ const cut = lines.length > maxLines || text.length > 900;
90
+ const shown = lines.slice(0, maxLines).map((l) => (l.length > maxLine ? `${l.slice(0, maxLine - 1)}…` : l));
91
+ return cut ? `${shown.join('\n')}\n(Shortened to fit; ask to see the full change before approving.)` : text;
92
+ }
93
+
94
+ export async function guardDestructive(
95
+ server: McpServer,
96
+ toolName: string,
97
+ args: Args,
98
+ preview: string,
99
+ formText: string = preview,
100
+ ): Promise<{ proceed: true; byPerson: boolean } | { proceed: false; result: CallToolResult }> {
101
+ if (server.server.getClientCapabilities()?.elicitation) {
102
+ try {
103
+ const answer = await server.server.elicitInput({
104
+ message: `${formText} Proceed?`,
105
+ requestedSchema: {
106
+ type: 'object',
107
+ properties: { confirm: { type: 'boolean', title: 'Proceed', description: 'Tick (Space), then submit, to make this change.' } },
108
+ required: ['confirm'],
109
+ },
110
+ }, { timeout: PERSON_TIMEOUT_MS });
111
+ if (answer.action === 'accept' && answer.content?.confirm === true) return { proceed: true, byPerson: true };
112
+ return { proceed: false, result: text({ cancelled: true, action: toolName, would: preview, reason: NOT_APPROVED[answer.action] }) };
113
+ } catch (err) {
114
+ if ((err as { code?: number } | undefined)?.code === ErrorCode.RequestTimeout) {
115
+ return { proceed: false, result: text({ cancelled: true, action: toolName, would: preview, reason: 'No answer within 10 minutes; nothing was changed.' }) };
116
+ }
117
+ // The host can ask the person but asking failed: nothing is changed. Falling back to `confirm`
118
+ // would let the model approve its own change.
119
+ return { proceed: false, result: text({ cancelled: true, action: toolName, would: preview, reason: 'The approval form could not be shown, so nothing was changed. Tell the person; they can ask again.' }) };
120
+ }
121
+ }
122
+ if (args.confirm === true) return { proceed: true, byPerson: false };
123
+ return {
124
+ proceed: false,
125
+ result: text({
126
+ dry_run: true,
127
+ action: toolName,
128
+ would: preview,
129
+ to_proceed: 'Show this to the user. Only after they agree, call again with confirm: true.',
130
+ }),
131
+ };
132
+ }
133
+
134
+ function defaultPreview(name: string, meta: ToolMeta, args: Args): string {
135
+ const shown = Object.entries(args)
136
+ .filter(([k]) => k !== 'confirm' && k !== 'org_id')
137
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
138
+ .join(', ');
139
+ return `${meta.title} (${name}${shown ? `: ${shown}` : ''}).`;
140
+ }
141
+
142
+ export function createRegistry(
143
+ server: McpServer,
144
+ opts: {
145
+ context: () => Promise<ResultContext> | ResultContext;
146
+ meta?: Record<string, ToolMeta>;
147
+ /** Decide the org for one call from its org_id argument (else link, only org, or ask). */
148
+ resolveOrg?: (explicit?: string) => Promise<{ id: string; name?: string }>;
149
+ /**
150
+ * Names for the ids of a call (agent, knowledge base, org…), shown in approvals instead of the ids,
151
+ * and the ids that name nothing (no card is shown for those).
152
+ */
153
+ displayNames?: (args: Args, orgId?: string) => Promise<{ names: Record<string, string>; missing?: string[] }>;
154
+ },
155
+ ) {
156
+ const metaTable = opts.meta ?? TOOL_META;
157
+ const registered: string[] = [];
158
+
159
+ return {
160
+ tool(name: string, description: string, shape: Shape, handler: Handler, extra: { preview?: (args: Args) => Promise<string>; guardWhen?: (args: Args) => Promise<boolean> } = {}): void {
161
+ const meta = metaTable[name];
162
+ if (!meta) throw new Error(`MCP tool "${name}" has no entry in tool-meta.ts`);
163
+
164
+ const inputSchema: Shape = { ...shape };
165
+ const orgScoped = meta.orgScoped !== false && Boolean(opts.resolveOrg);
166
+ if (orgScoped) {
167
+ inputSchema.org_id = z
168
+ .string()
169
+ .optional()
170
+ .describe('Organization id to act on. Omit it to use the linked folder or your only org; with several orgs you are asked.');
171
+ }
172
+ if (meta.paginate && ('limit' in shape || 'page' in shape)) {
173
+ throw new Error(`MCP tool "${name}" is paged by the registry; remove its own limit/page arguments`);
174
+ }
175
+ const paginate = Boolean(meta.paginate);
176
+ if (paginate) {
177
+ inputSchema.limit = z.number().int().min(1).max(200).optional().describe('Items per page (default 50)');
178
+ inputSchema.page = z.number().int().min(1).optional().describe('Page, starting at 1');
179
+ }
180
+ if (meta.guard) {
181
+ inputSchema.confirm = z
182
+ .boolean()
183
+ .optional()
184
+ .describe('Set true only after the user has agreed to the dry run. Ignored when the host can ask the user itself.');
185
+ }
186
+
187
+ server.registerTool(
188
+ name,
189
+ { title: meta.title, description, inputSchema, annotations: meta.annotations },
190
+ (async (args: Args) => {
191
+ try {
192
+ const org = orgScoped ? await opts.resolveOrg!(typeof args.org_id === 'string' ? args.org_id : undefined) : undefined;
193
+ return await runWithOrg(org?.id, async () => {
194
+ let confirmedByPerson = false;
195
+ // A guarded tool asks every time, or only when guardWhen says so (e.g. the agent may be live).
196
+ if (meta.guard && (!extra.guardWhen || await extra.guardWhen(args))) {
197
+ // Names, not ids, in what the person approves ("Darwin Web Test", not 6abb…).
198
+ const found: { names: Record<string, string>; missing?: string[] } = opts.displayNames
199
+ ? await opts.displayNames(args, org?.id).catch(() => ({ names: {} }))
200
+ : { names: {} };
201
+ if (found.missing?.length) {
202
+ return { content: [{ type: 'text', text: `Error: Not found: ${found.missing.join(', ')}. Nothing was changed and nothing was asked. Look it up again (list tools give the ids) and retry.` }], isError: true };
203
+ }
204
+ const names: Record<string, string> = found.names;
205
+ const named = (s: string) => Object.entries(names).reduce((t, [id, label]) => t.split(id).join(`"${label}"`), s);
206
+ const base = named(extra.preview ? await extra.preview(args) : meta.describe ? meta.describe(args) : defaultPreview(name, meta, args));
207
+ const ctx = await opts.context();
208
+ const orgLabel = org ? org.name ?? names[org.id] ?? org.id : undefined;
209
+ const where = orgLabel ? ` Org: ${orgLabel}. Platform: ${ctx.environment}.` : ` Platform: ${ctx.environment}.`;
210
+ const gate = await guardDestructive(server, name, args, base + where, fitForm(base) + where);
211
+ if (!gate.proceed) return gate.result;
212
+ confirmedByPerson = gate.byPerson;
213
+ }
214
+ const result = await handler(args, { confirmedByPerson });
215
+ return withContext(result, { ...(await opts.context()), ...(confirmedByPerson ? { approved_by_person: true } : {}) }, paginate ? { limit: Number(args.limit ?? 50), page: Number(args.page ?? 1) } : undefined);
216
+ });
217
+ } catch (err) {
218
+ if (err instanceof NeedsInputError) {
219
+ // The resolver's text names the CLI flag; an MCP caller passes org_id.
220
+ const message = err.message.replace('Choose one with --org <id>', 'Call again with org_id set to one of the ids below');
221
+ return text({ needs_input: true, message, choices: err.choices, to_proceed: 'Ask the user which organization to use, then call again with org_id set to its id.' });
222
+ }
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
225
+ }
226
+ }) as never,
227
+ );
228
+ registered.push(name);
229
+ },
230
+ names(): string[] {
231
+ return [...registered];
232
+ },
233
+ };
234
+ }
235
+
236
+ export type Registry = ReturnType<typeof createRegistry>;
package/src/mcp/server.ts CHANGED
@@ -2,14 +2,96 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { IndemnClient } from '../sdk/client.js';
4
4
  import { registerTools } from './tools.js';
5
+ import { setAskers, type Askers } from '../core/scope.js';
6
+ import { cliVersion, SessionExpiredError, PERSON_TIMEOUT_MS } from '../core/runtime.js';
7
+
8
+ /**
9
+ * Questions the CLI may need answered during a tool call, asked through the
10
+ * host (Claude Code shows a dialog). Stdin carries the MCP protocol, so the
11
+ * terminal is never used. Without elicitation: the org question becomes a
12
+ * needs_input result, and an MFA renewal asks the person to log in again.
13
+ */
14
+ export function mcpAskers(server: McpServer): Askers {
15
+ const canAsk = () => Boolean(server.server.getClientCapabilities()?.elicitation);
16
+ // A person needs time to answer; the MCP default of 60 s is too short.
17
+ const ask = (params: Parameters<typeof server.server.elicitInput>[0]) => server.server.elicitInput(params, { timeout: PERSON_TIMEOUT_MS });
18
+ return {
19
+ chooseOrg: async (choices) => {
20
+ if (!canAsk()) return undefined;
21
+ try {
22
+ const answer = await ask({
23
+ message: 'You belong to several organizations. Which one should this act on?',
24
+ requestedSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ org_id: {
28
+ type: 'string',
29
+ title: 'Organization',
30
+ oneOf: choices.map((c) => ({ const: c.id, title: c.name ? `${c.name} (${c.id})` : c.id })),
31
+ },
32
+ },
33
+ required: ['org_id'],
34
+ },
35
+ });
36
+ return answer.action === 'accept' && typeof answer.content?.org_id === 'string' ? answer.content.org_id : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ },
41
+ form: async (message, fields) => {
42
+ if (!canAsk()) return undefined;
43
+ try {
44
+ const answer = await ask({
45
+ message,
46
+ requestedSchema: {
47
+ type: 'object',
48
+ properties: Object.fromEntries(fields.map((f) => [f.key, { type: 'string', title: f.title, description: f.description }])),
49
+ required: fields.filter((f) => f.required).map((f) => f.key),
50
+ },
51
+ });
52
+ if (answer.action !== 'accept' || !answer.content) return undefined;
53
+ return Object.fromEntries(Object.entries(answer.content).map(([k, v]) => [k, String(v ?? '')]));
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ },
58
+ mfaCode: async () => {
59
+ if (!canAsk()) {
60
+ throw new SessionExpiredError('The session needs renewing and your account uses MFA. Run `indemn login` in a terminal, then retry.');
61
+ }
62
+ const answer = await ask({
63
+ message: 'Your Indemn session needs renewing. Enter the MFA code that was just emailed to you.',
64
+ requestedSchema: {
65
+ type: 'object',
66
+ properties: { code: { type: 'string', title: 'MFA code', description: '6 digits', minLength: 6, maxLength: 6 } },
67
+ required: ['code'],
68
+ },
69
+ });
70
+ if (answer.action !== 'accept' || typeof answer.content?.code !== 'string') {
71
+ throw new SessionExpiredError('MFA code not entered. Run `indemn login` in a terminal, then retry.');
72
+ }
73
+ return answer.content.code;
74
+ },
75
+ };
76
+ }
77
+
78
+ /** Guidance every MCP host (Claude Code, Jarvis, others) gives the model. */
79
+ const SERVER_INSTRUCTIONS = [
80
+ 'Indemn tools manage AI agents on the Indemn platform for the signed-in person.',
81
+ 'Talking to people: call agents, knowledge bases, skills, test sets, rubrics and runs by their names, never ids. Show an id only when the person asks for one; tell two items with the same name apart by a detail such as the channel or phone number. When the person wants more about an item, give its url (its page in the dashboard). Use plain words instead of internal field names, keep answers short, and offer details on request.',
82
+ 'Changes: when the host shows an approval form, that form is the question; call the tool and do not ask again in chat. context.approved_by_person means the person approved in that form. When a tool returns dry_run instead, show it and call again with confirm: true only after the person agreed.',
83
+ 'If a tool returns needs_input, ask the person and call again with their answer.',
84
+ ].join('\n');
5
85
 
6
86
  export async function startServer(): Promise<void> {
87
+ IndemnClient.setClientKind('mcp');
7
88
  const client = new IndemnClient();
8
89
  const server = new McpServer({
9
90
  name: 'indemn',
10
- version: '1.0.0',
11
- });
91
+ version: cliVersion(),
92
+ }, { instructions: SERVER_INSTRUCTIONS });
12
93
 
94
+ setAskers(mcpAskers(server));
13
95
  registerTools(server, client);
14
96
 
15
97
  const transport = new StdioServerTransport();