@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 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.42",
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
- // ===== FREE TIER: Claude-powered search (zero cost to Adrata) =====
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
- 'Search for a company using Claude\'s training data. Returns a structured profile with industry, key people, estimated size, competitors, and sales angles. Free tier — no API key needed. For verified real-time data, upgrade to Pro.',
1129
- { name: z.string().describe('Company name to research (e.g. "Stripe", "Snowflake", "Datadog")') },
1130
- async (a) => findCompany(a.name));
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
- 'Search for a professional using Claude\'s training data. Returns a structured profile with title, company, background, expertise, and sales context. Free tier no API key needed. For verified contact info and buyer signals, upgrade to Pro.',
1134
- { name: z.string().describe('Full name of the person to research (e.g. "Patrick Collison", "Jensen Huang")') },
1135
- async (a) => findPerson(a.name));
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.42",
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.42",
18
+ "version": "1.0.44",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -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',
@@ -1,11 +1,20 @@
1
1
  /**
2
- * Free Tier Claude-Powered Search
2
+ * find_company / find_person
3
3
  *
4
- * These tools return structured prompts that guide Claude (the LLM running the
5
- * MCP client) to search its own training data for company and person profiles.
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
- * Cost to Adrata: $0 — Claude's own knowledge does the work.
8
- * No API key needed, no account needed, unlimited usage.
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
+ ];