@adrata/adrata-mcp 1.0.42 → 1.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/access/tiers.js +7 -0
- package/api-bridge.js +3 -0
- package/package.json +2 -2
- package/server.js +34 -8
- package/server.json +2 -2
- package/tool-annotations.js +9 -0
- package/tools/free-search.js +131 -9
- package/tools/launches/work-launch-tools.js +286 -0
package/access/tiers.js
CHANGED
|
@@ -247,6 +247,13 @@ export const TOOL_TIERS = {
|
|
|
247
247
|
get_work_board_rollup: TIERS.ENTERPRISE,
|
|
248
248
|
list_work_board_rollups: TIERS.ENTERPRISE,
|
|
249
249
|
set_work_board_archived: TIERS.ENTERPRISE,
|
|
250
|
+
// Launches: a lens over the boards (tools/work-launch-tools.js).
|
|
251
|
+
list_work_launches: TIERS.ENTERPRISE,
|
|
252
|
+
get_work_launch: TIERS.ENTERPRISE,
|
|
253
|
+
create_work_launch: TIERS.ENTERPRISE,
|
|
254
|
+
update_work_launch: TIERS.ENTERPRISE,
|
|
255
|
+
add_work_item_to_launch: TIERS.ENTERPRISE,
|
|
256
|
+
remove_work_item_from_launch: TIERS.ENTERPRISE,
|
|
250
257
|
set_work_board_column_wip_limit: TIERS.ENTERPRISE,
|
|
251
258
|
attach_work_item_qa_evidence: TIERS.ENTERPRISE,
|
|
252
259
|
verify_work_item_qa_evidence_playback: TIERS.ENTERPRISE,
|
package/api-bridge.js
CHANGED
|
@@ -211,6 +211,8 @@ const ALLOWED_PREFIXES = [
|
|
|
211
211
|
'/api/v1/work-board-rollups',
|
|
212
212
|
'/api/v1/work-boards',
|
|
213
213
|
'/api/v1/work-items',
|
|
214
|
+
// A launch is a lens over the boards; its writes are board writes.
|
|
215
|
+
'/api/v1/work-launches',
|
|
214
216
|
// The containers above the cards (initiative / epic / feature) and the
|
|
215
217
|
// "add this to the roadmap" verb. Creating an epic proposal is a governed
|
|
216
218
|
// write like any other; the strategic commitment itself still moves through
|
|
@@ -343,6 +345,7 @@ const PATH_WRITE_SCOPES = [
|
|
|
343
345
|
['/api/v1/work-boards', 'write:tasks'],
|
|
344
346
|
['/api/v1/work-items', 'write:tasks'],
|
|
345
347
|
['/api/v1/work-board-rollups', 'write:tasks'],
|
|
348
|
+
['/api/v1/work-launches', 'write:tasks'],
|
|
346
349
|
];
|
|
347
350
|
|
|
348
351
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrata/adrata-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.44",
|
|
4
4
|
"description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. About 275 tools registered at startup for companies, people, deals, actions, buyer groups, warm intros, webhooks and intelligence, plus 65 more behind eight named toolsets you load with enable_toolset.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
|
-
"test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js toolsets/prospecting.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/work-hub/field-changes.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js"
|
|
12
|
+
"test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js toolsets/prospecting.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/launches/work-launch-tools.test.js tools/work-hub/audit.test.js tools/work-hub/field-changes.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js tools/free-search.test.js"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"mcp",
|
package/server.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
getValidAgentToken,
|
|
41
41
|
} from './access/auth.js';
|
|
42
42
|
import { TIERS } from './access/tiers.js';
|
|
43
|
-
import { findCompany, findPerson } from './tools/free-search.js';
|
|
43
|
+
import { findCompany, findPerson, findCompanyInWorkspace, findPersonInWorkspace } from './tools/free-search.js';
|
|
44
44
|
import { applySecurityLayer } from './security.js';
|
|
45
45
|
import { describeEdgeBlock } from './http/edge-block.js';
|
|
46
46
|
import { createRateLimitRegistry } from './http/rate-limit.js';
|
|
@@ -53,6 +53,7 @@ import { connectWorkspace, disconnectWorkspace, getConnectionStatus, getValidTok
|
|
|
53
53
|
import { registerEnterpriseTools } from './tools/enterprise-tools.js';
|
|
54
54
|
import { registerEmailTools } from './tools/email-tools.js';
|
|
55
55
|
import { registerWorkBoardTools } from './tools/work-board-tools.js';
|
|
56
|
+
import { registerWorkLaunchTools } from './tools/launches/work-launch-tools.js';
|
|
56
57
|
import { registerSourceControlTools } from './tools/source-control/connection-tools.js';
|
|
57
58
|
import { registerRoadmapTools } from './tools/roadmap-tools.js';
|
|
58
59
|
import { registerProvisioningTools } from './tools/provisioning/onboarding-tools.js';
|
|
@@ -1122,17 +1123,30 @@ server.tool('move_pipeline_card',
|
|
|
1122
1123
|
}
|
|
1123
1124
|
});
|
|
1124
1125
|
|
|
1125
|
-
// =====
|
|
1126
|
+
// ===== find_company / find_person =====
|
|
1127
|
+
//
|
|
1128
|
+
// A seat with a credential gets its WORKSPACE RECORD (found / not found / could
|
|
1129
|
+
// not check). Only a server with no credential at all falls back to the
|
|
1130
|
+
// training-data profile prompt, which carries no upsell. See tools/free-search.js.
|
|
1131
|
+
|
|
1132
|
+
function hasWorkspaceCredential() {
|
|
1133
|
+
const auth = currentAuth();
|
|
1134
|
+
return Boolean(auth?.token || auth?.apiKey);
|
|
1135
|
+
}
|
|
1126
1136
|
|
|
1127
1137
|
server.tool('find_company',
|
|
1128
|
-
'
|
|
1129
|
-
{ name: z.string().describe('Company name
|
|
1130
|
-
async (a) =>
|
|
1138
|
+
'Find a company. Connected to a workspace, returns that workspace\'s company record (or says plainly that none matches, or that the workspace could not be checked). With no workspace connected, returns an unverified profile from Claude\'s training data.',
|
|
1139
|
+
{ name: z.string().describe('Company name or domain (e.g. "Stripe", "stripe.com")') },
|
|
1140
|
+
async (a) => (hasWorkspaceCredential()
|
|
1141
|
+
? findCompanyInWorkspace(a.name, (query) => api('GET', '/api/v1/companies', { params: { search: query, limit: 25, page: 1 } }))
|
|
1142
|
+
: findCompany(a.name)));
|
|
1131
1143
|
|
|
1132
1144
|
server.tool('find_person',
|
|
1133
|
-
'
|
|
1134
|
-
{ name: z.string().describe('Full name of the person
|
|
1135
|
-
async (a) =>
|
|
1145
|
+
'Find a person. Connected to a workspace, returns that workspace\'s person record (or says plainly that none matches, or that the workspace could not be checked). With no workspace connected, returns an unverified profile from Claude\'s training data.',
|
|
1146
|
+
{ name: z.string().describe('Full name or email of the person (e.g. "Patrick Collison")') },
|
|
1147
|
+
async (a) => (hasWorkspaceCredential()
|
|
1148
|
+
? findPersonInWorkspace(a.name, (query) => api('GET', '/api/v1/people', { params: { search: query, limit: 25, page: 1 } }))
|
|
1149
|
+
: findPerson(a.name)));
|
|
1136
1150
|
|
|
1137
1151
|
// ===== GOVERNED WRITES (shared) =====
|
|
1138
1152
|
//
|
|
@@ -2395,6 +2409,18 @@ registerWorkBoardTools(server, {
|
|
|
2395
2409
|
getGrantedScope: () => loadTokens()?.scope,
|
|
2396
2410
|
});
|
|
2397
2411
|
|
|
2412
|
+
// ===== LAUNCH TOOLS =====
|
|
2413
|
+
// A launch is a lens over the boards — a named, dated set a card can be on
|
|
2414
|
+
// without leaving its board. Same governed contract; see work-launch-tools.js.
|
|
2415
|
+
registerWorkLaunchTools(server, {
|
|
2416
|
+
z,
|
|
2417
|
+
api,
|
|
2418
|
+
ok,
|
|
2419
|
+
validateApiBridgeRequest,
|
|
2420
|
+
buildMutationHeaders,
|
|
2421
|
+
getGrantedScope: () => loadTokens()?.scope,
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2398
2424
|
registerSourceControlTools(server, {
|
|
2399
2425
|
z,
|
|
2400
2426
|
api,
|
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.44",
|
|
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.44",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
},
|
package/tool-annotations.js
CHANGED
|
@@ -123,6 +123,12 @@ const DOMAIN_OVERRIDES = {
|
|
|
123
123
|
add_to_roadmap: 'board',
|
|
124
124
|
audit_work_hub: 'board',
|
|
125
125
|
set_work_board_archived: 'board',
|
|
126
|
+
list_work_launches: 'board',
|
|
127
|
+
get_work_launch: 'board',
|
|
128
|
+
create_work_launch: 'board',
|
|
129
|
+
update_work_launch: 'board',
|
|
130
|
+
add_work_item_to_launch: 'board',
|
|
131
|
+
remove_work_item_from_launch: 'board',
|
|
126
132
|
list_forms: 'forms',
|
|
127
133
|
get_form: 'forms',
|
|
128
134
|
create_form: 'forms',
|
|
@@ -253,6 +259,9 @@ const IDEMPOTENT_WRITES = new Set([
|
|
|
253
259
|
// overwritten — which makes a retry with the same key land on the same state.
|
|
254
260
|
// The restore is a plain clear of the same five columns.
|
|
255
261
|
'archive_work_item', 'unarchive_work_item',
|
|
262
|
+
// A launch membership is an upsert keyed on (launch, card): replaying the
|
|
263
|
+
// same add re-ranks the same row; a repeat remove deletes a row already gone.
|
|
264
|
+
'create_work_launch', 'update_work_launch', 'add_work_item_to_launch', 'remove_work_item_from_launch',
|
|
256
265
|
// A repeat block is the same edge (the pair is unique), and a repeat
|
|
257
266
|
// unblock deletes a row that is already gone. Neither compounds.
|
|
258
267
|
'block_work_item', 'unblock_work_item',
|
package/tools/free-search.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* find_company / find_person
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Connected to a workspace, these return the WORKSPACE RECORD — the thing a
|
|
5
|
+
* seller asking "find Acme" means. Measured on Colin's day-one audit
|
|
6
|
+
* (2026-09-12): a connected seat got back a raw model prompt ending "Upgrade to
|
|
7
|
+
* Adrata Pro for enriched intelligence" instead of the record sitting in its own
|
|
8
|
+
* CRM. Owner ruling 2026-09-11: no seat sees credits, prices, quotas or upsells.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* Three states, never two:
|
|
11
|
+
* - found: the workspace record(s), labelled as such;
|
|
12
|
+
* - not found: said plainly — the workspace was checked and has no match;
|
|
13
|
+
* - could not look: said plainly — nothing was ruled out.
|
|
14
|
+
*
|
|
15
|
+
* With no credential at all there is no workspace to check, so the tool falls
|
|
16
|
+
* back to a structured training-data profile prompt, labelled unverified, with
|
|
17
|
+
* no upsell in it.
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
// ---------------------------------------------------------------------------
|
|
@@ -56,8 +65,7 @@ IMPORTANT: Respond with ONLY a valid JSON object matching this exact schema. No
|
|
|
56
65
|
"competitors": ["Competitor 1", "Competitor 2", "Competitor 3"],
|
|
57
66
|
"recent_highlights": "Notable recent developments, funding, acquisitions, or strategic moves from training data",
|
|
58
67
|
"ideal_sales_angles": "2-3 sentence suggestion for how a seller might approach this company",
|
|
59
|
-
"data_freshness": "Based on training data through early 2025. Some details may have changed."
|
|
60
|
-
"upgrade_prompt": "Want verified contacts, real-time headcount, funding rounds, hiring signals, and intent data? Upgrade to Adrata Pro for enriched intelligence."
|
|
68
|
+
"data_freshness": "Based on training data through early 2025. Some details may have changed."
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
Rules:
|
|
@@ -84,8 +92,7 @@ IMPORTANT: Respond with ONLY a valid JSON object matching this exact schema. No
|
|
|
84
92
|
"notable_achievements": "Key accomplishments, board seats, speaking engagements, publications",
|
|
85
93
|
"linkedin_likely": "Likely LinkedIn URL pattern (e.g. linkedin.com/in/firstname-lastname) — note: unverified",
|
|
86
94
|
"sales_context": "2-3 sentence suggestion for how a seller might approach this person — what they likely care about",
|
|
87
|
-
"data_freshness": "Based on training data through early 2025. Current role may have changed."
|
|
88
|
-
"upgrade_prompt": "Want verified email, phone, social profiles, recent activity, and buyer signals? Upgrade to Adrata Pro for enriched contact intelligence."
|
|
95
|
+
"data_freshness": "Based on training data through early 2025. Current role may have changed."
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
Rules:
|
|
@@ -158,3 +165,118 @@ export function findPerson(name) {
|
|
|
158
165
|
cacheSet(cacheKey, result);
|
|
159
166
|
return result;
|
|
160
167
|
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Workspace-record lookup (connected seats)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const WORKSPACE_MATCH_LIMIT = 5;
|
|
174
|
+
|
|
175
|
+
/** Pull the record array out of the API's list envelope, whatever its shape. */
|
|
176
|
+
export function recordsFromListResponse(response, collection) {
|
|
177
|
+
const candidates = [
|
|
178
|
+
response,
|
|
179
|
+
response?.data,
|
|
180
|
+
response?.data?.[collection],
|
|
181
|
+
response?.data?.items,
|
|
182
|
+
response?.data?.data,
|
|
183
|
+
response?.[collection],
|
|
184
|
+
response?.items,
|
|
185
|
+
];
|
|
186
|
+
for (const candidate of candidates) {
|
|
187
|
+
if (Array.isArray(candidate)) return candidate;
|
|
188
|
+
}
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalize(value) {
|
|
193
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function recordName(record, kind) {
|
|
197
|
+
if (kind === 'person') {
|
|
198
|
+
return (
|
|
199
|
+
record?.fullName ||
|
|
200
|
+
record?.name ||
|
|
201
|
+
[record?.firstName, record?.lastName].filter(Boolean).join(' ')
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return record?.name;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function exactMatches(records, query, kind) {
|
|
208
|
+
const wanted = normalize(query);
|
|
209
|
+
return records.filter((record) => {
|
|
210
|
+
if (normalize(recordName(record, kind)) === wanted) return true;
|
|
211
|
+
if (kind === 'company') {
|
|
212
|
+
return [record?.domain, record?.website].some((d) => normalize(d).replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') === wanted);
|
|
213
|
+
}
|
|
214
|
+
return normalize(record?.email) === wanted;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function textResult(payload, isError = false) {
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
221
|
+
...(isError ? { isError: true } : {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Look the name up in the connected workspace and return what is there.
|
|
227
|
+
*
|
|
228
|
+
* `lookup(name)` performs the authenticated list read and resolves to the raw
|
|
229
|
+
* API response; it is injected so this stays testable without a server.
|
|
230
|
+
*/
|
|
231
|
+
async function findInWorkspace(kind, name, lookup) {
|
|
232
|
+
const label = kind === 'company' ? 'company' : 'person';
|
|
233
|
+
const query = String(name ?? '').trim();
|
|
234
|
+
let response;
|
|
235
|
+
try {
|
|
236
|
+
response = await lookup(query);
|
|
237
|
+
} catch {
|
|
238
|
+
// The honest third state. Never an empty result, never "not found".
|
|
239
|
+
return textResult(
|
|
240
|
+
{
|
|
241
|
+
source: 'workspace',
|
|
242
|
+
status: 'could_not_check',
|
|
243
|
+
query,
|
|
244
|
+
message: `Couldn't check the workspace for a ${label} named "${query}" right now, so nothing was ruled out. Try again in a moment.`,
|
|
245
|
+
},
|
|
246
|
+
true,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const records = recordsFromListResponse(response, kind === 'company' ? 'companies' : 'people');
|
|
251
|
+
if (records.length === 0) {
|
|
252
|
+
return textResult({
|
|
253
|
+
source: 'workspace',
|
|
254
|
+
status: 'not_found',
|
|
255
|
+
query,
|
|
256
|
+
message: `No ${label} matching "${query}" is in this workspace.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const exact = exactMatches(records, query, kind);
|
|
261
|
+
const matches = (exact.length > 0 ? exact : records).slice(0, WORKSPACE_MATCH_LIMIT);
|
|
262
|
+
return textResult({
|
|
263
|
+
source: 'workspace',
|
|
264
|
+
status: exact.length > 0 ? 'found' : 'similar_found',
|
|
265
|
+
query,
|
|
266
|
+
message:
|
|
267
|
+
exact.length > 0
|
|
268
|
+
? `Found ${exact.length === 1 ? 'the' : exact.length} workspace ${label} record${exact.length === 1 ? '' : 's'} for "${query}".`
|
|
269
|
+
: `No exact match for "${query}"; these workspace ${label} records are the closest.`,
|
|
270
|
+
[kind === 'company' ? 'companies' : 'people']: matches,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** find_company for a seat with a credential: the workspace record, three-state. */
|
|
275
|
+
export function findCompanyInWorkspace(name, lookup) {
|
|
276
|
+
return findInWorkspace('company', name, lookup);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** find_person for a seat with a credential: the workspace record, three-state. */
|
|
280
|
+
export function findPersonInWorkspace(name, lookup) {
|
|
281
|
+
return findInWorkspace('person', name, lookup);
|
|
282
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch tools for the Adrata MCP server — a named, dated set of cards that
|
|
3
|
+
* must ship together, read as a LENS over the boards.
|
|
4
|
+
*
|
|
5
|
+
* The design: `spoq/epics/backlog/launches-are-lenses/EPIC.md`. The short form
|
|
6
|
+
* an agent needs before touching these:
|
|
7
|
+
*
|
|
8
|
+
* - A launch is NOT a board. A card stays on its own board, in its own
|
|
9
|
+
* column, under its own epic; membership here changes none of that and
|
|
10
|
+
* none of the board's instruments. Do not transfer a card to "put it on a
|
|
11
|
+
* launch" — that is the model these tools exist to replace.
|
|
12
|
+
* - A card can be on MANY launches. Priority (p0 | p1 | p2) is per launch and
|
|
13
|
+
* lives on the membership, because the card's one tag column is spoken
|
|
14
|
+
* for (on the Adrata board it is `severity`, and a P0 written there would
|
|
15
|
+
* overwrite `critical`).
|
|
16
|
+
* - Membership is a CLAIM with provenance — who decided, how sure, and why —
|
|
17
|
+
* on the same terms as `set_work_item_tag`. An agent that decides a card
|
|
18
|
+
* belongs on a launch is `model` and must give a confidence.
|
|
19
|
+
* - Readiness is COUNTS, derived on read, never a percentage and never
|
|
20
|
+
* stored (hierarchy Decision 4).
|
|
21
|
+
*
|
|
22
|
+
* "Get all the P0s done" is `get_work_launch` with `priority: "p0"`: the cards
|
|
23
|
+
* come back ranked and each one says why it is there.
|
|
24
|
+
*
|
|
25
|
+
* Writes follow the same governed contract as every write on this server:
|
|
26
|
+
* dryRun defaults to TRUE; a live write needs dryRun:false AND approved:true
|
|
27
|
+
* AND a reason AND an idempotencyKey.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const GOVERNED_NOTE =
|
|
31
|
+
' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey (reuse the SAME key on retry — adding a card that is already on the launch re-ranks it rather than duplicating it).';
|
|
32
|
+
|
|
33
|
+
const PRIORITIES = ['p0', 'p1', 'p2'];
|
|
34
|
+
const EFFORTS = ['quick', 'standard', 'deep'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Narrow a launch's cards by priority. Pure and exported so the filter rule —
|
|
38
|
+
* the one thing a "give me the P0s" call can silently get wrong — is testable
|
|
39
|
+
* without a server.
|
|
40
|
+
*
|
|
41
|
+
* `unranked` selects cards on the launch with NO priority, which is a real
|
|
42
|
+
* state ("on the launch, not yet ranked") and must be reachable, on the same
|
|
43
|
+
* reasoning the scope control keeps `Unassigned` and `Untyped` selectable.
|
|
44
|
+
*/
|
|
45
|
+
export function filterLaunchCards(cards, priority, effort) {
|
|
46
|
+
let list = Array.isArray(cards) ? cards : [];
|
|
47
|
+
if (priority === 'unranked') list = list.filter((card) => !card?.launchPriority);
|
|
48
|
+
else if (priority) list = list.filter((card) => card?.launchPriority === priority);
|
|
49
|
+
if (effort === 'unjudged') list = list.filter((card) => !card?.launchEffort);
|
|
50
|
+
else if (effort) list = list.filter((card) => card?.launchEffort === effort);
|
|
51
|
+
return list;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register the launch tools.
|
|
56
|
+
*
|
|
57
|
+
* @param {McpServer} server
|
|
58
|
+
* @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope }
|
|
59
|
+
*/
|
|
60
|
+
export function registerWorkLaunchTools(
|
|
61
|
+
server,
|
|
62
|
+
{ z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
|
|
63
|
+
) {
|
|
64
|
+
|
|
65
|
+
server.tool(
|
|
66
|
+
'list_work_launches',
|
|
67
|
+
'List the launches in the workspace — each a named, dated set of cards that must ship together, with its readiness as COUNTS (cards, p0/p1/p2/unranked, and how many sit in each stage). A launch is a lens over the boards, not a board: its cards stay on their own boards. Start here; get_work_launch needs an id from this list.',
|
|
68
|
+
{},
|
|
69
|
+
async () => {
|
|
70
|
+
const data = await api('GET', '/api/v1/work-launches');
|
|
71
|
+
const launches = data?.data || [];
|
|
72
|
+
return ok({ count: launches.length, launches });
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
server.tool(
|
|
77
|
+
'get_work_launch',
|
|
78
|
+
'Read one launch whole: its readiness counts and every member card the caller can see, p0 first. Each card carries its home board and column, its own tag, the ids it is blockedBy, and its membership claim — launchPriority, launchEffort (quick | standard | deep: a routing signal, not an estimate), launchReason, launchSource, launchConfidence. Pass priority to narrow: "p0" is the answer to "get all the P0s done"; "unranked" finds cards nobody has ranked. Pass effort to pick the quick wins for a fast lane, or the deep ones for a heavier model and a higher effort tier.',
|
|
79
|
+
{
|
|
80
|
+
launchId: z.string().describe('Launch id from list_work_launches.'),
|
|
81
|
+
priority: z
|
|
82
|
+
.enum([...PRIORITIES, 'unranked'])
|
|
83
|
+
.optional()
|
|
84
|
+
.describe('Narrow the cards to one priority band, or to the unranked ones. Omit for all.'),
|
|
85
|
+
effort: z
|
|
86
|
+
.enum([...EFFORTS, 'unjudged'])
|
|
87
|
+
.optional()
|
|
88
|
+
.describe('Narrow to quick wins, standard work, deep exploration, or the cards nobody has judged. Omit for all.'),
|
|
89
|
+
},
|
|
90
|
+
async (args) => {
|
|
91
|
+
const data = await api('GET', `/api/v1/work-launches/${encodeURIComponent(args.launchId)}`);
|
|
92
|
+
const launch = data?.data;
|
|
93
|
+
if (!launch) return ok({ launch: null });
|
|
94
|
+
const { cards: all = [], ...rest } = launch;
|
|
95
|
+
const cards = filterLaunchCards(all, args.priority, args.effort);
|
|
96
|
+
return ok({
|
|
97
|
+
launch: rest,
|
|
98
|
+
filter: args.priority ?? null,
|
|
99
|
+
effort: args.effort ?? null,
|
|
100
|
+
count: cards.length,
|
|
101
|
+
of: all.length,
|
|
102
|
+
cards,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
server.tool(
|
|
108
|
+
'create_work_launch',
|
|
109
|
+
`Create a launch: a named, dated set of cards that must ship together. It owns no columns and no cards of its own — add cards with add_work_item_to_launch and they stay exactly where they are on their boards. Check list_work_launches first; two launches with one intent is a list nobody trusts.${GOVERNED_NOTE}`,
|
|
110
|
+
{
|
|
111
|
+
name: z.string().describe('e.g. "Adrata September Launch". Named for the outcome, not the month alone.'),
|
|
112
|
+
description: z
|
|
113
|
+
.string()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe('Who it is for and what "live" means — the sentence a reader needs to judge whether a card belongs.'),
|
|
116
|
+
targetOn: z.string().optional().describe('The day it must be live, YYYY-MM-DD. A date, never an hour.'),
|
|
117
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live write.'),
|
|
118
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
119
|
+
reason: z.string().optional().describe('Required for a live write: the audit reason.'),
|
|
120
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
121
|
+
},
|
|
122
|
+
async (args) => {
|
|
123
|
+
const path = '/api/v1/work-launches';
|
|
124
|
+
const preview = validateApiBridgeRequest({
|
|
125
|
+
method: 'POST',
|
|
126
|
+
path,
|
|
127
|
+
dryRun: args.dryRun,
|
|
128
|
+
approved: args.approved,
|
|
129
|
+
reason: args.reason,
|
|
130
|
+
idempotencyKey: args.idempotencyKey,
|
|
131
|
+
grantedScope: getGrantedScope(),
|
|
132
|
+
});
|
|
133
|
+
if (preview?.dryRun) {
|
|
134
|
+
return ok({
|
|
135
|
+
...preview,
|
|
136
|
+
wouldCreate: { name: args.name, description: args.description, targetOn: args.targetOn },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const data = await api('POST', path, {
|
|
140
|
+
body: { name: args.name, description: args.description, targetOn: args.targetOn },
|
|
141
|
+
headers: buildMutationHeaders(args),
|
|
142
|
+
});
|
|
143
|
+
return ok({ created: true, launch: data?.data });
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
server.tool(
|
|
148
|
+
'update_work_launch',
|
|
149
|
+
`Rename a launch, move its target day, set its lifecycle (open | shipped | abandoned), or archive it. Status is the LIFECYCLE, not progress — progress is derived from the member cards and cannot be set. Archiving hides the launch from lists and from every card's launch chips; nothing is deleted.${GOVERNED_NOTE}`,
|
|
150
|
+
{
|
|
151
|
+
launchId: z.string().describe('Launch id from list_work_launches.'),
|
|
152
|
+
name: z.string().optional(),
|
|
153
|
+
description: z.string().optional(),
|
|
154
|
+
targetOn: z.string().optional().describe('YYYY-MM-DD, or "" to clear the date.'),
|
|
155
|
+
status: z.enum(['open', 'shipped', 'abandoned']).optional(),
|
|
156
|
+
archived: z.boolean().optional(),
|
|
157
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live write.'),
|
|
158
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
159
|
+
reason: z.string().optional().describe('Required for a live write: the audit reason.'),
|
|
160
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
161
|
+
},
|
|
162
|
+
async (args) => {
|
|
163
|
+
const path = `/api/v1/work-launches/${encodeURIComponent(args.launchId)}`;
|
|
164
|
+
const preview = validateApiBridgeRequest({
|
|
165
|
+
method: 'PATCH',
|
|
166
|
+
path,
|
|
167
|
+
dryRun: args.dryRun,
|
|
168
|
+
approved: args.approved,
|
|
169
|
+
reason: args.reason,
|
|
170
|
+
idempotencyKey: args.idempotencyKey,
|
|
171
|
+
grantedScope: getGrantedScope(),
|
|
172
|
+
});
|
|
173
|
+
const body = {
|
|
174
|
+
name: args.name,
|
|
175
|
+
description: args.description,
|
|
176
|
+
targetOn: args.targetOn,
|
|
177
|
+
status: args.status,
|
|
178
|
+
isArchived: args.archived,
|
|
179
|
+
};
|
|
180
|
+
if (preview?.dryRun) return ok({ ...preview, wouldUpdate: body });
|
|
181
|
+
const data = await api('PATCH', path, { body, headers: buildMutationHeaders(args) });
|
|
182
|
+
return ok({ updated: true, launch: data?.data });
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
server.tool(
|
|
187
|
+
'add_work_item_to_launch',
|
|
188
|
+
`Put a card on a launch, with a priority and the reason it belongs there. The card does NOT move: it keeps its board, column, tag and epic — a launch is a lens. Adding a card that is already on the launch re-ranks it and replaces its reason and provenance. source is who decided: an agent classifying a card is "model" and MUST give a confidence; claiming "human" would launder a guess as a person's judgement.${GOVERNED_NOTE}`,
|
|
189
|
+
{
|
|
190
|
+
launchId: z.string().describe('Launch id from list_work_launches.'),
|
|
191
|
+
itemId: z.string().describe('Card id.'),
|
|
192
|
+
priority: z
|
|
193
|
+
.enum(PRIORITIES)
|
|
194
|
+
.optional()
|
|
195
|
+
.describe(
|
|
196
|
+
'p0 = cannot go live without it, or the first hour lies; p1 = the first week is not indispensable without it; p2 = launch quality that can trail by a week or two. Omit to add unranked.'
|
|
197
|
+
),
|
|
198
|
+
effort: z
|
|
199
|
+
.enum(EFFORTS)
|
|
200
|
+
.optional()
|
|
201
|
+
.describe(
|
|
202
|
+
'quick = a known fix in a known place, hours not days; standard = ordinary card work; deep = needs exploration or a design (a heavier model / higher effort tier). A routing signal, never an estimate. Omit to leave unjudged.'
|
|
203
|
+
),
|
|
204
|
+
membershipReason: z
|
|
205
|
+
.string()
|
|
206
|
+
.describe('Why this card is on the launch, in a sentence. Shown on the launch beside the card.'),
|
|
207
|
+
source: z.enum(['human', 'model', 'rules']).describe('Who decided membership.'),
|
|
208
|
+
confidence: z.number().min(0).max(1).optional().describe('0-1. REQUIRED when source is "model".'),
|
|
209
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live write.'),
|
|
210
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
211
|
+
reason: z.string().optional().describe('Required for a live write: the audit reason.'),
|
|
212
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
213
|
+
},
|
|
214
|
+
async (args) => {
|
|
215
|
+
const path = `/api/v1/work-launches/${encodeURIComponent(args.launchId)}/items`;
|
|
216
|
+
const preview = validateApiBridgeRequest({
|
|
217
|
+
method: 'POST',
|
|
218
|
+
path,
|
|
219
|
+
dryRun: args.dryRun,
|
|
220
|
+
approved: args.approved,
|
|
221
|
+
reason: args.reason,
|
|
222
|
+
idempotencyKey: args.idempotencyKey,
|
|
223
|
+
grantedScope: getGrantedScope(),
|
|
224
|
+
});
|
|
225
|
+
const body = {
|
|
226
|
+
itemId: args.itemId,
|
|
227
|
+
priority: args.priority,
|
|
228
|
+
effort: args.effort,
|
|
229
|
+
reason: args.membershipReason,
|
|
230
|
+
source: args.source,
|
|
231
|
+
confidence: args.confidence,
|
|
232
|
+
};
|
|
233
|
+
if (preview?.dryRun) return ok({ ...preview, wouldAdd: body });
|
|
234
|
+
const data = await api('POST', path, { body, headers: buildMutationHeaders(args) });
|
|
235
|
+
const launch = data?.data;
|
|
236
|
+
return ok({
|
|
237
|
+
added: true,
|
|
238
|
+
launchId: args.launchId,
|
|
239
|
+
itemId: args.itemId,
|
|
240
|
+
priority: args.priority ?? null,
|
|
241
|
+
effort: args.effort ?? null,
|
|
242
|
+
readiness: launch?.readiness,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.tool(
|
|
248
|
+
'remove_work_item_from_launch',
|
|
249
|
+
`Take a card off a launch. The card itself is untouched — it stays on its board in its column. Use this when a card was added in error or the launch no longer needs it; to say a card is DONE, move it on its board instead and the launch's readiness will show it.${GOVERNED_NOTE}`,
|
|
250
|
+
{
|
|
251
|
+
launchId: z.string().describe('Launch id from list_work_launches.'),
|
|
252
|
+
itemId: z.string().describe('Card id.'),
|
|
253
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live write.'),
|
|
254
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
255
|
+
reason: z.string().optional().describe('Required for a live write: the audit reason.'),
|
|
256
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
257
|
+
},
|
|
258
|
+
async (args) => {
|
|
259
|
+
const path = `/api/v1/work-launches/${encodeURIComponent(args.launchId)}/items/${encodeURIComponent(args.itemId)}`;
|
|
260
|
+
const preview = validateApiBridgeRequest({
|
|
261
|
+
method: 'DELETE',
|
|
262
|
+
path,
|
|
263
|
+
dryRun: args.dryRun,
|
|
264
|
+
approved: args.approved,
|
|
265
|
+
reason: args.reason,
|
|
266
|
+
idempotencyKey: args.idempotencyKey,
|
|
267
|
+
grantedScope: getGrantedScope(),
|
|
268
|
+
});
|
|
269
|
+
if (preview?.dryRun) {
|
|
270
|
+
return ok({ ...preview, wouldRemove: { launchId: args.launchId, itemId: args.itemId } });
|
|
271
|
+
}
|
|
272
|
+
const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
|
|
273
|
+
return ok({ removed: true, launchId: args.launchId, itemId: args.itemId, readiness: data?.data?.readiness });
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Tool names registered here, for the tier map and the toolset manifest. */
|
|
279
|
+
export const WORK_LAUNCH_TOOL_NAMES = [
|
|
280
|
+
'list_work_launches',
|
|
281
|
+
'get_work_launch',
|
|
282
|
+
'create_work_launch',
|
|
283
|
+
'update_work_launch',
|
|
284
|
+
'add_work_item_to_launch',
|
|
285
|
+
'remove_work_item_from_launch',
|
|
286
|
+
];
|