@indemn/cli 1.7.7 → 1.7.9

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +1 -1
  4. package/README.md +5 -3
  5. package/dist/cli/auth.d.ts.map +1 -1
  6. package/dist/cli/auth.js +95 -62
  7. package/dist/cli/auth.js.map +1 -1
  8. package/dist/mcp/registry.d.ts +12 -0
  9. package/dist/mcp/registry.d.ts.map +1 -1
  10. package/dist/mcp/registry.js +36 -3
  11. package/dist/mcp/registry.js.map +1 -1
  12. package/dist/mcp/server.d.ts.map +1 -1
  13. package/dist/mcp/server.js +15 -1
  14. package/dist/mcp/server.js.map +1 -1
  15. package/dist/mcp/tool-meta.d.ts.map +1 -1
  16. package/dist/mcp/tool-meta.js +47 -2
  17. package/dist/mcp/tool-meta.js.map +1 -1
  18. package/dist/mcp/tools.d.ts.map +1 -1
  19. package/dist/mcp/tools.js +139 -4
  20. package/dist/mcp/tools.js.map +1 -1
  21. package/dist/sdk/access.d.ts +60 -0
  22. package/dist/sdk/access.d.ts.map +1 -0
  23. package/dist/sdk/access.js +115 -0
  24. package/dist/sdk/access.js.map +1 -0
  25. package/dist/sdk/auth.d.ts +7 -0
  26. package/dist/sdk/auth.d.ts.map +1 -1
  27. package/dist/sdk/auth.js +21 -2
  28. package/dist/sdk/auth.js.map +1 -1
  29. package/dist/sdk/browser-login.d.ts +49 -0
  30. package/dist/sdk/browser-login.d.ts.map +1 -0
  31. package/dist/sdk/browser-login.js +159 -0
  32. package/dist/sdk/browser-login.js.map +1 -0
  33. package/dist/sdk/campaigns.d.ts +82 -3
  34. package/dist/sdk/campaigns.d.ts.map +1 -1
  35. package/dist/sdk/campaigns.js +106 -4
  36. package/dist/sdk/campaigns.js.map +1 -1
  37. package/dist/sdk/client.d.ts +2 -0
  38. package/dist/sdk/client.d.ts.map +1 -1
  39. package/dist/sdk/client.js +31 -3
  40. package/dist/sdk/client.js.map +1 -1
  41. package/dist/sdk/credentials.d.ts +2 -0
  42. package/dist/sdk/credentials.d.ts.map +1 -1
  43. package/dist/sdk/credentials.js +4 -0
  44. package/dist/sdk/credentials.js.map +1 -1
  45. package/dist/sdk/eval.d.ts +3 -3
  46. package/dist/sdk/eval.js +3 -3
  47. package/dist/sdk/types.d.ts +3 -0
  48. package/dist/sdk/types.d.ts.map +1 -1
  49. package/dist/sdk/types.js +0 -1
  50. package/dist/sdk/types.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/cli/auth.ts +93 -63
  53. package/src/mcp/registry.ts +43 -4
  54. package/src/mcp/server.ts +20 -1
  55. package/src/mcp/tool-meta.ts +49 -2
  56. package/src/mcp/tools.ts +177 -5
  57. package/src/sdk/access.ts +143 -0
  58. package/src/sdk/auth.ts +23 -2
  59. package/src/sdk/browser-login.ts +193 -0
  60. package/src/sdk/campaigns.ts +125 -4
  61. package/src/sdk/client.ts +25 -3
  62. package/src/sdk/credentials.ts +5 -0
  63. package/src/sdk/eval.ts +3 -3
  64. package/src/sdk/types.ts +3 -0
  65. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +7 -0
  66. package/test/mcp/access.test.ts +158 -0
  67. package/test/mcp/campaigns.test.ts +165 -0
  68. package/test/mcp/tools-snapshot.test.ts +2 -2
  69. package/test/sdk/auth.test.ts +2 -1
  70. package/test/sdk/browser-login.test.ts +147 -0
  71. package/test/sdk/campaigns.test.ts +76 -18
  72. package/test/sdk/client.test.ts +134 -1
  73. package/test/sdk/credentials.test.ts +6 -1
package/src/cli/auth.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { Command } from 'commander';
1
+ import { Command, Option } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import * as readline from 'readline';
5
5
  import { Writable } from 'node:stream';
6
- import { customHosts, login, logout, whoami, type WhoAmI } from '../sdk/auth.js';
6
+ import { customHosts, login, loginWithBrowser, logout, whoami, type LoginResult, type WhoAmI } from '../sdk/auth.js';
7
+ import { currentAccess, refreshAccess } from '../sdk/access.js';
8
+ import { IndemnClient } from '../sdk/client.js';
7
9
  import { findLinkFile } from '../core/org-resolver.js';
8
10
  import { getOrgFlag } from '../core/scope.js';
9
11
  import { SessionExpiredError } from '../core/runtime.js';
@@ -61,70 +63,90 @@ function describeOrgRule(info: WhoAmI): string {
61
63
  return 'looked up on first use';
62
64
  }
63
65
 
66
+ async function browserLogin(env: Environment): Promise<LoginResult> {
67
+ const spinner = ora();
68
+ try {
69
+ const result = await loginWithBrowser(env, {
70
+ showUrl: (url, opened) => {
71
+ console.log(opened ? 'Opening your browser to sign in…' : 'Open this link in your browser to sign in:');
72
+ console.log(chalk.dim(` ${url}`));
73
+ spinner.start('Waiting for you to approve in the browser (5 minutes)…');
74
+ },
75
+ });
76
+ spinner.stop();
77
+ return result;
78
+ } catch (err) {
79
+ spinner.fail('Sign-in failed');
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ async function passwordLogin(env: Environment): Promise<LoginResult | null> {
85
+ const email = await prompt('Email: ');
86
+ if (!email) {
87
+ printError('Email is required');
88
+ return null;
89
+ }
90
+ const password = await promptPassword('Password: ');
91
+ if (!password) {
92
+ printError('Password is required');
93
+ return null;
94
+ }
95
+ const spinner = ora('Authenticating...').start();
96
+ try {
97
+ return await login(email, password, env, {
98
+ mfaCodePrompt: async () => {
99
+ spinner.stop();
100
+ console.log(chalk.yellow('MFA code sent to your email.'));
101
+ const code = await prompt('MFA Code: ');
102
+ if (!code) {
103
+ throw new Error('MFA code is required');
104
+ }
105
+ spinner.start('Verifying MFA code...');
106
+ return code;
107
+ },
108
+ });
109
+ } catch (err) {
110
+ spinner.fail('Authentication failed');
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ function printLoginResult(result: LoginResult): void {
116
+ printSuccess(`Logged in as ${result.email} on ${result.environment}`);
117
+ if (result.expiresAt) {
118
+ console.log(chalk.dim(` Session valid until ${new Date(result.expiresAt).toLocaleString()}; run \`indemn login\` again after that.`));
119
+ }
120
+ if (result.orgs.length === 0) {
121
+ console.log(chalk.yellow(' You are not a member of any organization yet. Ask an admin to add you.'));
122
+ } else {
123
+ console.log(chalk.dim(` Organizations (${result.orgs.length}):`));
124
+ for (const o of result.orgs) console.log(chalk.dim(` ${o.id} ${o.name}`));
125
+ if (result.orgs.length > 1) {
126
+ console.log(chalk.dim(' Choose one per command with --org <id>, or pin a project folder with `indemn link --org <id>`.'));
127
+ }
128
+ }
129
+ }
130
+
64
131
  export function registerAuthCommands(program: Command): void {
65
132
  program
66
133
  .command('login')
67
- .description('Authenticate with Indemn platform')
134
+ .description('Sign in through your browser (the dashboard approves this computer)')
68
135
  .option('--env <environment>', 'Target environment')
136
+ // Email/password/emailed-code prompts in the terminal; kept for one or two releases.
137
+ .addOption(new Option('--password', 'Sign in with email and password in the terminal').hideHelp())
69
138
  .action(async (options) => {
139
+ const env = (options.env || process.env.INDEMN_ENV || 'dev') as Environment;
140
+ if (env !== 'dev' && env !== 'production') {
141
+ printError('Environment must be "dev" or "production"');
142
+ process.exit(1);
143
+ }
70
144
  try {
71
- const env = (options.env || process.env.INDEMN_ENV || 'dev') as Environment;
72
- if (env !== 'dev' && env !== 'production') {
73
- printError('Environment must be "dev" or "production"');
74
- process.exit(1);
75
- }
76
-
77
- const email = await prompt('Email: ');
78
- if (!email) {
79
- printError('Email is required');
80
- process.exit(1);
81
- }
82
-
83
- const password = await promptPassword('Password: ');
84
- if (!password) {
85
- printError('Password is required');
86
- process.exit(1);
87
- }
88
-
89
- const spinner = ora('Authenticating...').start();
90
-
91
- try {
92
- const result = await login(email, password, env, {
93
- mfaCodePrompt: async () => {
94
- spinner.stop();
95
- console.log(chalk.yellow('MFA code sent to your email.'));
96
- const code = await prompt('MFA Code: ');
97
- if (!code) {
98
- throw new Error('MFA code is required');
99
- }
100
- spinner.start('Verifying MFA code...');
101
- return code;
102
- },
103
- });
104
- spinner.succeed(`Logged in as ${result.email} on ${result.environment}`);
105
- if (result.expiresAt) {
106
- console.log(chalk.dim(` Session valid until ${new Date(result.expiresAt).toLocaleString()}; run \`indemn login\` again after that.`));
107
- }
108
- if (result.orgs.length === 0) {
109
- console.log(chalk.yellow(' You are not a member of any organization yet. Ask an admin to add you.'));
110
- } else {
111
- console.log(chalk.dim(` Organizations (${result.orgs.length}):`));
112
- for (const o of result.orgs) console.log(chalk.dim(` ${o.id} ${o.name}`));
113
- if (result.orgs.length > 1) {
114
- console.log(chalk.dim(' Choose one per command with --org <id>, or pin a project folder with `indemn link --org <id>`.'));
115
- }
116
- }
117
- } catch (err) {
118
- spinner.fail('Authentication failed');
119
- if (err instanceof Error) {
120
- printError(err.message);
121
- } else {
122
- printError(err);
123
- }
124
- process.exit(1);
125
- }
145
+ const result = options.password ? await passwordLogin(env) : await browserLogin(env);
146
+ if (!result) process.exit(1);
147
+ printLoginResult(result);
126
148
  } catch (err) {
127
- printError(err);
149
+ printError(err instanceof Error ? err.message : err);
128
150
  process.exit(1);
129
151
  }
130
152
  });
@@ -146,16 +168,19 @@ export function registerAuthCommands(program: Command): void {
146
168
  .command('whoami')
147
169
  .description('Display current user and environment info')
148
170
  .option('--json', 'Output as JSON')
149
- .action((options) => {
171
+ .action(async (options) => {
150
172
  try {
151
173
  const info = whoami();
152
174
  if (!info) {
153
175
  printError(new SessionExpiredError('Not logged in. Run `indemn login` first.'));
154
176
  process.exit(3);
155
177
  }
178
+ // Roles as copilot reports them now (display only; the servers decide each request).
179
+ const c = new IndemnClient();
180
+ const access = (await refreshAccess(c)) ?? currentAccess(c);
156
181
 
157
182
  if (options.json) {
158
- printJson({ ...info, org_rule: describeOrgRule(info) });
183
+ printJson({ ...info, org_rule: describeOrgRule(info), access: access ?? undefined });
159
184
  return;
160
185
  }
161
186
 
@@ -167,9 +192,14 @@ export function registerAuthCommands(program: Command): void {
167
192
  console.log(` Session: valid until ${new Date(info.sessionExpiresAt).toLocaleString()} (then \`indemn login\`)`);
168
193
  }
169
194
  console.log(` Org here: ${describeOrgRule(info)}`);
170
- if (info.orgs.length) {
195
+ if (access?.is_indemn_staff) console.log(` Indemn staff: yes`);
196
+ const orgs = access ? access.orgs.map((o) => ({ id: o.org_id, name: o.org_name, role: o.role })) : info.orgs;
197
+ if (orgs.length) {
171
198
  console.log(` Orgs:`);
172
- for (const o of info.orgs) console.log(` ${o.id} ${o.name}`);
199
+ for (const o of orgs) {
200
+ const role = 'role' in o && o.role ? chalk.dim(` (${o.role})`) : '';
201
+ console.log(` ${o.id} ${o.name}${role}`);
202
+ }
173
203
  }
174
204
  // The servers only when one is not the platform's default: the usual ones are noise.
175
205
  const custom = customHosts(info);
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod/v3';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import type { Environment } from '../sdk/types.js';
5
5
  import { NeedsInputError, PERSON_TIMEOUT_MS } from '../core/runtime.js';
@@ -169,10 +169,29 @@ export function createRegistry(
169
169
  * and the ids that name nothing (no card is shown for those).
170
170
  */
171
171
  displayNames?: (args: Args, orgId?: string) => Promise<{ names: Record<string, string>; missing?: string[] }>;
172
+ /**
173
+ * Role awareness (display only; the servers still decide): hide tools the person can't use
174
+ * anywhere, refuse early with the reason when the person's access says so, and name the
175
+ * person's role when a server refuses.
176
+ */
177
+ access?: {
178
+ visible: (name: string) => boolean;
179
+ deny: (name: string, orgId?: string) => Promise<string | null>;
180
+ explain: (orgId?: string) => Promise<string | null>;
181
+ };
172
182
  },
173
183
  ) {
174
184
  const metaTable = opts.meta ?? TOOL_META;
175
185
  const registered: string[] = [];
186
+ const handles = new Map<string, RegisteredTool>();
187
+
188
+ // A refusal from the servers names the person's role (handlers report errors as results).
189
+ const withRoleNote = async (result: CallToolResult, orgId?: string): Promise<CallToolResult> => {
190
+ const first = result.isError ? result.content?.[0] : undefined;
191
+ if (!opts.access || !first || first.type !== 'text' || !/^Error: (Not allowed|Access denied)/.test(first.text)) return result;
192
+ const role = await opts.access.explain(orgId).catch(() => null);
193
+ return role ? { ...result, content: [{ ...first, text: `${first.text} ${role}` }, ...result.content.slice(1)] } : result;
194
+ };
176
195
 
177
196
  return {
178
197
  tool(name: string, description: string, shape: Shape, handler: Handler, extra: { preview?: (args: Args) => Promise<string>; guardWhen?: (args: Args) => Promise<boolean> } = {}): void {
@@ -202,12 +221,18 @@ export function createRegistry(
202
221
  .describe('Set true only after the user has agreed to the dry run. Ignored when the host can ask the user itself.');
203
222
  }
204
223
 
205
- server.registerTool(
224
+ const handle = server.registerTool(
206
225
  name,
207
226
  { title: meta.title, description, inputSchema, annotations: meta.annotations },
208
227
  (async (args: Args) => {
228
+ let orgId: string | undefined;
209
229
  try {
210
230
  const org = orgScoped ? await opts.resolveOrg!(typeof args.org_id === 'string' ? args.org_id : undefined) : undefined;
231
+ orgId = org?.id;
232
+ const refusal = opts.access ? await opts.access.deny(name, org?.id) : null;
233
+ if (refusal) {
234
+ return { content: [{ type: 'text', text: `Error: Not allowed: ${refusal} Nothing was changed.` }], isError: true };
235
+ }
211
236
  return await runWithOrg(org?.id, async () => {
212
237
  let confirmedByPerson = false;
213
238
  // A guarded tool asks every time, or only when guardWhen says so (e.g. the agent may be live).
@@ -233,7 +258,7 @@ export function createRegistry(
233
258
  if (!gate.proceed) return gate.result;
234
259
  confirmedByPerson = gate.byPerson;
235
260
  }
236
- const result = await handler(args, { confirmedByPerson });
261
+ const result = await withRoleNote(await handler(args, { confirmedByPerson }), orgId);
237
262
  return withContext(result, { ...(await opts.context()), ...(confirmedByPerson ? { approved_by_person: true } : {}) }, paginate ? { limit: Number(args.limit ?? 50), page: Number(args.page ?? 1) } : undefined);
238
263
  });
239
264
  } catch (err) {
@@ -242,16 +267,30 @@ export function createRegistry(
242
267
  const message = err.message.replace('Choose one with --org <id>', 'Call again with org_id set to one of the ids below');
243
268
  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.' });
244
269
  }
245
- const message = err instanceof Error ? err.message : String(err);
270
+ let message = err instanceof Error ? err.message : String(err);
271
+ if (opts.access && /^(Not allowed|Access denied)/.test(message)) {
272
+ const role = await opts.access.explain(orgId).catch(() => null);
273
+ if (role) message = `${message} ${role}`;
274
+ }
246
275
  return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
247
276
  }
248
277
  }) as never,
249
278
  );
250
279
  registered.push(name);
280
+ handles.set(name, handle);
251
281
  },
252
282
  names(): string[] {
253
283
  return [...registered];
254
284
  },
285
+ /** Show only the tools the person can use somewhere; the host is told when the list changes. */
286
+ applyVisibility(): void {
287
+ if (!opts.access) return;
288
+ for (const [name, handle] of handles) {
289
+ const show = opts.access.visible(name);
290
+ if (show && !handle.enabled) handle.enable();
291
+ else if (!show && handle.enabled) handle.disable();
292
+ }
293
+ },
255
294
  };
256
295
  }
257
296
 
package/src/mcp/server.ts CHANGED
@@ -2,6 +2,7 @@ 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 { refreshAccess } from '../sdk/access.js';
5
6
  import { setAskers, type Askers } from '../core/scope.js';
6
7
  import { cliVersion, PERSON_TIMEOUT_MS } from '../core/runtime.js';
7
8
 
@@ -75,8 +76,26 @@ export async function startServer(): Promise<void> {
75
76
  }, { instructions: SERVER_INSTRUCTIONS });
76
77
 
77
78
  setAskers(mcpAskers(server));
78
- registerTools(server, client);
79
+ const registry = registerTools(server, client);
80
+
81
+ // A host session (INDEMN_TOKEN) may be a different identity than any saved login,
82
+ // so never hide tools for it — deny() still refuses with the reason when a call
83
+ // isn't allowed. A saved login hides from what it already knows, before the host
84
+ // lists tools, then keeps it fresh.
85
+ const ownLogin = client.getCredential()?.source === 'config';
86
+ if (ownLogin) registry.applyVisibility();
79
87
 
80
88
  const transport = new StdioServerTransport();
81
89
  await server.connect(transport);
90
+
91
+ const refresh = () =>
92
+ refreshAccess(client).then(
93
+ () => { if (ownLogin) registry.applyVisibility(); },
94
+ () => undefined,
95
+ );
96
+ void refresh();
97
+ setInterval(() => void refresh(), ACCESS_REFRESH_MS).unref();
82
98
  }
99
+
100
+ /** How often a running MCP server asks again what the person may do. */
101
+ const ACCESS_REFRESH_MS = 15 * 60_000;
@@ -29,6 +29,17 @@ const write = (title: string, extra: Partial<ToolAnnotations> = {}): ToolMeta =>
29
29
  annotations: { title, readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, ...extra },
30
30
  });
31
31
 
32
+ /** The parts a call changes, in words: "audience, channel policy". */
33
+ const parts = (a: Record<string, unknown>, keys: string[]) => keys.filter((k) => a[k] !== undefined && a[k] !== '').map((k) => k.replace(/_/g, ' ')).join(', ');
34
+
35
+ /** A book change with its new values, so the person sees what they approve: "field map to Company → account.name". */
36
+ const bookChanges = (a: Record<string, any>) => [
37
+ a.source !== undefined && `source to ${a.source?.kind ?? JSON.stringify(a.source)}`,
38
+ Array.isArray(a.field_map) && `field map to ${a.field_map.map((m: { source_path?: string; schema_key?: string }) => `${m.source_path} → ${m.schema_key}`).join(', ')}`,
39
+ Array.isArray(a.identity_keys) && `identity keys to ${a.identity_keys.join(', ')}`,
40
+ a.display && `display to ${['primary', 'secondary'].filter((k) => Array.isArray(a.display[k])).map((k) => `${k} ${a.display[k].join(', ')}`).join('; ')}`,
41
+ ].filter(Boolean).join('; ');
42
+
32
43
  /** Overwrites or removes something that cannot be restored from the tool itself. */
33
44
  const destroy = (title: string, extra: Partial<ToolAnnotations> = {}): ToolMeta => ({
34
45
  title,
@@ -217,8 +228,44 @@ export const TOOL_META: Record<string, ToolMeta> = {
217
228
  // Billing (read-only)
218
229
  get_billing: read('Billing status, invoices, usage or plans'),
219
230
 
220
- // Campaigns (read-only)
221
- get_campaigns: read('Read campaigns and templates'),
231
+ // Campaigns: each change is approved on a card; nothing here sends a message, and nothing deletes.
232
+ get_campaigns: read('Read campaigns, templates and books'),
233
+ save_campaign: {
234
+ ...write('Create or change campaign'), guard: true,
235
+ describe: (a) => a.campaign_id
236
+ ? `This will change the ${parts(a, ['audience', 'escalation_policy', 'channel_policy'])} in the draft of campaign ${a.campaign_id} (it takes effect when the campaign is published).`
237
+ : `This will create campaign "${a.name}" as a draft; it sends nothing until it is published and started.`,
238
+ },
239
+ set_touchpoint: {
240
+ ...write('Add or change touchpoint', { idempotentHint: true }), guard: true,
241
+ describe: (a) => `This will add or change touchpoint "${a.key}" in the draft of campaign ${a.campaign_id}${a.channel ? ` (${a.channel})` : ''}${a.terminal_action === 'send' ? '. It is set to SEND to customers directly once the campaign is published and running' : ''}.`,
242
+ },
243
+ publish_campaign: { ...write('Publish campaign'), guard: true, describe: (a) => `This will publish the draft of campaign ${a.campaign_id} as its next version; if it is running, it uses it from its next run.` },
244
+ set_campaign_status: {
245
+ ...write('Start, pause or archive campaign'), guard: true,
246
+ describe: (a) => a.status === 'running'
247
+ ? `This will START campaign ${a.campaign_id}: it enrols the matching records of its book and runs its touchpoints (touchpoints set to send reach real customers).`
248
+ : a.status === 'archived' ? `This will archive campaign ${a.campaign_id} for good: it cannot be started again.`
249
+ : `This will pause campaign ${a.campaign_id}: its touchpoints stop until it is started again.`,
250
+ },
251
+ save_campaign_template: {
252
+ ...write('Create or change campaign template'), guard: true,
253
+ describe: (a) => a.template_id
254
+ ? `This will save a new version of template ${a.template_id} (${parts(a, ['subject', 'body', 'html', 'uses_variables', 'skills', 'selection_tags'])}); touchpoints keep the version they are set to until it is set again.`
255
+ : `This will create ${a.channel} template "${a.name}".`,
256
+ },
257
+ save_template_skill: {
258
+ ...write('Create or change template skill'), guard: true,
259
+ describe: (a) => a.template_skill_id
260
+ ? `This will change template skill ${a.template_skill_id} (${parts(a, ['name', 'instructions'])}); templates using it write with it from their campaigns' next run.`
261
+ : `This will create template skill "${a.name}".`,
262
+ },
263
+ save_book: {
264
+ ...write('Create or change book'), guard: true,
265
+ describe: (a) => a.book_id
266
+ ? `This will change book ${a.book_id}: ${bookChanges(a)}.`
267
+ : `This will create book "${a.name}" for this solution.`,
268
+ },
222
269
 
223
270
  // V2 platform and Jarvis (Darwin)
224
271
  platform_list: read('List V2 platform items'),
package/src/mcp/tools.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod/v3';
2
+ import { currentAccess, orgDenial, refreshAccess, roleIn, usableSomewhere } from '../sdk/access.js';
2
3
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import type { IndemnClient } from '../sdk/client.js';
4
5
  import { AgentInsightsSDK, AgentsSDK } from '../sdk/agents.js';
@@ -12,7 +13,7 @@ import { DistributionSDK } from '../sdk/distribution.js';
12
13
  import { AnalyticsSDK, AuditSDK, OrgSettingsSDK } from '../sdk/analytics.js';
13
14
  import { TicketsSDK } from '../sdk/tickets.js';
14
15
  import { BillingSDK } from '../sdk/billing.js';
15
- import { CampaignsSDK } from '../sdk/campaigns.js';
16
+ import { CampaignsSDK, type Touchpoint } from '../sdk/campaigns.js';
16
17
  import { PlatformSDK } from '../sdk/platform.js';
17
18
  import { jarvisChat } from '../sdk/jarvis-chat.js';
18
19
  import { applyConfigChange, describePlan, liveText, mayBeLive, modelArg, planConfigChange, type ConfigChange } from '../sdk/config-change.js';
@@ -167,9 +168,32 @@ const skillDefinition = z.preprocess(parseJsonText, z.object({
167
168
  }).passthrough()).optional(),
168
169
  }).passthrough());
169
170
 
171
+ // Campaigns are on for an org only when Indemn has turned Solutions on: the dashboard hides them otherwise, and the
172
+ // campaigns service does not check it, so these tools ask first (before any approval card).
173
+ const CAMPAIGN_TOOLS = new Set(['get_campaigns', 'save_campaign', 'set_touchpoint', 'publish_campaign', 'set_campaign_status', 'save_campaign_template', 'save_template_skill', 'save_book']);
174
+ const SOLUTIONS_OFF = 'Campaigns are off for this organization: Solutions is not turned on (the dashboard hides campaigns too). Indemn staff can turn it on with set_org_admin_settings (solutions: true); anyone else asks the Indemn team to turn it on.';
175
+
170
176
  export function registerTools(server: McpServer, client: IndemnClient): Registry {
177
+ // Role awareness: the folder's linked org (or INDEMN_ORG_ID) narrows what counts; otherwise any org.
178
+ const orgInView = () => process.env.INDEMN_ORG_ID || client.getLink()?.org_id || undefined;
171
179
  const reg = createRegistry(server, {
172
180
  context: () => client.getResultContext(),
181
+ access: {
182
+ visible: (name) => usableSomewhere(name, currentAccess(client), orgInView()),
183
+ // Refuse early only when the saved access still says no after asking again (it may be stale).
184
+ deny: async (name, orgId) => {
185
+ if (CAMPAIGN_TOOLS.has(name) && orgId) {
186
+ const on = await client.copilotGet<{ enableSolutions?: boolean }>(`/organization/${orgId}/configurations`).then((c) => c?.enableSolutions === true, () => false);
187
+ if (!on) return SOLUTIONS_OFF;
188
+ }
189
+ if (!orgDenial(name, currentAccess(client), orgId)) return null;
190
+ return orgDenial(name, await refreshAccess(client), orgId);
191
+ },
192
+ explain: async (orgId) => {
193
+ const role = roleIn((await refreshAccess(client)) ?? currentAccess(client), orgId);
194
+ return role ? `Your role: ${role}.` : null;
195
+ },
196
+ },
173
197
  // Each call decides its own org; an answer given once is not reused for the next call.
174
198
  resolveOrg: (explicit) => client.resolveOrg(explicit, { cache: false }),
175
199
  // The person approves a change by names: look up the ids the call names.
@@ -204,7 +228,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
204
228
  add(args.ticket_id, null, (id) => tickets.get(id).then((t) => { const x = t as { ticket_ref?: string; title?: string }; return label([x.ticket_ref, x.title].filter(Boolean).join(' ')); })),
205
229
  add(args.rule_id, null, (id) => tickets.listRules({ includeTrashed: true }).then((rules) => label((rules as Array<{ _id?: string; id?: string; name?: string }>).find((r) => (r._id ?? r.id) === id)?.name))),
206
230
  add(args.distribution_id, null, (id) => distribution.list().then((d) => { const p = (d.paths ?? []).find((x) => x.id === id); return label(p ? `${p.path_name ?? ''} (${p.host_name ?? '*'}${p.path_expression ?? ''})`.trim() : undefined); })),
207
- add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label((ts as Array<{ _id?: string; name?: string }>).find((t) => t._id === id)?.name))),
231
+ // A website template, else a campaign template.
232
+ add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label((ts as Array<{ _id?: string; name?: string }>).find((t) => t._id === id)?.name))
233
+ .then((l) => l.name ? l : new CampaignsSDK(client).template(id).then((t) => label((t as { name?: unknown }).name), () => l))),
234
+ add(args.campaign_id, 'campaign', (id) => new CampaignsSDK(client).get(id) as Promise<{ name?: unknown }>),
235
+ add(args.book_id, 'book', (id) => new CampaignsSDK(client).book(id)),
236
+ add(args.template_skill_id, 'template skill', (id) => new CampaignsSDK(client).templateSkill(id)),
208
237
  add(args.owner_id, null, (id) => orgs.listMembers().then((ms) => { const m = ms.find((x) => x.user_id === id); return label(m?.name ?? m?.email); })),
209
238
  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,
210
239
  add(orgId, null, (id) => client.copilotGet<{ organization?: { name?: string } }>(`/organization/${id}`).then((r) => r.organization)),
@@ -1845,10 +1874,10 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1845
1874
 
1846
1875
  reg.tool(
1847
1876
  'get_campaigns',
1848
- 'Read-only campaigns: list, one campaign (campaign_id), its counts / forecast / next-touchpoints / versions, templates, one template (template_id, versions: true for subject and body), or template skills.',
1877
+ 'Read campaigns: list, one campaign (campaign_id, with its draft and touchpoints), its counts / forecast / next-touchpoints / versions, templates, one template (template_id, versions: true for subject and body), template skills, books, or one book (book_id).',
1849
1878
  {
1850
- what: z.enum(['list', 'campaign', 'counts', 'forecast', 'next-touchpoints', 'versions', 'templates', 'template', 'template-skills']),
1851
- campaign_id: z.string().optional(), template_id: z.string().optional(), versions: z.boolean().optional(),
1879
+ what: z.enum(['list', 'campaign', 'counts', 'forecast', 'next-touchpoints', 'versions', 'templates', 'template', 'template-skills', 'books', 'book']),
1880
+ campaign_id: z.string().optional(), template_id: z.string().optional(), book_id: z.string().optional(), versions: z.boolean().optional(),
1852
1881
  status: z.string().optional(), solution_id: z.string().optional(), channel: z.string().optional(),
1853
1882
  days: z.number().int().min(1).max(365).optional(), limit: z.number().int().min(1).max(500).optional(),
1854
1883
  },
@@ -1862,12 +1891,155 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
1862
1891
  case 'templates': return success(await c.templates({ channel: args.channel, solutionId: args.solution_id, limit: args.limit }));
1863
1892
  case 'template': return success(await c.template(need(args.template_id, 'template_id'), Boolean(args.versions)));
1864
1893
  case 'template-skills': return success(await c.templateSkills(args.solution_id));
1894
+ case 'books': return success(await c.books({ solutionId: args.solution_id, limit: args.limit }));
1895
+ case 'book': return success(await c.book(need(args.book_id, 'book_id')));
1865
1896
  default: return success(await c.detail(need(args.campaign_id, 'campaign_id'), args.what, args.days));
1866
1897
  }
1867
1898
  } catch (err) { return error(err); }
1868
1899
  },
1869
1900
  );
1870
1901
 
1902
+ // Campaigns configuration: every change is approved on a card. Nothing here sends a message, runs a book sync or
1903
+ // deletes (CampaignsSDK WRITABLE); a campaign ends archived. A save_* tool given an id changes that item, else creates.
1904
+ const campaigns = new CampaignsSDK(client);
1905
+ type CampaignArgs = Record<string, any>;
1906
+ // Only the tool's own arguments reach the service: not confirm/org_id, and not the "" or null a model fills unused
1907
+ // fields with (an empty value never blanks a saved field).
1908
+ const own = (args: CampaignArgs): CampaignArgs => Object.fromEntries(Object.entries(args).filter(([k, v]) => k !== 'confirm' && k !== 'org_id' && v !== '' && v !== null));
1909
+ const lacking = (a: CampaignArgs, keys: string[]) => keys.filter((k) => a[k] === undefined);
1910
+ // `problem` is checked before the card: a call that cannot work is answered at once, without asking or sending.
1911
+ const campaignTool = (name: string, description: string, shape: Record<string, z.ZodTypeAny>, run: (a: CampaignArgs) => Promise<unknown>, problem: (a: CampaignArgs) => string | null = () => null) =>
1912
+ reg.tool(name, description, shape, async (args) => {
1913
+ try {
1914
+ const a = own(args);
1915
+ const p = problem(a);
1916
+ if (p) throw new Error(p);
1917
+ return success(await run(a));
1918
+ } catch (err) { return error(err); }
1919
+ }, { guardWhen: async (args) => problem(own(args)) === null });
1920
+ // A create and then a change: if the change is refused, the item exists, so it is changed with its id next time.
1921
+ const thenChange = async (what: string, made: { id: string }, change: () => Promise<unknown>) => {
1922
+ try { return await change(); } catch (err) { throw new Error(`The ${what} was created (id ${made.id}), but ${err instanceof Error ? err.message : String(err)}. Change it with its id; do not create it again.`); }
1923
+ };
1924
+ const rule = (what: string) => jsonObject().describe(`${what}: a rule as an object or JSON text, e.g. {"all":[{"field":"status","op":"eq","value":"active"},{"field":"derived.days_out","op":"lte","value":60}]}. Ops: eq ne lt lte gt gte in nin exists missing contains; all / any / not combine rules.`);
1925
+ const DRAFT_PARTS = ['audience', 'escalation_policy', 'channel_policy'];
1926
+
1927
+ campaignTool(
1928
+ 'save_campaign',
1929
+ "Create a campaign (no campaign_id) as a draft for a solution's book, or change a campaign's draft: its audience (which records belong) and escalation and channel policies; what is not given stays. Touchpoints are set with set_touchpoint. Nothing is sent until the campaign is published (publish_campaign) and started (set_campaign_status running). Find the solution with list_solutions and its book with get_campaigns (what: books).",
1930
+ {
1931
+ campaign_id: z.string().optional().describe('The campaign to change; leave out to create one'),
1932
+ solution_id: z.string().optional(), book_id: z.string().optional(), name: z.string().optional(),
1933
+ campaign_type: z.string().optional().describe('To create: e.g. renewal, outreach'),
1934
+ entry: z.enum(['rolling', 'cohort']).optional().describe('To create: rolling (records join as they match, the default) or cohort (a fixed set, with closes)'),
1935
+ closes: z.string().optional().describe('To create a cohort campaign: ISO date it closes'),
1936
+ audience: rule('Which records belong').optional(),
1937
+ escalation_policy: jsonObject().optional().describe('e.g. {"after":{"touches_exhausted":true},"on_hard_failure":"escalate_immediately","then":"create_work_item","assign_to":null}'),
1938
+ channel_policy: jsonObject().optional().describe('e.g. {"quiet_hours":null,"max_per_record_per_window":null}'),
1939
+ },
1940
+ async ({ campaign_id, audience, escalation_policy, channel_policy, ...create }) => {
1941
+ const parts = { audience, escalation_policy, channel_policy };
1942
+ if (campaign_id) return campaigns.updateDraft(campaign_id, parts);
1943
+ const made = await campaigns.createCampaign(create as Parameters<CampaignsSDK['createCampaign']>[0]);
1944
+ return DRAFT_PARTS.some((k) => (parts as CampaignArgs)[k] !== undefined) ? thenChange('campaign', made, async () => ({ ...made, draft: await campaigns.updateDraft(made.id, parts) })) : made;
1945
+ },
1946
+ (a) => a.campaign_id
1947
+ ? (DRAFT_PARTS.some((k) => a[k] !== undefined) ? null : 'Nothing to change: give audience, escalation_policy or channel_policy (a campaign is not renamed here).')
1948
+ : (lacking(a, ['solution_id', 'book_id', 'name', 'campaign_type']).length ? `To create a campaign also give ${lacking(a, ['solution_id', 'book_id', 'name', 'campaign_type']).join(', ')}; to change one, give its campaign_id.` : null),
1949
+ );
1950
+
1951
+ campaignTool(
1952
+ 'set_touchpoint',
1953
+ "Add a touchpoint to a campaign's draft, or change the one with the same key (only the fields given change). when is a trigger rule: days before the due date ({\"field\":\"derived.days_out\",\"op\":\"lte\",\"value\":45}), a day of the month (calendar.day_of_month), days without a reply (silence.days) or an inbound intent (intent). terminal_action draft (the default) prepares the message for a person to review; send sends it to the customer, so use send only when the person asks for it. A template given without a version is pinned to its latest.",
1954
+ {
1955
+ campaign_id: z.string(),
1956
+ key: z.string().trim().min(1).describe('Unique in the campaign, e.g. t2_reminder_45'),
1957
+ when: rule('When it fires').optional(),
1958
+ channel: z.enum(['email', 'voice', 'sms']).optional(),
1959
+ terminal_action: z.enum(['draft', 'send']).optional(),
1960
+ order: z.number().int().optional(),
1961
+ template_id: z.string().optional(), template_version_n: z.number().int().optional(),
1962
+ grouping: jsonObject().optional().describe('e.g. {"key":"insured_account","recipient":"resolved"} (key: record, insured_account or fixed_recipient)'),
1963
+ required_variables: jsonArray(z.string()).optional(),
1964
+ },
1965
+ async ({ campaign_id, ...touchpoint }) => campaigns.setTouchpoint(campaign_id, touchpoint as Touchpoint),
1966
+ );
1967
+
1968
+ campaignTool(
1969
+ 'publish_campaign',
1970
+ "Publish a campaign's draft as its next version (refused, with every problem listed, while it has no audience or touchpoint). A running campaign uses the new version from its next run.",
1971
+ { campaign_id: z.string(), change_summary: z.string().optional() },
1972
+ async (a) => campaigns.publish(a.campaign_id, a.change_summary),
1973
+ );
1974
+
1975
+ campaignTool(
1976
+ 'set_campaign_status',
1977
+ 'Start (running), pause (paused) or archive (archived, for good) a campaign. Starting needs a published version; it then enrols the matching records of its book and runs its touchpoints. Campaigns are never deleted: asked to delete one, offer to archive it and say it is archived, not deleted.',
1978
+ { campaign_id: z.string(), status: z.enum(['running', 'paused', 'archived']) },
1979
+ async (a) => campaigns.setStatus(a.campaign_id, a.status),
1980
+ );
1981
+
1982
+ const TEMPLATE_PARTS = ['subject', 'body', 'html', 'uses_variables', 'skills', 'selection_tags'];
1983
+ campaignTool(
1984
+ 'save_campaign_template',
1985
+ 'Create a campaign message template (no template_id), or change one: a change is saved as its new version, made from the latest one with the fields given. Touchpoints keep the version they are set to until set_touchpoint sets the template again.',
1986
+ {
1987
+ template_id: z.string().optional().describe('The template to change; leave out to create one'),
1988
+ solution_id: z.string().optional(), name: z.string().optional(), channel: z.enum(['email', 'voice', 'sms']).optional(),
1989
+ subject: z.string().optional().describe('Email subject. Placeholders: {{ record.<book field> }}, e.g. {{ record.policy.number }}, or contact.*, campaign.*, enrollment.*'),
1990
+ body: z.string().optional().describe('Message text, with placeholders as in subject'),
1991
+ html: z.string().optional(),
1992
+ uses_variables: jsonArray(z.string()).optional().describe('Every placeholder key the template uses, e.g. ["record.policy.number","contact.first_name"] (the service refuses an undeclared one)'),
1993
+ skills: jsonObject().optional(), selection_tags: jsonObject().optional(),
1994
+ change_summary: z.string().optional(),
1995
+ },
1996
+ async ({ template_id, solution_id, name, channel, ...content }) => template_id
1997
+ ? campaigns.updateTemplate(template_id, content)
1998
+ : campaigns.createTemplate({ solution_id, name, channel, ...content }),
1999
+ (a) => a.template_id
2000
+ ? (TEMPLATE_PARTS.some((k) => a[k] !== undefined) ? null : `Nothing to change: give ${TEMPLATE_PARTS.join(', ')}.`)
2001
+ : (lacking(a, ['solution_id', 'name', 'channel', 'body']).length ? `To create a template also give ${lacking(a, ['solution_id', 'name', 'channel', 'body']).join(', ')}; to change one, give its template_id.` : null),
2002
+ );
2003
+
2004
+ campaignTool(
2005
+ 'save_template_skill',
2006
+ "Create a template skill (no template_skill_id): writing instructions a template uses when its message is composed. Or change one's name or instructions; templates using it write with the new text from their campaigns' next run, with no publish.",
2007
+ {
2008
+ template_skill_id: z.string().optional().describe('The skill to change; leave out to create one'),
2009
+ solution_id: z.string().optional(), key: z.string().optional().describe('To create: unique in the solution, e.g. friendly_tone'),
2010
+ name: z.string().optional(), instructions: z.string().optional(),
2011
+ },
2012
+ async ({ template_skill_id, solution_id, key, name, instructions }) => template_skill_id
2013
+ ? campaigns.updateTemplateSkill(template_skill_id, { name, instructions })
2014
+ : campaigns.createTemplateSkill({ solution_id, key, name, instructions }),
2015
+ (a) => a.template_skill_id
2016
+ ? (a.name !== undefined || a.instructions !== undefined ? null : 'Nothing to change: give name or instructions.')
2017
+ : (lacking(a, ['solution_id', 'key', 'name', 'instructions']).length ? `To create a template skill also give ${lacking(a, ['solution_id', 'key', 'name', 'instructions']).join(', ')}; to change one, give its template_skill_id.` : null),
2018
+ );
2019
+
2020
+ const BOOK_PARTS = ['source', 'field_map', 'identity_keys', 'display'];
2021
+ campaignTool(
2022
+ 'save_book',
2023
+ "Create a solution's book, the records a campaign works from (no book_id; a solution has one book, so look with get_campaigns what: books first). Or change a book's source, field map (which source column fills which field), identity keys (what makes a record unique; only while it has no records) or display (fields shown for a record); each part given replaces the book's. Keys are the book's schema keys, e.g. policy.number, insured.name.",
2024
+ {
2025
+ book_id: z.string().optional().describe('The book to change; leave out to create one'),
2026
+ solution_id: z.string().optional(), name: z.string().optional(),
2027
+ schema_id: z.string().optional().describe("To create: the entity schema; left out, the solution type's default fields"),
2028
+ source: jsonObject().optional().describe('{"kind":"upload"} (records uploaded as CSV in the dashboard) or {"kind":"connection","integration_id":"…"}'),
2029
+ field_map: jsonArray(jsonObject()).optional().describe('e.g. [{"schema_key":"policy.number","source_path":"Policy #"}]'),
2030
+ identity_keys: jsonArray(z.string()).optional().describe('e.g. ["policy.number"]'),
2031
+ display: jsonObject().optional().describe('e.g. {"primary":["policy.number"],"secondary":["insured.name"]}'),
2032
+ },
2033
+ async ({ book_id, solution_id, name, schema_id, source, ...parts }) => {
2034
+ if (book_id) return campaigns.updateBook(book_id, { source, ...parts });
2035
+ const made = await campaigns.createBook({ solution_id, name, schema_id, source });
2036
+ return Object.keys(parts).length ? thenChange('book', made, () => campaigns.updateBook(made.id, parts)) : made;
2037
+ },
2038
+ (a) => a.book_id
2039
+ ? (BOOK_PARTS.some((k) => a[k] !== undefined) ? null : `Nothing to change: give ${BOOK_PARTS.join(', ')}.`)
2040
+ : (lacking(a, ['solution_id', 'name']).length ? `To create a book also give ${lacking(a, ['solution_id', 'name']).join(', ')}; to change one, give its book_id.` : null),
2041
+ );
2042
+
1871
2043
  const platform = new PlatformSDK(client);
1872
2044
  const platformResource = z.enum(['agents', 'templates', 'components', 'connectors', 'connector-bindings', 'scenarios']);
1873
2045