@adrata/adrata-mcp 1.0.7 → 1.0.8

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 CHANGED
@@ -418,12 +418,58 @@ Actions support `metadata` -- a JSONB object for type-specific data. Use it for
418
418
  "Log a linkedin_connection_request action for this person with metadata containing the message I sent"
419
419
  ```
420
420
 
421
+ ### Running several MCP processes under separate identities
422
+
423
+ `~/.config/adrata/agent.json` -- the session `adrata login` writes -- is
424
+ machine-wide. Every MCP process on a host where anyone has ever run
425
+ `adrata login` therefore authenticates as that one person, including a process
426
+ that was launched with its own `ADRATA_API_KEY`: the key is still sent as
427
+ `X-API-Key`, but the bearer token is what the API resolves the caller from.
428
+
429
+ Usually that is exactly right. It is wrong when several processes on one host
430
+ are supposed to act as *different* principals -- a fleet of QA lanes, each
431
+ holding its own key so that its board writes are graded as an independent agent
432
+ rather than as the shared human. The API is not confused in that case: it
433
+ correctly grades a human session's write as human, records it, returns `200`,
434
+ and counts it toward nothing.
435
+
436
+ Set `ADRATA_MCP_PREFER_API_KEY=1` in that process's env, alongside its
437
+ `ADRATA_API_KEY`, and it acts as its own `api_key:<id>` actor.
438
+
439
+ ```jsonc
440
+ {
441
+ "mcpServers": {
442
+ "adrata": {
443
+ "command": "npx",
444
+ "args": ["-y", "@adrata/starfield-mcp@latest"],
445
+ "env": {
446
+ "ADRATA_API_KEY": "ak_this_lanes_own_key",
447
+ "ADRATA_MCP_PREFER_API_KEY": "1"
448
+ }
449
+ }
450
+ }
451
+ }
452
+ ```
453
+
454
+ Three things it deliberately does not do:
455
+
456
+ - **It only reads the environment variable.** A token sitting in
457
+ `~/.config/adrata/cli.json` is ambient machine state, not a statement of
458
+ intent by whoever launched this process, and never triggers the preference.
459
+ - **It does not outrank `ADRATA_OAUTH_TOKEN`**, which stays first in the chain.
460
+ - **It does not touch named identity pools** (`ADRATA_MCP_IDENTITY_POOL`), which
461
+ return before the credential chain is consulted at all.
462
+
463
+ With the flag unset -- the default, and every ordinary single-user install --
464
+ the credential chain is byte-for-byte what it was.
465
+
421
466
  ## Environment Variables
422
467
 
423
468
  | Variable | Required | Default | Description |
424
469
  |----------|----------|---------|-------------|
425
470
  | `ADRATA_API_KEY` | No | -- | Your API key from Settings > API Keys. Enables pro tier. |
426
471
  | `ADRATA_OAUTH_TOKEN` | No | -- | OAuth bearer token. Enables enterprise tier. |
472
+ | `ADRATA_MCP_PREFER_API_KEY` | No | unset (off) | `1`/`true`/`yes`/`on` makes an **env-var** `ADRATA_API_KEY` outrank the shared `adrata login` session and any stored `connect_workspace` session, and grants it enterprise tier. Leave it unset unless this process was deliberately given its own credential -- see below. |
427
473
  | `ADRATA_API_URL` | No | `https://api.adrata.com` | API base URL |
428
474
  | `ADRATA_MCP_SERVER_NAME` | No | `@adrata/adrata-mcp` | Display name reported in `serverInfo`. `@adrata/starfield-mcp` sets it to `Starfield`. Does not change the tool prefix, which comes from your client's config key. |
429
475
  | `ADRATA_MCP_TRANSPORT` | No | `stdio` | Transport: `stdio` or `http` |
package/access/auth.js CHANGED
@@ -13,6 +13,10 @@
13
13
  * terminal agent, and this MCP server all read the same file, so one device-flow
14
14
  * sign-in serves all three surfaces. The MCP's own tokens.json (written by
15
15
  * connect_workspace) and the legacy cli.json keep working unchanged.
16
+ *
17
+ * ONE OPT-IN REORDERS THAT LIST: ADRATA_MCP_PREFER_API_KEY. See
18
+ * `preferExplicitApiKey` below for why it exists and what it deliberately does
19
+ * not do.
16
20
  */
17
21
 
18
22
  import { TIERS, getToolTier, tierSatisfies } from './tiers.js';
@@ -98,11 +102,57 @@ export function loadAgentSession() {
98
102
  }
99
103
  }
100
104
 
105
+ /**
106
+ * Env-var API key ONLY - never the ambient legacy cli.json token.
107
+ *
108
+ * The distinction is the whole safety of the opt-in below. An env var is set by
109
+ * whoever launched this process, for this process; cli.json is machine state a
110
+ * user may have had for months without knowing. Letting the ambient one reorder
111
+ * the chain would silently change which identity an ordinary user writes as.
112
+ */
113
+ export function explicitEnvApiKey(env = process.env) {
114
+ const key = env.ADRATA_API_KEY?.trim() || env.ADRATA_API_TOKEN?.trim() || '';
115
+ return key || null;
116
+ }
117
+
118
+ /**
119
+ * Should an explicitly-supplied ADRATA_API_KEY outrank the shared on-disk
120
+ * session? Off unless asked for.
121
+ *
122
+ * The problem it solves: the shared `adrata login` session at step 2 is a
123
+ * MACHINE-WIDE file, so on a host where anyone has ever run `adrata login`,
124
+ * every MCP process on that host authenticates as that one human - including
125
+ * processes that were deliberately handed their own distinct API key. The key
126
+ * is still sent (server.js attaches X-API-Key alongside the bearer), but the
127
+ * API resolves the bearer, so the caller's identity is the shared human's.
128
+ *
129
+ * For a fleet of QA lanes that is not a tier problem, it is an IDENTITY
130
+ * problem: the API grades a human-token write as Verifier::Human, which counts
131
+ * zero toward the agent gate, so a lane's verification lands, stores, reads
132
+ * back fine - and moves nothing. Independence that is declared and not real is
133
+ * the exact failure the two-gate design exists to prevent.
134
+ *
135
+ * Why an opt-in flag rather than flipping precedence outright: precedence here
136
+ * is machine-wide too. A single-user laptop that has both `adrata login` and an
137
+ * ADRATA_API_KEY in a shell profile would silently drop from the OAuth session
138
+ * to an API key, changing the acting identity and the audit trail of every
139
+ * subsequent write, to fix a problem that user does not have. The flag makes
140
+ * the caller say "this process was given its own credential on purpose".
141
+ *
142
+ * Accepts 1/true/yes/on, case-insensitive; anything else (including unset) is off.
143
+ */
144
+ export function preferExplicitApiKey(env = process.env) {
145
+ const raw = env.ADRATA_MCP_PREFER_API_KEY?.trim().toLowerCase();
146
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
147
+ }
148
+
101
149
  /**
102
150
  * Determine the user's tier from environment / stored tokens / API key.
103
151
  *
104
152
  * Checks (in priority order):
105
153
  * 1. ADRATA_OAUTH_TOKEN env var (enterprise)
154
+ * 1b. ADRATA_API_KEY env var, ONLY when ADRATA_MCP_PREFER_API_KEY is set
155
+ * (enterprise — see preferExplicitApiKey)
106
156
  * 2. Shared agent session — agent.json from `adrata login` (enterprise)
107
157
  * 3. Stored OAuth tokens from connect_workspace flow (enterprise)
108
158
  * 4. ADRATA_API_KEY env var / legacy cli.json token (pro)
@@ -170,6 +220,34 @@ export function authenticate() {
170
220
  };
171
221
  }
172
222
 
223
+ // Opt-in: an explicitly supplied env API key outranks every on-disk store.
224
+ //
225
+ // Tier is ENTERPRISE, not PRO, and that is load-bearing rather than
226
+ // generous. An Adrata API key is a real workspace credential — the API
227
+ // resolves it to an `api_key:<id>` actor and authorises the governed board
228
+ // routes on it. Every Starfield board tool is tagged ENTERPRISE in tiers.js,
229
+ // so leaving this at PRO would hand the caller its own identity and then have
230
+ // checkToolAccess refuse `move_work_item` and
231
+ // `satisfy_work_item_acceptance_criterion` before either left the process:
232
+ // the fix would resolve the credential correctly and still deliver nothing.
233
+ // The tier here describes what the credential can reach, and this one reaches
234
+ // the workspace.
235
+ const preferredApiKey = preferExplicitApiKey() ? explicitEnvApiKey() : null;
236
+ if (preferredApiKey) {
237
+ return {
238
+ tier: TIERS.ENTERPRISE,
239
+ // No bearer: server.js sends X-API-Key alone, so the API resolves this
240
+ // process to its own api_key actor instead of the shared human session.
241
+ token: null,
242
+ apiKey: preferredApiKey,
243
+ apiUrl,
244
+ workspaceId,
245
+ authenticated: true,
246
+ source: 'api_key',
247
+ preferredApiKey: true,
248
+ };
249
+ }
250
+
173
251
  // Shared agent session (device-flow OAuth) — the primary on-disk store,
174
252
  // shared with the Adrata CLI and the @adrata terminal agent.
175
253
  const agentSession = loadAgentSession();
package/access/tiers.js CHANGED
@@ -245,9 +245,12 @@ export const TOOL_TIERS = {
245
245
  move_work_item: TIERS.ENTERPRISE,
246
246
  transfer_work_item_between_boards: TIERS.ENTERPRISE,
247
247
  set_work_item_tag: TIERS.ENTERPRISE,
248
+ block_work_item: TIERS.ENTERPRISE,
249
+ unblock_work_item: TIERS.ENTERPRISE,
248
250
  set_work_item_kind: TIERS.ENTERPRISE,
249
251
  create_work_item: TIERS.ENTERPRISE,
250
252
  add_work_item_acceptance_criterion: TIERS.ENTERPRISE,
253
+ classify_work_item_acceptance_criterion: TIERS.ENTERPRISE,
251
254
  satisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
252
255
  record_work_item_criterion_engineering_proof: TIERS.ENTERPRISE,
253
256
  unsatisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Make a 429 legible, and make a hot retry loop impossible.
3
+ *
4
+ * The incident this exists for. Measured on the production ALB, 2026-09-02:
5
+ * request volume went from ~76,000/day to **7,148,120** in one day, of which
6
+ * **7,059,080 were 4XX** and only 76,913 were 2XX. Real product traffic never
7
+ * changed — the 2XX count matches the previous day almost exactly. The whole
8
+ * increase was one client being refused, over and over, at up to 428 requests
9
+ * per second for four hours.
10
+ *
11
+ * A single five-minute ALB access log from the peak:
12
+ *
13
+ * 90,898 x 429 one client IP, user-agent "node"
14
+ * 27,569 x POST /api/v1/work-items/01M17788.../worker-lease/release
15
+ * 23,131 x POST /api/v1/work-items/01M172QQ.../worker-lease/release
16
+ * 21,016 x POST /api/v1/work-items/01M1DGCY.../worker-lease/release
17
+ *
18
+ * Five card ids account for essentially all of it, so nothing was being
19
+ * achieved: the same few lease releases were attempted tens of thousands of
20
+ * times each.
21
+ *
22
+ * The cause was not malice or a runaway `for` loop. It was an ERROR MESSAGE.
23
+ * `api()` in server.js handled 401 (refresh the token and retry once) and
24
+ * nothing else, so a 429 fell through to the generic shape:
25
+ *
26
+ * API POST /api/v1/work-items/<id>/worker-lease/release → 429: {...}
27
+ *
28
+ * That sentence does not say "rate limit", does not say how long to wait, and
29
+ * does not say "stop". A caller reading it — an agent, a supervisor loop, a
30
+ * person — sees a transient-looking failure on an operation that MUST succeed
31
+ * to release a lease, and tries again immediately. The rate limiter then
32
+ * refuses the retry, producing an identical message, and the loop closes.
33
+ *
34
+ * This matters beyond the bill. A lease that cannot be released is a claim that
35
+ * never clears, and AGENTS.md already records what that does downstream: the
36
+ * work queue reported `queue=212 claimed=211 free=1` and told every lane "ALL
37
+ * QUEUES ARE DRAINED. STOP AND REPORT." while 211 of those claims were held on
38
+ * cards that had already left the column. The storm and the false all-clear are
39
+ * the same defect seen from two ends.
40
+ *
41
+ * So the fix is two things, and the second is the important one:
42
+ *
43
+ * 1. Wait and retry a SMALL, BOUNDED number of times, honouring Retry-After.
44
+ * 2. When the budget is spent, throw a message that names the rate limit and
45
+ * tells the caller in plain words not to retry immediately.
46
+ *
47
+ * A retry policy alone would not have prevented this. The client was already
48
+ * "retrying"; it just had no delay and no ceiling, because nothing told it it
49
+ * was being throttled.
50
+ */
51
+
52
+ /**
53
+ * How many total attempts a single call may make before giving up.
54
+ *
55
+ * Deliberately small. The failure mode being prevented is a large budget, and
56
+ * `rate-limit.test.js` pins the ceiling so raising it has to be a decision
57
+ * somebody makes on purpose rather than a number that drifts.
58
+ */
59
+ export const RATE_LIMIT_MAX_ATTEMPTS = 3;
60
+
61
+ /**
62
+ * The longest this client will ever block on one attempt.
63
+ *
64
+ * A server may legitimately answer `Retry-After: 86400`. Honouring that
65
+ * literally would hang an MCP tool call for a day, which reads to the caller as
66
+ * a hang rather than a limit — and a hang gets killed and retried, which is the
67
+ * behaviour we are trying to remove.
68
+ */
69
+ export const RATE_LIMIT_MAX_WAIT_MS = 30_000;
70
+
71
+ /** Base of the exponential backoff, before any server-supplied floor. */
72
+ const BASE_DELAY_MS = 500;
73
+
74
+ /**
75
+ * Turn a `Retry-After` header into milliseconds.
76
+ *
77
+ * Accepts both forms RFC 9110 allows — delta-seconds and an HTTP-date — and
78
+ * returns `null` for anything it cannot read. `null` means "we were told
79
+ * nothing", which is different from "we were told zero", and the caller treats
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".
82
+ */
83
+ export function parseRetryAfterMs(headerValue, nowMs = Date.now()) {
84
+ if (headerValue == null) return null;
85
+ const raw = String(headerValue).trim();
86
+ if (raw === '') return null;
87
+
88
+ if (/^[+-]?\d+$/.test(raw)) {
89
+ const seconds = Number(raw);
90
+ // A negative delta-seconds is not "retry in the past", it is a malformed
91
+ // header. Guessing zero from it would hand the caller a no-wait retry,
92
+ // which is the exact behaviour this module exists to prevent.
93
+ if (seconds < 0) return null;
94
+ return Math.min(seconds * 1000, RATE_LIMIT_MAX_WAIT_MS);
95
+ }
96
+
97
+ // An HTTP-date always carries letters (a month name and a zone). Requiring
98
+ // one stops Date.parse from cheerfully interpreting stray numeric junk as a
99
+ // year and returning a confident, wrong timestamp.
100
+ if (!/[a-z]/i.test(raw)) return null;
101
+
102
+ const asDate = Date.parse(raw);
103
+ if (Number.isNaN(asDate)) return null;
104
+ // A date already in the past means "you may retry now", not "wait a negative
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);
107
+ }
108
+
109
+ /**
110
+ * How long to wait before attempt N+1.
111
+ *
112
+ * `retryAfterMs` can only ever RAISE the wait. A server that says "try again in
113
+ * 1ms" does not get to override our backoff — trusting the throttler about how
114
+ * fast we may return is precisely what produced 428 requests per second.
115
+ */
116
+ export function rateLimitDelayMs(attempt, retryAfterMs, rng = Math.random) {
117
+ const exponential = BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
118
+ // Jitter spreads a fleet of workers that were all refused in the same second,
119
+ // so they do not return in the same second either. It is injectable because a
120
+ // non-deterministic delay is otherwise untestable, and an untestable backoff
121
+ // is how a floor silently stops being a floor.
122
+ const jittered = exponential + Math.floor(rng() * BASE_DELAY_MS);
123
+ const own = Math.min(jittered, RATE_LIMIT_MAX_WAIT_MS);
124
+ if (typeof retryAfterMs !== 'number' || Number.isNaN(retryAfterMs)) return own;
125
+ return Math.min(Math.max(own, retryAfterMs), RATE_LIMIT_MAX_WAIT_MS);
126
+ }
127
+
128
+ /** Render a wait as something a person or a model reads without ambiguity. */
129
+ function describeWait(retryAfterMs) {
130
+ if (typeof retryAfterMs !== 'number' || Number.isNaN(retryAfterMs) || retryAfterMs <= 0) {
131
+ return 'at least a minute';
132
+ }
133
+ const seconds = Math.max(1, Math.round(retryAfterMs / 1000));
134
+ return `${seconds}s`;
135
+ }
136
+
137
+ /**
138
+ * The message thrown once the retry budget is spent.
139
+ *
140
+ * This string is the actual fix. It has to do three things the old generic
141
+ * message did not: say the words "rate limit" so the failure is not mistaken
142
+ * for a transient error, say how long to wait, and say plainly not to retry
143
+ * immediately — because the caller is frequently a language model, and a model
144
+ * reading an opaque failure on a must-succeed operation will try again.
145
+ */
146
+ export function describeRateLimit({ method, path, attempts, retryAfterMs }) {
147
+ return [
148
+ `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
+ '',
151
+ `DO NOT retry this call immediately. Wait ${describeWait(retryAfterMs)} before trying again,`,
152
+ 'or hand the work back and let a later pass pick it up.',
153
+ '',
154
+ 'Retrying a 429 without waiting is what produced 7,059,080 rejected requests against',
155
+ 'production on 2026-09-02 — one client repeating the same few lease releases tens of',
156
+ 'thousands of times each. If this call is a worker-lease release, note that the lease',
157
+ 'will expire on its own; a stuck release does not require a hot loop to resolve.',
158
+ ].join('\n');
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
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.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -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 edge-block.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 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"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
@@ -42,9 +42,9 @@
42
42
  "files": [
43
43
  "server.js",
44
44
  "api-bridge.js",
45
+ "http/",
45
46
  "analytics.js",
46
47
  "security.js",
47
- "edge-block.js",
48
48
  "transport-http.js",
49
49
  "resources.js",
50
50
  "tool-annotations.js",
@@ -1,8 +1,8 @@
1
1
  /** Branded MCP launch profiles over one shared server implementation. */
2
2
  export const PRODUCT_PROFILES = Object.freeze({
3
3
  adrata: Object.freeze({ displayName: 'Adrata', domains: null }),
4
- bounce: Object.freeze({
5
- displayName: 'Bounce',
4
+ oasis: Object.freeze({
5
+ displayName: 'Oasis',
6
6
  domains: Object.freeze([
7
7
  'actions', 'agent', 'bridge', 'calendar', 'email', 'infra',
8
8
  'meetings', 'sequences', 'webhooks',
package/server.js CHANGED
@@ -42,7 +42,13 @@ import {
42
42
  import { TIERS } from './access/tiers.js';
43
43
  import { findCompany, findPerson } from './tools/free-search.js';
44
44
  import { applySecurityLayer } from './security.js';
45
- import { describeEdgeBlock } from './edge-block.js';
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
52
  import { registerMemoryTools, wrapWithEventLogging, registerProfileResource } from './tools/memory.js';
47
53
  import { registerBillingTools } from './tools/billing.js';
48
54
  import { registerMorningBrief } from './tools/morning-brief.js';
@@ -213,6 +219,28 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
213
219
  res = await request(currentToken);
214
220
  }
215
221
 
222
+ // A 429 used to fall straight through to the generic error below, which
223
+ // never said "rate limit" and never said how long to wait. Callers therefore
224
+ // read it as transient and retried immediately — 7,059,080 rejected requests
225
+ // against production on 2026-09-02. Wait, retry a bounded number of times,
226
+ // and if the limit still holds, fail with a message that says so. See
227
+ // 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
+ }
243
+
216
244
  const text = await res.text();
217
245
  let data;
218
246
  try { data = JSON.parse(text); } catch { data = { raw: text }; }
@@ -527,8 +555,15 @@ server.tool('workspace_status',
527
555
  return ok({
528
556
  connected: AUTH.authenticated,
529
557
  tier: AUTH.tier,
530
- credential: CREDENTIALS[AUTH.source] ?? AUTH.source,
558
+ // Name the opt-in explicitly. Whether this process is acting as its own
559
+ // api_key actor or as the machine-wide shared human session is the whole
560
+ // question a fleet lane runs this tool to answer, and both report
561
+ // `source: 'api_key'`-adjacent states that look alike from outside.
562
+ credential: AUTH.preferredApiKey
563
+ ? 'ADRATA_API_KEY (environment), preferred over any on-disk session by ADRATA_MCP_PREFER_API_KEY'
564
+ : CREDENTIALS[AUTH.source] ?? AUTH.source,
531
565
  authSource: AUTH.source,
566
+ preferredApiKey: Boolean(AUTH.preferredApiKey),
532
567
  apiBase: API_BASE,
533
568
  workspaceId,
534
569
  workspaceName,
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.7",
6
+ "version": "1.0.8",
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.7",
18
+ "version": "1.0.8",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -241,7 +241,14 @@ const IDEMPOTENT_WRITES = new Set([
241
241
  // comment, criterion, or card.
242
242
  'move_work_item', 'transfer_work_item_between_boards', 'set_work_board_column_wip_limit', 'set_work_item_tag', 'set_work_item_kind',
243
243
  'create_work_item', 'comment_on_work_item', 'flag_work_item',
244
+ // A repeat block is the same edge (the pair is unique), and a repeat
245
+ // unblock deletes a row that is already gone. Neither compounds.
246
+ 'block_work_item', 'unblock_work_item',
244
247
  'add_work_item_acceptance_criterion',
248
+ // Reclassifying is a PUT of the whole routing decision, not an append:
249
+ // replaying the same key rewrites the criterion to the same route and the
250
+ // server's receipt table records one change, never a second one.
251
+ 'classify_work_item_acceptance_criterion',
245
252
  // Ticking and un-ticking are both genuinely idempotent, and for different
246
253
  // reasons worth keeping straight: a repeat tick is a no-op because the server
247
254
  // only writes the ticker, column and note where they were empty, and a repeat
@@ -371,7 +371,7 @@ export function registerWorkBoardTools(
371
371
 
372
372
  server.tool(
373
373
  'list_my_work_items',
374
- `The cards that are YOURS, across every board in the workspace, ordered the way a developer actually picks: escalated first (the top band of each card's OWN scheme — a P1 is never equated with a Critical), then by the board's own left-to-right flow so the earliest active stage comes first, then longest-waiting. Cards in a terminal column (Production, Deep backlog) sink to the bottom, because nothing should be picked up from them.
374
+ `The cards that are YOURS, across every board in the workspace, ordered the way a developer actually picks: parked cards last (Deep backlog is where work was deliberately shelved, and offering it back argues with whoever parked it), then escalated first (the top band of each card's OWN scheme — a P1 is never equated with a Critical), then by the board's own left-to-right flow so the earliest active stage comes first, then longest-waiting. Production sits at the end of that flow, so nothing is picked up from it either.
375
375
 
376
376
  "Yours" is TWO things: cards you OWN (you are the assignee, carrying it end to end) and cards whose CURRENT PASS you hold (you took it at a stage — the QA case). Both appear here, and each card carries assignee and handler so you can tell which of the two put it in front of you. A QA person owns none of the cards they are testing, so a queue keyed only on the assignee would tell them they had no work while four cards sat on their bench.
377
377
 
@@ -553,7 +553,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
553
553
 
554
554
  server.tool(
555
555
  'claim_next_work_item_qa_pass',
556
- `Atomically choose and take the first workable QA pass for ONE exact product in ONE requested gate across every board this worker can see. Product is read from the card, never inferred from the board that happens to hold it. QA1 and QA2 pools must name their gate explicitly; a QA1 drain can never consume QA2 work and vice versa. Concurrent sessions receive distinct cards; one MCP worker holds at most one pass at a time. An empty result means no eligible pass remains in that product/gate. The response returns the selected board and card, explains the selection, and carries the exact acceptance criteria and recorded-QA requirements. Capability material stays inside this MCP process.${GOVERNED_NOTE}`,
556
+ `Atomically choose and take the first workable QA pass for ONE exact product in ONE requested gate across every board this worker can see. Product is read from the card, never inferred from the board that happens to hold it. QA1 and QA2 pools must name their gate explicitly; a QA1 drain can never consume QA2 work and vice versa. Concurrent sessions receive distinct cards; one MCP worker holds at most one pass at a time. An empty result is TWO different situations and the response tells them apart: an empty skippedBlocked means no eligible pass remains in that product/gate, so move to another lane; a non-empty skippedBlocked means there IS work here and every card is waiting on another card, so read the blockers rather than re-claiming. The response returns the selected board and card, explains the selection, and carries the exact acceptance criteria and recorded-QA requirements. Capability material stays inside this MCP process.${GOVERNED_NOTE}`,
557
557
  {
558
558
  qaGate: z.enum(['Staging QA1', 'Staging QA2']).describe('Exact gate this worker pool is allowed to drain.'),
559
559
  product: z.string().min(1).max(120).describe('Exact card product to drain, such as Adrata or Starfield, across all visible boards.'),
@@ -604,13 +604,30 @@ Start here for "what should I work on". With includeUnassigned it also returns t
604
604
  body,
605
605
  headers: buildMutationHeaders(args),
606
606
  });
607
- const selected = data?.data;
607
+ // The route answers with an OUTCOME, not a bare card: `claimed` may be
608
+ // null while `skippedBlocked` explains why. Reading `data.data` as the
609
+ // card directly would silently produce an undefined workItemId.
610
+ const outcome = data?.data ?? {};
611
+ const selected = outcome.claimed;
612
+ const skippedBlocked = outcome.skippedBlocked ?? [];
608
613
  if (!selected) {
609
- return ok({ claimed: false, qaGate: args.qaGate, product: args.product, note: `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.` });
614
+ // The empty-handed case is two different situations and a lane acts on
615
+ // them differently: an empty gate means pick another lane, while a
616
+ // fully-blocked one means go and look at the blockers.
617
+ return ok({
618
+ claimed: false,
619
+ qaGate: args.qaGate,
620
+ product: args.product,
621
+ skippedBlocked,
622
+ note: skippedBlocked.length
623
+ ? `No workable ${args.product} ${args.qaGate} pass remains: ${skippedBlocked.length} card(s) in this gate are blocked by another card. Do not re-claim — look at the blockers listed in skippedBlocked.`
624
+ : `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.`,
625
+ });
610
626
  }
611
627
  const visible = rememberWorkerLease(selected.workItemId, selected.grant, args.idempotencyKey);
612
628
  return ok({
613
629
  claimed: true,
630
+ skippedBlocked,
614
631
  workItemId: selected.workItemId,
615
632
  boardId: selected.boardId,
616
633
  boardName: selected.boardName,
@@ -1589,6 +1606,106 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1589
1606
  }
1590
1607
  );
1591
1608
 
1609
+ server.tool(
1610
+ 'block_work_item',
1611
+ `Record that one card CANNOT START until another lands. Use this for a real dependency — the fix that has to ship first, the fixture the test needs, the decision the work waits on — not for a note or a "related to". A recorded dependency changes what the queue hands out: a blocked card stops being offered as workable to a lane, and is instead shown with the blocker named, so the next agent does not pay a full read-and-release to rediscover it.
1612
+
1613
+ Reading direction is "itemId is blocked by blockedByItemId".
1614
+
1615
+ The blocker resolves ON ITS OWN when it reaches Ready to Ship or Production — there is nothing to unset, and no "resolved" flag to keep in step. A blocker parked in Backlog or Deep backlog still blocks, and the queue says so in those words, because that case needs an owner decision rather than a retry.
1616
+
1617
+ Cross-BOARD links are allowed (a CRO card blocked by a Starfield defect is the case this exists for). A cycle is refused: if the blocker already depends on this card, directly or through others, the write returns 409 rather than creating a loop in which no card could ever be started.
1618
+
1619
+ This says "do not start this yet". It NEVER says "this is already proved" — a dependency does not satisfy an acceptance criterion, and QA evidence is never inherited across a link.${GOVERNED_NOTE}`,
1620
+ {
1621
+ itemId: z.string().describe('The card that cannot start.'),
1622
+ blockedByItemId: z.string().describe('The card it is waiting on.'),
1623
+ reason: z
1624
+ .string()
1625
+ .describe('Why this dependency is real, in a sentence. Required — this is what the next agent reads instead of re-deriving it.'),
1626
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
1627
+ approved: z.boolean().optional().describe('Required true for a live change.'),
1628
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
1629
+ },
1630
+ async (args) => {
1631
+ if (args.itemId === args.blockedByItemId) {
1632
+ return ok({
1633
+ error: true,
1634
+ message: 'A card cannot block itself.',
1635
+ });
1636
+ }
1637
+
1638
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/dependencies`;
1639
+ const preview = validateApiBridgeRequest({
1640
+ method: 'POST',
1641
+ path,
1642
+ dryRun: args.dryRun,
1643
+ approved: args.approved,
1644
+ reason: args.reason,
1645
+ idempotencyKey: args.idempotencyKey,
1646
+ grantedScope: getGrantedScope(),
1647
+ });
1648
+ if (preview?.dryRun) {
1649
+ return ok({
1650
+ ...preview,
1651
+ wouldBlock: {
1652
+ itemId: args.itemId,
1653
+ blockedByItemId: args.blockedByItemId,
1654
+ reason: args.reason,
1655
+ },
1656
+ });
1657
+ }
1658
+
1659
+ const data = await api('POST', path, {
1660
+ body: {
1661
+ blockedByItemId: args.blockedByItemId,
1662
+ reason: args.reason,
1663
+ idempotencyKey: args.idempotencyKey,
1664
+ },
1665
+ headers: buildMutationHeaders(args),
1666
+ });
1667
+ return ok({ blocked: true, itemId: args.itemId, blockedBy: data?.data });
1668
+ }
1669
+ );
1670
+
1671
+ server.tool(
1672
+ 'unblock_work_item',
1673
+ `Remove a dependency that should not have been recorded — a link added in error, or one that turned out not to be real.
1674
+
1675
+ This is NOT how a dependency ends in the normal course. A blocker resolves by REACHING Ready to Ship or Production, which the queue derives on its own; deleting the edge instead would say the dependency never existed and lose why the card waited. Use this only to correct the record.
1676
+
1677
+ Removing an edge that is not there succeeds: the caller asked for a state, and that state is what they get.${GOVERNED_NOTE}`,
1678
+ {
1679
+ itemId: z.string().describe('The blocked card.'),
1680
+ blockedByItemId: z.string().describe('The blocker to unlink.'),
1681
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
1682
+ approved: z.boolean().optional().describe('Required true for a live change.'),
1683
+ reason: z.string().optional().describe('Required for a live change: why this link was wrong.'),
1684
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
1685
+ },
1686
+ async (args) => {
1687
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/dependencies/${encodeURIComponent(args.blockedByItemId)}`;
1688
+ const preview = validateApiBridgeRequest({
1689
+ method: 'DELETE',
1690
+ path,
1691
+ dryRun: args.dryRun,
1692
+ approved: args.approved,
1693
+ reason: args.reason,
1694
+ idempotencyKey: args.idempotencyKey,
1695
+ grantedScope: getGrantedScope(),
1696
+ });
1697
+ if (preview?.dryRun) {
1698
+ return ok({
1699
+ ...preview,
1700
+ wouldUnblock: { itemId: args.itemId, blockedByItemId: args.blockedByItemId },
1701
+ });
1702
+ }
1703
+
1704
+ const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
1705
+ return ok({ unblocked: true, itemId: args.itemId, blockedBy: data?.data });
1706
+ }
1707
+ );
1708
+
1592
1709
  server.tool(
1593
1710
  'set_work_item_tag',
1594
1711
  `Set a card's URGENCY tag. The scheme must be one the board reads (severity | priority | impact) and the source must be human, model, or rules. A "model" tag REQUIRES a confidence: the board drops a low-confidence model tag, and a model tag with no confidence cannot be held to that floor, so it would be trusted by default — backwards. This does NOT say what kind of work the card is — that is a separate field; use set_work_item_kind, and note that setting one never disturbs the other.${GOVERNED_NOTE}`,
@@ -1917,6 +2034,84 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1917
2034
  }
1918
2035
  );
1919
2036
 
2037
+ server.tool(
2038
+ 'classify_work_item_acceptance_criterion',
2039
+ `Change WHICH KIND OF PROOF one existing acceptance criterion needs, and record who decided. Use this when a criterion is routed \`product\` but a signed-in browser could not establish it AT ALL — its \`thenText\` is about a test suite, a named source file, a migration receipt, an ECS task definition, a CI leg, an HTTP status contract, a log record or a vendor invoice. A criterion routed \`product\` by mistake is not merely mislabelled: it demands a recording of something that has no screen, so it can never legitimately pass, and the QA lane that draws it spends a whole pass discovering that.
2040
+
2041
+ THIS IS NOT A WAIVER, AND THE ASYMMETRY IS THE POINT. \`engineering\` asks a reviewer for MORE than \`product\`, not less — an exact deployed build SHA, named executed code/test/configuration/runtime references, and a reviewer independent of the author (see record_work_item_criterion_engineering_proof). \`both\` requires BOTH and is the STRICTEST of the three. What \`engineering\` drops is the one demand that was never satisfiable: a browser replay of a thing that has no rendered surface. Both QA gates still run either way, and no route changes whether a criterion has been proved — only what would count as proving it.
2042
+
2043
+ SO ROUTE FROM THE CRITERION'S OWN WORDS, NOT FROM WHAT IS CHEAP TODAY. Can a browser DISPLAY the result its \`thenText\` asks for? Then it is \`product\` and it stays \`product\`. A fixture you do not have, an environment that is down, or a journey that is tedious to record are reasons to report a blocker, never reasons to reclassify. If the criterion carries a code fact AND a seller-visible outcome it is \`both\`, or leave it \`product\`; quietly splitting that difference downward is the one thing this tool could be used to launder.
2044
+
2045
+ \`engineeringReason\` is REQUIRED for \`engineering\` and \`both\` and REFUSED for \`product\`: say in plain language why no meaningful product-person retest exists, because a later reader has to trust the routing rather than re-derive it. \`note\` is the separate audit sentence for THIS act — why the classification is changing now.
2046
+
2047
+ The server refuses the change when the card is standing at a QA gate AND you are the credential that wrote the criterion, in the direction that adds the engineering claim or drops the product one. That is deliberate: reclassifying your own check out of the recording a reviewer just asked for is the exact move the rule exists to stop. Ask the reviewer to reclassify it, or say why on the card. Classification belongs at grooming. Every change writes a durable receipt carrying the previous route, the new one, both sentences, your credential, and the column and dwell the card was standing in.${GOVERNED_NOTE}`,
2048
+ {
2049
+ itemId: z.string().describe('Card id.'),
2050
+ criterionId: z
2051
+ .string()
2052
+ .describe(
2053
+ 'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'
2054
+ ),
2055
+ verificationRoute: z
2056
+ .enum(['product', 'engineering', 'both'])
2057
+ .describe(
2058
+ 'REQUIRED. The route this criterion should have had. `product` needs a ' +
2059
+ 'signed-in browser recording; `engineering` needs executed ' +
2060
+ 'code/test/runtime proof against an exact build; `both` needs BOTH ' +
2061
+ 'and is the STRICTEST of the three, not a compromise.'
2062
+ ),
2063
+ engineeringReason: z
2064
+ .string()
2065
+ .optional()
2066
+ .describe(
2067
+ 'Required for engineering or both, refused for product: why no meaningful ' +
2068
+ 'product-person retest exists. Plain language, 600 characters or fewer.'
2069
+ ),
2070
+ note: z
2071
+ .string()
2072
+ .optional()
2073
+ .describe(
2074
+ 'Why the classification is changing NOW — the audit note for this one act, ' +
2075
+ 'distinct from engineeringReason, which is the standing claim about the criterion.'
2076
+ ),
2077
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to apply it.'),
2078
+ approved: z.boolean().optional().describe('Required true for a live write.'),
2079
+ reason: z
2080
+ .string()
2081
+ .optional()
2082
+ .describe('Required for a live write: why this criterion is being reclassified.'),
2083
+ idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
2084
+ },
2085
+ async (args) => {
2086
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria/${encodeURIComponent(args.criterionId)}/verification-route`;
2087
+ const preview = validateApiBridgeRequest({
2088
+ method: 'PUT',
2089
+ path,
2090
+ dryRun: args.dryRun,
2091
+ approved: args.approved,
2092
+ reason: args.reason,
2093
+ idempotencyKey: args.idempotencyKey,
2094
+ grantedScope: getGrantedScope(),
2095
+ });
2096
+ const classification = {
2097
+ verificationRoute: args.verificationRoute,
2098
+ engineeringReason: args.engineeringReason,
2099
+ note: args.note,
2100
+ };
2101
+ if (preview?.dryRun) {
2102
+ return ok({
2103
+ ...preview,
2104
+ wouldClassify: { itemId: args.itemId, criterionId: args.criterionId, classification },
2105
+ });
2106
+ }
2107
+ const data = await api('PUT', path, {
2108
+ body: classification,
2109
+ headers: buildMutationHeaders(args),
2110
+ });
2111
+ return ok({ classified: true, criterion: data?.data });
2112
+ }
2113
+ );
2114
+
1920
2115
  server.tool(
1921
2116
  'satisfy_work_item_acceptance_criterion',
1922
2117
  `Tick one acceptance criterion with the evidence you actually observed.
@@ -2299,10 +2494,13 @@ export const WORK_BOARD_TOOL_NAMES = [
2299
2494
  'set_work_item_kind',
2300
2495
  'create_work_item',
2301
2496
  'add_work_item_acceptance_criterion',
2497
+ 'classify_work_item_acceptance_criterion',
2302
2498
  'satisfy_work_item_acceptance_criterion',
2303
2499
  'record_work_item_criterion_engineering_proof',
2304
2500
  'unsatisfy_work_item_acceptance_criterion',
2305
2501
  'get_work_item_comments',
2306
2502
  'comment_on_work_item',
2307
2503
  'flag_work_item',
2504
+ 'block_work_item',
2505
+ 'unblock_work_item',
2308
2506
  ];
File without changes