@adrata/adrata-mcp 1.0.6 → 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 +46 -0
- package/access/auth.js +78 -0
- package/access/tiers.js +3 -0
- package/http/edge-block.js +118 -0
- package/http/rate-limit.js +159 -0
- package/package.json +3 -2
- package/product-profile.js +2 -2
- package/server.js +57 -5
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +39 -0
- package/tool-annotations.js +7 -0
- package/tools/work-board-tools.js +344 -10
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,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
|
+
}
|
|
@@ -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.
|
|
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 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,6 +42,7 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"server.js",
|
|
44
44
|
"api-bridge.js",
|
|
45
|
+
"http/",
|
|
45
46
|
"analytics.js",
|
|
46
47
|
"security.js",
|
|
47
48
|
"transport-http.js",
|
package/product-profile.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
displayName: '
|
|
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
|
@@ -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,13 @@ 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 './http/edge-block.js';
|
|
46
|
+
import {
|
|
47
|
+
parseRetryAfterMs,
|
|
48
|
+
rateLimitDelayMs,
|
|
49
|
+
describeRateLimit,
|
|
50
|
+
RATE_LIMIT_MAX_ATTEMPTS,
|
|
51
|
+
} from './http/rate-limit.js';
|
|
44
52
|
import { registerMemoryTools, wrapWithEventLogging, registerProfileResource } from './tools/memory.js';
|
|
45
53
|
import { registerBillingTools } from './tools/billing.js';
|
|
46
54
|
import { registerMorningBrief } from './tools/morning-brief.js';
|
|
@@ -211,11 +219,45 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
|
|
|
211
219
|
res = await request(currentToken);
|
|
212
220
|
}
|
|
213
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
|
+
|
|
214
244
|
const text = await res.text();
|
|
215
245
|
let data;
|
|
216
246
|
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
217
247
|
|
|
218
248
|
if (!res.ok) {
|
|
249
|
+
// An HTML body cannot have come from the API — every AppError serialises as
|
|
250
|
+
// JSON — so it means the request was refused in front of us. Saying so is
|
|
251
|
+
// the whole point: a bare 403 is indistinguishable from insufficient_scope,
|
|
252
|
+
// and reading it as a token problem is what starts a needless reconnect.
|
|
253
|
+
const edge = describeEdgeBlock({
|
|
254
|
+
status: res.status,
|
|
255
|
+
text,
|
|
256
|
+
method,
|
|
257
|
+
path,
|
|
258
|
+
requestBody: body,
|
|
259
|
+
});
|
|
260
|
+
if (edge) throw new Error(edge);
|
|
219
261
|
throw new Error(`API ${method} ${path} → ${res.status}: ${JSON.stringify(data).slice(0, 200)}`);
|
|
220
262
|
}
|
|
221
263
|
return data;
|
|
@@ -299,9 +341,12 @@ const moneyWrite = {
|
|
|
299
341
|
* to start, which is the exact failure this whole surface keeps producing.
|
|
300
342
|
*/
|
|
301
343
|
const SERVER_NAME = process.env.ADRATA_MCP_SERVER_NAME?.trim() || '@adrata/adrata-mcp';
|
|
344
|
+
const PACKAGE_VERSION = JSON.parse(
|
|
345
|
+
readFileSync(new URL('./package.json', import.meta.url), 'utf8')
|
|
346
|
+
).version;
|
|
302
347
|
|
|
303
348
|
function createMcpServer() {
|
|
304
|
-
const server = new McpServer({ name: SERVER_NAME, version:
|
|
349
|
+
const server = new McpServer({ name: SERVER_NAME, version: PACKAGE_VERSION });
|
|
305
350
|
|
|
306
351
|
// ---------------------------------------------------------------------------
|
|
307
352
|
// Tier-gating wrapper
|
|
@@ -510,8 +555,15 @@ server.tool('workspace_status',
|
|
|
510
555
|
return ok({
|
|
511
556
|
connected: AUTH.authenticated,
|
|
512
557
|
tier: AUTH.tier,
|
|
513
|
-
|
|
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,
|
|
514
565
|
authSource: AUTH.source,
|
|
566
|
+
preferredApiKey: Boolean(AUTH.preferredApiKey),
|
|
515
567
|
apiBase: API_BASE,
|
|
516
568
|
workspaceId,
|
|
517
569
|
workspaceName,
|
|
@@ -558,7 +610,7 @@ function assertProductCapability(capability, requested) {
|
|
|
558
610
|
}
|
|
559
611
|
|
|
560
612
|
async function resolveExactCapability(reference) {
|
|
561
|
-
const response = await api('GET', '/api/v1/ai
|
|
613
|
+
const response = await api('GET', '/api/v1/ai-crm-tools/capabilities/describe', {
|
|
562
614
|
params: { ref: reference },
|
|
563
615
|
});
|
|
564
616
|
return assertProductCapability(response?.capability ?? response?.data?.capability, reference);
|
|
@@ -576,7 +628,7 @@ server.tool('search_capabilities',
|
|
|
576
628
|
if (PRODUCT_NAMESPACE && requestedNamespace && requestedNamespace !== PRODUCT_NAMESPACE) {
|
|
577
629
|
throw new Error(`${SERVER_NAME} search is fixed to the ${PRODUCT_NAMESPACE} namespace.`);
|
|
578
630
|
}
|
|
579
|
-
return ok(await api('GET', '/api/v1/ai
|
|
631
|
+
return ok(await api('GET', '/api/v1/ai-crm-tools/capabilities/search', {
|
|
580
632
|
params: {
|
|
581
633
|
q: args.query,
|
|
582
634
|
namespace: PRODUCT_NAMESPACE || requestedNamespace,
|
|
@@ -618,7 +670,7 @@ server.tool('run_capability',
|
|
|
618
670
|
if (args.idempotencyKey) headers['idempotency-key'] = args.idempotencyKey;
|
|
619
671
|
if (args.reason) headers['x-adrata-reason'] = args.reason;
|
|
620
672
|
if (args.confirmSpend === true) headers['x-adrata-approved'] = 'true';
|
|
621
|
-
return ok(await api('POST', '/api/v1/ai
|
|
673
|
+
return ok(await api('POST', '/api/v1/ai-crm-tools/execute', { body, headers }));
|
|
622
674
|
});
|
|
623
675
|
|
|
624
676
|
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.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.
|
|
18
|
+
"version": "1.0.8",
|
|
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
|
|
package/tool-annotations.js
CHANGED
|
@@ -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
|
|
@@ -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.
|
|
@@ -327,7 +371,7 @@ export function registerWorkBoardTools(
|
|
|
327
371
|
|
|
328
372
|
server.tool(
|
|
329
373
|
'list_my_work_items',
|
|
330
|
-
`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.
|
|
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.
|
|
331
375
|
|
|
332
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.
|
|
333
377
|
|
|
@@ -509,7 +553,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
509
553
|
|
|
510
554
|
server.tool(
|
|
511
555
|
'claim_next_work_item_qa_pass',
|
|
512
|
-
`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}`,
|
|
513
557
|
{
|
|
514
558
|
qaGate: z.enum(['Staging QA1', 'Staging QA2']).describe('Exact gate this worker pool is allowed to drain.'),
|
|
515
559
|
product: z.string().min(1).max(120).describe('Exact card product to drain, such as Adrata or Starfield, across all visible boards.'),
|
|
@@ -560,13 +604,30 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
560
604
|
body,
|
|
561
605
|
headers: buildMutationHeaders(args),
|
|
562
606
|
});
|
|
563
|
-
|
|
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 ?? [];
|
|
564
613
|
if (!selected) {
|
|
565
|
-
|
|
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
|
+
});
|
|
566
626
|
}
|
|
567
627
|
const visible = rememberWorkerLease(selected.workItemId, selected.grant, args.idempotencyKey);
|
|
568
628
|
return ok({
|
|
569
629
|
claimed: true,
|
|
630
|
+
skippedBlocked,
|
|
570
631
|
workItemId: selected.workItemId,
|
|
571
632
|
boardId: selected.boardId,
|
|
572
633
|
boardName: selected.boardName,
|
|
@@ -1189,11 +1250,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
1189
1250
|
|
|
1190
1251
|
server.tool(
|
|
1191
1252
|
'move_work_item',
|
|
1192
|
-
`Move a card to another column on the same board — and,
|
|
1253
|
+
`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
1254
|
|
|
1194
1255
|
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
1256
|
|
|
1196
|
-
|
|
1257
|
+
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
1258
|
{
|
|
1198
1259
|
itemId: z.string().describe('Card id to move.'),
|
|
1199
1260
|
toColumnId: z
|
|
@@ -1209,7 +1270,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
1209
1270
|
.boolean()
|
|
1210
1271
|
.optional()
|
|
1211
1272
|
.describe(
|
|
1212
|
-
'Pick this card up as part of
|
|
1273
|
+
'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
1274
|
),
|
|
1214
1275
|
force: z
|
|
1215
1276
|
.boolean()
|
|
@@ -1229,6 +1290,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
1229
1290
|
.string()
|
|
1230
1291
|
.optional()
|
|
1231
1292
|
.describe('Required for a live move. Reuse the SAME key on retry; the server replays.'),
|
|
1293
|
+
acknowledgeUnmetCriteria: z
|
|
1294
|
+
.boolean()
|
|
1295
|
+
.optional()
|
|
1296
|
+
.describe(
|
|
1297
|
+
'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.'
|
|
1298
|
+
),
|
|
1232
1299
|
receipt: z
|
|
1233
1300
|
.object({
|
|
1234
1301
|
commitSha: z
|
|
@@ -1314,6 +1381,18 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
1314
1381
|
const count = (board.items ?? []).filter(
|
|
1315
1382
|
(candidate) => candidate.columnId === args.toColumnId
|
|
1316
1383
|
).length;
|
|
1384
|
+
if (args.claim === true && isQaStageName(target.name)) {
|
|
1385
|
+
return ok({
|
|
1386
|
+
...preview,
|
|
1387
|
+
blocked: true,
|
|
1388
|
+
code: 'qa_pass_requires_worker_lease_claim',
|
|
1389
|
+
message:
|
|
1390
|
+
item.columnId === args.toColumnId
|
|
1391
|
+
? `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.`
|
|
1392
|
+
: `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.`,
|
|
1393
|
+
wouldSend: false,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1317
1396
|
return ok({
|
|
1318
1397
|
...preview,
|
|
1319
1398
|
wouldMove: {
|
|
@@ -1333,6 +1412,33 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
1333
1412
|
});
|
|
1334
1413
|
}
|
|
1335
1414
|
|
|
1415
|
+
// The generic move claim predates executable QA worker leases. It can
|
|
1416
|
+
// name a handler, but it cannot return and retain the bearer/fence that
|
|
1417
|
+
// every later agent write in a QA dwell must prove. Sending that legacy
|
|
1418
|
+
// shape now produces an opaque 409 and leaves no claimed pass. Resolve
|
|
1419
|
+
// the target before the mutation and direct the worker to the dedicated
|
|
1420
|
+
// atomic lease endpoint instead. The API fence remains mandatory.
|
|
1421
|
+
if (args.claim === true) {
|
|
1422
|
+
const itemData = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`);
|
|
1423
|
+
const item = itemData?.data;
|
|
1424
|
+
const boardData = item?.boardId
|
|
1425
|
+
? await api('GET', `/api/v1/work-boards/${encodeURIComponent(item.boardId)}`)
|
|
1426
|
+
: null;
|
|
1427
|
+
const target = boardData?.data?.columns?.find(
|
|
1428
|
+
(column) => column.id === args.toColumnId
|
|
1429
|
+
);
|
|
1430
|
+
if (isQaStageName(target?.name)) {
|
|
1431
|
+
return ok({
|
|
1432
|
+
error: true,
|
|
1433
|
+
code: 'qa_pass_requires_worker_lease_claim',
|
|
1434
|
+
message:
|
|
1435
|
+
item?.columnId === args.toColumnId
|
|
1436
|
+
? `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.`
|
|
1437
|
+
: `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.`,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1336
1442
|
// A same-column move is only a reorder: the QA dwell and server lease
|
|
1337
1443
|
// remain open. Read the source immediately before the governed write so
|
|
1338
1444
|
// this process drops its capability only when the move truly transitions
|
|
@@ -1356,6 +1462,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
1356
1462
|
// about the assignee at all.
|
|
1357
1463
|
claim: args.claim === true ? true : undefined,
|
|
1358
1464
|
force: args.force === true ? true : undefined,
|
|
1465
|
+
// The API's documented escape hatch, which this connector used to
|
|
1466
|
+
// drop on the floor: the field was never in the body, so a person
|
|
1467
|
+
// driving the board through MCP could be refused a QA exit and had
|
|
1468
|
+
// no way to record the override the refusal told them to send.
|
|
1469
|
+
acknowledgeUnmetCriteria:
|
|
1470
|
+
args.acknowledgeUnmetCriteria === true ? true : undefined,
|
|
1359
1471
|
receipt: args.receipt,
|
|
1360
1472
|
},
|
|
1361
1473
|
headers: mutationHeadersForItem(args, args.itemId),
|
|
@@ -1494,6 +1606,106 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
1494
1606
|
}
|
|
1495
1607
|
);
|
|
1496
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
|
+
|
|
1497
1709
|
server.tool(
|
|
1498
1710
|
'set_work_item_tag',
|
|
1499
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}`,
|
|
@@ -1624,7 +1836,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
|
|
|
1624
1836
|
'create_work_item',
|
|
1625
1837
|
`Create a card on a board. Lands in the named column, or in Backlog on a standard board when none is given.
|
|
1626
1838
|
|
|
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.
|
|
1839
|
+
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
1840
|
|
|
1629
1841
|
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
1842
|
|
|
@@ -1645,6 +1857,24 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
1645
1857
|
givenText: z.string().optional().describe('Starting state, when one is required.'),
|
|
1646
1858
|
whenText: z.string().describe('Action the verifier performs.'),
|
|
1647
1859
|
thenText: z.string().describe('Observable result that must follow.'),
|
|
1860
|
+
verificationRoute: z
|
|
1861
|
+
.enum(['product', 'engineering', 'both'])
|
|
1862
|
+
.describe(
|
|
1863
|
+
'REQUIRED. How this criterion gets proved. `product` needs a ' +
|
|
1864
|
+
'signed-in browser recording; `engineering` needs executed code/' +
|
|
1865
|
+
'test/runtime proof; `both` needs BOTH and is the STRICTEST of ' +
|
|
1866
|
+
'the three, not a compromise. Choose from what the thenText ' +
|
|
1867
|
+
'says: can a browser DISPLAY the result? Then product. Is the ' +
|
|
1868
|
+
'result an ECS task definition, a migration receipt, a CI leg, ' +
|
|
1869
|
+
'an HTTP status contract, a log record, a vendor invoice or a ' +
|
|
1870
|
+
'test suite? Then engineering.'
|
|
1871
|
+
),
|
|
1872
|
+
engineeringReason: z
|
|
1873
|
+
.string()
|
|
1874
|
+
.optional()
|
|
1875
|
+
.describe(
|
|
1876
|
+
'Required for engineering or both: why no meaningful product-person retest exists.'
|
|
1877
|
+
),
|
|
1648
1878
|
})
|
|
1649
1879
|
)
|
|
1650
1880
|
.optional()
|
|
@@ -1737,19 +1967,40 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
1737
1967
|
);
|
|
1738
1968
|
criteria.push(result?.data);
|
|
1739
1969
|
}
|
|
1740
|
-
return ok({
|
|
1970
|
+
return ok({
|
|
1971
|
+
created: true,
|
|
1972
|
+
item: await reconcileCriteriaCount(api, item, criteria),
|
|
1973
|
+
criteria,
|
|
1974
|
+
});
|
|
1741
1975
|
}
|
|
1742
1976
|
);
|
|
1743
1977
|
|
|
1744
1978
|
server.tool(
|
|
1745
1979
|
'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,
|
|
1980
|
+
`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
1981
|
{
|
|
1748
1982
|
itemId: z.string().describe('Card id.'),
|
|
1749
1983
|
whereText: z.string().describe('Surface, environment, account, or role to check.'),
|
|
1750
1984
|
givenText: z.string().optional().describe('Starting state, when one is required.'),
|
|
1751
1985
|
whenText: z.string().describe('Action the verifier performs.'),
|
|
1752
1986
|
thenText: z.string().describe('Observable result that must follow.'),
|
|
1987
|
+
verificationRoute: z
|
|
1988
|
+
.enum(['product', 'engineering', 'both'])
|
|
1989
|
+
.describe(
|
|
1990
|
+
'REQUIRED. How this criterion gets proved. `product` needs a signed-in ' +
|
|
1991
|
+
'browser recording; `engineering` needs executed code/test/runtime ' +
|
|
1992
|
+
'proof; `both` needs BOTH and is the STRICTEST of the three, not a ' +
|
|
1993
|
+
'compromise. Choose from what the thenText says: can a browser ' +
|
|
1994
|
+
'DISPLAY the result? Then product. Is the result an ECS task ' +
|
|
1995
|
+
'definition, a migration receipt, a CI leg, an HTTP status contract, ' +
|
|
1996
|
+
'a log record, a vendor invoice or a test suite? Then engineering.'
|
|
1997
|
+
),
|
|
1998
|
+
engineeringReason: z
|
|
1999
|
+
.string()
|
|
2000
|
+
.optional()
|
|
2001
|
+
.describe(
|
|
2002
|
+
'Required for engineering or both: why no meaningful product-person retest exists.'
|
|
2003
|
+
),
|
|
1753
2004
|
dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
|
|
1754
2005
|
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
1755
2006
|
reason: z
|
|
@@ -1771,6 +2022,8 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
1771
2022
|
givenText: args.givenText,
|
|
1772
2023
|
whenText: args.whenText,
|
|
1773
2024
|
thenText: args.thenText,
|
|
2025
|
+
verificationRoute: args.verificationRoute,
|
|
2026
|
+
engineeringReason: args.engineeringReason,
|
|
1774
2027
|
};
|
|
1775
2028
|
if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
|
|
1776
2029
|
const data = await api('POST', path, {
|
|
@@ -1781,6 +2034,84 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
1781
2034
|
}
|
|
1782
2035
|
);
|
|
1783
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
|
+
|
|
1784
2115
|
server.tool(
|
|
1785
2116
|
'satisfy_work_item_acceptance_criterion',
|
|
1786
2117
|
`Tick one acceptance criterion with the evidence you actually observed.
|
|
@@ -2163,10 +2494,13 @@ export const WORK_BOARD_TOOL_NAMES = [
|
|
|
2163
2494
|
'set_work_item_kind',
|
|
2164
2495
|
'create_work_item',
|
|
2165
2496
|
'add_work_item_acceptance_criterion',
|
|
2497
|
+
'classify_work_item_acceptance_criterion',
|
|
2166
2498
|
'satisfy_work_item_acceptance_criterion',
|
|
2167
2499
|
'record_work_item_criterion_engineering_proof',
|
|
2168
2500
|
'unsatisfy_work_item_acceptance_criterion',
|
|
2169
2501
|
'get_work_item_comments',
|
|
2170
2502
|
'comment_on_work_item',
|
|
2171
2503
|
'flag_work_item',
|
|
2504
|
+
'block_work_item',
|
|
2505
|
+
'unblock_work_item',
|
|
2172
2506
|
];
|