@adrata/adrata-mcp 1.0.8 → 1.0.30

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.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * Provisioning: seating a workspace user, and the onboarding link that reaches them.
3
+ *
4
+ * # Why this pack exists
5
+ *
6
+ * Adrata is onboarding real customers, and the act an AE actually performs is
7
+ * "mint a link and drop it in the Zoom chat". Until now MCP could not perform
8
+ * it at all, for two separate reasons that look like one:
9
+ *
10
+ * 1. **There is no purpose-built tool.** The only route to seating was the
11
+ * chat tool `invite_user` reached through `adrata_ai_tool_execute`, which
12
+ * `tool-annotations.js` classifies `legacy_bridge`. It takes ONE email
13
+ * (`"required": ["email"]` in routes-ai `.../definitions/specialized/
14
+ * workspace_defs.rs`) and carries no adaptive payload whatsoever.
15
+ *
16
+ * 2. **That route cannot complete a write from here, by design.** Measured
17
+ * 2026-09-06 against `routes-ai/src/tools/ai_crm_tools/mod.rs:427`:
18
+ *
19
+ * if !interactive_human {
20
+ * return Err(AppError::Forbidden(
21
+ * "Machine principals cannot approve AI CRM writes; an
22
+ * interactive human session must authorize the action"))
23
+ * }
24
+ *
25
+ * Every MCP client is a machine principal, so `invite_user` through the
26
+ * bridge previews forever and seats nobody. That is the "capability
27
+ * nothing can reach" class from `.claude/CLAUDE.md`: reading the tool
28
+ * catalogue makes you believe seating works from MCP, and the failure
29
+ * arrives only at the last step.
30
+ *
31
+ * So this pack is not a convenience wrapper over a working path. It is the
32
+ * first path from MCP that can finish.
33
+ *
34
+ * # Why there is ONE tool and not two
35
+ *
36
+ * The obvious shape is a `seat_user` tool plus a `create_onboarding_link`
37
+ * tool. It is the wrong shape here, and the reason is written down in
38
+ * `code/api/src/routes/v1/entities/org/workspace/invitations.rs`:
39
+ *
40
+ * > There were two ways to seat a workspace user and only one of them
41
+ * > invited anybody. [...] Measured in production on 2026-08-31:
42
+ * > `noah@adrata.com` was seated as an admin, the row was real and
43
+ * > `list_users` confirmed it, and the invitee's inbox was empty.
44
+ *
45
+ * Seating and inviting are one operation because `POST /workspace/members/
46
+ * invite` does both in one call: it finds-or-creates the user, adds the
47
+ * membership, and mints the token. A separate "seat without a link" tool would
48
+ * reconstruct the exact defect that module exists to prevent, one layer up.
49
+ * One invitation, one tool.
50
+ *
51
+ * # The scope story, which is better than it looks and worse than it reads
52
+ *
53
+ * `/workspace` sits in `GRANDFATHERED_UNMAPPED` (middleware `scope_guard/
54
+ * grandfathered.rs`), so `required_scopes_for` returns an empty vec and this
55
+ * route needs NO OAuth scope. The default grant can therefore call it — there
56
+ * is no `write:integrations`-shaped trap here, and the tool is reachable today.
57
+ *
58
+ * That register is explicitly "A DEBT REGISTER, NOT A PERMANENT EXEMPTION",
59
+ * and paying the debt down is where the danger is. Workspace administration
60
+ * would naturally map to an `admin:*` scope, and `access/oauth.js` records —
61
+ * with its own measurement — that `admin:*` is **permanently ungrantable**
62
+ * through this client: `consented_scope_grant` refuses every `admin:`-prefixed
63
+ * scope before it consults the client registration, under every role including
64
+ * `super_admin`. So mapping `/workspace` to `admin:workspace` would not be a
65
+ * #2435-shaped outage that heals when the API catches up; it would kill this
66
+ * tool permanently. Whoever pays that debt down must map member-invite to a
67
+ * NON-admin write scope (`write:members`, say) and add it to
68
+ * `OAUTH_WRITE_SCOPE`, or accept that MCP provisioning stops working.
69
+ *
70
+ * The real gate today is not a scope at all: `invite_member` requires the
71
+ * caller to be a workspace admin (`is_workspace_admin`), so a seller's
72
+ * connection gets a 403 from the handler regardless of what it holds.
73
+ *
74
+ * # The token is a credential
75
+ *
76
+ * An invite URL seats its bearer in the workspace. It is treated the way the
77
+ * companion migration treats it — `20270106005522_workspace_invitation_records
78
+ * .sql` stores only the token's SHA-256 and says why:
79
+ *
80
+ * > Anyone holding the token can seat themselves in the workspace.
81
+ *
82
+ * Consequences honoured here: the URL is returned to the caller once and is
83
+ * never written to a log line, never put in an error message, and never placed
84
+ * in a preview (a preview has minted nothing, so it has no URL to leak). The
85
+ * replay ledger below holds URLs in memory only, and `redactInviteUrl` is the
86
+ * only shape allowed anywhere diagnostic.
87
+ */
88
+
89
+ import { createHash } from 'node:crypto';
90
+
91
+ import {
92
+ isLiveWrite,
93
+ missingLiveWriteFields,
94
+ liveWriteRefusal,
95
+ } from '../../governance/governed-args.js';
96
+
97
+ /**
98
+ * The ONE minting endpoint. Named as a constant because the whole argument
99
+ * above depends on there being exactly one, and a second literal elsewhere in
100
+ * this file would be the first step back to two paths.
101
+ */
102
+ export const INVITE_PATH = '/api/v1/workspace/members/invite';
103
+ export const SEATS_PATH = '/api/v1/workspace/members';
104
+
105
+ /**
106
+ * Launch identifiers must match the column's own CHECK constraint:
107
+ *
108
+ * launch TEXT CHECK (launch IS NULL OR launch ~ '^[a-z][a-z0-9_]{0,63}$')
109
+ *
110
+ * Validated here rather than left to the database because the failure
111
+ * otherwise arrives as a 500 with a constraint name in it, after the user has
112
+ * already been created — the invite handler creates the user BEFORE it mints,
113
+ * so a late rejection leaves a seated user with no link. That is the same
114
+ * half-invited state the `managerId` pre-check in `members.rs` was written to
115
+ * avoid, and it deserves the same treatment.
116
+ *
117
+ * The vocabulary itself is deliberately NOT enumerated here. The migration
118
+ * explains why the column is free text: "The set of launches is a product
119
+ * decision that will move faster than migrations." An enum in this file would
120
+ * reintroduce exactly that coupling, one process further out.
121
+ */
122
+ /**
123
+ * The role that makes somebody the operator of their own workspace, and the
124
+ * reason `role` on this tool is REQUIRED rather than defaulted.
125
+ *
126
+ * # The contradiction this constant exists to close
127
+ *
128
+ * This tool used to default the seat to `seller`. `canOfferTeamInvite`
129
+ * (`code/desktop/src/renderer/src/features/chat/team-invite-admin.ts`) accepts
130
+ * only `admin`, on an owner ruling of 2026-09-06 — "admins only, keep the
131
+ * routing as is" — because all three routes the offer opens end at an
132
+ * admin-gated server call, and offering somebody an action the server answers
133
+ * 403 to is worse than staying silent.
134
+ *
135
+ * Neither decision is wrong on its own. Together they made the team-invite
136
+ * offer dead for everybody minted the default way, and the two halves are
137
+ * wired to each other more tightly than that: the offer only fires for a
138
+ * `launch`, and a `launch` is set ONLY on an invitation minted here
139
+ * (`launch.ts`: "It is set on the invitation during the sale"). So the
140
+ * trigger's population was exactly this tool's output, and the gate's
141
+ * population excluded all of it. A feature whose trigger and whose gate are
142
+ * wired to disjoint populations has no audience at all.
143
+ *
144
+ * # Why REQUIRED, rather than flipping the default to `admin`
145
+ *
146
+ * Because the tool genuinely cannot know, and both guesses are wrong in
147
+ * opposite directions:
148
+ *
149
+ * - Defaulting `seller` silently under-grants the buyer, who is the person
150
+ * the AE just sold to and the one who will add their own team. That is the
151
+ * defect above: it costs the whole growth loop and shows up as nothing.
152
+ * - Defaulting `admin` silently over-grants everybody else. An AE seating
153
+ * three people at a customer during a pilot would mint three workspace
154
+ * admins nobody decided to create. A grant that widens by default is the
155
+ * failure direction you cannot take back.
156
+ *
157
+ * **"First member" is not derivable here, and that was checked rather than
158
+ * assumed.** `create_workspace` seats its creator as `admin` in the same
159
+ * transaction (`workspace_repo/core.rs`: `INSERT INTO workspace_users ...
160
+ * VALUES ($1, $2, $3, 'admin', NOW())`), so the seat list is NEVER empty at
161
+ * mint time and a "no seats yet" rule can never fire. `workspace_users`
162
+ * carries no `first`, `owner` or `seq` column — only `joinedAt` — so "the
163
+ * first customer seat" would have to be a heuristic about which existing seat
164
+ * is the AE's, which is an identity guess dressed as a fact.
165
+ *
166
+ * The server keeps its own `seller` default (`members.rs`, and a second route
167
+ * in `admin/security/core/handlers.rs`) and should: least privilege is right
168
+ * for a general-purpose member-invite endpoint reached by many callers. What
169
+ * was wrong was a THIRD default, in a tool whose audience is one specific
170
+ * person, disagreeing silently with the gate downstream of it.
171
+ *
172
+ * So this tool asks. It costs the caller one field, the preview prints the
173
+ * answer before anything is written, and no seat is ever granted by omission.
174
+ *
175
+ * # Why this value is `admin` exactly
176
+ *
177
+ * `team-invite-admin.ts` argues the point in full: `workspace_users.role` is
178
+ * free-form TEXT, and the two server gates do not accept identically — REST
179
+ * normalizes `workspace_admin` and `owner` up to admin, while chat's
180
+ * `RoleTier::from_auth_role` matches only `admin`, `super_admin` and `owner`
181
+ * literally. `admin` is the one spelling BOTH accept and the only one
182
+ * `normalize_assignable_role` will write. `onboarding-tools.test.js` binds
183
+ * this constant to that module's real offer set so the two cannot drift apart
184
+ * again in silence.
185
+ */
186
+ export const WORKSPACE_SETUP_ROLE = 'admin';
187
+
188
+ /** The ordinary seat: somebody joining a team that already exists. */
189
+ export const WORKSPACE_MEMBER_ROLE = 'seller';
190
+
191
+ const LAUNCH_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
192
+ const SOURCE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
193
+ const PREPARED_NOTE_MAX = 2000;
194
+ const MAX_PREPARED_ACCOUNTS = 100;
195
+
196
+ /** Matches `idempotency_key TEXT CHECK (char_length BETWEEN 8 AND 255)`. */
197
+ const IDEMPOTENCY_KEY_MIN = 8;
198
+ const IDEMPOTENCY_KEY_MAX = 255;
199
+
200
+ /**
201
+ * How many replay entries to keep. Bounded because each entry holds a live
202
+ * credential: an unbounded map would accumulate every invite URL the process
203
+ * has ever minted, turning a long-lived MCP server into a token store nobody
204
+ * decided to build.
205
+ */
206
+ const REPLAY_LEDGER_MAX = 256;
207
+
208
+ /**
209
+ * Render an invite URL safe for a log, an error, or a report.
210
+ *
211
+ * The token is the entire secret, so this keeps the origin and path — enough
212
+ * to tell a reader which environment minted it — and destroys the rest.
213
+ * Nothing in this pack may print an invite URL any other way.
214
+ */
215
+ export function redactInviteUrl(url) {
216
+ if (!url) return null;
217
+ try {
218
+ const parsed = new URL(String(url));
219
+ return `${parsed.origin}${parsed.pathname}?token=[redacted]`;
220
+ } catch {
221
+ return '[redacted invite url]';
222
+ }
223
+ }
224
+
225
+ /**
226
+ * A stable fingerprint of what a call would mint.
227
+ *
228
+ * Keys are sorted so that argument order cannot make two identical requests
229
+ * look different — a caller who names `launch` before `email` must not get a
230
+ * second invitation for it. Arrays are NOT sorted: the order of
231
+ * `preparedAccountIds` is caller-meaningful and a reorder is a different
232
+ * prepared workspace.
233
+ */
234
+ export function fingerprintInvitation(body) {
235
+ const canonical = JSON.stringify(body, Object.keys(body).sort());
236
+ return createHash('sha256').update(canonical).digest('hex');
237
+ }
238
+
239
+ /**
240
+ * In-process replay safety for minting.
241
+ *
242
+ * # Why this is here and not only at the API
243
+ *
244
+ * Criterion: repeating an identical call with the same idempotency key must
245
+ * return the same URL and must not mint a second invitation. Measured against
246
+ * the deployed route on 2026-09-06, `invite_member` reads no `Idempotency-Key`
247
+ * header at all — it calls `uuid::Uuid::new_v4()` unconditionally, so a second
248
+ * identical call mints a SECOND token to the same person. The seat is
249
+ * idempotent (`find_user_by_email` reuses the row); the credential is not.
250
+ *
251
+ * The companion migration adds the server-side answer —
252
+ * `workspace_invitation_records_idempotency_idx`, unique on
253
+ * `(workspace_id, idempotency_key)` — but that column is written by route code
254
+ * that is still in flight. Until it lands, a client-side ledger is the only
255
+ * thing standing between a retried tool call and a second live credential
256
+ * mailed to the same person.
257
+ *
258
+ * # What this is NOT
259
+ *
260
+ * It is process-local and memory-only. A restarted server, a second MCP
261
+ * process, or a call made through any other client will not see these entries,
262
+ * so this is a guard and not a guarantee. It is deliberately not persisted:
263
+ * writing invite URLs to disk to improve deduplication would trade a bounded
264
+ * in-memory credential for an unbounded on-disk one, which is a worse problem
265
+ * than the one being solved. The `Idempotency-Key` header is sent on every
266
+ * live call regardless, so the moment the server honours it the authoritative
267
+ * check is the server's and this becomes a fast path.
268
+ *
269
+ * Keyed by workspace as well as key, so one tenant's idempotency key can never
270
+ * resolve to another tenant's minted invitation.
271
+ */
272
+ export function createReplayLedger({ max = REPLAY_LEDGER_MAX } = {}) {
273
+ const entries = new Map();
274
+
275
+ // JSON-encoded rather than concatenated with a separator. An idempotency key
276
+ // is caller-supplied and may contain the separator, so `a` + sep + `b c` and
277
+ // `a b` + sep + `c` would collide — and a collision here means one tenant's
278
+ // key resolving to another tenant's minted invitation, which is the exact
279
+ // boundary this composition exists to hold.
280
+ const compose = (workspaceId, idempotencyKey) =>
281
+ JSON.stringify([workspaceId || 'unknown-workspace', idempotencyKey]);
282
+
283
+ return {
284
+ get(workspaceId, idempotencyKey) {
285
+ return entries.get(compose(workspaceId, idempotencyKey)) ?? null;
286
+ },
287
+ remember(workspaceId, idempotencyKey, record) {
288
+ // Oldest-first eviction. A Map iterates in insertion order, so the first
289
+ // key is the oldest; dropping it bounds the number of live credentials
290
+ // held in memory at any moment.
291
+ if (entries.size >= max) {
292
+ const oldest = entries.keys().next().value;
293
+ if (oldest !== undefined) entries.delete(oldest);
294
+ }
295
+ entries.set(compose(workspaceId, idempotencyKey), record);
296
+ return record;
297
+ },
298
+ get size() {
299
+ return entries.size;
300
+ },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Validate the adaptive payload before anything is created.
306
+ *
307
+ * Returns an array of human-readable problems. Empty means valid. Checked
308
+ * client-side because `invite_member` creates the user row BEFORE it mints,
309
+ * so a constraint rejection at the database leaves a seated user holding no
310
+ * link — visible in `list_members`, unable to sign in. Exactly the state the
311
+ * production incident on 2026-08-31 produced.
312
+ */
313
+ export function validateOnboardingArgs(args = {}) {
314
+ const problems = [];
315
+
316
+ const email = String(args.email ?? '').trim();
317
+ if (!email) problems.push('email is required');
318
+ else if (!email.includes('@') || email.length < 3 || email.length > 320) {
319
+ problems.push(`email "${email}" is not a usable address`);
320
+ }
321
+
322
+ // The seat is a grant, and this tool has no honest default for it. Refusing
323
+ // here rather than substituting is the whole of WORKSPACE_SETUP_ROLE's
324
+ // argument: the caller knows which person they just sold to and the tool
325
+ // does not. Checked before the write for the same reason every other clause
326
+ // in this function is — the invite route creates the user row before it
327
+ // mints, so a late refusal leaves a seated user with no link.
328
+ const role = String(args.role ?? '').trim();
329
+ if (!role) {
330
+ problems.push(
331
+ 'role is required and has no default, because both possible defaults are wrong in opposite'
332
+ + ` directions. Say "${WORKSPACE_SETUP_ROLE}" for the person setting the workspace up — the`
333
+ + ' buyer, who will be adding their own team and who is the only role offered the team invite'
334
+ + ` — or "${WORKSPACE_MEMBER_ROLE}" for somebody joining a team that already exists.`
335
+ + ' Guessing here either silently denies the buyer their own team or silently makes a'
336
+ + ' teammate an admin nobody decided to create',
337
+ );
338
+ }
339
+
340
+ if (args.launch !== undefined && args.launch !== null) {
341
+ const launch = String(args.launch);
342
+ if (!LAUNCH_PATTERN.test(launch)) {
343
+ problems.push(
344
+ `launch "${launch}" must be a short lower-case identifier matching ${LAUNCH_PATTERN.source}`
345
+ + ' — it is an identifier for a loop, not a sentence describing one',
346
+ );
347
+ }
348
+ }
349
+
350
+ if (args.source !== undefined && args.source !== null) {
351
+ const source = String(args.source);
352
+ if (!SOURCE_PATTERN.test(source)) {
353
+ problems.push(`source "${source}" must match ${SOURCE_PATTERN.source}`);
354
+ }
355
+ }
356
+
357
+ if (args.preparedAccountIds !== undefined && args.preparedAccountIds !== null) {
358
+ const ids = args.preparedAccountIds;
359
+ if (!Array.isArray(ids)) problems.push('preparedAccountIds must be an array of company ids');
360
+ else {
361
+ if (ids.length > MAX_PREPARED_ACCOUNTS) {
362
+ problems.push(
363
+ `preparedAccountIds holds ${ids.length} ids; at most ${MAX_PREPARED_ACCOUNTS} accounts can be prepared for one person`,
364
+ );
365
+ }
366
+ if (ids.some((id) => typeof id !== 'string' || !id.trim())) {
367
+ problems.push('preparedAccountIds must contain non-empty company id strings');
368
+ }
369
+ }
370
+ }
371
+
372
+ if (args.preparedNote !== undefined && args.preparedNote !== null) {
373
+ const note = String(args.preparedNote);
374
+ if (note.length > PREPARED_NOTE_MAX) {
375
+ problems.push(
376
+ `preparedNote is ${note.length} characters; the column is bounded at ${PREPARED_NOTE_MAX}`
377
+ + ' so it cannot become a place to stash a transcript',
378
+ );
379
+ }
380
+ }
381
+
382
+ if (args.idempotencyKey !== undefined && args.idempotencyKey !== null) {
383
+ const key = String(args.idempotencyKey).trim();
384
+ if (key && (key.length < IDEMPOTENCY_KEY_MIN || key.length > IDEMPOTENCY_KEY_MAX)) {
385
+ problems.push(
386
+ `idempotencyKey must be ${IDEMPOTENCY_KEY_MIN}-${IDEMPOTENCY_KEY_MAX} characters`
387
+ + ` (got ${key.length}); the storage column enforces the same bound`,
388
+ );
389
+ }
390
+ }
391
+
392
+ return problems;
393
+ }
394
+
395
+ /** The exact body this tool would send. Kept in one place so preview and live write cannot drift. */
396
+ export function buildInvitationBody(args = {}) {
397
+ const body = {
398
+ email: String(args.email ?? '').trim(),
399
+ // NO DEFAULT, deliberately. See WORKSPACE_SETUP_ROLE. An absent role
400
+ // reaches the server as the empty string and is refused there by name,
401
+ // rather than quietly becoming a grant nobody chose.
402
+ role: String(args.role ?? '').trim(),
403
+ };
404
+ if (args.managerId) body.managerId = args.managerId;
405
+
406
+ // ── The adaptive payload ────────────────────────────────────────────────
407
+ // INTEGRATION POINT (2026-09-06): these four fields are sent and the
408
+ // DEPLOYED endpoint ignores them. `InviteMemberRequest` in
409
+ // `code/api/src/routes/v1/entities/org/workspace/members.rs` declares
410
+ // exactly `email`, `role` and `manager_id`; serde drops unknown fields
411
+ // silently, so the call succeeds, a link is minted, and the launch is
412
+ // simply not recorded. They are sent anyway rather than withheld, because
413
+ // the route change carrying `InvitationContext` is in flight on
414
+ // `feature/onboarding-grant-record` and the wire shape should not need a
415
+ // second edit when it lands.
416
+ //
417
+ // What this means for a caller TODAY, and it is stated in the tool's own
418
+ // response rather than only here: the seat and the link are real, and the
419
+ // adaptive configuration is not yet durable.
420
+ //
421
+ // Note also that `InviteMemberRequest` carries no
422
+ // `#[serde(rename_all = "camelCase")]` — `manager_id` works only because it
423
+ // declares `alias = "managerId"` by hand. Any new field added there needs
424
+ // the same treatment or the camelCase name silently does nothing, which is
425
+ // the defect class `.claude/CLAUDE.md` records against the document-ingest
426
+ // route (422 `missing field file_base64`).
427
+ if (args.launch) body.launch = String(args.launch);
428
+ if (Array.isArray(args.preparedAccountIds) && args.preparedAccountIds.length > 0) {
429
+ body.preparedAccountIds = args.preparedAccountIds.map((id) => String(id));
430
+ }
431
+ if (args.preparedNote) body.preparedNote = String(args.preparedNote);
432
+ body.source = args.source ? String(args.source) : 'mcp_provisioning';
433
+
434
+ return body;
435
+ }
436
+
437
+ export function registerProvisioningTools(
438
+ server,
439
+ {
440
+ z,
441
+ api,
442
+ ok,
443
+ validateApiBridgeRequest,
444
+ buildMutationHeaders,
445
+ getGrantedScope = () => undefined,
446
+ getWorkspaceId = () => null,
447
+ ledger = createReplayLedger(),
448
+ },
449
+ ) {
450
+ server.tool(
451
+ 'list_workspace_seats',
452
+ 'List the people already seated in the connected workspace, with their role. Read-only.'
453
+ + ' Check this BEFORE minting an onboarding link: re-inviting somebody who already holds a live'
454
+ + ' invitation is refused by a unique index (one live invitation per address per workspace), and'
455
+ + ' the useful action for an existing seat is a resend rather than a second link.'
456
+ + ' Scoped to the connected workspace by the API from the token; there is no parameter that can'
457
+ + ' widen it to another tenant.',
458
+ {},
459
+ async () => {
460
+ const response = await api('GET', SEATS_PATH);
461
+ const seats = response?.data ?? response ?? [];
462
+ return ok({ workspaceId: getWorkspaceId(), seats, count: Array.isArray(seats) ? seats.length : null });
463
+ },
464
+ );
465
+
466
+ server.tool(
467
+ 'create_onboarding_link',
468
+ 'Seat a person in the connected workspace and mint the adaptive onboarding link an AE drops into a'
469
+ + ' Zoom chat. One call does both: the user row, the membership, and the setup link are a single'
470
+ + ' operation, because seating without inviting is the production defect that'
471
+ + ' `workspace/invitations.rs` exists to prevent (a real admin was seated in 2026-08-31 with an'
472
+ + ' empty inbox and no way to sign in).'
473
+ + ' Name the launch this person should land in and the accounts prepared for them, so they arrive'
474
+ + ' in a configured workspace rather than an empty one.'
475
+ + ` Say who they are: role is REQUIRED and has no default. "${WORKSPACE_SETUP_ROLE}" is the person`
476
+ + ' standing the workspace up — the buyer, who will add their own team, and the only role Adrata'
477
+ + ` ever offers the team invite to. "${WORKSPACE_MEMBER_ROLE}" is somebody joining a team that`
478
+ + ' already exists.'
479
+ + ' THE RETURNED URL IS A CREDENTIAL: anyone holding it can seat themselves in this workspace.'
480
+ + ' Treat it like a password, deliver it over a channel you trust, and do not paste it into a log,'
481
+ + ' a ticket, or a shared document.'
482
+ + ' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a'
483
+ + ' reason, and an idempotencyKey — reuse the SAME key on retry and the same link is returned'
484
+ + ' rather than a second credential being mailed to the same person.'
485
+ + ' This route requires no OAuth write scope (the /workspace family is grandfathered unmapped), so'
486
+ + ' approval and audit are the client-side gate; the API separately requires the connected user to'
487
+ + ' be a workspace admin and answers 403 otherwise.',
488
+ {
489
+ email: z.string().describe('The person to seat. The invitation is addressed here.'),
490
+ role: z
491
+ .string()
492
+ .describe(
493
+ 'REQUIRED, and there is deliberately no default — the two candidates are wrong in opposite'
494
+ + ` directions. Pass "${WORKSPACE_SETUP_ROLE}" for the person setting this workspace up: the`
495
+ + ' buyer you just sold to, who will be adding their own team. They are the ONLY role Adrata'
496
+ + ' ever offers the team invite to, so seating them any other way means they cannot bring'
497
+ + ` anybody in. Pass "${WORKSPACE_MEMBER_ROLE}" for somebody joining a team that already`
498
+ + ' exists. Other canonical assignable roles are accepted (seller_manager, manager, leader,'
499
+ + ' operations, enablement, csm, account_manager, marketing, finance, viewer); the API'
500
+ + ' rejects anything else by name, before it creates anybody.',
501
+ ),
502
+ launch: z
503
+ .string()
504
+ .optional()
505
+ .describe(
506
+ 'Which onboarding loop this person lands in, as a short lower-case identifier'
507
+ + ' (^[a-z][a-z0-9_]{0,63}$). Omit it rather than guessing: an absent launch is the honest'
508
+ + ' cold case and is stored as NULL, whereas a wrong one silently lands somebody in the'
509
+ + ' wrong loop.',
510
+ ),
511
+ preparedAccountIds: z
512
+ .array(z.string())
513
+ .optional()
514
+ .describe(
515
+ 'Company ids prepared for this person before they ever sign in, so their first screen has'
516
+ + ' their accounts on it. Order is meaningful and preserved.',
517
+ ),
518
+ preparedNote: z
519
+ .string()
520
+ .optional()
521
+ .describe('Context the configuring seller wants carried into the launch. Bounded at 2000 characters.'),
522
+ managerId: z
523
+ .string()
524
+ .optional()
525
+ .describe(
526
+ 'Optional reporting line. Must already be a member of this workspace — the API validates it'
527
+ + ' BEFORE creating anything, so a bad id cannot leave a half-invited user behind.',
528
+ ),
529
+ source: z
530
+ .string()
531
+ .optional()
532
+ .describe('How this invitation came to exist, for reading the funnel later. Defaults to mcp_provisioning.'),
533
+ dryRun: z
534
+ .boolean()
535
+ .optional()
536
+ .describe('Defaults to true. Returns a preview naming the launch and the prepared accounts and mints nothing.'),
537
+ approved: z
538
+ .boolean()
539
+ .optional()
540
+ .describe('Required (true) for a live write. Records that the caller confirmed minting a credential.'),
541
+ reason: z.string().optional().describe('Required for a live write. Recorded as the audit reason (X-Adrata-Reason).'),
542
+ idempotencyKey: z
543
+ .string()
544
+ .optional()
545
+ .describe(
546
+ 'Required for a live write, 8-255 characters. Sent as Idempotency-Key. Reusing it with an'
547
+ + ' IDENTICAL request replays the first link; reusing it with a DIFFERENT request is refused'
548
+ + ' rather than silently minting or silently replaying the wrong invitation.',
549
+ ),
550
+ },
551
+ async (args) => {
552
+ const problems = validateOnboardingArgs(args);
553
+ if (problems.length > 0) {
554
+ return ok({
555
+ error: true,
556
+ executed: false,
557
+ minted: false,
558
+ problems,
559
+ message:
560
+ `Nothing was sent. ${problems.length} problem(s) with the invitation: ${problems.join('; ')}.`
561
+ + ' These are checked here rather than at the database because the invite route creates the'
562
+ + ' user row before it mints the token, so a late rejection leaves a seated user with no link.',
563
+ });
564
+ }
565
+
566
+ const body = buildInvitationBody(args);
567
+ const request = { method: 'POST', path: INVITE_PATH };
568
+
569
+ // The three-field gate, checked before `validateApiBridgeRequest` so the
570
+ // refusal can name ALL of what is missing. That helper throws on the
571
+ // first one it finds, which tells a caller to add a reason and then, one
572
+ // round trip later, to add an idempotency key. Same gate, said once.
573
+ if (isLiveWrite(args)) {
574
+ const missing = missingLiveWriteFields(args);
575
+ if (missing.length > 0) {
576
+ return ok({
577
+ ...liveWriteRefusal(args, request),
578
+ executed: false,
579
+ minted: false,
580
+ });
581
+ }
582
+ }
583
+
584
+ let validation;
585
+ try {
586
+ validation = validateApiBridgeRequest({
587
+ method: 'POST',
588
+ path: INVITE_PATH,
589
+ dryRun: args.dryRun,
590
+ approved: args.approved,
591
+ reason: args.reason,
592
+ idempotencyKey: args.idempotencyKey,
593
+ grantedScope: getGrantedScope(),
594
+ });
595
+ } catch (err) {
596
+ return ok({ error: true, executed: false, minted: false, message: err.message });
597
+ }
598
+
599
+ if (validation.dryRun) {
600
+ // A preview has minted nothing, so it has no URL and no token to leak.
601
+ // It names the launch and the accounts because those are the two things
602
+ // a reviewer is being asked to approve — an approval step that does not
603
+ // show what is being configured is decoration.
604
+ return ok({
605
+ dryRun: true,
606
+ executed: false,
607
+ minted: false,
608
+ action: `Seat ${body.email} as ${body.role} and mint one onboarding link`,
609
+ role: body.role,
610
+ // The seat's consequence, said in the language of what the person
611
+ // will and will not be able to do, at the moment somebody is being
612
+ // asked to approve it. An approval step that prints a role name and
613
+ // not its effect is asking for a rubber stamp.
614
+ roleNote:
615
+ body.role === WORKSPACE_SETUP_ROLE
616
+ ? `As ${WORKSPACE_SETUP_ROLE} they can set this workspace up and add their own team;`
617
+ + ' Adrata will offer them the team invite once their first loop closes.'
618
+ : `As ${body.role} they can use the workspace but cannot invite anybody, and Adrata will`
619
+ + ` never offer them the team invite. Seat them as ${WORKSPACE_SETUP_ROLE} instead if`
620
+ + ' this is the person standing the workspace up.',
621
+ launch: body.launch ?? null,
622
+ launchNote: body.launch
623
+ ? `They will land in the "${body.launch}" launch.`
624
+ : 'No launch named. They land in the default cold start rather than a configured loop.',
625
+ preparedAccountIds: body.preparedAccountIds ?? [],
626
+ preparedAccountCount: body.preparedAccountIds?.length ?? 0,
627
+ preparedNote: body.preparedNote ?? null,
628
+ adaptivePayloadDurable: false,
629
+ adaptivePayloadNote:
630
+ 'INTEGRATION POINT: the deployed invite endpoint accepts email, role and managerId only, so'
631
+ + ' launch, preparedAccountIds, preparedNote and source are sent but not yet stored. The seat'
632
+ + ' and the link are real; the adaptive configuration becomes durable when the'
633
+ + ' workspace_invitation_records route change ships.',
634
+ preview: validation,
635
+ body,
636
+ note:
637
+ 'Nothing was written and no token exists. Re-call with dryRun:false, approved:true, a reason,'
638
+ + ' and an idempotencyKey to mint the link.',
639
+ });
640
+ }
641
+
642
+ // ── Live write ──────────────────────────────────────────────────────
643
+ const workspaceId = getWorkspaceId();
644
+ const key = String(args.idempotencyKey).trim();
645
+ const fingerprint = fingerprintInvitation(body);
646
+ const previous = ledger.get(workspaceId, key);
647
+
648
+ if (previous) {
649
+ if (previous.fingerprint !== fingerprint) {
650
+ // The dangerous case, and the reason the ledger stores a fingerprint
651
+ // rather than only a URL. Returning the stored link would hand back
652
+ // an invitation for a DIFFERENT person or a different launch than
653
+ // the one just requested; minting would defeat the key's whole
654
+ // purpose. Refusing is the only answer that is not a lie.
655
+ return ok({
656
+ error: true,
657
+ refused: true,
658
+ executed: false,
659
+ minted: false,
660
+ message:
661
+ `idempotencyKey "${key}" was already used in this workspace for a DIFFERENT invitation.`
662
+ + ' Nothing was minted and the earlier link was not returned: replaying it would hand back'
663
+ + ' an invitation for different arguments, and minting would make the key meaningless.'
664
+ + ' Use a new idempotencyKey, or re-send the identical arguments to replay the first link.',
665
+ });
666
+ }
667
+ return ok({
668
+ dryRun: false,
669
+ executed: true,
670
+ minted: false,
671
+ replayed: true,
672
+ inviteUrl: previous.inviteUrl,
673
+ userId: previous.userId,
674
+ email: body.email,
675
+ message:
676
+ `Replayed the invitation already minted under idempotencyKey "${key}". No second invitation`
677
+ + ' was created and no second seat was taken.',
678
+ });
679
+ }
680
+
681
+ let data;
682
+ try {
683
+ data = await api('POST', INVITE_PATH, {
684
+ body,
685
+ headers: buildMutationHeaders(args),
686
+ });
687
+ } catch (err) {
688
+ // The error is surfaced verbatim EXCEPT that it is never allowed to
689
+ // carry a URL: a failure late in minting can echo the request back.
690
+ return ok({
691
+ error: true,
692
+ executed: false,
693
+ minted: false,
694
+ message: String(err?.message ?? err).replace(/https?:\/\/\S*token=\S*/gi, '[redacted invite url]'),
695
+ });
696
+ }
697
+
698
+ const payload = data?.data ?? data ?? {};
699
+ const inviteUrl = payload.inviteUrl ?? null;
700
+ const userId = payload.userId ?? null;
701
+
702
+ if (inviteUrl) ledger.remember(workspaceId, key, { fingerprint, inviteUrl, userId });
703
+
704
+ return ok({
705
+ dryRun: false,
706
+ executed: true,
707
+ minted: Boolean(inviteUrl),
708
+ replayed: false,
709
+ email: body.email,
710
+ role: body.role,
711
+ userId,
712
+ inviteUrl,
713
+ launch: body.launch ?? null,
714
+ preparedAccountIds: body.preparedAccountIds ?? [],
715
+ adaptivePayloadDurable: false,
716
+ adaptivePayloadNote:
717
+ 'The seat and the link are live. launch/preparedAccountIds/preparedNote were sent but the'
718
+ + ' deployed endpoint does not yet store them — see the integration point in tools/provisioning.js.',
719
+ credentialWarning:
720
+ 'inviteUrl is a credential: anyone holding it can seat themselves in this workspace. Deliver it'
721
+ + ' over a trusted channel and do not paste it into a log, a ticket, or a shared document.',
722
+ replaySafety:
723
+ `Retry with idempotencyKey "${key}" and identical arguments to get this same link back rather`
724
+ + ' than a second one. That guard is in-process: a restarted or second MCP server will not see it.',
725
+ });
726
+ },
727
+ );
728
+ }
729
+
730
+ export const PROVISIONING_TOOL_NAMES = ['list_workspace_seats', 'create_onboarding_link'];