@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 +118 -0
- package/package.json +3 -2
- package/server.js +21 -4
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +39 -0
- package/tools/work-board-tools.js +142 -6
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.
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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({
|
|
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,
|
|
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, {
|