@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.
package/access/oauth.js CHANGED
@@ -163,6 +163,33 @@ export const OAUTH_WRITE_SCOPE = [
163
163
  *
164
164
  * curl -s https://api.adrata.com/health | jq -r .build_sha
165
165
  * git show <sha>:code/api/crates/security/src/scopes.rs | grep -c write:integrations
166
+ *
167
+ * **Do not add an `admin:` scope to this list, or to any list in this file.**
168
+ * That check above is necessary and not sufficient, and `admin:audit` is the
169
+ * case that proves it. Measured 2026-09-04 against the deployed production
170
+ * build `5d4d3141e9`, while trying to let a QA lane read login audit receipts:
171
+ *
172
+ * - `admin:audit` IS a real `ApiScope`. It parses, it is in `all()`, and it
173
+ * therefore clears `canonicalize_api_scopes` — so the version check above
174
+ * answers "recognised" and the scope looks available.
175
+ * - It is nonetheless ungrantable through this client, permanently and by
176
+ * design. `consented_scope_grant` (consent_scopes.rs) refuses every scope
177
+ * starting with `admin:` BEFORE it consults the client's registration,
178
+ * under every role including `super_admin`, and even when an administrator
179
+ * provisioned the scope on the client. `CONSENT_ELEVATABLE_SCOPES` names
180
+ * the reason: "workspace administration. Never grantable through this path
181
+ * under any role."
182
+ *
183
+ * So requesting it would be a strictly worse #2435 — not an outage that heals
184
+ * when the API catches up, but one that never heals. The route that does work
185
+ * is a confidential client provisioned by an admin at `POST /oauth/clients`
186
+ * and the client-credentials grant, which checks the registration alone and
187
+ * carries no `admin:` refusal. That is an owner decision and a credential this
188
+ * package does not hold.
189
+ *
190
+ * `access/oauth-scope-grantability.test.js` now enforces all of this against
191
+ * the API's own source, so the next attempt fails in CI rather than in
192
+ * production.
166
193
  */
167
194
  export const OAUTH_PENDING_SCOPE = ['write:integrations'].join(' ');
168
195
 
package/access/tiers.js CHANGED
@@ -27,6 +27,12 @@ export const TOOL_TIERS = {
27
27
  // or reach a second workspace from the CLI at all.
28
28
  list_workspaces: TIERS.ENTERPRISE,
29
29
  switch_workspace: TIERS.ENTERPRISE,
30
+ // Provisioning a customer's workspace: reading who is seated, and seating
31
+ // somebody with the onboarding link that reaches them. Enterprise because it
32
+ // is workspace administration -- the API separately requires the connected
33
+ // user to be a workspace admin and answers 403 otherwise.
34
+ list_workspace_seats: TIERS.ENTERPRISE,
35
+ create_onboarding_link: TIERS.ENTERPRISE,
30
36
  adrata_api_catalog: TIERS.FREE,
31
37
  paper_app_audit: TIERS.FREE,
32
38
  adrata_desktop_app_audit: TIERS.FREE,
@@ -220,6 +226,7 @@ export const TOOL_TIERS = {
220
226
  list_work_boards: TIERS.ENTERPRISE,
221
227
  list_source_control_connections: TIERS.ENTERPRISE,
222
228
  get_source_control_connection_events: TIERS.ENTERPRISE,
229
+ bind_repository_to_board: TIERS.ENTERPRISE,
223
230
  get_work_item_delivery_evidence: TIERS.ENTERPRISE,
224
231
  audit_work_hub: TIERS.ENTERPRISE,
225
232
  list_work_item_acceptance_criteria: TIERS.ENTERPRISE,
@@ -251,11 +258,18 @@ export const TOOL_TIERS = {
251
258
  create_work_item: TIERS.ENTERPRISE,
252
259
  add_work_item_acceptance_criterion: TIERS.ENTERPRISE,
253
260
  classify_work_item_acceptance_criterion: TIERS.ENTERPRISE,
261
+ edit_work_item_acceptance_criterion: TIERS.ENTERPRISE,
262
+ delete_work_item_acceptance_criterion: TIERS.ENTERPRISE,
254
263
  satisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
255
264
  record_work_item_criterion_engineering_proof: TIERS.ENTERPRISE,
256
265
  unsatisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
257
266
  comment_on_work_item: TIERS.ENTERPRISE,
258
267
  flag_work_item: TIERS.ENTERPRISE,
268
+ // Retiring a card and bringing it back. Same reasoning as every board write:
269
+ // these change a real workspace record.
270
+ archive_work_item: TIERS.ENTERPRISE,
271
+ delete_work_item: TIERS.ENTERPRISE,
272
+ unarchive_work_item: TIERS.ENTERPRISE,
259
273
  // The containers above the cards, and the "add this to the roadmap" verb.
260
274
  // Same reasoning as every board tool: scopes are real workspace records.
261
275
  list_work_scopes: TIERS.ENTERPRISE,
package/api-bridge.js CHANGED
@@ -251,6 +251,32 @@ export function normalizeApiPath(path) {
251
251
  if (!path || typeof path !== 'string') throw new Error('path is required');
252
252
  if (!path.startsWith('/')) throw new Error('path must start with /');
253
253
  if (path.includes('://')) throw new Error('path must be relative, not an absolute URL');
254
+ // Authorize the pathname that api() will actually send, not a raw prefix
255
+ // that WHATWG URL parsing can turn into a different route. Query values
256
+ // belong in `params`; fragments, backslashes and controls are never paths.
257
+ if (/[\\?#\u0000-\u0020\u007f]/.test(path) || path.includes('//')) {
258
+ throw new Error('path must be a canonical pathname; use params for query values');
259
+ }
260
+ const base = 'https://mcp-path.invalid';
261
+ const resolved = new URL(path, base);
262
+ if (resolved.origin !== base || resolved.pathname !== path) {
263
+ throw new Error('path must not change origin or pathname during URL normalization');
264
+ }
265
+ // One ordinary percent-encoded opaque segment is supported (spaces/UTF-8,
266
+ // for example). Refuse encoded structural characters and residual percent
267
+ // signs so another decoder cannot reveal a separator or dot segment later.
268
+ // Do not repeatedly decode and then bless a different path as authorized.
269
+ for (const segment of path.split('/')) {
270
+ let decoded;
271
+ try {
272
+ decoded = decodeURIComponent(segment);
273
+ } catch {
274
+ throw new Error('path contains invalid percent encoding');
275
+ }
276
+ if (decoded === '.' || decoded === '..' || /[%/\\?#\u0000-\u001f\u007f]/.test(decoded)) {
277
+ throw new Error('path contains an ambiguous encoded or dot segment');
278
+ }
279
+ }
254
280
  return path;
255
281
  }
256
282
 
@@ -161,7 +161,7 @@ const PREVIEW_LABELS = {
161
161
  * that was withheld plus what is missing to authorize it. So it always prints
162
162
  * the method and path, never a bare "ok".
163
163
  */
164
- export function previewMarkdown(heading, preview = {}) {
164
+ export function previewMarkdown(heading, preview = {}, { executionAvailable = true } = {}) {
165
165
  const {
166
166
  method,
167
167
  path,
@@ -180,6 +180,7 @@ export function previewMarkdown(heading, preview = {}) {
180
180
  let text = `## ${heading} — Preview Only (nothing was written)\n\n`;
181
181
  text += `- **Method:** ${method}\n`;
182
182
  text += `- **Path:** ${path}\n`;
183
+ if (wouldSend === false) text += '- **Would send:** false\n';
183
184
  text += `- **Required scope:** ${requiredScope}\n`;
184
185
  text += `- **Scope held:** ${scopeHeld}\n`;
185
186
 
@@ -199,7 +200,11 @@ export function previewMarkdown(heading, preview = {}) {
199
200
  text += `\n### Body that would be sent\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\`\n`;
200
201
  }
201
202
 
202
- text += `\n### To execute\n\nRe-call with \`dryRun:false\`, \`approved:true\`, a \`reason\`, and an \`idempotencyKey\`.\n`;
203
+ if (executionAvailable) {
204
+ text += `\n### To execute\n\nRe-call with \`dryRun:false\`, \`approved:true\`, a \`reason\`, and an \`idempotencyKey\`.\n`;
205
+ } else {
206
+ text += '\n### Live execution unavailable\n\nThis preview cannot be executed while the safety hold remains. Approval does not lift it.\n';
207
+ }
203
208
  if (note) text += `\n${note}\n`;
204
209
  return text;
205
210
  }
@@ -97,22 +97,31 @@ export function describeEdgeBlock({ status, text, method, path, requestBody } =
97
97
  const found = offendingContent(requestBody);
98
98
  const because = found.length
99
99
  ? `The request body contains ${found.join(' and ')}, which the edge's path-traversal rule matches.`
100
- : 'No known trigger was found in this request body, so the matching rule is not one already measured. Report the request id and the body, and check the WAF sampled requests.';
100
+ : 'No known trigger was found in this request body, the HTML response alone cannot identify the rule. IP rate limiting can return the same page even for a bodyless GET. Check WAF sampled requests using the request time and request id if available; redact credentials and private payloads.';
101
101
 
102
102
  return [
103
103
  `API ${method} ${path} → ${status}, refused at the network edge before it reached Adrata.`,
104
104
  '',
105
105
  'This is NOT an authentication or scope failure, and reconnecting the workspace will not',
106
106
  'change it. The proof is the response body: the Adrata API serialises every error as JSON,',
107
- 'so an HTML error page from this host was produced by something in front of it (an AWS WAF',
108
- 'managed rule on the ALB). Your session is unaffected — do not call connect_workspace, and',
107
+ 'so an HTML error page from this host was produced by something in front of it (for example an AWS WAF',
108
+ 'content or rate rule on the ALB). Your session is unaffected — do not call connect_workspace, and',
109
109
  'do not ask the owner to reconnect.',
110
110
  '',
111
111
  because,
112
112
  '',
113
- 'What to do: rewrite the offending text and resend. A relative path reads the same as',
114
- '`code/desktop` or `<repo root>/packages/client-runtime`, and the record is no worse for it.',
115
- 'Do NOT strip out markdown tables or fenced code blocks those were measured to pass, and',
116
- 'removing them only makes the record vaguer.',
113
+ ...(found.length
114
+ ? [
115
+ 'What to do: confirm the original write did not apply before correcting the measured path token.',
116
+ 'A changed payload needs a new idempotency key; retries of unchanged payloads keep the original key.',
117
+ 'A relative path can be written as `code/desktop` or `<repo root>/packages/client-runtime`.',
118
+ 'If the block persists, check rate limiting and WAF sampled requests before retrying again.',
119
+ 'Do NOT strip out markdown tables or fenced code blocks — those were measured to pass.',
120
+ ]
121
+ : [
122
+ 'Pause the batch and reduce request concurrency. Resume after the rate window clears,',
123
+ 'preserving idempotency keys and checking which writes already succeeded.',
124
+ 'Do not rewrite harmless content or keep retrying immediately without identifying the rule.',
125
+ ]),
117
126
  ].join('\n');
118
127
  }
@@ -78,7 +78,7 @@ const BASE_DELAY_MS = 500;
78
78
  * returns `null` for anything it cannot read. `null` means "we were told
79
79
  * nothing", which is different from "we were told zero", and the caller treats
80
80
  * them differently: an absent value falls back to our own backoff, while an
81
- * explicit `0` is honoured as the server saying "immediately is fine".
81
+ * explicit `0` adds no server floor (our own backoff still applies).
82
82
  */
83
83
  export function parseRetryAfterMs(headerValue, nowMs = Date.now()) {
84
84
  if (headerValue == null) return null;
@@ -91,7 +91,7 @@ export function parseRetryAfterMs(headerValue, nowMs = Date.now()) {
91
91
  // header. Guessing zero from it would hand the caller a no-wait retry,
92
92
  // which is the exact behaviour this module exists to prevent.
93
93
  if (seconds < 0) return null;
94
- return Math.min(seconds * 1000, RATE_LIMIT_MAX_WAIT_MS);
94
+ return seconds * 1000;
95
95
  }
96
96
 
97
97
  // An HTTP-date always carries letters (a month name and a zone). Requiring
@@ -103,7 +103,7 @@ export function parseRetryAfterMs(headerValue, nowMs = Date.now()) {
103
103
  if (Number.isNaN(asDate)) return null;
104
104
  // A date already in the past means "you may retry now", not "wait a negative
105
105
  // amount of time" — clamp at zero rather than returning a nonsense delay.
106
- return Math.min(Math.max(asDate - nowMs, 0), RATE_LIMIT_MAX_WAIT_MS);
106
+ return Math.max(asDate - nowMs, 0);
107
107
  }
108
108
 
109
109
  /**
@@ -122,7 +122,7 @@ export function rateLimitDelayMs(attempt, retryAfterMs, rng = Math.random) {
122
122
  const jittered = exponential + Math.floor(rng() * BASE_DELAY_MS);
123
123
  const own = Math.min(jittered, RATE_LIMIT_MAX_WAIT_MS);
124
124
  if (typeof retryAfterMs !== 'number' || Number.isNaN(retryAfterMs)) return own;
125
- return Math.min(Math.max(own, retryAfterMs), RATE_LIMIT_MAX_WAIT_MS);
125
+ return Math.max(own, retryAfterMs);
126
126
  }
127
127
 
128
128
  /** Render a wait as something a person or a model reads without ambiguity. */
@@ -130,7 +130,8 @@ function describeWait(retryAfterMs) {
130
130
  if (typeof retryAfterMs !== 'number' || Number.isNaN(retryAfterMs) || retryAfterMs <= 0) {
131
131
  return 'at least a minute';
132
132
  }
133
- const seconds = Math.max(1, Math.round(retryAfterMs / 1000));
133
+ if (!Number.isFinite(retryAfterMs)) return "the server-specified cooldown";
134
+ const seconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
134
135
  return `${seconds}s`;
135
136
  }
136
137
 
@@ -146,7 +147,9 @@ function describeWait(retryAfterMs) {
146
147
  export function describeRateLimit({ method, path, attempts, retryAfterMs }) {
147
148
  return [
148
149
  `Rate limited (HTTP 429) by the Adrata API on ${method} ${path}.`,
149
- `Already retried with backoff ${attempts} times; the limit is still in force.`,
150
+ attempts === 0
151
+ ? "No request was sent: a previous response established an active cooldown."
152
+ : `Made ${attempts} request attempt${attempts === 1 ? '' : 's'}; the limit is still in force.`,
150
153
  '',
151
154
  `DO NOT retry this call immediately. Wait ${describeWait(retryAfterMs)} before trying again,`,
152
155
  'or hand the work back and let a later pass pick it up.',
@@ -157,3 +160,63 @@ export function describeRateLimit({ method, path, attempts, retryAfterMs }) {
157
160
  'will expire on its own; a stuck release does not require a hot loop to resolve.',
158
161
  ].join('\n');
159
162
  }
163
+
164
+ /**
165
+ * One authenticated caller's process-local cooldown. Retry-After is a floor, not
166
+ * permission to retry at our shorter tool timeout. Keep it across tool calls
167
+ * and check again after async auth refresh and every retry wait. Already
168
+ * in-flight requests may extend this deadline; they must never shorten it.
169
+ */
170
+ export function createRateLimitPolicy({
171
+ now = Date.now,
172
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
173
+ rng = Math.random,
174
+ } = {}) {
175
+ let retryAt = 0;
176
+ const beforeRequest = ({ method, path }) => {
177
+ const remaining = retryAt - now();
178
+ if (remaining > 0) {
179
+ throw new Error(describeRateLimit({ method, path, attempts: 0, retryAfterMs: remaining }));
180
+ }
181
+ };
182
+ const retry = async (response, request, { method, path }) => {
183
+ let attempts = 1;
184
+ while (response.status === 429) {
185
+ const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'), now());
186
+ const delay = rateLimitDelayMs(attempts, retryAfterMs, rng);
187
+ retryAt = Math.max(retryAt, now() + delay);
188
+ if (attempts >= RATE_LIMIT_MAX_ATTEMPTS && retryAfterMs === null) {
189
+ retryAt = Math.max(retryAt, now() + 60_000);
190
+ }
191
+ const remaining = retryAt - now();
192
+ if (attempts >= RATE_LIMIT_MAX_ATTEMPTS || remaining > RATE_LIMIT_MAX_WAIT_MS) {
193
+ throw new Error(describeRateLimit({ method, path, attempts, retryAfterMs: remaining }));
194
+ }
195
+ await sleep(remaining);
196
+ beforeRequest({ method, path });
197
+ attempts += 1;
198
+ response = await request();
199
+ }
200
+ return response;
201
+ };
202
+ return { beforeRequest, retry, isCoolingDown: () => retryAt > now() };
203
+ }
204
+
205
+ /** Keep hosted callers separate; only idle, expired policies may be retired. */
206
+ export function createRateLimitRegistry({ now = Date.now, ...options } = {}) {
207
+ const policies = new Map();
208
+ return (principal) => {
209
+ for (const [key, entry] of policies) {
210
+ if (key !== principal && now() - entry.lastUsed > 300_000 && !entry.policy.isCoolingDown()) {
211
+ policies.delete(key);
212
+ }
213
+ }
214
+ let entry = policies.get(principal);
215
+ if (!entry) {
216
+ entry = { policy: createRateLimitPolicy({ now, ...options }), lastUsed: now() };
217
+ policies.set(principal, entry);
218
+ }
219
+ entry.lastUsed = now();
220
+ return entry.policy;
221
+ };
222
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.8",
4
- "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. 80+ tools for companies, people, deals, actions, buyer groups, warm intros, webhooks, intelligence, and more.",
3
+ "version": "1.0.30",
4
+ "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. About 275 tools registered at startup for companies, people, deals, actions, buyer groups, warm intros, webhooks and intelligence, plus 65 more behind eight named toolsets you load with enable_toolset.",
5
5
  "type": "module",
6
6
  "main": "server.js",
7
7
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js tool-annotations.test.js toolsets.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/roadmap-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js"
12
+ "test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
package/security.js CHANGED
@@ -143,18 +143,169 @@ export function rateLimitResponse(retryAfterMs) {
143
143
  // 2. Input Validation
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
+ /**
147
+ * Length caps for TOP-LEVEL string tool arguments, by argument name.
148
+ *
149
+ * `applySecurityLayer` wraps every tool this server registers, so these caps
150
+ * are the FIRST thing any argument meets — before Zod, before the handler,
151
+ * before the API. Until 2026-09-05 they TRUNCATED, silently, and the tool then
152
+ * returned success: a card flag ended mid-word at exactly 1000 characters
153
+ * ("Do not substitute API SHA or bounce the card merely t") and the
154
+ * prohibition was gone for every subsequent reader including its author.
155
+ *
156
+ * Every comparable cap in the Rust API refuses instead — comments 2000,
157
+ * criterion parts 600, engineering-proof fields 600, reviewer model 120,
158
+ * evidence summary 600 — several enforced twice, once in the handler and
159
+ * again as a database CHECK. So every caller already handles a refusal. This
160
+ * layer was the only one that cut, and it ran first, which is why the cut was
161
+ * invisible from both ends.
162
+ */
163
+ const DEFAULT_STRING_CAP = 1000;
164
+
165
+ /**
166
+ * The API's own cap on a card comment, and on every free-text `reason` that
167
+ * routes through `validate_body`: flag, block, dependency, sharing, QA-flow
168
+ * note. Rust calls it `MAX_COMMENT_BODY_CHARS`
169
+ * (`code/api/crates/routes-gtm/src/work_boards/mod.rs`).
170
+ *
171
+ * `security.cap-contract.test.js` reads that constant out of the Rust
172
+ * source and fails if this number ever drops below it, because THAT is the
173
+ * divergence this whole change is about: a cap here that sits below the cap
174
+ * downstream cuts a value the API would have accepted, and the API never gets
175
+ * to refuse it.
176
+ */
177
+ const API_COMMENT_BODY_CAP = 2000;
178
+
179
+ /**
180
+ * Sanity ceiling for an argument that carries a document or a data file rather
181
+ * than prose (a CSV to import, a knowledge file's markdown, a paper's HTML).
182
+ * Deliberately far above any product rule: the point is to bound the transport,
183
+ * and to let the API be the layer that says how big a document may be.
184
+ */
185
+ const BULK_PAYLOAD_CAP = 1_000_000;
186
+
187
+ const STRING_CAPS_BY_KEY = {
188
+ // Long-form prose the API caps far lower anyway (a comment body is refused
189
+ // at 2000). 10000 here is a sanity ceiling, not the product rule.
190
+ body: 10000,
191
+ message: 10000,
192
+ description: 10000,
193
+
194
+ // The reason family. These were the DANGEROUS truncations and the reason
195
+ // this cap exists as its own class:
196
+ //
197
+ // flag_work_item.reason API cap 2000 — a 1000-cut value was
198
+ // ACCEPTED truncated. This is the flag
199
+ // defect: a prohibition ended mid-word at
200
+ // exactly 1000 characters and read as
201
+ // complete to every later reader.
202
+ // block_work_item.reason API cap 2000 — same shape.
203
+ // move_work_item.reason NO API cap at all, so nothing downstream
204
+ // transfer_...between_boards could ever catch the cut. Permanently and
205
+ // silently shortened to 1000.
206
+ // every X-Adrata-Reason header NO API cap anywhere in Rust
207
+ // (api-bridge.js) (presence-only). Same silence.
208
+ //
209
+ // Raising these to the API's own 2000 is what keeps this change from
210
+ // trading a silent cut for a refusal of honest input: a 1400-character
211
+ // reason is legitimate, the API accepts it, and until now this layer cut it.
212
+ reason: API_COMMENT_BODY_CAP,
213
+
214
+ // `auditReason` is the same field family under a different name: the shared
215
+ // `moneyWrite` envelope (server.js) spreads it into EVERY money write, so it
216
+ // was the one reason-shaped argument the first version of this table missed.
217
+ auditReason: API_COMMENT_BODY_CAP,
218
+
219
+ // `record_work_item_qa_failure_and_release.details` is composed into a flag
220
+ // reason (`"QA criterion failure (<id>): " + details`) and so lands under
221
+ // the same 2000 API cap. A 1000-character cut plus that ~14-character prefix
222
+ // stayed comfortably under 2000, which is exactly why the "~1,014 characters,
223
+ // 200 OK" measurement was silent. Known rough edge: a `details` near 2000
224
+ // composes to over 2000 and the API refuses the composed value with its own
225
+ // message. That is a refusal rather than a silence, which is the property
226
+ // this change is about.
227
+ details: API_COMMENT_BODY_CAP,
228
+
229
+ // Search terms. A query longer than this is a paste, not a search.
230
+ query: 500,
231
+ search: 500,
232
+
233
+ // --- Payloads, not prose ---
234
+ //
235
+ // These carry a document or a data file, and at the old 1000 default they
236
+ // were the WORST instances of the truncation: `bulk_import`/`manage_data`
237
+ // took a CSV — any real one is past 1000 in its first few rows — cut it to
238
+ // 1000, imported the fragment, and reported success. A partial import that
239
+ // announces itself as complete is harder to notice than a lost sentence.
240
+ //
241
+ // The ceiling here is a transport sanity bound, NOT a product rule. This
242
+ // layer has no business deciding how large a knowledge file may be; the API
243
+ // owns that, and it can refuse. What this layer must never again do is
244
+ // decide it silently.
245
+ csvData: BULK_PAYLOAD_CAP,
246
+ content: BULK_PAYLOAD_CAP,
247
+ };
248
+
249
+ /** The cap that applies to a top-level string argument named `key`. */
250
+ export function maxLengthForKey(key) {
251
+ return Object.prototype.hasOwnProperty.call(STRING_CAPS_BY_KEY, key)
252
+ ? STRING_CAPS_BY_KEY[key]
253
+ : DEFAULT_STRING_CAP;
254
+ }
255
+
256
+ /**
257
+ * The refusal text. It names the actual length, the cap, and how much to cut,
258
+ * because the caller is usually an agent that can shorten and retry — and it
259
+ * says plainly that nothing was written, since the whole point of the defect
260
+ * this replaces was a caller believing a truncated write had landed.
261
+ */
262
+ export function overLengthMessage(field, length, maxLength) {
263
+ return `Parameter "${field}" is ${length} characters and the cap is ${maxLength} — `
264
+ + `remove ${length - maxLength}. Nothing was written: the value was refused whole `
265
+ + `rather than cut, so shorten it and call again.`;
266
+ }
267
+
146
268
  /**
147
269
  * Common validation schemas for tool parameters.
148
270
  * These wrap and tighten the existing Zod schemas.
149
271
  */
150
272
  export const validators = {
151
- /** Sanitize a string — strip control characters, limit length. */
152
- sanitizeString(value, maxLength = 1000) {
153
- if (typeof value !== 'string') return value;
154
- // Strip control characters except newline/tab
155
- // eslint-disable-next-line no-control-regex
156
- const cleaned = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
157
- return cleaned.slice(0, maxLength);
273
+ /**
274
+ * Strip control characters, and REFUSE a string longer than `maxLength`.
275
+ *
276
+ * Returns `{ value, error }` rather than a bare string on purpose. Until
277
+ * 2026-09-05 this ended `return cleaned.slice(0, maxLength)` — the value was
278
+ * cut, `validateToolInput` still reported `valid: true`, and the write went
279
+ * on to return 200. The object shape means a caller cannot use the result as
280
+ * a string without noticing, so the refusal cannot be dropped the way the
281
+ * silent slice was.
282
+ *
283
+ * Length is counted in CODE POINTS (`[...cleaned].length`), matching the
284
+ * Rust API's `chars().count()` downstream rather than UTF-16 code units, so
285
+ * a value this layer accepts is not one the next layer measures differently.
286
+ */
287
+ sanitizeString(value, maxLength = DEFAULT_STRING_CAP, field = 'value') {
288
+ if (typeof value !== 'string') return { value, error: null };
289
+ // Vertical tab (0x0B) and form feed (0x0C) ARE whitespace: they separate
290
+ // words. Deleting them outright fuses the tokens either side into one —
291
+ // "evidence<VT>01M1RAMY" becomes "evidence01M1RAMY" — and that is worse to
292
+ // read than a truncation, because the sentence still looks complete, so a
293
+ // reader has no tell at all. They collapse to a space. Every other control
294
+ // character in the class is not a separator, so removing it stays correct.
295
+ //
296
+ // Tab (0x09), newline (0x0A) and carriage return (0x0D) sit outside both
297
+ // classes and pass through untouched, as they always have.
298
+ const cleaned = value
299
+ // eslint-disable-next-line no-control-regex
300
+ .replace(/[\x0B\x0C]/g, ' ')
301
+ // eslint-disable-next-line no-control-regex
302
+ .replace(/[\x00-\x08\x0E-\x1F\x7F]/g, '');
303
+ // Measured AFTER stripping: control characters must not eat the budget.
304
+ const length = [...cleaned].length;
305
+ if (length > maxLength) {
306
+ return { value: cleaned, error: overLengthMessage(field, length, maxLength) };
307
+ }
308
+ return { value: cleaned, error: null };
158
309
  },
159
310
 
160
311
  /** Validate that a string doesn't contain obvious injection patterns. */
@@ -223,13 +374,15 @@ export function validateToolInput(toolName, args) {
223
374
  continue;
224
375
  }
225
376
 
226
- // Sanitize string values
227
- const maxLen = key === 'body' || key === 'message' || key === 'description'
228
- ? 10000
229
- : key === 'query' || key === 'search'
230
- ? 500
231
- : 1000;
232
- sanitized[key] = validators.sanitizeString(value, maxLen);
377
+ // Sanitize string values. An over-long value is REFUSED, not cut:
378
+ // see the cap table above for why this layer used to truncate silently.
379
+ const { value: cleaned, error: lengthError } =
380
+ validators.sanitizeString(value, maxLengthForKey(key), key);
381
+ if (lengthError) {
382
+ errors.push(lengthError);
383
+ continue;
384
+ }
385
+ sanitized[key] = cleaned;
233
386
 
234
387
  // Validate specific field types
235
388
  if (key === 'id' || key.endsWith('Id')) {
package/server.js CHANGED
@@ -43,12 +43,7 @@ import { TIERS } from './access/tiers.js';
43
43
  import { findCompany, findPerson } from './tools/free-search.js';
44
44
  import { applySecurityLayer } from './security.js';
45
45
  import { describeEdgeBlock } from './http/edge-block.js';
46
- import {
47
- parseRetryAfterMs,
48
- rateLimitDelayMs,
49
- describeRateLimit,
50
- RATE_LIMIT_MAX_ATTEMPTS,
51
- } from './http/rate-limit.js';
46
+ import { createRateLimitRegistry } from './http/rate-limit.js';
52
47
  import { registerMemoryTools, wrapWithEventLogging, registerProfileResource } from './tools/memory.js';
53
48
  import { registerBillingTools } from './tools/billing.js';
54
49
  import { registerMorningBrief } from './tools/morning-brief.js';
@@ -60,6 +55,7 @@ import { registerEmailTools } from './tools/email-tools.js';
60
55
  import { registerWorkBoardTools } from './tools/work-board-tools.js';
61
56
  import { registerSourceControlTools } from './tools/source-control/connection-tools.js';
62
57
  import { registerRoadmapTools } from './tools/roadmap-tools.js';
58
+ import { registerProvisioningTools } from './tools/provisioning/onboarding-tools.js';
63
59
  import { registerPaperTools } from './tools/paper-tools.js';
64
60
  import { register as registerAlwaysLoadedTools } from './toolsets/revenue/always-loaded.js';
65
61
  import { register as registerExtensibilityTools } from './toolsets/extensibility.js';
@@ -129,7 +125,15 @@ function refreshFailureError(err) {
129
125
  return err instanceof Error ? err : new Error(String(err));
130
126
  }
131
127
 
128
+ const rateLimitForPrincipal = createRateLimitRegistry();
129
+
132
130
  async function api(method, path, { params, body, headers: extraHeaders } = {}) {
131
+ const auth = currentAuth();
132
+ // Hosted requests carry a validated principal fingerprint. Stdio retains
133
+ // its auth object across token refresh. Never share one user's API cooldown
134
+ // with another user of the hosted MCP process.
135
+ const rateLimitPolicy = rateLimitForPrincipal(auth.securityPrincipal ?? auth);
136
+ rateLimitPolicy.beforeRequest({ method, path });
133
137
  const url = new URL(path, API_BASE);
134
138
  if (params) {
135
139
  for (const [k, v] of Object.entries(params)) {
@@ -138,7 +142,6 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
138
142
  }
139
143
 
140
144
  // For stored OAuth tokens, auto-refresh if expired before the request
141
- const auth = currentAuth();
142
145
  assertOAuthIssuerMatchesTarget(auth, API_BASE);
143
146
  let currentToken = auth.token;
144
147
  if (auth.source === 'stored' || auth.source === 'stored_pool') {
@@ -177,6 +180,7 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
177
180
  Object.assign(headers, extraHeaders || {});
178
181
 
179
182
  const request = (token) => {
183
+ rateLimitPolicy.beforeRequest({ method, path });
180
184
  const requestHeaders = { ...headers };
181
185
  if (token) requestHeaders.Authorization = `Bearer ${token}`;
182
186
  return fetch(url.toString(), {
@@ -225,21 +229,7 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
225
229
  // against production on 2026-09-02. Wait, retry a bounded number of times,
226
230
  // and if the limit still holds, fail with a message that says so. See
227
231
  // rate-limit.js for the measured incident.
228
- let rateLimitAttempts = 1;
229
- let retryAfterMs = null;
230
- while (res.status === 429 && rateLimitAttempts < RATE_LIMIT_MAX_ATTEMPTS) {
231
- retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
232
- const waitMs = rateLimitDelayMs(rateLimitAttempts, retryAfterMs);
233
- await new Promise((resolve) => setTimeout(resolve, waitMs));
234
- rateLimitAttempts += 1;
235
- res = await request(currentToken);
236
- }
237
- if (res.status === 429) {
238
- retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')) ?? retryAfterMs;
239
- throw new Error(
240
- describeRateLimit({ method, path, attempts: rateLimitAttempts, retryAfterMs }),
241
- );
242
- }
232
+ res = await rateLimitPolicy.retry(res, () => request(currentToken), { method, path });
243
233
 
244
234
  const text = await res.text();
245
235
  let data;
@@ -2405,7 +2395,29 @@ registerWorkBoardTools(server, {
2405
2395
  getGrantedScope: () => loadTokens()?.scope,
2406
2396
  });
2407
2397
 
2408
- registerSourceControlTools(server, { z, api, ok });
2398
+ registerSourceControlTools(server, {
2399
+ z,
2400
+ api,
2401
+ ok,
2402
+ validateApiBridgeRequest,
2403
+ buildMutationHeaders,
2404
+ getGrantedScope: () => loadTokens()?.scope,
2405
+ });
2406
+
2407
+ // Seating a customer's people and minting the onboarding link an AE hands them.
2408
+ // The first path from MCP that can actually finish: `invite_user` through
2409
+ // `adrata_ai_tool_execute` refuses every machine principal at the confirmation
2410
+ // step (ai_crm_tools/mod.rs:427), so the bridge could preview a seat forever and
2411
+ // create none. See tools/provisioning.js for why this is one tool and not two.
2412
+ registerProvisioningTools(server, {
2413
+ z,
2414
+ api,
2415
+ ok,
2416
+ validateApiBridgeRequest,
2417
+ buildMutationHeaders,
2418
+ getGrantedScope: () => loadTokens()?.scope,
2419
+ getWorkspaceId: () => loadTokens()?.workspaceId ?? AUTH?.workspaceId ?? null,
2420
+ });
2409
2421
 
2410
2422
  // The containers above the cards, and the "add this to the roadmap" verb
2411
2423
  // (company/decisions/2026-08-06-spoq-roadmap-sync.md).
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.adrata/adrata-mcp",
4
4
  "description": "Adrata revenue-intelligence MCP server: companies, people, opportunities, actions, buyer groups, enrichment, email, and workspace operations for AI agents.",
5
5
  "status": "active",
6
- "version": "1.0.8",
6
+ "version": "1.0.30",
7
7
  "websiteUrl": "https://adrata.com/developers",
8
8
  "repository": {
9
9
  "url": "https://github.com/adrata/adrata",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@adrata/adrata-mcp",
18
- "version": "1.0.8",
18
+ "version": "1.0.30",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },