@adrata/adrata-mcp 1.0.19 → 1.0.40
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.
- package/README.md +3 -0
- package/access/tiers.js +27 -0
- package/api-bridge.js +6 -0
- package/package.json +3 -3
- package/security.js +167 -14
- package/server.js +24 -1
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +16 -3
- package/skills/ship-the-card/SKILL.md +48 -39
- package/tool-annotations.js +17 -0
- package/tools/provisioning/onboarding-tools.js +730 -0
- package/tools/source-control/connection-tools.js +117 -1
- package/tools/work-board-tools.js +449 -39
- package/tools/work-hub/criteria-quality.js +254 -5
- package/tools/work-hub/field-changes.js +25 -0
- package/toolsets/prospecting.js +194 -3
- package/toolsets/revenue/always-loaded.js +2 -2
|
@@ -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'];
|