@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 +27 -0
- package/access/tiers.js +14 -0
- package/api-bridge.js +26 -0
- package/governance/governed-args.js +7 -2
- package/http/edge-block.js +16 -7
- package/http/rate-limit.js +69 -6
- package/package.json +3 -3
- package/security.js +167 -14
- package/server.js +35 -23
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +40 -4
- package/tool-annotations.js +23 -0
- package/tools/provisioning/onboarding-tools.js +730 -0
- package/tools/source-control/connection-tools.js +117 -1
- package/tools/work-board-tools.js +441 -25
- package/tools/work-hub/audit.js +74 -8
- package/tools/work-hub/criteria-quality.js +212 -0
- package/toolsets/communications.js +42 -64
- package/toolsets/spaces.js +10 -1
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
|
-
|
|
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
|
}
|
package/http/edge-block.js
CHANGED
|
@@ -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,
|
|
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
|
-
'
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
}
|
package/http/rate-limit.js
CHANGED
|
@@ -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`
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM.
|
|
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
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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.
|
|
18
|
+
"version": "1.0.30",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
},
|