@adrata/adrata-mcp 1.0.6 → 1.0.7

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/edge-block.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tell an edge block apart from an authorization failure.
3
+ *
4
+ * The problem this exists for. api.adrata.com resolves to the ALB directly, and
5
+ * an AWS WAF web ACL sits on that ALB. When a managed rule matches a REQUEST
6
+ * BODY the request is refused at the edge and never reaches the Rust API, so
7
+ * the caller gets a bare HTML page:
8
+ *
9
+ * <html>
10
+ * <head><title>403 Forbidden</title></head>
11
+ * <body>
12
+ * <center><h1>403 Forbidden</h1></center>
13
+ * </body>
14
+ * </html>
15
+ *
16
+ * 403 is also what the API returns for a genuine `insufficient_scope`. Nothing
17
+ * in the old error message distinguished them, so the reasonable conclusion was
18
+ * "my token lost board write access" — and the reasonable next step was to ask
19
+ * the owner to reconnect the workspace. That reconnect loop has been run more
20
+ * than once for a cause that had nothing to do with credentials.
21
+ *
22
+ * The tell is decisive and needs no guessing. The Adrata API serialises EVERY
23
+ * error through one `IntoResponse` impl that always emits a JSON envelope
24
+ * (`code/api/crates/core/src/error.rs`), and its fallback route is JSON-only.
25
+ * An HTML body from this host is therefore structurally impossible to have come
26
+ * from the application. Something in front of it answered.
27
+ *
28
+ * Measured 2026-08-29 against production, same endpoint, same session, same
29
+ * everything but the body text:
30
+ *
31
+ * plain prose ....................... 404 JSON (reached the app)
32
+ * markdown table, pipes only ........ 404 JSON (reached the app)
33
+ * fenced code block, backticks ...... 404 JSON (reached the app)
34
+ * JSX with angle brackets ........... 404 JSON (reached the app)
35
+ * "aaa ../ bbb" ..................... 403 HTML (refused at the edge)
36
+ * "link:../../packages/..." ......... 403 HTML (refused at the edge)
37
+ * "the file /etc/passwd is not read" 404 JSON (reached the app)
38
+ *
39
+ * So the trigger is the path-traversal token `../`, not markdown structure —
40
+ * which matters, because "avoid tables and code fences" is the wrong lesson and
41
+ * would quietly strip evidence out of bug reports for no reason.
42
+ */
43
+
44
+ /** Substrings measured to be refused at the edge, most specific first. */
45
+ const MEASURED_TRIGGERS = [
46
+ { pattern: '../', label: 'a relative path segment (`../`)' },
47
+ { pattern: '..\\', label: 'a Windows relative path segment (`..\\`)' },
48
+ { pattern: '..%2f', label: 'a percent-encoded path segment (`..%2f`)' },
49
+ { pattern: '..%5c', label: 'a percent-encoded path segment (`..%5c`)' },
50
+ ];
51
+
52
+ /**
53
+ * True when a response body cannot have come from the Adrata API.
54
+ *
55
+ * Deliberately narrow. It looks for an HTML document rather than for "not
56
+ * JSON": an empty body, a timeout, or a truncated read are different failures
57
+ * and must not be reported as an edge block.
58
+ */
59
+ export function looksLikeEdgeHtml(text) {
60
+ if (typeof text !== 'string') return false;
61
+ const head = text.trimStart().slice(0, 400).toLowerCase();
62
+ if (!head.startsWith('<html') && !head.startsWith('<!doctype html')) return false;
63
+ return head.includes('<title>') || head.includes('<h1>');
64
+ }
65
+
66
+ /**
67
+ * Name the content the edge is most likely to have objected to.
68
+ *
69
+ * Returns every measured trigger present, because a body often carries more
70
+ * than one and fixing only the first sends the caller round again. Unknown is
71
+ * reported as unknown: a body with no measured trigger returns an empty list
72
+ * rather than a guess, so the caller is never told to edit the wrong sentence.
73
+ */
74
+ export function offendingContent(requestBody) {
75
+ if (requestBody == null) return [];
76
+ let serialized;
77
+ try {
78
+ serialized = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody);
79
+ } catch {
80
+ return [];
81
+ }
82
+ if (typeof serialized !== 'string') return [];
83
+ const haystack = serialized.toLowerCase();
84
+ return MEASURED_TRIGGERS.filter(({ pattern }) => haystack.includes(pattern)).map(
85
+ ({ label }) => label
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Build the message for a request the edge refused, or return null when this
91
+ * was an ordinary API error and the caller should report it unchanged.
92
+ */
93
+ export function describeEdgeBlock({ status, text, method, path, requestBody } = {}) {
94
+ if (!looksLikeEdgeHtml(text)) return null;
95
+ if (typeof status !== 'number' || status < 400 || status > 499) return null;
96
+
97
+ const found = offendingContent(requestBody);
98
+ const because = found.length
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.';
101
+
102
+ return [
103
+ `API ${method} ${path} → ${status}, refused at the network edge before it reached Adrata.`,
104
+ '',
105
+ 'This is NOT an authentication or scope failure, and reconnecting the workspace will not',
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',
109
+ 'do not ask the owner to reconnect.',
110
+ '',
111
+ because,
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.',
117
+ ].join('\n');
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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 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 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"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
@@ -44,6 +44,7 @@
44
44
  "api-bridge.js",
45
45
  "analytics.js",
46
46
  "security.js",
47
+ "edge-block.js",
47
48
  "transport-http.js",
48
49
  "resources.js",
49
50
  "tool-annotations.js",
package/server.js CHANGED
@@ -31,6 +31,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
31
31
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
32
32
  import { z } from 'zod';
33
33
  import { AsyncLocalStorage } from 'node:async_hooks';
34
+ import { readFileSync } from 'node:fs';
34
35
  import {
35
36
  authenticate,
36
37
  reauthenticate,
@@ -41,6 +42,7 @@ import {
41
42
  import { TIERS } from './access/tiers.js';
42
43
  import { findCompany, findPerson } from './tools/free-search.js';
43
44
  import { applySecurityLayer } from './security.js';
45
+ import { describeEdgeBlock } from './edge-block.js';
44
46
  import { registerMemoryTools, wrapWithEventLogging, registerProfileResource } from './tools/memory.js';
45
47
  import { registerBillingTools } from './tools/billing.js';
46
48
  import { registerMorningBrief } from './tools/morning-brief.js';
@@ -216,6 +218,18 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
216
218
  try { data = JSON.parse(text); } catch { data = { raw: text }; }
217
219
 
218
220
  if (!res.ok) {
221
+ // An HTML body cannot have come from the API — every AppError serialises as
222
+ // JSON — so it means the request was refused in front of us. Saying so is
223
+ // the whole point: a bare 403 is indistinguishable from insufficient_scope,
224
+ // and reading it as a token problem is what starts a needless reconnect.
225
+ const edge = describeEdgeBlock({
226
+ status: res.status,
227
+ text,
228
+ method,
229
+ path,
230
+ requestBody: body,
231
+ });
232
+ if (edge) throw new Error(edge);
219
233
  throw new Error(`API ${method} ${path} → ${res.status}: ${JSON.stringify(data).slice(0, 200)}`);
220
234
  }
221
235
  return data;
@@ -299,9 +313,12 @@ const moneyWrite = {
299
313
  * to start, which is the exact failure this whole surface keeps producing.
300
314
  */
301
315
  const SERVER_NAME = process.env.ADRATA_MCP_SERVER_NAME?.trim() || '@adrata/adrata-mcp';
316
+ const PACKAGE_VERSION = JSON.parse(
317
+ readFileSync(new URL('./package.json', import.meta.url), 'utf8')
318
+ ).version;
302
319
 
303
320
  function createMcpServer() {
304
- const server = new McpServer({ name: SERVER_NAME, version: '1.0.0' });
321
+ const server = new McpServer({ name: SERVER_NAME, version: PACKAGE_VERSION });
305
322
 
306
323
  // ---------------------------------------------------------------------------
307
324
  // Tier-gating wrapper
@@ -558,7 +575,7 @@ function assertProductCapability(capability, requested) {
558
575
  }
559
576
 
560
577
  async function resolveExactCapability(reference) {
561
- const response = await api('GET', '/api/v1/ai/crm-tools/capabilities/describe', {
578
+ const response = await api('GET', '/api/v1/ai-crm-tools/capabilities/describe', {
562
579
  params: { ref: reference },
563
580
  });
564
581
  return assertProductCapability(response?.capability ?? response?.data?.capability, reference);
@@ -576,7 +593,7 @@ server.tool('search_capabilities',
576
593
  if (PRODUCT_NAMESPACE && requestedNamespace && requestedNamespace !== PRODUCT_NAMESPACE) {
577
594
  throw new Error(`${SERVER_NAME} search is fixed to the ${PRODUCT_NAMESPACE} namespace.`);
578
595
  }
579
- return ok(await api('GET', '/api/v1/ai/crm-tools/capabilities/search', {
596
+ return ok(await api('GET', '/api/v1/ai-crm-tools/capabilities/search', {
580
597
  params: {
581
598
  q: args.query,
582
599
  namespace: PRODUCT_NAMESPACE || requestedNamespace,
@@ -618,7 +635,7 @@ server.tool('run_capability',
618
635
  if (args.idempotencyKey) headers['idempotency-key'] = args.idempotencyKey;
619
636
  if (args.reason) headers['x-adrata-reason'] = args.reason;
620
637
  if (args.confirmSpend === true) headers['x-adrata-approved'] = 'true';
621
- return ok(await api('POST', '/api/v1/ai/crm-tools/execute', { body, headers }));
638
+ return ok(await api('POST', '/api/v1/ai-crm-tools/execute', { body, headers }));
622
639
  });
623
640
 
624
641
  server.tool('adrata_api_catalog',
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",
6
+ "version": "1.0.7",
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.6",
18
+ "version": "1.0.7",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -32,6 +32,14 @@ their operating procedure.
32
32
  be executable at the current gate. Add or correct missing coverage before
33
33
  calling the card clean; never turn a vague criterion green by interpreting it
34
34
  generously.
35
+ 5. Missing independent QA evidence by itself is expected at QA1 entry and never
36
+ justifies a bounce. Do not report a vague “missing evidence path”: name the
37
+ allegedly missing product route, fixture, credential, artifact location, or
38
+ current-dwell QA receipt, and resolve relative artifact paths against the
39
+ owning worktree or durable location before declaring them absent. If the
40
+ executable prerequisite truly is missing, keep the card in QA and run the
41
+ same-card fix-and-retest loop; if only the independent receipt is missing,
42
+ start the pass.
35
43
 
36
44
  ## Preserve independent gates
37
45
 
@@ -41,6 +49,27 @@ their operating procedure.
41
49
  credential whose clean receipt opened QA1. Never impersonate a human to bypass
42
50
  this rule. A human may perform the final acceptance even when they own or
43
51
  authored the card.
52
+ - **Take that credential from a QA lane, and take a different one per
53
+ worktree.** Staging seeds twenty-four of them — `qa-lane-1@adrata.test`
54
+ through `qa-lane-24@adrata.test`, lanes 1-18 `admin` and 19-24 `seller`.
55
+ There are deliberately more lanes than the fleet can drive, so a tester
56
+ never queues behind an identity:
57
+
58
+ ```bash
59
+ node scripts/qa-staging-lane.mjs --list # every lane and its role
60
+ node scripts/qa-staging-lane.mjs <lane> --write-env # this worktree's tests/e2e/.env
61
+ ```
62
+
63
+ The rule above is unsatisfiable with one shared login, and the failure is
64
+ silent rather than loud: every recording is the same actor, so cards get
65
+ honestly CLAIMED and none can be legitimately VERIFIED. Sharing a lane
66
+ between two live agents is the same defect wearing a different hat — they
67
+ share a session, a workspace switch and a chat history, so one lane's
68
+ navigation lands in another lane's recording. Record the lane on the receipt,
69
+ so "a different credential" is a checkable claim rather than an assertion.
70
+ The accounts come from the non-production boot seed
71
+ (`code/api/crates/schema/src/seeds/non_prod.rs`) and exist only on staging
72
+ and local; they cannot be created in production.
44
73
  - For non-trivial QA2 work, use at least one independent adversarial reviewer
45
74
  when subagents are authorized and available. Parallelize separable surfaces
46
75
  such as UI, backend/security, and migration/deployment behavior. Give each
@@ -117,6 +146,16 @@ does not establish that the screen is visually correct.
117
146
  - Reopen both uploaded gate recordings before handoff. Prove each fresh
118
147
  short-lived URL loads and that each video can play, seek, and enter
119
148
  fullscreen; an upload response alone is not a media pass.
149
+ - A staging QA lane and the canonical board may intentionally use separate data
150
+ planes. Do not copy a production card into staging, reuse the builder's browser
151
+ credential, or put an OAuth bearer or presigned media URL in the transcript.
152
+ Run `npm run --silent qa:canonical-card-review -- <card-id>` under the fresh
153
+ reviewer's named `ADRATA_MCP_IDENTITY_POOL` and dedicated
154
+ `ADRATA_MCP_CONFIG_DIR`. It opens the exact deployed product bundle through a
155
+ one-use, read-only loopback session: upstream reads keep the QA OAuth identity,
156
+ writes fail locally, and private media capabilities remain process-private.
157
+ Record the returned session fingerprint and the deployed app build in the
158
+ playback evidence, then close the bridge.
120
159
 
121
160
  ## Manage cards as outcomes, not bug counters
122
161
 
@@ -5,6 +5,11 @@ import { basename, resolve } from 'node:path';
5
5
 
6
6
  const MAX_QA_IMAGE_BYTES = 10 * 1024 * 1024;
7
7
  const MAX_QA_VIDEO_BYTES = 100 * 1024 * 1024;
8
+ const QA_STAGE_NAMES = new Set(['staging qa1', 'staging qa2']);
9
+
10
+ function isQaStageName(name) {
11
+ return QA_STAGE_NAMES.has(String(name ?? '').trim().toLowerCase());
12
+ }
8
13
 
9
14
  /** Read evidence bytes locally without ever putting them in the MCP transcript. */
10
15
  export async function loadLocalQaEvidenceFile(filePath) {
@@ -178,6 +183,45 @@ export function describeMissingAcceptanceCriteria(criteria) {
178
183
  return 'This card would have no executable acceptance criteria. It can be captured, but it is not ready for build or QA until at least one where/when/then criterion is added.';
179
184
  }
180
185
 
186
+ /**
187
+ * Make the created card's own criteria counter agree with the criteria returned
188
+ * beside it.
189
+ *
190
+ * Criteria are written by a second call, AFTER `POST /items` has already
191
+ * answered. So the card snapshot in that answer was serialized before the rows
192
+ * existed and its derived `criteria.total` reads 0 — while the array a few
193
+ * lines below it in the same response holds the rows that were just committed.
194
+ * Two fields of one response, disagreeing.
195
+ *
196
+ * That is not cosmetic. An agent reading `item.criteria.total` from a create it
197
+ * just made concludes the card has NO executable definition of done, at exactly
198
+ * the moment the board exists to prevent that, and then either reports the card
199
+ * as not ready for build or re-adds criteria that already exist. The correct
200
+ * data is in the same response a few lines lower, which is what makes it
201
+ * invisible.
202
+ *
203
+ * The repair is a fresh read of the card rather than a locally patched number,
204
+ * because the counter is derived server-side and the server is the only party
205
+ * that can state it. The read is skipped entirely when no criteria were written
206
+ * — there is no stale counter to repair, and the create path should not spend a
207
+ * request proving that.
208
+ *
209
+ * A failed read-back must NOT turn a create that succeeded into an error: the
210
+ * card and its criteria are on the record either way. In that one case the
211
+ * count is reconciled from the rows this function is holding, which is a fact it
212
+ * already has rather than a guess.
213
+ */
214
+ export async function reconcileCriteriaCount(api, item, criteria) {
215
+ if (!item?.id || criteria.length === 0) return item;
216
+ try {
217
+ const fresh = await api('GET', `/api/v1/work-items/${encodeURIComponent(item.id)}`);
218
+ if (fresh?.data?.criteria) return fresh.data;
219
+ } catch {
220
+ // Fall through to the local reconciliation below.
221
+ }
222
+ return { ...item, criteria: { ...(item.criteria ?? {}), total: criteria.length } };
223
+ }
224
+
181
225
  /**
182
226
  * The satisfaction sub-resource of one criterion, POSTed to tick and DELETEd to
183
227
  * un-tick.
@@ -1189,11 +1233,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
1189
1233
 
1190
1234
  server.tool(
1191
1235
  'move_work_item',
1192
- `Move a card to another column on the same board — and, with claim:true, pick it up in the same action. The server does this in ONE transaction: it closes the card's open dwell, appends the transition to the history, records you as the handler of the pass the card is now on, and updates the card. Dropping a card into the column it is already in is a REORDER and deliberately does not restamp the stage timer.
1236
+ `Move a card to another column on the same board — and, outside QA, use claim:true to pick it up in the same action. The server does this in ONE transaction: it closes the card's open dwell, appends the transition to the history, records you as the handler of the pass the card is now on, and updates the card. Dropping a card into the column it is already in is a REORDER and deliberately does not restamp the stage timer.
1193
1237
 
1194
1238
  A card carries TWO people and they are not interchangeable. The OWNER (assignee) is whoever carries the card end to end — the engineer who builds it, and the person a QA bounce sends it back to. The HANDLER is whoever took the pass the card is on right now, which at a QA gate is the tester and nowhere else is usually the owner. claim:true always takes the pass; it takes ownership ONLY of a card nobody owns. So a QA pick-up on an engineer's card leaves the engineer owning it, which is what makes the two-gate flow work at all.
1195
1239
 
1196
- This is both halves of the developer loop. Claiming is a parameter and not a second tool on purpose: picking a card up is one act, and a separate "assign" call is the one that gets skipped leaving a card in an active column with no owner, which is the exact finding the board's unassigned glyph exists to shout about.${GOVERNED_NOTE}`,
1240
+ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-private lease and fencing capability before it can write in Staging QA1 or Staging QA2. Use claim_work_item_qa_pass (or claim_next_work_item_qa_pass) after the card is in QA; never use move_work_item(claim:true) to take a QA pass. This connector refuses that legacy shape locally instead of sending an unfenced write the API must reject.${GOVERNED_NOTE}`,
1197
1241
  {
1198
1242
  itemId: z.string().describe('Card id to move.'),
1199
1243
  toColumnId: z
@@ -1209,7 +1253,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
1209
1253
  .boolean()
1210
1254
  .optional()
1211
1255
  .describe(
1212
- 'Pick this card up as part of this move: it records YOU as the handler of the pass the card lands on, and makes you the owner only if the card has no owner. "You" is resolved from the authenticated token — there is no way to claim on somebody else\'s behalf. Use it whenever you are picking work up. Taking a pass on a card SOMEBODY ELSE OWNS is allowed and normal (that is a QA pick-up) and leaves their ownership alone; taking a pass somebody else is already HOLDING is refused (see force). Re-claiming a pass you already hold is a no-op, not an error. The handler is written to the card\'s open dwell, so a same-column claim works too — that is how you take a pass on a card already sitting in your stage.'
1256
+ 'Pick this card up as part of a NON-QA move: it records YOU as the handler of the pass the card lands on, and makes you the owner only if the card has no owner. "You" is resolved from the authenticated token — there is no way to claim on somebody else\'s behalf. Staging QA1 and Staging QA2 use claim_work_item_qa_pass instead because every executable QA write must carry a process-private lease and fence. Taking a non-QA pass somebody else is already HOLDING is refused (see force). Re-claiming a non-QA pass you already hold is a no-op, not an error.'
1213
1257
  ),
1214
1258
  force: z
1215
1259
  .boolean()
@@ -1229,6 +1273,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
1229
1273
  .string()
1230
1274
  .optional()
1231
1275
  .describe('Required for a live move. Reuse the SAME key on retry; the server replays.'),
1276
+ acknowledgeUnmetCriteria: z
1277
+ .boolean()
1278
+ .optional()
1279
+ .describe(
1280
+ 'Record an explicit override for moving a card FORWARD out of Staging QA1 or Staging QA2 with acceptance criteria still unverified. The first such move is always refused (409) naming what is open; sending this is the acknowledgement itself, and the server stores the count on the transition row where a release review reads it later. IT IS A HUMAN ACT: the API refuses it to a program (403) before it reads the flag, so an agent setting it gets a refusal rather than a waiver. It is also NOT how a card gets parked — moving a card to Backlog or Deep backlog is a different kind of move and needs no override at all.'
1281
+ ),
1232
1282
  receipt: z
1233
1283
  .object({
1234
1284
  commitSha: z
@@ -1314,6 +1364,18 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
1314
1364
  const count = (board.items ?? []).filter(
1315
1365
  (candidate) => candidate.columnId === args.toColumnId
1316
1366
  ).length;
1367
+ if (args.claim === true && isQaStageName(target.name)) {
1368
+ return ok({
1369
+ ...preview,
1370
+ blocked: true,
1371
+ code: 'qa_pass_requires_worker_lease_claim',
1372
+ message:
1373
+ item.columnId === args.toColumnId
1374
+ ? `Card ${args.itemId} is already in ${target.name}. Use claim_work_item_qa_pass to take this exact QA dwell atomically; move_work_item cannot create or retain the required private lease fence.`
1375
+ : `Move card ${args.itemId} into ${target.name} without claim:true, then use claim_work_item_qa_pass to take the new QA dwell atomically. move_work_item cannot create or retain the required private lease fence.`,
1376
+ wouldSend: false,
1377
+ });
1378
+ }
1317
1379
  return ok({
1318
1380
  ...preview,
1319
1381
  wouldMove: {
@@ -1333,6 +1395,33 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
1333
1395
  });
1334
1396
  }
1335
1397
 
1398
+ // The generic move claim predates executable QA worker leases. It can
1399
+ // name a handler, but it cannot return and retain the bearer/fence that
1400
+ // every later agent write in a QA dwell must prove. Sending that legacy
1401
+ // shape now produces an opaque 409 and leaves no claimed pass. Resolve
1402
+ // the target before the mutation and direct the worker to the dedicated
1403
+ // atomic lease endpoint instead. The API fence remains mandatory.
1404
+ if (args.claim === true) {
1405
+ const itemData = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`);
1406
+ const item = itemData?.data;
1407
+ const boardData = item?.boardId
1408
+ ? await api('GET', `/api/v1/work-boards/${encodeURIComponent(item.boardId)}`)
1409
+ : null;
1410
+ const target = boardData?.data?.columns?.find(
1411
+ (column) => column.id === args.toColumnId
1412
+ );
1413
+ if (isQaStageName(target?.name)) {
1414
+ return ok({
1415
+ error: true,
1416
+ code: 'qa_pass_requires_worker_lease_claim',
1417
+ message:
1418
+ item?.columnId === args.toColumnId
1419
+ ? `Card ${args.itemId} is already in ${target.name}. Use claim_work_item_qa_pass to take this exact QA dwell atomically; move_work_item cannot create or retain the required private lease fence.`
1420
+ : `Move card ${args.itemId} into ${target.name} without claim:true, then use claim_work_item_qa_pass to take the new QA dwell atomically. move_work_item cannot create or retain the required private lease fence.`,
1421
+ });
1422
+ }
1423
+ }
1424
+
1336
1425
  // A same-column move is only a reorder: the QA dwell and server lease
1337
1426
  // remain open. Read the source immediately before the governed write so
1338
1427
  // this process drops its capability only when the move truly transitions
@@ -1356,6 +1445,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
1356
1445
  // about the assignee at all.
1357
1446
  claim: args.claim === true ? true : undefined,
1358
1447
  force: args.force === true ? true : undefined,
1448
+ // The API's documented escape hatch, which this connector used to
1449
+ // drop on the floor: the field was never in the body, so a person
1450
+ // driving the board through MCP could be refused a QA exit and had
1451
+ // no way to record the override the refusal told them to send.
1452
+ acknowledgeUnmetCriteria:
1453
+ args.acknowledgeUnmetCriteria === true ? true : undefined,
1359
1454
  receipt: args.receipt,
1360
1455
  },
1361
1456
  headers: mutationHeadersForItem(args, args.itemId),
@@ -1624,7 +1719,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
1624
1719
  'create_work_item',
1625
1720
  `Create a card on a board. Lands in the named column, or in Backlog on a standard board when none is given.
1626
1721
 
1627
- ACCEPTANCE CRITERIA ARE FIRST-CLASS RECORDS, not prose buried in \`body\`. Use \`acceptanceCriteria\` for the executable definition of done: where to check, any starting state, what action to perform, and the observable result. The card and every criterion are replay-safe under one idempotency-key family, so a retry after a partial failure cannot duplicate either. If you cannot write criteria, capture what you know in \`body\`; the preview will mark the card as not ready rather than inventing outcomes nobody agreed to.
1722
+ ACCEPTANCE CRITERIA ARE FIRST-CLASS RECORDS, not prose buried in \`body\`. Use \`acceptanceCriteria\` for the executable definition of done: where to check, any starting state, what action to perform, and the observable result. Author the proof route at the same time: \`verificationRoute\` is REQUIRED on every criterion, because an omitted route silently became \`product\` and that is what a browser recording is then demanded for. Use \`engineering\`/\`both\` with a plain-language \`engineeringReason\` when no meaningful product-person retest exists. The card and every criterion are replay-safe under one idempotency-key family, so a retry after a partial failure cannot duplicate either. If you cannot write criteria, capture what you know in \`body\`; the preview will mark the card as not ready rather than inventing outcomes nobody agreed to.
1628
1723
 
1629
1724
  ONE CARD IS ONE QA JUDGEMENT. If your criteria list needs QA to make more than one call ("follows the OS theme" AND "the toggle persists" AND "every surface is restyled"), that is several cards, not one — a bounce from a multi-outcome card names nothing actionable. Implementation steps ("create a React hook", "rename the CSS variables") are never cards; they are lines inside one.
1630
1725
 
@@ -1645,6 +1740,24 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1645
1740
  givenText: z.string().optional().describe('Starting state, when one is required.'),
1646
1741
  whenText: z.string().describe('Action the verifier performs.'),
1647
1742
  thenText: z.string().describe('Observable result that must follow.'),
1743
+ verificationRoute: z
1744
+ .enum(['product', 'engineering', 'both'])
1745
+ .describe(
1746
+ 'REQUIRED. How this criterion gets proved. `product` needs a ' +
1747
+ 'signed-in browser recording; `engineering` needs executed code/' +
1748
+ 'test/runtime proof; `both` needs BOTH and is the STRICTEST of ' +
1749
+ 'the three, not a compromise. Choose from what the thenText ' +
1750
+ 'says: can a browser DISPLAY the result? Then product. Is the ' +
1751
+ 'result an ECS task definition, a migration receipt, a CI leg, ' +
1752
+ 'an HTTP status contract, a log record, a vendor invoice or a ' +
1753
+ 'test suite? Then engineering.'
1754
+ ),
1755
+ engineeringReason: z
1756
+ .string()
1757
+ .optional()
1758
+ .describe(
1759
+ 'Required for engineering or both: why no meaningful product-person retest exists.'
1760
+ ),
1648
1761
  })
1649
1762
  )
1650
1763
  .optional()
@@ -1737,19 +1850,40 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1737
1850
  );
1738
1851
  criteria.push(result?.data);
1739
1852
  }
1740
- return ok({ created: true, item, criteria });
1853
+ return ok({
1854
+ created: true,
1855
+ item: await reconcileCriteriaCount(api, item, criteria),
1856
+ criteria,
1857
+ });
1741
1858
  }
1742
1859
  );
1743
1860
 
1744
1861
  server.tool(
1745
1862
  'add_work_item_acceptance_criterion',
1746
- `Add one executable acceptance criterion to a card. This is grooming, not a comment: write where the check runs, the action, and the observable result. The server keeps criteria ordered and auditable.${GOVERNED_NOTE}`,
1863
+ `Add one executable acceptance criterion to a card. This is grooming, not a comment: write where the check runs, the action, the observable result, and its proof route. verificationRoute is REQUIRED -- an omitted route silently became product, which then demands a browser recording for a criterion about a CI job. engineering and both require a plain-language engineeringReason. The server stores the criterion and its initial route audit receipt atomically.${GOVERNED_NOTE}`,
1747
1864
  {
1748
1865
  itemId: z.string().describe('Card id.'),
1749
1866
  whereText: z.string().describe('Surface, environment, account, or role to check.'),
1750
1867
  givenText: z.string().optional().describe('Starting state, when one is required.'),
1751
1868
  whenText: z.string().describe('Action the verifier performs.'),
1752
1869
  thenText: z.string().describe('Observable result that must follow.'),
1870
+ verificationRoute: z
1871
+ .enum(['product', 'engineering', 'both'])
1872
+ .describe(
1873
+ 'REQUIRED. How this criterion gets proved. `product` needs a signed-in ' +
1874
+ 'browser recording; `engineering` needs executed code/test/runtime ' +
1875
+ 'proof; `both` needs BOTH and is the STRICTEST of the three, not a ' +
1876
+ 'compromise. Choose from what the thenText says: can a browser ' +
1877
+ 'DISPLAY the result? Then product. Is the result an ECS task ' +
1878
+ 'definition, a migration receipt, a CI leg, an HTTP status contract, ' +
1879
+ 'a log record, a vendor invoice or a test suite? Then engineering.'
1880
+ ),
1881
+ engineeringReason: z
1882
+ .string()
1883
+ .optional()
1884
+ .describe(
1885
+ 'Required for engineering or both: why no meaningful product-person retest exists.'
1886
+ ),
1753
1887
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
1754
1888
  approved: z.boolean().optional().describe('Required true for a live write.'),
1755
1889
  reason: z
@@ -1771,6 +1905,8 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1771
1905
  givenText: args.givenText,
1772
1906
  whenText: args.whenText,
1773
1907
  thenText: args.thenText,
1908
+ verificationRoute: args.verificationRoute,
1909
+ engineeringReason: args.engineeringReason,
1774
1910
  };
1775
1911
  if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
1776
1912
  const data = await api('POST', path, {