@aioproductoscom/mcp 0.15.10 → 0.16.0

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.
@@ -0,0 +1,1074 @@
1
+ // The remote MCP tool registry. Each tool is a thin, declarative wrapper over an
2
+ // existing token-authed REST endpoint (/api/me/* or /api/pm/*), so there is ZERO
3
+ // business-logic duplication — the MCP route forwards the caller's bearer token to
4
+ // the same endpoint the stdio MCP already uses. Adding a tool = one entry here.
5
+ //
6
+ // SAFETY: this surface is read-heavy by design. It exposes the FULL product
7
+ // read surface — board, insights backlog, features, OKRs, experiments, releases,
8
+ // sprints, Pages, the codebase map, support inbox, team channels, bookings, plus
9
+ // the revenue-weighted analytics — and a SAFE write set (tasks + capture_insight).
10
+ // Customer-facing / multiplayer writes (replying to a support visitor, cancelling
11
+ // or rescheduling a guest's booking, posting to a Comms channel) are deliberately
12
+ // omitted: they can be triggered by anyone tagging the agent, so they need tighter
13
+ // guardrails before they ship here. Their token-authed endpoints exist
14
+ // (/api/me/comms POST, /api/me/scheduling POST) — wiring them is an opt-in.
15
+ import { weeklyMemoHtml } from "./ui-views.js";
16
+ // Reads are the default; the four writes carry these explicitly. Nothing is
17
+ // destructive. openWorldHint=false everywhere — every tool is scoped to the
18
+ // caller's own org, never an external/open domain.
19
+ const READ_ONLY = {
20
+ readOnlyHint: true,
21
+ destructiveHint: false,
22
+ idempotentHint: true,
23
+ openWorldHint: false,
24
+ };
25
+ /** A write that creates a NEW record each call (create_task, comment, capture_insight). */
26
+ const WRITE_CREATE = {
27
+ readOnlyHint: false,
28
+ destructiveHint: false,
29
+ idempotentHint: false,
30
+ openWorldHint: false,
31
+ };
32
+ /** A write that sets fields to a target state (update_task) — same args, same result. */
33
+ const WRITE_UPDATE = {
34
+ readOnlyHint: false,
35
+ destructiveHint: false,
36
+ idempotentHint: true,
37
+ openWorldHint: false,
38
+ };
39
+ /** A permanent, irreversible delete (delete_task). Cascades to the record's children. */
40
+ const WRITE_DELETE = {
41
+ readOnlyHint: false,
42
+ destructiveHint: true,
43
+ idempotentHint: true,
44
+ openWorldHint: false,
45
+ };
46
+ /** Every tool surfaces hints in tools/list; reads inherit the READ_ONLY default. */
47
+ export function annotationsFor(t) {
48
+ return t.annotations ?? READ_ONLY;
49
+ }
50
+ const str = (v) => typeof v === "string" && v.length > 0 ? v : undefined;
51
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
52
+ function qs(params) {
53
+ const p = new URLSearchParams();
54
+ for (const [k, v] of Object.entries(params))
55
+ if (v !== undefined)
56
+ p.set(k, String(v));
57
+ const s = p.toString();
58
+ return s ? `?${s}` : "";
59
+ }
60
+ const obj = (properties, required = []) => ({
61
+ type: "object",
62
+ properties,
63
+ ...(required.length ? { required } : {}),
64
+ });
65
+ const s = (description) => ({ type: "string", description });
66
+ const n = (description) => ({ type: "number", description });
67
+ const PLAYBOOK = `You manage product work on AIOProductOS for the connected member — board, insights, features, support, and analytics all hang off ONE customer-record spine.
68
+
69
+ How to operate:
70
+ - Ground first. Call get_product_brain before reasoning about the product; it returns revenue + top accounts, web + product analytics, features, recent verbatim signals, and open work. Go deeper with the dedicated reads: list_insights (full voice-of-customer backlog), list_ideas (votable idea backlog), list_features, list_initiatives + list_objectives (the strategy ladder + live progress), list_experiments, list_releases, list_sprints, list_pages/get_page (docs + PRDs), get_codebase_map.
71
+ - Prioritise on evidence — affected accounts + MRR + reach — never an invented score. The revenue is on the same record as the behaviour and the feedback; use it (analyze_nps and analyze_nrr are revenue-weighted; get_customer_360 joins money + people + voice for one account).
72
+ - Keep work tied to the spine end to end: idea → feature (promote_idea), and line-of-sight objective → initiative → feature → task → outcome. Insights are the evidence behind it. Resolve names to ids with pm_meta / list_initiatives before create/update; never guess an id.
73
+ - Confirm what you changed in plain language, and claim only writes you actually made.`;
74
+ export const MCP_TOOLS = [
75
+ {
76
+ name: "get_pm_playbook",
77
+ title: "PM Playbook",
78
+ examples: [{ description: "How should I operate before planning the roadmap?", arguments: {} }],
79
+ kind: "static",
80
+ description: "How to operate as a product manager on AIOProductOS. No arguments and no side effects — returns the same operating guide as plain text every call (deterministic): how to ground in the product brain, keep work welded to the spine (insight→feature→task→outcome), prioritise on evidence (affected accounts + MRR + reach), and what 'done' means. Call it FIRST, before planning or prioritising, to load the house rules the other tools assume.",
81
+ inputSchema: obj({}),
82
+ run: () => PLAYBOOK,
83
+ },
84
+ {
85
+ name: "whoami",
86
+ title: "Connected Identity",
87
+ examples: [{ description: "Which AIOProductOS org, member, and products am I connected to?", arguments: {} }],
88
+ kind: "rest",
89
+ description: "Show the connected AIOProductOS identity (org, member) AND the org's products (id, name, is_primary). Read-only; returns the identity plus the product list. For a multi-product org, call this first to get the product ids, then pass one as `product_id` to any product-scoped tool; omit product_id to use the primary.",
90
+ inputSchema: obj({}),
91
+ rest: () => ({ method: "GET", path: "/api/me" }),
92
+ },
93
+ {
94
+ name: "pm_meta",
95
+ title: "PM Metadata",
96
+ examples: [{ description: "List the lists, statuses, members, and features so I can resolve names to ids.", arguments: {} }],
97
+ kind: "rest",
98
+ description: "List the org's PM lists, statuses, members, and features as id+name pairs. Read-only; returns arrays for resolution only (list_features carries the richer catalogue). Call it to turn a name into an id before create_task / update_task — never guess an id.",
99
+ inputSchema: obj({}),
100
+ rest: () => ({ method: "GET", path: "/api/pm/meta" }),
101
+ },
102
+ {
103
+ name: "get_product_brain",
104
+ title: "Product Brain Snapshot",
105
+ examples: [{ description: "Give me a grounded snapshot of the product before I reason about it.", arguments: {} }],
106
+ kind: "rest",
107
+ description: "A grounded snapshot of the org's product so YOU can reason about it. Returns one JSON object with: revenue + top paying accounts (ranked by MRR), web + product analytics headline metrics, the feature list, recent verbatim customer signals (newest first), and open-work counts — each block empty when that source isn't flowing yet. The time-windowed sections (revenue, cost, web + product analytics, feature usage) honour `window` (7 | 30 | 90 days, default 30). Single call, no pagination. Start here to ground, then go deeper with the dedicated list_* reads and the analytics tools. Optional product_id (the org's primary product when omitted).",
108
+ inputSchema: obj({
109
+ product_id: s("Product id, from whoami (optional; the org's primary product when omitted)."),
110
+ window: { type: "number", enum: [7, 30, 90], default: 30, description: "Time window in days for the revenue, cost, and analytics sections (optional; 7, 30, or 90; default 30)." },
111
+ }),
112
+ rest: (a) => ({ method: "GET", path: `/api/me/brain${qs({ product_id: str(a.product_id), window: num(a.window) ?? str(a.window) })}` }),
113
+ },
114
+ {
115
+ name: "get_weekly_signal_memo",
116
+ title: "Weekly Signal Memo",
117
+ examples: [{ description: "What changed in customer signal this week?", arguments: {} }],
118
+ kind: "rest",
119
+ description: "The Weekly Product Signal Memo — the last 7 days of customer signal clustered into themes (insights grouped by feature, ranked by the revenue behind them) with verbatim quotes, week-over-week deltas (new / repeated / stronger / weaker), concluded experiments, and shipped releases. Deterministic — every count is off real rows, no fabricated quotes. Optional `week` (ISO 'YYYY-Www') for a past week; `generate=1` rebuilds + persists the current week now. Read-only apart from that rebuild; returns the persisted memo, empty when the requested week has none. Open a weekly review with it, then drill into a theme with list_insights.",
120
+ inputSchema: obj({
121
+ week: s("ISO week to fetch, format 'YYYY-Www' e.g. '2026-W27' (optional; the latest persisted week when omitted)."),
122
+ generate: s("Pass '1' to rebuild and persist the current week's memo now instead of reading the stored one (optional)."),
123
+ }),
124
+ rest: (a) => ({
125
+ method: "GET",
126
+ path: `/api/me/weekly-signal${qs({ week: str(a.week), generate: str(a.generate) })}`,
127
+ }),
128
+ htmlView: weeklyMemoHtml, // #65 — rich ui:// card in MCP-UI hosts (text fallback always present)
129
+ },
130
+ {
131
+ name: "get_roadmap_drift",
132
+ title: "Roadmap Drift",
133
+ examples: [{ description: "How badly are we drifting from the roadmap this quarter?", arguments: { window: "quarter" } }],
134
+ kind: "rest",
135
+ description: "Planned vs shipped features over a window: a drift score (0-100, 100 = perfect alignment), counts (planned / shipped / on-time / slipped / unplanned / orphaned), median slip days, and the top slipped + unplanned ships. Deterministic, no LLM cost. window = week | month | quarter (default quarter); optional product_id. Read-only; returns the drift report, zeroed when nothing was planned or shipped in the window. Use it in planning reviews to check delivery against the roadmap, then open the slipped features with list_features.",
136
+ inputSchema: obj({
137
+ window: { type: "string", enum: ["week", "month", "quarter"], default: "quarter", description: "Lookback window to compare planned vs shipped over (optional; default quarter)." },
138
+ product_id: s("Product id, from whoami (optional; spans all the org's products when omitted)."),
139
+ }),
140
+ rest: (a) => ({
141
+ method: "GET",
142
+ path: `/api/me/roadmap-drift${qs({ window: str(a.window), product_id: str(a.product_id) })}`,
143
+ }),
144
+ },
145
+ {
146
+ name: "get_customer_360",
147
+ title: "Customer 360",
148
+ examples: [{ description: "Show me everything about the Acme account.", arguments: { query: "acme.com" } }],
149
+ kind: "rest",
150
+ description: "Everything about ONE customer, resolved by id, email, domain, or company name: profile, subscription + MRR, how many users sit under the account, and their verbatim feedback. Read-only; returns the matched account, or an empty result when nothing matches the query. The money + people + voice join on one record — call it before answering anything about a specific account.",
151
+ inputSchema: obj({ query: s("The account to resolve: an account id, a user's email, a company domain (e.g. 'acme.com'), or a company name.") }, ["query"]),
152
+ rest: (a) => ({
153
+ method: "GET",
154
+ path: `/api/me/customer${qs({ q: str(a.query) })}`,
155
+ }),
156
+ },
157
+ {
158
+ name: "analyze_nps",
159
+ title: "NPS (revenue-weighted)",
160
+ examples: [{ description: "What's our NPS over the last quarter, weighted by revenue?", arguments: { window_days: 90 } }],
161
+ kind: "rest",
162
+ description: "NPS for the product: the standard −100…100 score AND revenue-weighted NPS (each respondent weighted by their account MRR), plus detractor accounts ranked by MRR-at-risk (highest first). Surfaces when your biggest customers are the unhappy ones even if the headline looks fine. Computed deterministically off survey responses inside `window_days` (default 90, valid 1–365); returns an empty result when none fall in the window. product_id optional (primary product when omitted). Quantify sentiment after get_product_brain, then dig into a detractor with get_customer_360.",
163
+ inputSchema: obj({ product_id: s("Product id, from whoami (optional; the org's primary product when omitted)."), window_days: { ...n("Lookback window in days (optional; default 90, i.e. the last quarter)."), default: 90, minimum: 1 } }),
164
+ rest: (a) => ({
165
+ method: "GET",
166
+ path: `/api/me/nps${qs({ product_id: str(a.product_id), window_days: num(a.window_days) })}`,
167
+ }),
168
+ },
169
+ {
170
+ name: "analyze_nrr",
171
+ title: "Net Revenue Retention",
172
+ examples: [{ description: "How does revenue retention compare to logo retention this quarter?", arguments: { window_days: 90 } }],
173
+ kind: "rest",
174
+ description: "Net Revenue Retention (revenue-weighted) next to logo retention (count-weighted), the expansion/contraction/churn split, and the accounts that lost the most MRR (ranked, highest loss first). The divergence is the point: '92% of logos but 78% of revenue' means a big account churned. Computed deterministically off subscription movements inside `window_days` (default 90, valid 1–365); empty when none fall in the window. Quantify revenue health, then follow the top-losing accounts into get_customer_360.",
175
+ inputSchema: obj({ window_days: { ...n("Lookback window in days (optional; default 90, i.e. the last quarter)."), default: 90, minimum: 1 } }),
176
+ rest: (a) => ({ method: "GET", path: `/api/me/nrr${qs({ window_days: num(a.window_days) })}` }),
177
+ },
178
+ {
179
+ name: "analyze_funnel",
180
+ title: "Conversion Funnel",
181
+ examples: [{ description: "Build the signup → activation → paid funnel.", arguments: { steps: ["signup", "activated", "subscribed"] } }],
182
+ kind: "rest",
183
+ description: "Build a conversion funnel from the product's own events: distinct users per step, step-to-step conversion %, and drop-off, evaluated in the exact order you pass. Needs product-analytics events flowing; returns empty counts when none match. Pass `steps` as an ordered list of 2+ event names — call it with NO steps first to get the menu of available event names rather than guessing them. Optional product_id and window_days (default 30, valid 1–365). Pairs with analyze_paths to see where the drop-offs go.",
184
+ inputSchema: obj({
185
+ steps: { type: "array", items: { type: "string" }, description: "Ordered list of 2+ event names forming the funnel; omit to get the menu of available event names first." },
186
+ product_id: s("Product id, from whoami (optional; the org's primary product when omitted)."),
187
+ window_days: { ...n("Lookback window in days (optional; default 30)."), default: 30, minimum: 1 },
188
+ }),
189
+ rest: (a) => {
190
+ const steps = Array.isArray(a.steps) ? a.steps.filter((x) => typeof x === "string") : [];
191
+ const parts = [];
192
+ for (const st of steps)
193
+ parts.push(`step=${encodeURIComponent(st)}`);
194
+ if (str(a.product_id))
195
+ parts.push(`product_id=${encodeURIComponent(str(a.product_id))}`);
196
+ if (num(a.window_days) !== undefined)
197
+ parts.push(`window=${num(a.window_days)}`);
198
+ return { method: "GET", path: `/api/me/funnel${parts.length ? `?${parts.join("&")}` : ""}` };
199
+ },
200
+ },
201
+ {
202
+ name: "get_retention",
203
+ title: "Cohort Retention",
204
+ examples: [{ description: "Show weekly retention cohorts for the last 8 weeks.", arguments: { window_days: 56 } }],
205
+ kind: "rest",
206
+ description: "Weekly cohort retention for the product: users grouped by first-seen week (one row per cohort, newest last), with the share still active each subsequent week — a lower-triangular grid. Needs product-analytics events flowing; returns empty cohorts when the product has none. window_days default 56 = 8 weekly cohorts (min 7; roughly one extra cohort per added 7 days). product_id optional (primary product when omitted).",
207
+ inputSchema: obj({ product_id: s("Product id, from whoami (optional; the org's primary product when omitted)."), window_days: { ...n("Lookback window in days (optional; default 56 = 8 weekly cohorts)."), default: 56, minimum: 7 } }),
208
+ rest: (a) => ({
209
+ method: "GET",
210
+ path: `/api/me/retention${qs({ product_id: str(a.product_id), window: num(a.window_days) })}`,
211
+ }),
212
+ },
213
+ {
214
+ name: "analyze_paths",
215
+ title: "User Path Flow",
216
+ examples: [{ description: "What do users do right after they sign up?", arguments: { start: "signup" } }],
217
+ kind: "rest",
218
+ description: "Trace what users do AFTER a start event — the journey flow (Sankey) from the product's own events. Returns the next-step transitions ranked by user count (most common first), empty when no events match. Pass `start` to anchor on an event, or omit for the most common start (call analyze_funnel with no steps to list the event names). Optional product_id and window_days (default 30, valid 1–365).",
219
+ inputSchema: obj({ start: s("Event name to anchor the flow on (optional; the most common start event when omitted — analyze_funnel with no steps lists the event names)."), product_id: s("Product id, from whoami (optional; the org's primary product when omitted)."), window_days: n("Lookback window in days (optional; default 30).") }),
220
+ rest: (a) => ({
221
+ method: "GET",
222
+ path: `/api/me/paths${qs({ start: str(a.start), product_id: str(a.product_id), window: num(a.window_days) })}`,
223
+ }),
224
+ },
225
+ {
226
+ name: "list_tasks",
227
+ title: "List Tasks",
228
+ examples: [{ description: "Show the tasks on the board.", arguments: {} }],
229
+ kind: "rest",
230
+ description: "List the org's board tasks and return the matches with their status, priority, assignees, and any linked feature/insight/sprint. Optionally narrow by status_id or list_id — resolve either via pm_meta. Read-only; returns an empty list when nothing matches. Use it to find a task id before get_task, update_task, or comment_on_task.",
231
+ inputSchema: obj({ status_id: s("Only tasks in this status; resolve the id via pm_meta (optional)."), list_id: s("Only tasks on this list; resolve the id via pm_meta (optional).") }),
232
+ rest: (a) => ({
233
+ method: "GET",
234
+ path: `/api/pm/tasks${qs({ status_id: str(a.status_id), list_id: str(a.list_id) })}`,
235
+ }),
236
+ },
237
+ {
238
+ name: "get_task",
239
+ title: "Get Task",
240
+ examples: [{ description: "Open this task with its comments and assignees.", arguments: { id: "task_8f3a" } }],
241
+ kind: "rest",
242
+ description: "Get one task by id and return it with its full comments and assignees. Read-only. Resolve the id first with list_tasks — never guess it; pair with update_task or comment_on_task to act on what you read.",
243
+ inputSchema: obj({ id: s("Task id, from list_tasks.") }, ["id"]),
244
+ rest: (a) => ({ method: "GET", path: `/api/pm/tasks/${encodeURIComponent(str(a.id) ?? "")}` }),
245
+ },
246
+ {
247
+ name: "create_task",
248
+ title: "Create Task",
249
+ examples: [{ description: "Create a high-priority task and link it to a feature.", arguments: { title: "Fix SSO redirect loop", priority: "high", feature_id: "feat_sso" } }],
250
+ kind: "rest",
251
+ annotations: WRITE_CREATE,
252
+ description: "Create a task and return the created task. list_id defaults to the org's first list when omitted; feature_id / insight_id link it to the spine and sprint_id schedules it into a sprint. Resolve list/status/feature/insight/member ids via pm_meta and sprint_id via list_sprints — never guess them. Only title is required.",
253
+ inputSchema: obj({
254
+ title: s("Task title (the only required field)."),
255
+ description: s("Task body / details (optional)."),
256
+ priority: { type: "string", enum: ["urgent", "high", "normal", "low"], description: "Priority level, urgent highest (optional)." },
257
+ list_id: s("List to create the task on; resolve the id via pm_meta (optional; the org's first list when omitted)."),
258
+ status_id: s("Initial status; resolve the id via pm_meta (optional)."),
259
+ feature_id: s("Feature id to link on the spine, from pm_meta or list_features (optional)."),
260
+ insight_id: s("Insight id to link on the spine, from list_insights (optional)."),
261
+ sprint_id: s("Schedule into a sprint (optional; resolve the id via list_sprints)."),
262
+ assignee_member_ids: { type: "array", items: { type: "string" }, description: "Member ids to assign, from pm_meta (optional)." },
263
+ }, ["title"]),
264
+ rest: (a) => ({ method: "POST", path: "/api/pm/tasks", body: a }),
265
+ },
266
+ {
267
+ name: "create_feature",
268
+ title: "Create Feature",
269
+ examples: [{ description: "Add a feature to the roadmap.", arguments: { name: "SAML SSO" } }],
270
+ kind: "rest",
271
+ annotations: WRITE_CREATE,
272
+ description: "Create a feature on the product spine and return it (id, key, name, status). The key is generated from the name; status starts 'active'. product_id defaults to the org's primary product when omitted (pass one from whoami for a multi-product org). Only name is required — create a feature here before linking tasks to it with create_task.",
273
+ inputSchema: obj({
274
+ name: s("Feature name (the only required field), e.g. 'SAML SSO'."),
275
+ description: s("What the feature is / why it matters (optional)."),
276
+ product_id: s("Product to create it under, from whoami (optional; the org's primary product when omitted)."),
277
+ initiative_id: s("Initiative to align this feature under for line-of-sight, from list_initiatives (optional)."),
278
+ objective_id: s("Objective (goal) to align this feature under directly when there's no intermediate initiative, from list_objectives (optional)."),
279
+ }, ["name"]),
280
+ rest: (a) => ({ method: "POST", path: "/api/me/features", body: a }),
281
+ },
282
+ {
283
+ name: "create_objective",
284
+ title: "Create Objective (OKR)",
285
+ examples: [{ description: "Set a Q3 objective with a key result.", arguments: { name: "Reach $50k MRR", period: "Q3 2026", key_results: [{ name: "MRR", target_value: 50000, unit: "USD" }] } }],
286
+ kind: "rest",
287
+ annotations: WRITE_CREATE,
288
+ description: "Create an objective, optionally with key results, and return it. period is free text (e.g. 'Q3 2026'); product_id and parent_id (a parent objective) are optional and verified in-org. Each key result takes name + optional unit / start_value / target_value. Only name is required.",
289
+ inputSchema: obj({
290
+ name: s("Objective name (the only required field), e.g. 'Reach $50k MRR'."),
291
+ description: s("Context for the objective (optional)."),
292
+ period: s("Free-text period, e.g. 'Q3 2026' (optional)."),
293
+ product_id: s("Product to scope it to, from whoami (optional)."),
294
+ parent_id: s("Parent objective id to nest under, from list_objectives (optional)."),
295
+ key_results: {
296
+ type: "array",
297
+ description: "Key results to attach (optional; up to 10).",
298
+ items: {
299
+ type: "object",
300
+ properties: {
301
+ name: { type: "string", description: "Key result name, e.g. 'MRR'." },
302
+ unit: { type: "string", description: "Unit, e.g. 'USD' or '%' (optional)." },
303
+ start_value: { type: "number", description: "Starting value (optional; default 0)." },
304
+ target_value: { type: "number", description: "Target value (optional)." },
305
+ },
306
+ required: ["name"],
307
+ },
308
+ },
309
+ }, ["name"]),
310
+ rest: (a) => ({ method: "POST", path: "/api/me/objectives", body: a }),
311
+ },
312
+ {
313
+ name: "create_sprint",
314
+ title: "Create Sprint",
315
+ examples: [{ description: "Start a two-week sprint.", arguments: { name: "Sprint 12", goal: "Ship SSO", state: "active" } }],
316
+ kind: "rest",
317
+ annotations: WRITE_CREATE,
318
+ description: "Create a sprint and return it (id, name, goal, state, dates). state is 'future' (default) or 'active'; start_date / end_date are optional ISO 8601. Only name is required. Schedule tasks into it by passing the returned sprint id as sprint_id on create_task / update_task.",
319
+ inputSchema: obj({
320
+ name: s("Sprint name (the only required field), e.g. 'Sprint 12'."),
321
+ goal: s("The sprint goal (optional)."),
322
+ state: { type: "string", enum: ["future", "active"], description: "Lifecycle state (optional; default 'future')." },
323
+ start_date: s("Start, ISO 8601 e.g. '2026-07-15T00:00:00Z' (optional)."),
324
+ end_date: s("End, ISO 8601 (optional)."),
325
+ }, ["name"]),
326
+ rest: (a) => ({ method: "POST", path: "/api/me/sprints", body: a }),
327
+ },
328
+ {
329
+ name: "create_page",
330
+ title: "Create Page",
331
+ examples: [{ description: "Draft a PRD as a Page.", arguments: { title: "PRD: SSO", body: "Problem\n\nUsers can't self-serve SAML today." } }],
332
+ kind: "rest",
333
+ annotations: WRITE_CREATE,
334
+ description: "Create a Page (in-product doc / PRD on the spine) and return it (id, title). `body` is plain text — blank-line-separated blocks become paragraphs; omit it for a blank page. title defaults to 'Untitled'. product_id / parent_id (a parent page) are optional and verified in-org.",
335
+ inputSchema: obj({
336
+ title: s("Page title (optional; 'Untitled' when omitted)."),
337
+ body: s("Page content as plain text; blank lines separate paragraphs (optional)."),
338
+ icon: s("An emoji icon for the page (optional)."),
339
+ product_id: s("Product to scope it to, from whoami (optional)."),
340
+ parent_id: s("Parent page id to nest under, from list_pages (optional)."),
341
+ }, []),
342
+ rest: (a) => ({ method: "POST", path: "/api/me/pages", body: a }),
343
+ },
344
+ {
345
+ name: "update_feature",
346
+ title: "Update Feature",
347
+ examples: [{ description: "Mark a feature shipped.", arguments: { id: "feat_123", mark_shipped: true } }],
348
+ kind: "rest",
349
+ annotations: WRITE_UPDATE,
350
+ description: "Update a feature and return it; omitted fields are unchanged. status is 'active' | 'discovered' | 'archived' (there is NO 'shipped' status — set mark_shipped:true to stamp its ship date instead). target_date is 'YYYY-MM-DD' (or null to clear). Resolve the id via list_features; only id is required.",
351
+ inputSchema: obj({
352
+ id: s("Feature id to update, from list_features (required)."),
353
+ name: s("New name (optional)."),
354
+ description: s("New description; null clears it (optional)."),
355
+ status: { type: "string", enum: ["active", "discovered", "archived"], description: "Lifecycle status (optional)." },
356
+ target_date: s("Target ship date 'YYYY-MM-DD', or null to clear (optional)."),
357
+ mark_shipped: { type: "boolean", description: "true stamps the ship date now; false clears it (optional)." },
358
+ initiative_id: s("Align under this initiative (line-of-sight), from list_initiatives; null unlinks (optional)."),
359
+ objective_id: s("Align directly under this objective, from list_objectives; null unlinks (optional)."),
360
+ }, ["id"]),
361
+ rest: (a) => ({ method: "PATCH", path: "/api/me/features", body: a }),
362
+ },
363
+ {
364
+ name: "update_objective",
365
+ title: "Update Objective",
366
+ examples: [{ description: "Rename an objective.", arguments: { id: "obj_123", name: "Reach $75k MRR" } }],
367
+ kind: "rest",
368
+ annotations: WRITE_UPDATE,
369
+ description: "Update an objective's name / description / period and return it; omitted fields are unchanged (null clears description or period). Resolve the id via list_objectives; only id is required. To move a key result's value use update_key_result.",
370
+ inputSchema: obj({
371
+ id: s("Objective id, from list_objectives (required)."),
372
+ name: s("New name (optional)."),
373
+ description: s("New description; null clears it (optional)."),
374
+ period: s("New period, e.g. 'Q4 2026'; null clears it (optional)."),
375
+ }, ["id"]),
376
+ rest: (a) => ({ method: "PATCH", path: "/api/me/objectives", body: a }),
377
+ },
378
+ {
379
+ name: "update_key_result",
380
+ title: "Update Key Result",
381
+ examples: [{ description: "Log KR progress.", arguments: { id: "kr_123", current_value: 42000 } }],
382
+ kind: "rest",
383
+ annotations: WRITE_UPDATE,
384
+ description: "Update a key result — most often to move current_value as progress lands — and return it; omitted fields are unchanged. Resolve the id via list_objectives (each objective carries its key_results with ids). Only id is required.",
385
+ inputSchema: obj({
386
+ id: s("Key result id, from list_objectives (required)."),
387
+ current_value: n("New current value (optional)."),
388
+ target_value: n("New target value; null clears it (optional)."),
389
+ start_value: n("New starting baseline (optional)."),
390
+ name: s("New name (optional)."),
391
+ unit: s("New unit, e.g. 'USD'; null clears it (optional)."),
392
+ }, ["id"]),
393
+ rest: (a) => ({ method: "PATCH", path: "/api/me/key-results", body: a }),
394
+ },
395
+ {
396
+ name: "update_sprint",
397
+ title: "Update Sprint",
398
+ examples: [{ description: "Close a sprint.", arguments: { id: "spr_123", state: "closed" } }],
399
+ kind: "rest",
400
+ annotations: WRITE_UPDATE,
401
+ description: "Update a sprint and return it. state is 'future' | 'active' | 'closed' — moving to 'closed' stamps the completion time, reopening clears it. start_date / end_date are ISO 8601 (or null to clear). Resolve the id via list_sprints; only id is required.",
402
+ inputSchema: obj({
403
+ id: s("Sprint id, from list_sprints (required)."),
404
+ name: s("New name (optional)."),
405
+ goal: s("New goal; null clears it (optional)."),
406
+ state: { type: "string", enum: ["future", "active", "closed"], description: "Lifecycle state; 'closed' completes it (optional)." },
407
+ start_date: s("Start, ISO 8601, or null (optional)."),
408
+ end_date: s("End, ISO 8601, or null (optional)."),
409
+ }, ["id"]),
410
+ rest: (a) => ({ method: "PATCH", path: "/api/me/sprints", body: a }),
411
+ },
412
+ {
413
+ name: "update_page",
414
+ title: "Update Page",
415
+ examples: [{ description: "Archive a page.", arguments: { id: "page_123", archived: true } }],
416
+ kind: "rest",
417
+ annotations: WRITE_UPDATE,
418
+ description: "Update a Page — rename, set icon, replace the body, or archive/unarchive (archived:true hides it, false restores it). `body` is plain text (blank lines → paragraphs) and REPLACES the page content. Omitted fields are unchanged. Resolve the id via list_pages; only id is required.",
419
+ inputSchema: obj({
420
+ id: s("Page id, from list_pages (required)."),
421
+ title: s("New title (optional)."),
422
+ icon: s("New emoji icon; null clears it (optional)."),
423
+ body: s("New content as plain text; blank lines separate paragraphs. REPLACES existing content (optional)."),
424
+ archived: { type: "boolean", description: "true archives (hides) the page; false restores it (optional)." },
425
+ }, ["id"]),
426
+ rest: (a) => ({ method: "PATCH", path: "/api/me/pages", body: a }),
427
+ },
428
+ {
429
+ name: "list_initiatives",
430
+ title: "List Initiatives",
431
+ examples: [{ description: "Show the strategic initiatives and what rolls up to each.", arguments: {} }],
432
+ kind: "rest",
433
+ description: "List the org's initiatives — the strategic layer between goals and features (goal → initiative → feature → epic → release). Each returns its name, status, timeframe, the objective it rolls up to (if any), and its linked-feature count. Read-only; empty when none. Resolve an initiative id here before create_feature / update_feature (initiative_id) or update_initiative.",
434
+ inputSchema: obj({ product_id: s("Only initiatives for this product, from whoami (optional; all products when omitted).") }),
435
+ rest: (a) => ({ method: "GET", path: `/api/me/initiatives${qs({ product_id: str(a.product_id) })}` }),
436
+ },
437
+ {
438
+ name: "create_initiative",
439
+ title: "Create Initiative",
440
+ examples: [{ description: "Add an initiative under a goal.", arguments: { name: "Win enterprise", objective_id: "obj_123", timeframe: "H2 2026" } }],
441
+ kind: "rest",
442
+ annotations: WRITE_CREATE,
443
+ description: "Create an initiative — a strategic effort that groups features and rolls up to a goal — and return it. Link it to a goal with objective_id (from list_objectives) to build line-of-sight. status ∈ planned|active|paused|done|abandoned (default planned); timeframe is free text ('H2 2026'). product_id defaults to the primary product. Only name is required. Then align features to it via create_feature / update_feature (initiative_id).",
444
+ inputSchema: obj({
445
+ name: s("Initiative name (the only required field), e.g. 'Win enterprise'."),
446
+ description: s("What the initiative is / why it matters (optional)."),
447
+ objective_id: s("Goal this rolls up to, from list_objectives (optional; builds line-of-sight)."),
448
+ status: { type: "string", enum: ["planned", "active", "paused", "done", "abandoned"], description: "Lifecycle status (optional; default 'planned')." },
449
+ timeframe: s("Free-text timeframe, e.g. 'Q3 2026' or 'H2 2026' (optional)."),
450
+ product_id: s("Product to scope it to, from whoami (optional; the primary product when omitted)."),
451
+ }, ["name"]),
452
+ rest: (a) => ({ method: "POST", path: "/api/me/initiatives", body: a }),
453
+ },
454
+ {
455
+ name: "update_initiative",
456
+ title: "Update Initiative",
457
+ examples: [{ description: "Move an initiative to active.", arguments: { id: "init_123", status: "active" } }],
458
+ kind: "rest",
459
+ annotations: WRITE_UPDATE,
460
+ description: "Update an initiative and return it; omitted fields are unchanged. Re-point it to a different goal with objective_id (null unlinks). status ∈ planned|active|paused|done|abandoned. Resolve the id via list_initiatives; only id is required.",
461
+ inputSchema: obj({
462
+ id: s("Initiative id, from list_initiatives (required)."),
463
+ name: s("New name (optional)."),
464
+ description: s("New description; null clears it (optional)."),
465
+ objective_id: s("New parent goal, from list_objectives; null unlinks (optional)."),
466
+ status: { type: "string", enum: ["planned", "active", "paused", "done", "abandoned"], description: "Lifecycle status (optional)." },
467
+ timeframe: s("New timeframe; null clears it (optional)."),
468
+ }, ["id"]),
469
+ rest: (a) => ({ method: "PATCH", path: `/api/me/initiatives/${encodeURIComponent(str(a.id) ?? "")}`, body: a }),
470
+ },
471
+ {
472
+ name: "list_ideas",
473
+ title: "List Ideas",
474
+ examples: [{ description: "What are the top-voted ideas?", arguments: {} }],
475
+ kind: "rest",
476
+ description: "List the org's ideas — the native, votable idea backlog — ranked by vote count (highest first). Each returns its title, status, vote count, author, and the feature it was promoted to (if any). status ∈ new|under_review|planned|promoted|declined (optional filter). Read-only; empty when none. Ideas are distinct from insights: an idea is a proposal a team votes on; an insight is a piece of customer evidence. Resolve an idea id here before update_idea / vote_idea / promote_idea.",
477
+ inputSchema: obj({
478
+ status: { type: "string", enum: ["new", "under_review", "planned", "promoted", "declined"], description: "Filter by status (optional)." },
479
+ product_id: s("Only ideas for this product, from whoami (optional)."),
480
+ }),
481
+ rest: (a) => ({ method: "GET", path: `/api/me/ideas${qs({ status: str(a.status), product_id: str(a.product_id) })}` }),
482
+ },
483
+ {
484
+ name: "create_idea",
485
+ title: "Create Idea",
486
+ examples: [{ description: "Capture an idea born from customer feedback.", arguments: { title: "Bulk-edit tasks", body: "Several accounts asked to change status on many tasks at once." } }],
487
+ kind: "rest",
488
+ annotations: WRITE_CREATE,
489
+ description: "Create an idea in the backlog and return it (starts with 0 votes, status 'new'). Link the evidence it came from with insight_id (from list_insights). product_id defaults to the primary product. Only title is required. Grow it with vote_idea, then promote_idea turns the winner into a roadmap feature.",
490
+ inputSchema: obj({
491
+ title: s("Idea title (the only required field), e.g. 'Bulk-edit tasks'."),
492
+ body: s("The idea in more detail (optional)."),
493
+ insight_id: s("Customer insight this idea came from, from list_insights (optional; welds evidence to the idea)."),
494
+ product_id: s("Product to scope it to, from whoami (optional; the primary product when omitted)."),
495
+ }, ["title"]),
496
+ rest: (a) => ({ method: "POST", path: "/api/me/ideas", body: a }),
497
+ },
498
+ {
499
+ name: "update_idea",
500
+ title: "Update Idea",
501
+ examples: [{ description: "Move an idea under review.", arguments: { id: "idea_123", status: "under_review" } }],
502
+ kind: "rest",
503
+ annotations: WRITE_UPDATE,
504
+ description: "Update an idea's title / body / status and return it; omitted fields unchanged. status ∈ new|under_review|planned|promoted|declined (set 'promoted' via promote_idea instead, so a feature is actually created). Resolve the id via list_ideas; only id is required.",
505
+ inputSchema: obj({
506
+ id: s("Idea id, from list_ideas (required)."),
507
+ title: s("New title (optional)."),
508
+ body: s("New body; null clears it (optional)."),
509
+ status: { type: "string", enum: ["new", "under_review", "planned", "promoted", "declined"], description: "New status (optional; prefer promote_idea over setting 'promoted' by hand)." },
510
+ }, ["id"]),
511
+ rest: (a) => ({ method: "PATCH", path: `/api/me/ideas/${encodeURIComponent(str(a.id) ?? "")}`, body: a }),
512
+ },
513
+ {
514
+ name: "vote_idea",
515
+ title: "Vote on Idea",
516
+ examples: [{ description: "Upvote an idea.", arguments: { id: "idea_123" } }],
517
+ kind: "rest",
518
+ annotations: WRITE_UPDATE,
519
+ description: "Cast (or remove) the connected member's vote on an idea and return the new vote state. Adds your vote by default; pass remove:true to take it back. One vote per member — voting twice is a no-op. Resolve the id via list_ideas; only id is required.",
520
+ inputSchema: obj({
521
+ id: s("Idea id, from list_ideas (required)."),
522
+ remove: { type: "boolean", description: "true removes your vote instead of adding it (optional; default false)." },
523
+ }, ["id"]),
524
+ rest: (a) => ({
525
+ method: a.remove === true ? "DELETE" : "POST",
526
+ path: `/api/me/ideas/${encodeURIComponent(str(a.id) ?? "")}/vote`,
527
+ }),
528
+ },
529
+ {
530
+ name: "promote_idea",
531
+ title: "Promote Idea to Feature",
532
+ examples: [{ description: "Turn the top idea into a roadmap feature.", arguments: { id: "idea_123" } }],
533
+ kind: "rest",
534
+ annotations: WRITE_CREATE,
535
+ description: "Promote an idea into a roadmap feature: creates a feature from the idea (name + description), stamps the idea 'promoted' and links it to the new feature, and returns the feature id. Idempotent — an already-promoted idea returns its existing feature. Resolve the id via list_ideas; only id is required. Align the new feature to an initiative/goal afterwards with update_feature.",
536
+ inputSchema: obj({
537
+ id: s("Idea id to promote, from list_ideas (required)."),
538
+ product_id: s("Product to create the feature under, from whoami (optional; the idea's product or the primary when omitted)."),
539
+ }, ["id"]),
540
+ rest: (a) => ({
541
+ method: "POST",
542
+ path: `/api/me/ideas/${encodeURIComponent(str(a.id) ?? "")}/promote`,
543
+ body: a,
544
+ }),
545
+ },
546
+ {
547
+ name: "create_release",
548
+ title: "Create Release",
549
+ examples: [{ description: "Log a shipped release.", arguments: { version: "v2.4.0", changelog: "SSO + faster board", released_at: "2026-07-15T00:00:00Z" } }],
550
+ kind: "rest",
551
+ annotations: WRITE_CREATE,
552
+ description: "Create a release and return it (id, version, changelog, released_at). Omit released_at for an unreleased/draft entry. product_id defaults to the org's primary product. Only version is required.",
553
+ inputSchema: obj({
554
+ version: s("Version string (required), e.g. 'v2.4.0'."),
555
+ changelog: s("What shipped (optional)."),
556
+ released_at: s("Ship time, ISO 8601 (optional; omit for a draft)."),
557
+ product_id: s("Product, from whoami (optional; the primary product when omitted)."),
558
+ }, ["version"]),
559
+ rest: (a) => ({ method: "POST", path: "/api/me/releases", body: a }),
560
+ },
561
+ {
562
+ name: "update_release",
563
+ title: "Update Release",
564
+ examples: [{ description: "Mark a release shipped.", arguments: { id: "rel_123", released_at: "2026-07-15T12:00:00Z" } }],
565
+ kind: "rest",
566
+ annotations: WRITE_UPDATE,
567
+ description: "Update a release and return it; omitted fields unchanged. Set released_at to ship it (or null to move it back to draft). Resolve the id via list_releases; only id is required.",
568
+ inputSchema: obj({
569
+ id: s("Release id, from list_releases (required)."),
570
+ version: s("New version (optional)."),
571
+ changelog: s("New changelog; null clears it (optional)."),
572
+ released_at: s("Ship time ISO 8601, or null for draft (optional)."),
573
+ }, ["id"]),
574
+ rest: (a) => ({ method: "PATCH", path: "/api/me/releases", body: a }),
575
+ },
576
+ {
577
+ name: "create_experiment",
578
+ title: "Create Experiment",
579
+ examples: [{ description: "Start a hypothesis.", arguments: { title: "Shorter onboarding lifts activation", metric: "activation rate", target: "+5pp" } }],
580
+ kind: "rest",
581
+ annotations: WRITE_CREATE,
582
+ description: "Create a PM experiment (a Build-Measure-Learn hypothesis) and return it. state is 'hypothesis' (default) | 'build' | 'measure' | 'learn'. Only title is required. This is the PM tracker list_experiments reads, not the analytics A/B engine.",
583
+ inputSchema: obj({
584
+ title: s("Experiment title / the hypothesis in a line (required)."),
585
+ hypothesis: s("The full hypothesis (optional)."),
586
+ metric: s("The metric it moves, e.g. 'activation rate' (optional)."),
587
+ target: s("Target change, e.g. '+5pp' (optional)."),
588
+ state: { type: "string", enum: ["hypothesis", "build", "measure", "learn"], description: "Build-Measure-Learn stage (optional; default 'hypothesis')." },
589
+ product_id: s("Product, from whoami (optional)."),
590
+ }, ["title"]),
591
+ rest: (a) => ({ method: "POST", path: "/api/me/experiments", body: a }),
592
+ },
593
+ {
594
+ name: "update_experiment",
595
+ title: "Update Experiment",
596
+ examples: [{ description: "Record the result.", arguments: { id: "exp_123", state: "learn", verdict: "validated", decision: "persevere" } }],
597
+ kind: "rest",
598
+ annotations: WRITE_UPDATE,
599
+ description: "Update a PM experiment — advance its state and record the outcome — and return it. state ∈ hypothesis|build|measure|learn; verdict ∈ validated|invalidated; decision ∈ pivot|persevere. Resolve the id via list_experiments; only id is required.",
600
+ inputSchema: obj({
601
+ id: s("Experiment id, from list_experiments (required)."),
602
+ title: s("New title (optional)."),
603
+ hypothesis: s("New hypothesis; null clears it (optional)."),
604
+ metric: s("New metric; null clears it (optional)."),
605
+ target: s("New target; null clears it (optional)."),
606
+ state: { type: "string", enum: ["hypothesis", "build", "measure", "learn"], description: "Build-Measure-Learn stage (optional)." },
607
+ verdict: { type: "string", enum: ["validated", "invalidated"], description: "Outcome (optional)." },
608
+ decision: { type: "string", enum: ["pivot", "persevere"], description: "What you'll do next (optional)." },
609
+ result: s("Free-text result / what you learned; null clears it (optional)."),
610
+ }, ["id"]),
611
+ rest: (a) => ({ method: "PATCH", path: "/api/me/experiments", body: a }),
612
+ },
613
+ {
614
+ name: "list_decisions",
615
+ title: "List Decisions",
616
+ examples: [{ description: "What have we decided recently?", arguments: {} }],
617
+ kind: "rest",
618
+ description: "List the org's logged decisions — title, rationale, status, and any linked feature/release/objective — newest first. Returns an empty list when none. Optional status filter (decided | proposed | revisit). Resolve a decision id here before update_decision.",
619
+ inputSchema: obj({
620
+ status: { type: "string", enum: ["decided", "proposed", "revisit"], description: "Filter by status (optional)." },
621
+ }),
622
+ rest: (a) => ({ method: "GET", path: `/api/me/decisions${qs({ status: str(a.status) })}` }),
623
+ },
624
+ {
625
+ name: "create_decision",
626
+ title: "Log Decision",
627
+ examples: [{ description: "Record a decision linked to a feature.", arguments: { title: "Ship SSO before mobile", rationale: "Enterprise deals need it", link_type: "feature", link_id: "feat_123" } }],
628
+ kind: "rest",
629
+ annotations: WRITE_CREATE,
630
+ description: "Log a decision and return it. status is 'decided' (default) | 'proposed' | 'revisit'; a 'decided' one stamps the decision time. Optionally weld it to a feature / release / objective via link_type + link_id (verified in-org). Only title is required.",
631
+ inputSchema: obj({
632
+ title: s("The decision in a line (required)."),
633
+ rationale: s("Why — the reasoning (optional)."),
634
+ status: { type: "string", enum: ["decided", "proposed", "revisit"], description: "Decision status (optional; default 'decided')." },
635
+ link_type: { type: "string", enum: ["feature", "release", "objective"], description: "What it's linked to (optional; pair with link_id)." },
636
+ link_id: s("Id of the linked feature/release/objective, from list_features / list_releases / list_objectives (optional)."),
637
+ }, ["title"]),
638
+ rest: (a) => ({ method: "POST", path: "/api/me/decisions", body: a }),
639
+ },
640
+ {
641
+ name: "update_decision",
642
+ title: "Update Decision",
643
+ examples: [{ description: "Revisit a decision.", arguments: { id: "dec_123", status: "revisit" } }],
644
+ kind: "rest",
645
+ annotations: WRITE_UPDATE,
646
+ description: "Update a decision and return it; omitted fields unchanged. Moving status to 'decided' re-stamps the decision time. Re-link via link_type + link_id (verified in-org), or clear with nulls. Resolve the id via list_decisions; only id is required.",
647
+ inputSchema: obj({
648
+ id: s("Decision id, from list_decisions (required)."),
649
+ title: s("New title (optional)."),
650
+ rationale: s("New rationale; null clears it (optional)."),
651
+ status: { type: "string", enum: ["decided", "proposed", "revisit"], description: "New status (optional)." },
652
+ link_type: { type: "string", enum: ["feature", "release", "objective"], description: "New link target (optional)." },
653
+ link_id: s("New linked id, or null to unlink (optional)."),
654
+ }, ["id"]),
655
+ rest: (a) => ({ method: "PATCH", path: "/api/me/decisions", body: a }),
656
+ },
657
+ {
658
+ name: "delete_task",
659
+ title: "Delete Task",
660
+ examples: [{ description: "Permanently delete a task.", arguments: { id: "task_123" } }],
661
+ kind: "rest",
662
+ annotations: WRITE_DELETE,
663
+ description: "PERMANENTLY delete a task and return the deleted id. Irreversible — there is no undo. Cascades: the task's comments, assignees, tags, attachments, time entries, outcomes, events, relations, and its SUBTASKS are deleted with it; experiment/insight/meeting links to it are cleared. Resolve the id via list_tasks and confirm intent first — prefer update_task (e.g. move it to a done/archived status) when you only want it off the active board.",
664
+ inputSchema: obj({ id: s("Task id to permanently delete, from list_tasks (required).") }, ["id"]),
665
+ rest: (a) => ({ method: "DELETE", path: `/api/pm/tasks/${encodeURIComponent(String(a.id))}` }),
666
+ },
667
+ {
668
+ name: "post_to_channel",
669
+ title: "Post to Channel",
670
+ examples: [{ description: "Post an update to a team channel.", arguments: { channel_id: "chan_123", body: "Shipped SSO — closing the epic." } }],
671
+ kind: "rest",
672
+ annotations: WRITE_CREATE,
673
+ description: "Post a message to a team Comms channel you belong to, as the connected member, and return the posted message. It appears live for teammates and is org-visible — keep it work-relevant. Resolve channel_id via list_channels; you can only post to channels you're a member of. Both channel_id and body are required.",
674
+ inputSchema: obj({
675
+ channel_id: s("Channel id to post into, from list_channels (required)."),
676
+ body: s("Message text, visible to all channel members (required)."),
677
+ }, ["channel_id", "body"]),
678
+ rest: (a) => ({ method: "POST", path: "/api/me/comms", body: { action: "post", channel_id: a.channel_id, body: a.body } }),
679
+ },
680
+ {
681
+ name: "reply_in_channel",
682
+ title: "Reply in Channel Thread",
683
+ examples: [{ description: "Reply in a thread under a teammate's message.", arguments: { channel_id: "chan_123", parent_id: "msg_9", body: "On it — PR up in an hour." } }],
684
+ kind: "rest",
685
+ annotations: WRITE_CREATE,
686
+ description: "Reply in a thread under a specific message in a Comms channel you belong to, as the connected member, and return the posted reply — org-visible to the channel. Resolve channel_id via list_channels and the parent message's id via read_channel. channel_id, parent_id, and body are all required.",
687
+ inputSchema: obj({
688
+ channel_id: s("Channel id, from list_channels (required)."),
689
+ parent_id: s("Parent message id to thread under, from read_channel (required)."),
690
+ body: s("Reply text, visible to all channel members (required)."),
691
+ }, ["channel_id", "parent_id", "body"]),
692
+ rest: (a) => ({ method: "POST", path: "/api/me/comms", body: { action: "reply", channel_id: a.channel_id, parent_id: a.parent_id, body: a.body } }),
693
+ },
694
+ {
695
+ name: "reply_to_conversation",
696
+ title: "Reply to Support Conversation",
697
+ examples: [{ description: "Send a public reply to a support conversation.", arguments: { conversation_id: "conv_42", body: "Thanks for flagging — the export bug is fixed and live." } }],
698
+ kind: "rest",
699
+ annotations: WRITE_CREATE,
700
+ description: "Send a public reply to a support-inbox conversation, as the connected member, and return the result — it goes to the end-user on the conversation's channel. A write. Resolve conversation_id via list_conversations; read the thread with get_conversation before replying. conversation_id and body are required. For an internal-only note use add_note; to close it use resolve_conversation.",
701
+ inputSchema: obj({
702
+ conversation_id: s("Conversation id to reply to, from list_conversations (required)."),
703
+ body: s("Public reply text sent to the end-user (required)."),
704
+ }, ["conversation_id", "body"]),
705
+ rest: (a) => ({ method: "POST", path: "/api/me/inbox", body: { action: "reply", conversation_id: a.conversation_id, body: a.body } }),
706
+ },
707
+ {
708
+ name: "add_note",
709
+ title: "Add Internal Note",
710
+ examples: [{ description: "Leave an internal note on a conversation for teammates.", arguments: { conversation_id: "conv_42", body: "Repro'd — linking to feat_sso." } }],
711
+ kind: "rest",
712
+ annotations: WRITE_CREATE,
713
+ description: "Add an INTERNAL note to a support conversation — visible only to your team, never sent to the end-user — and return the result. A write. Use it to record context or hand off; resolve conversation_id via list_conversations. conversation_id and body are required. For a public reply use reply_to_conversation instead.",
714
+ inputSchema: obj({
715
+ conversation_id: s("Conversation id to note on, from list_conversations (required)."),
716
+ body: s("Internal note text, teammates-only (required)."),
717
+ }, ["conversation_id", "body"]),
718
+ rest: (a) => ({ method: "POST", path: "/api/me/inbox", body: { action: "note", conversation_id: a.conversation_id, body: a.body } }),
719
+ },
720
+ {
721
+ name: "resolve_conversation",
722
+ title: "Resolve Conversation",
723
+ examples: [{ description: "Mark a support conversation resolved.", arguments: { conversation_id: "conv_42" } }],
724
+ kind: "rest",
725
+ annotations: WRITE_UPDATE,
726
+ description: "Mark a support-inbox conversation resolved (closed) and return the result. A write; idempotent — resolving an already-resolved conversation is a no-op. Resolve conversation_id via list_conversations first. Do it after you've replied and the ask is handled.",
727
+ inputSchema: obj({ conversation_id: s("Conversation id to resolve, from list_conversations (required).") }, ["conversation_id"]),
728
+ rest: (a) => ({ method: "POST", path: "/api/me/inbox", body: { action: "resolve", conversation_id: a.conversation_id } }),
729
+ },
730
+ {
731
+ name: "cancel_booking",
732
+ title: "Cancel Booking",
733
+ examples: [{ description: "Cancel a scheduled call.", arguments: { booking_id: "book_7" } }],
734
+ kind: "rest",
735
+ annotations: WRITE_UPDATE,
736
+ description: "Cancel a scheduled booking (call/meeting) and return the result — the invitee is notified per the scheduling settings. A write. Resolve booking_id via list_bookings first; never guess it. To move it instead of cancelling, use reschedule_booking.",
737
+ inputSchema: obj({ booking_id: s("Booking id to cancel, from list_bookings (required).") }, ["booking_id"]),
738
+ rest: (a) => ({ method: "POST", path: "/api/me/scheduling", body: { action: "cancel", booking_id: a.booking_id } }),
739
+ },
740
+ {
741
+ name: "reschedule_booking",
742
+ title: "Reschedule Booking",
743
+ examples: [{ description: "Move a call to a new start time.", arguments: { booking_id: "book_7", start: "2026-07-20T15:00:00Z" } }],
744
+ kind: "rest",
745
+ annotations: WRITE_UPDATE,
746
+ description: "Reschedule a booking to a new start time (ISO 8601) and return the result — the invitee is notified. A write. Resolve booking_id via list_bookings first. booking_id and start are required.",
747
+ inputSchema: obj({
748
+ booking_id: s("Booking id to move, from list_bookings (required)."),
749
+ start: s("New start time, ISO 8601 (e.g. 2026-07-20T15:00:00Z) (required)."),
750
+ }, ["booking_id", "start"]),
751
+ rest: (a) => ({ method: "POST", path: "/api/me/scheduling", body: { action: "reschedule", booking_id: a.booking_id, start: a.start } }),
752
+ },
753
+ {
754
+ name: "review_artifact",
755
+ title: "PRD Review",
756
+ examples: [
757
+ { description: "Review this feature spec before I send it for sign-off.", arguments: { target_type: "feature", target_id: "feat_8f3a" } },
758
+ ],
759
+ kind: "rest",
760
+ annotations: WRITE_CREATE,
761
+ description: "Agent-as-critic over a DRAFT artifact (a feature spec, experiment plan, or page): checks it against a baseline PM bar — clear problem/hypothesis, a measurable success metric, evidence cited, risks named, a rollout/experiment plan — and returns structured findings (section, severity, a CONCRETE suggested fix, and a verbatim evidence quote) plus a 0-100 score. A write: each call re-runs the review and persists it as a new version (see list_artifact_versions). Resolve target_id first — via pm_meta or list_features for a feature, list_experiments for an experiment, list_pages for a page. One small LLM call; use it before sending a draft for sign-off.",
762
+ inputSchema: obj({
763
+ target_id: s("Id of the feature/experiment/page to review — from pm_meta, list_features, list_experiments, or list_pages."),
764
+ target_type: { type: "string", enum: ["feature", "experiment", "page"], description: "What kind of artifact target_id is: a feature (spec), an experiment (plan), or a page (doc/PRD)." },
765
+ rubric_id: s("Score against a specific rubric; omit to use the org's default rubric (or the built-in baseline)."),
766
+ }, ["target_id", "target_type"]),
767
+ rest: (a) => ({ method: "POST", path: "/api/me/review-artifact", body: a }),
768
+ },
769
+ {
770
+ name: "list_artifact_versions",
771
+ title: "Artifact Versions",
772
+ examples: [
773
+ { description: "Show the review history for this feature.", arguments: { target_id: "feat_8f3a", target_type: "feature" } },
774
+ ],
775
+ kind: "rest",
776
+ description: "Version history of an artifact's AI reviews (F5): every review run is a version with its score, model, cost, who/what generated it, and whether it's the current one. Read-only; returns the version list, empty when the artifact has never been reviewed. Use it to see how a feature/experiment/page's review changed over time and to pick the version_id to pass to revert_to_version. Takes the same target_id/target_type you'd pass to review_artifact.",
777
+ inputSchema: obj({
778
+ target_id: s("Id of the reviewed feature/experiment/page — the same id passed to review_artifact."),
779
+ target_type: { type: "string", enum: ["feature", "experiment", "page"], description: "What kind of artifact target_id is: a feature (spec), an experiment (plan), or a page (doc/PRD)." },
780
+ }, ["target_id", "target_type"]),
781
+ rest: (a) => ({
782
+ method: "GET",
783
+ path: `/api/me/artifact-versions${qs({ target_id: str(a.target_id), target_type: str(a.target_type) })}`,
784
+ }),
785
+ },
786
+ {
787
+ name: "revert_to_version",
788
+ title: "Revert Artifact Version",
789
+ examples: [
790
+ { description: "The latest review is worse — revert to the previous one.", arguments: { version_id: "ver_8f3a", reason: "new review missed the metric finding" } },
791
+ ],
792
+ kind: "rest",
793
+ annotations: WRITE_CREATE,
794
+ description: "Restore an earlier artifact version (F5) to current and return the now-current version: the existing current version is flipped to 'reverted' (kept for the learning signal) and the chosen version becomes current again. A write — not idempotent, since re-running reverts again. version_id is the version you want to RESTORE; get it from list_artifact_versions and never guess it. Optional reason is recorded.",
795
+ inputSchema: obj({
796
+ version_id: s("Id of the version to restore (make current), from list_artifact_versions."),
797
+ reason: s("Why you're reverting (optional, recorded)."),
798
+ }, ["version_id"]),
799
+ rest: (a) => ({ method: "POST", path: "/api/me/revert-version", body: a }),
800
+ },
801
+ {
802
+ name: "update_task",
803
+ title: "Update Task",
804
+ examples: [{ description: "Move this task to the In Progress status.", arguments: { id: "task_8f3a", status_id: "status_in_progress" } }, { description: "Schedule this task into the next sprint.", arguments: { id: "task_8f3a", sprint_id: "sprint_2026_w27" } }],
805
+ kind: "rest",
806
+ annotations: WRITE_UPDATE,
807
+ description: "Update one or more of a task's fields and return the updated task; fields you omit are left unchanged (idempotent — re-sending the same values is a no-op). Pass sprint_id: null to remove the task from its sprint. Resolve ids first — the task via get_task/list_tasks, and status/feature/insight/sprint/member ids via pm_meta and the list_* reads — never guess them. Only id is required.",
808
+ inputSchema: obj({
809
+ id: s("Task id, from list_tasks or get_task."),
810
+ title: s("New title (optional; omitted fields stay unchanged)."),
811
+ description: s("New body / details (optional)."),
812
+ priority: { type: "string", enum: ["urgent", "high", "normal", "low"], description: "New priority level, urgent highest (optional)." },
813
+ status_id: s("New status; resolve the id via pm_meta (optional)."),
814
+ feature_id: s("Feature id to link on the spine, from pm_meta or list_features (optional)."),
815
+ insight_id: s("Insight id to link on the spine, from list_insights (optional)."),
816
+ sprint_id: s("Move into a sprint, or null to remove (optional; resolve via list_sprints)."),
817
+ assignee_member_ids: { type: "array", items: { type: "string" }, description: "Member ids to assign, from pm_meta (optional)." },
818
+ }, ["id"]),
819
+ rest: (a) => {
820
+ const { id, ...body } = a;
821
+ return { method: "PATCH", path: `/api/pm/tasks/${encodeURIComponent(str(id) ?? "")}`, body };
822
+ },
823
+ },
824
+ {
825
+ name: "comment_on_task",
826
+ title: "Comment on Task",
827
+ examples: [{ description: "Leave a progress note on this task.", arguments: { id: "task_8f3a", body: "Shipped behind a flag — verifying in production." } }],
828
+ kind: "rest",
829
+ annotations: WRITE_CREATE,
830
+ description: "Add a comment to a task, authored as the connected member, and return the created comment. Use to record progress, a decision, or a handoff — the comment is visible to the whole org, so keep it work-relevant. Resolve the task id first with get_task or list_tasks; both id and body are required.",
831
+ inputSchema: obj({ id: s("Task id, from list_tasks or get_task."), body: s("Comment text; posted as the connected member and visible to the whole org.") }, ["id", "body"]),
832
+ rest: (a) => ({
833
+ method: "POST",
834
+ path: `/api/pm/tasks/${encodeURIComponent(str(a.id) ?? "")}/comments`,
835
+ body: { body: str(a.body) },
836
+ }),
837
+ },
838
+ {
839
+ name: "capture_insight",
840
+ title: "Capture Insight",
841
+ examples: [{ description: "Log a customer request against the account and feature it's about.", arguments: { body: "Two enterprise accounts asked for SAML this week.", kind: "opportunity", account_id: "acct_acme", feature_id: "feat_sso" } }],
842
+ kind: "rest",
843
+ annotations: WRITE_CREATE,
844
+ description: "Write a piece of customer feedback to the spine (the agent's own hand, not just reading) and return the created insight. Fires the same insight.created webhook a manual capture does — a real side-effect, so only capture genuine signal. Resolve account_id via get_customer_360 and feature_id via list_features and tie them when known; kind='opportunity' marks a prioritisable ask. Only body is required.",
845
+ inputSchema: obj({
846
+ body: s("The verbatim feedback / insight text (the only required field)."),
847
+ title: s("Short display title (optional)."),
848
+ kind: { type: "string", enum: ["insight", "opportunity"], description: "'insight' = raw signal; 'opportunity' = a prioritisable ask (optional)." },
849
+ product_id: s("Product id, from whoami (optional; the org's primary product when omitted)."),
850
+ feature_id: s("Feature id to link on the spine, from list_features or pm_meta (optional)."),
851
+ account_id: s("Account id it's about, from get_customer_360 (optional)."),
852
+ }, ["body"]),
853
+ rest: (a) => ({ method: "POST", path: "/api/me/insight", body: a }),
854
+ },
855
+ {
856
+ name: "list_conversations",
857
+ title: "List Support Conversations",
858
+ examples: [{ description: "Show the open support inbox.", arguments: {} }],
859
+ kind: "rest",
860
+ description: "List support-chat conversations in the inbox (open + snoozed by default; pass status='all' to include closed). Read-only; returns the matching conversations, empty when the inbox is clear. Optional product_id to scope to one product; open a full thread with get_conversation.",
861
+ inputSchema: obj({ product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted)."), status: { type: "string", enum: ["all"], description: "Pass 'all' to include closed (optional)." } }),
862
+ rest: (a) => ({
863
+ method: "GET",
864
+ path: `/api/me/inbox${qs({ product_id: str(a.product_id), status: str(a.status) })}`,
865
+ }),
866
+ },
867
+ {
868
+ name: "get_conversation",
869
+ title: "Read Support Conversation",
870
+ examples: [{ description: "Read the full thread for this conversation.", arguments: { conversation_id: "conv_42" } }],
871
+ kind: "rest",
872
+ description: "Read one support conversation: the visitor plus the full message thread, oldest first. Read-only. Resolve the conversation_id first with list_conversations — never guess it.",
873
+ inputSchema: obj({ conversation_id: s("Conversation id, from list_conversations.") }, ["conversation_id"]),
874
+ rest: (a) => ({
875
+ method: "GET",
876
+ path: `/api/me/inbox${qs({ conversation_id: str(a.conversation_id) })}`,
877
+ }),
878
+ },
879
+ {
880
+ name: "list_insights",
881
+ title: "Search Insights",
882
+ examples: [{ description: "Find opportunities that mention SSO.", arguments: { q: "SSO", kind: "opportunity" } }],
883
+ kind: "rest",
884
+ description: "Search the captured insight backlog (voice of customer) — the read twin of capture_insight. Read-only; returns the matching insights newest first, empty when nothing matches. Filters: status, kind (insight|opportunity), feature_id, account_id, product_id, and free-text q over title+body; limit default 50, max 200. Use it to survey the evidence behind a feature or account before prioritising — resolve feature_id via list_features and account_id via get_customer_360.",
885
+ inputSchema: obj({
886
+ q: s("Free-text search over title + body (optional)."),
887
+ status: s("Only insights in this workflow status (optional)."),
888
+ kind: { type: "string", enum: ["insight", "opportunity"], description: "'insight' = raw signal; 'opportunity' = a prioritisable ask (optional)." },
889
+ feature_id: s("Only insights linked to this feature; resolve the id via list_features or pm_meta (optional)."),
890
+ account_id: s("Only insights about this account; resolve the id via get_customer_360 (optional)."),
891
+ product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted)."),
892
+ limit: { ...n("Max rows to return (optional; default 50, max 200)."), default: 50, minimum: 1, maximum: 200 },
893
+ }),
894
+ rest: (a) => ({
895
+ method: "GET",
896
+ path: `/api/me/insights${qs({
897
+ q: str(a.q),
898
+ status: str(a.status),
899
+ kind: str(a.kind),
900
+ feature_id: str(a.feature_id),
901
+ account_id: str(a.account_id),
902
+ product_id: str(a.product_id),
903
+ limit: num(a.limit),
904
+ })}`,
905
+ }),
906
+ },
907
+ {
908
+ name: "list_features",
909
+ title: "List Features",
910
+ examples: [{ description: "Show the product's feature catalogue.", arguments: {} }],
911
+ kind: "rest",
912
+ description: "The product's feature catalogue with description, status, and when each was last touched — richer than pm_meta (which is just id+name for resolution). Read-only; returns the matching features, empty when none. Optional product_id and free-text q over name+key; use a feature id from here to link a task or insight on the spine.",
913
+ inputSchema: obj({ q: s("Free-text search over feature name + key (optional)."), product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted).") }),
914
+ rest: (a) => ({
915
+ method: "GET",
916
+ path: `/api/me/features${qs({ q: str(a.q), product_id: str(a.product_id) })}`,
917
+ }),
918
+ },
919
+ {
920
+ name: "list_objectives",
921
+ title: "List OKRs",
922
+ examples: [{ description: "Show the objectives and key results with live progress.", arguments: {} }],
923
+ kind: "rest",
924
+ description: "List the org's OKRs. Returns an array of objectives, each with its key results and live progress (0..1 between start and target), so you can prioritise toward what the team is actually trying to move. Read-only; empty when none are set. Read it before prioritising — tie proposed tasks to the objective they move, and cite the live progress when arguing priority. Optional product_id, from whoami.",
925
+ inputSchema: obj({ product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted).") }),
926
+ rest: (a) => ({ method: "GET", path: `/api/me/objectives${qs({ product_id: str(a.product_id) })}` }),
927
+ },
928
+ {
929
+ name: "list_experiments",
930
+ title: "List Experiments",
931
+ examples: [{ description: "What experiments are running and what did they conclude?", arguments: { state: "running" } }],
932
+ kind: "rest",
933
+ description: "List product experiments. Returns an array where each experiment carries its hypothesis, the metric it moves, the target, its current state, and — once concluded — the verdict and the decision that came out. Read-only; empty when none match. Use it to see what's being tested before proposing new work, and cite a concluded verdict as evidence when you create_task or review_artifact. Optional product_id (from whoami) and state filter.",
934
+ inputSchema: obj({ product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted)."), state: s("Only experiments in this state, e.g. 'running' (optional).") }),
935
+ rest: (a) => ({
936
+ method: "GET",
937
+ path: `/api/me/experiments${qs({ product_id: str(a.product_id), state: str(a.state) })}`,
938
+ }),
939
+ },
940
+ {
941
+ name: "list_releases",
942
+ title: "List Releases",
943
+ examples: [{ description: "What did we ship recently?", arguments: {} }],
944
+ kind: "rest",
945
+ description: "List shipped releases. Returns an array, newest first, where each release carries its version, changelog, and ship date. Read-only; empty when nothing has shipped. Use it to answer 'what did we ship recently?', to ground a changelog or launch summary in real ship dates, and to see what went out before reading get_roadmap_drift. Optional product_id, from whoami.",
946
+ inputSchema: obj({ product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted).") }),
947
+ rest: (a) => ({ method: "GET", path: `/api/me/releases${qs({ product_id: str(a.product_id) })}` }),
948
+ },
949
+ {
950
+ name: "list_pages",
951
+ title: "List Pages",
952
+ examples: [{ description: "List the docs and PRDs on the spine.", arguments: {} }],
953
+ kind: "rest",
954
+ description: "List the in-product docs / PRDs (Pages) on the spine. Returns an array of pages with title + id only — no content, so it stays cheap to scan. Read-only; empty when none exist. Use it to find the page id, then read the full content with get_page; pair with review_artifact (target_type 'page') to critique a draft PRD. Optional product_id, from whoami.",
955
+ inputSchema: obj({ product_id: s("Product id to scope to, from whoami (optional; spans all products when omitted).") }),
956
+ rest: (a) => ({ method: "GET", path: `/api/me/pages${qs({ product_id: str(a.product_id) })}` }),
957
+ },
958
+ {
959
+ name: "get_page",
960
+ title: "Read Page",
961
+ examples: [{ description: "Read this PRD's full content.", arguments: { id: "page_12" } }],
962
+ kind: "rest",
963
+ description: "Read one Page (doc / PRD) by id and return its full content. Read-only. Resolve the id first with list_pages — never guess it.",
964
+ inputSchema: obj({ id: s("Page id, from list_pages.") }, ["id"]),
965
+ rest: (a) => ({ method: "GET", path: `/api/me/pages${qs({ id: str(a.id) })}` }),
966
+ },
967
+ {
968
+ name: "get_codebase_map",
969
+ title: "Codebase Map",
970
+ examples: [{ description: "Where in the code does the billing logic live?", arguments: {} }],
971
+ kind: "rest",
972
+ description: "The auto-generated codebase brain map for one product: a plain-language summary, the module/node/edge counts, when the map was last generated, and the labels of the modules it found. Read-only; returns the latest generated map, empty when none has been generated for the product yet. Use it to ground 'where in the code does X live?' questions and to see how the codebase splits into modules before discussing architecture or scoping engineering work. Optional product_id, from whoami; omit for the org's primary product.",
973
+ inputSchema: obj({ product_id: s("Product id, from whoami (optional; the org's primary product when omitted).") }),
974
+ rest: (a) => ({ method: "GET", path: `/api/me/codebase${qs({ product_id: str(a.product_id) })}` }),
975
+ },
976
+ {
977
+ name: "list_sprints",
978
+ title: "List Sprints",
979
+ examples: [{ description: "Show the active sprint and recent ones.", arguments: { state: "active" } }],
980
+ kind: "rest",
981
+ description: "Sprints — name, goal, state, and window, newest first. Read-only; returns the matching sprints, empty when none exist. See the delivery cadence (active + recent), and resolve a sprint_id here before scheduling a task via create_task / update_task. Optional state filter (e.g. 'active').",
982
+ inputSchema: obj({ state: s("Filter by state, e.g. 'active' (optional).") }),
983
+ rest: (a) => ({ method: "GET", path: `/api/me/sprints${qs({ state: str(a.state) })}` }),
984
+ },
985
+ {
986
+ name: "list_channels",
987
+ title: "List Comms Channels",
988
+ examples: [{ description: "Which team channels do I belong to?", arguments: {} }],
989
+ kind: "rest",
990
+ description: "List the team Comms channels the connected member belongs to (membership-scoped). Read-only; returns the member's channels, empty when they belong to none. Call read_channel with a channel_id to read one.",
991
+ inputSchema: obj({}),
992
+ rest: () => ({ method: "GET", path: "/api/me/comms" }),
993
+ },
994
+ {
995
+ name: "read_channel",
996
+ title: "Read Comms Channel",
997
+ examples: [{ description: "Show the recent messages in this channel.", arguments: { channel_id: "chan_eng", limit: 50 } }],
998
+ kind: "rest",
999
+ description: "Read a Comms channel's recent messages, newest included (the connected member must be a channel member). Read-only; returns the messages, empty when the channel is silent. Resolve channel_id first with list_channels — never guess it. Optional limit.",
1000
+ inputSchema: obj({ channel_id: s("Channel id, from list_channels."), limit: n("Max messages to return (optional).") }, ["channel_id"]),
1001
+ rest: (a) => ({
1002
+ method: "GET",
1003
+ path: `/api/me/comms${qs({ channel_id: str(a.channel_id), limit: num(a.limit) })}`,
1004
+ }),
1005
+ },
1006
+ {
1007
+ name: "list_bookings",
1008
+ title: "List Bookings",
1009
+ examples: [{ description: "Show upcoming confirmed bookings.", arguments: {} }],
1010
+ kind: "rest",
1011
+ description: "Upcoming confirmed bookings on the org's scheduling. Read-only; returns the bookings, empty when none are scheduled. Pass include='all' for full history.",
1012
+ inputSchema: obj({ include: { type: "string", enum: ["all"], description: "Pass 'all' for history (optional)." } }),
1013
+ rest: (a) => ({ method: "GET", path: `/api/me/scheduling${qs({ include: str(a.include) })}` }),
1014
+ },
1015
+ // ---------------------------------------------------------------------------
1016
+ // Identity Resolution v2 — device-graph + end-user merge
1017
+ // ---------------------------------------------------------------------------
1018
+ {
1019
+ name: "get_device_candidates",
1020
+ title: "Device-Graph Identity Candidates",
1021
+ examples: [{ description: "Show end-users that share a device fingerprint.", arguments: {} }],
1022
+ kind: "rest",
1023
+ description: "Clusters of ≥2 end_users seen on the same device: 'anon_bridge' (high confidence — an anonymous visitor later identified) or 'device_shared' (low confidence — review only). Read-only; returns the candidate clusters, empty when none are found. Use it to find merge targets, then act with merge_end_users.",
1024
+ inputSchema: obj({}),
1025
+ rest: () => ({ method: "GET", path: "/api/me/identity" }),
1026
+ },
1027
+ {
1028
+ name: "list_identity_merges",
1029
+ title: "Identity Merge History",
1030
+ examples: [{ description: "Show recent end-user merges so I can undo the wrong one.", arguments: {} }],
1031
+ kind: "rest",
1032
+ description: "List the org's end-user merge history. Returns an array of merge events, newest first, where each carries its event id, kind (merge or unmerge), the target and source end-user ids, the reason, who ran it, when, and — for merges — whether it has already been reverted. Read-only; empty when no merges have ever run. Use it to audit identity changes and to find the event id to pass to unmerge_end_users (only un-reverted merges can be undone).",
1033
+ inputSchema: obj({ limit: { ...n("Max events to return (optional; default 50, max 200)."), default: 50, minimum: 1, maximum: 200 } }),
1034
+ rest: (a) => ({
1035
+ method: "GET",
1036
+ path: `/api/me/identity${qs({ view: "merges", limit: num(a.limit)?.toString() })}`,
1037
+ }),
1038
+ },
1039
+ {
1040
+ name: "merge_end_users",
1041
+ title: "Merge End-Users",
1042
+ examples: [{ description: "Merge two end-users that share a device into one.", arguments: { target_end_user_id: "eu_keep", source_end_user_ids: ["eu_discard"], reason: "device:anon_bridge" } }],
1043
+ kind: "rest",
1044
+ annotations: WRITE_CREATE,
1045
+ description: "Merge source end-users into a target and return the merge result, including the merge event id (also recoverable later via list_identity_merges): all FK rows (events, insights, tasks, …) are re-pointed onto the target and the sources are tombstoned. A write; reversible for 30 days via unmerge_end_users. Get the candidate ids from get_device_candidates first — never guess which users to fold together. target_end_user_id and source_end_user_ids are required.",
1046
+ inputSchema: obj({
1047
+ target_end_user_id: s("UUID of the end-user to keep, from get_device_candidates."),
1048
+ source_end_user_ids: { type: "array", items: { type: "string" }, description: "UUIDs of end-users to fold into the target, from get_device_candidates." },
1049
+ reason: s("Why the merge (optional, recorded)."),
1050
+ }, ["target_end_user_id", "source_end_user_ids"]),
1051
+ rest: (a) => ({
1052
+ method: "POST",
1053
+ path: "/api/me/identity",
1054
+ body: { action: "merge_end_users", target_end_user_id: str(a.target_end_user_id), source_end_user_ids: a.source_end_user_ids, reason: str(a.reason) },
1055
+ }),
1056
+ },
1057
+ {
1058
+ name: "unmerge_end_users",
1059
+ title: "Undo End-User Merge",
1060
+ examples: [{ description: "Undo a recent end-user merge.", arguments: { event_id: "uuid" } }],
1061
+ kind: "rest",
1062
+ // WRITE_UPDATE, not WRITE_CREATE: it restores a target state and re-running
1063
+ // is a no-op — the idempotentHint must agree with the description.
1064
+ annotations: WRITE_UPDATE,
1065
+ description: "Undo a previous end-user merge: reads the merge ledger and re-points every FK row (events, insights, tasks, …) back to its original end-user, un-tombstoning the folded-in sources. Safe to retry — a second undo of the same merge changes nothing (it fails with already_reverted). Use to correct a wrong identity merge (merges stay reversible for 30 days). Find the event_id with list_identity_merges (pick an un-reverted merge); event_id is required.",
1066
+ inputSchema: obj({ event_id: s("Id of the merge event to undo, from list_identity_merges.") }, ["event_id"]),
1067
+ rest: (a) => ({
1068
+ method: "POST",
1069
+ path: "/api/me/identity",
1070
+ body: { action: "unmerge_end_users", event_id: str(a.event_id) },
1071
+ }),
1072
+ },
1073
+ ];
1074
+ export const MCP_TOOLS_BY_NAME = Object.fromEntries(MCP_TOOLS.map((t) => [t.name, t]));