@nonbot/mcp 0.0.0-stage → 0.2.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,521 @@
1
+ import { z } from 'zod';
2
+ import { api, expectJson } from '../http.js';
3
+ import { validateGraph } from '../validate.js';
4
+ const DEFAULT_PAGE_SIZE = 25;
5
+ const MAX_PAGE_SIZE = 100;
6
+ function text(obj) {
7
+ return { type: 'text', text: JSON.stringify(obj, null, 2) };
8
+ }
9
+ export function registerPortfolioTools(server, ctx) {
10
+ server.registerTool('portfolio_list', {
11
+ title: 'List portfolios',
12
+ description: 'List the portfolios the caller can access (owned, team, and shared). Supports filtering by status/team and a case-insensitive substring match on the name, plus cursor pagination so large workspaces stay within the token budget. Read-only — safe to call freely.',
13
+ inputSchema: {
14
+ status: z.string().optional().describe('Only return portfolios with this status (e.g. "active", "draft").'),
15
+ teamId: z.string().optional().describe('Only return portfolios owned by this team id.'),
16
+ nameContains: z.string().optional().describe('Case-insensitive substring match on the portfolio name.'),
17
+ limit: z
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .max(MAX_PAGE_SIZE)
22
+ .optional()
23
+ .describe(`Max results to return (default ${DEFAULT_PAGE_SIZE}, max ${MAX_PAGE_SIZE}).`),
24
+ cursor: z.string().optional().describe('Opaque pagination cursor — pass the nextCursor returned by a previous call.'),
25
+ },
26
+ outputSchema: {
27
+ count: z.number().describe('Number of portfolios in this page.'),
28
+ total: z.number().describe('Total matching portfolios across all pages.'),
29
+ nextCursor: z.string().optional().describe('Pass to the next call to fetch the following page. Absent when there are no more.'),
30
+ portfolios: z.array(z.object({
31
+ id: z.string(),
32
+ name: z.string(),
33
+ description: z.string().optional(),
34
+ status: z.string(),
35
+ updatedAt: z.string().optional(),
36
+ teamId: z.string().optional(),
37
+ })),
38
+ },
39
+ annotations: { readOnlyHint: true, openWorldHint: true },
40
+ }, async (args) => {
41
+ const res = await api(ctx, '/api/portfolio/list');
42
+ const data = await expectJson(res, 'portfolio_list');
43
+ let rows = (data.portfolios ?? []).map((p) => ({
44
+ id: String(p.id),
45
+ name: String(p.name ?? ''),
46
+ description: p.description != null ? String(p.description) : undefined,
47
+ status: String(p.status ?? 'unknown'),
48
+ updatedAt: p.updatedAt != null ? String(p.updatedAt) : p.updated_at != null ? String(p.updated_at) : undefined,
49
+ teamId: p.teamId != null ? String(p.teamId) : p.team_id != null ? String(p.team_id) : undefined,
50
+ }));
51
+ if (args.status)
52
+ rows = rows.filter((r) => r.status === args.status);
53
+ if (args.teamId)
54
+ rows = rows.filter((r) => r.teamId === args.teamId);
55
+ if (args.nameContains) {
56
+ const q = args.nameContains.toLowerCase();
57
+ rows = rows.filter((r) => r.name.toLowerCase().includes(q));
58
+ }
59
+ rows.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''));
60
+ const total = rows.length;
61
+ const limit = args.limit ?? DEFAULT_PAGE_SIZE;
62
+ let offset = 0;
63
+ if (args.cursor !== undefined) {
64
+ const parsed = Number(args.cursor);
65
+ if (!Number.isInteger(parsed) || parsed < 0) {
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Invalid cursor "${args.cursor}". Pass a nextCursor returned by a previous portfolio_list call, or omit it to start from the beginning.`,
71
+ },
72
+ ],
73
+ isError: true,
74
+ };
75
+ }
76
+ offset = parsed;
77
+ }
78
+ const page = rows.slice(offset, offset + limit);
79
+ const nextOffset = offset + page.length;
80
+ const structured = {
81
+ count: page.length,
82
+ total,
83
+ ...(nextOffset < total ? { nextCursor: String(nextOffset) } : {}),
84
+ portfolios: page,
85
+ };
86
+ return { content: [text(structured)], structuredContent: structured };
87
+ });
88
+ server.registerTool('portfolio_get', {
89
+ title: 'Get a portfolio graph',
90
+ description: 'Fetch the full node/edge graph (plus viewport and version) for one portfolio. ALWAYS call this before portfolio_update so you hold the current version for optimistic-concurrency safety. The text content carries the full graph; structuredContent carries a quick summary (counts + version). Read-only.',
91
+ inputSchema: {
92
+ id: z.string().describe('Portfolio id (from portfolio_list).'),
93
+ },
94
+ outputSchema: {
95
+ id: z.string(),
96
+ name: z.string().optional(),
97
+ version: z.number().optional(),
98
+ nodeCount: z.number(),
99
+ edgeCount: z.number(),
100
+ },
101
+ annotations: { readOnlyHint: true, openWorldHint: true },
102
+ }, async (args) => {
103
+ if (!args.id) {
104
+ return { content: [{ type: 'text', text: 'id is required (get it from portfolio_list).' }], isError: true };
105
+ }
106
+ const res = await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(args.id)}`);
107
+ const data = await expectJson(res, 'portfolio_get');
108
+ const graph = (data.graph ?? data);
109
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
110
+ const edges = Array.isArray(graph.edges) ? graph.edges : [];
111
+ const structured = {
112
+ id: args.id,
113
+ ...(typeof data.name === 'string' ? { name: data.name } : {}),
114
+ ...(typeof graph.version === 'number' ? { version: graph.version } : {}),
115
+ nodeCount: nodes.length,
116
+ edgeCount: edges.length,
117
+ };
118
+ return { content: [text(data)], structuredContent: structured };
119
+ });
120
+ server.registerTool('portfolio_get_activation', {
121
+ title: 'Get an activation',
122
+ description: 'Fetch the launch payload + story-brief snapshot for one activation (Act-3 ▶ Run). Read-only.',
123
+ inputSchema: { activation_id: z.string().describe('The activation id.') },
124
+ annotations: { readOnlyHint: true, openWorldHint: true },
125
+ }, async (a) => {
126
+ const res = await api(ctx, `/api/portfolio/activations/${encodeURIComponent(a.activation_id)}`);
127
+ return { content: [text(await expectJson(res, 'portfolio_get_activation'))] };
128
+ });
129
+ server.registerTool('portfolio_subscribe_to_activations', {
130
+ title: 'List pending activations',
131
+ description: 'Poll the caller’s currently-pending activations (the Act-3 ▶ Run fallback). Read-only.',
132
+ inputSchema: {},
133
+ annotations: { readOnlyHint: true, openWorldHint: true },
134
+ }, async () => {
135
+ const res = await api(ctx, '/api/portfolio/activations');
136
+ return { content: [text(await expectJson(res, 'portfolio_subscribe_to_activations'))] };
137
+ });
138
+ server.registerTool('portfolio_versions', {
139
+ title: 'List portfolio snapshots',
140
+ description: 'List the saved version snapshots (time-travel restore points) for a portfolio — id, label, node count, created time. Read-only.',
141
+ inputSchema: { portfolio_id: z.string() },
142
+ outputSchema: {
143
+ count: z.number(),
144
+ versions: z.array(z.object({ id: z.string(), label: z.string(), nodeCount: z.number().optional(), versionNumber: z.number().optional(), createdAt: z.string().optional() })),
145
+ },
146
+ annotations: { readOnlyHint: true, openWorldHint: true },
147
+ }, async (a) => {
148
+ const res = await api(ctx, `/api/portfolio/versions?portfolioId=${encodeURIComponent(a.portfolio_id)}`);
149
+ const data = await expectJson(res, 'portfolio_versions');
150
+ const versions = (data.versions ?? []).map((v) => ({
151
+ id: String(v.id),
152
+ label: String(v.label ?? ''),
153
+ ...(typeof v.nodeCount === 'number' ? { nodeCount: v.nodeCount } : {}),
154
+ ...(typeof v.versionNumber === 'number' ? { versionNumber: v.versionNumber } : {}),
155
+ ...(v.createdAt != null ? { createdAt: String(v.createdAt) } : {}),
156
+ }));
157
+ const structured = { count: versions.length, versions };
158
+ return { content: [text(structured)], structuredContent: structured };
159
+ });
160
+ server.registerTool('portfolio_version_get', {
161
+ title: 'Load a portfolio snapshot',
162
+ description: 'Load one snapshot’s full graph (nodes + edges + viewport) — the restore source. To restore: load it here, then write it back with portfolio_update (which keeps OCC + a pre-write backup + the confirm gate). Read-only.',
163
+ inputSchema: { version_id: z.string() },
164
+ outputSchema: { id: z.string(), label: z.string().optional(), nodeCount: z.number(), edgeCount: z.number() },
165
+ annotations: { readOnlyHint: true, openWorldHint: true },
166
+ }, async (a) => {
167
+ const res = await api(ctx, `/api/portfolio/versions?versionId=${encodeURIComponent(a.version_id)}`);
168
+ const data = await expectJson(res, 'portfolio_version_get');
169
+ const v = (data.version ?? data);
170
+ const parse = (x) => {
171
+ if (typeof x !== 'string')
172
+ return x;
173
+ try {
174
+ return JSON.parse(x);
175
+ }
176
+ catch {
177
+ return x;
178
+ }
179
+ };
180
+ const pn = parse(v.nodes);
181
+ const pe = parse(v.edges);
182
+ const nodes = Array.isArray(pn) ? pn : [];
183
+ const edges = Array.isArray(pe) ? pe : [];
184
+ const full = { id: a.version_id, label: v.label, viewport: parse(v.viewport), nodes, edges };
185
+ const structured = { id: a.version_id, ...(typeof v.label === 'string' ? { label: v.label } : {}), nodeCount: nodes.length, edgeCount: edges.length };
186
+ return { content: [text(full)], structuredContent: structured };
187
+ });
188
+ server.registerTool('portfolio_characters', {
189
+ title: 'List characters (personas)',
190
+ description: 'List the Character personas the caller can see (their own + team), optionally scoped to a portfolio. Read-only.',
191
+ inputSchema: { portfolio_id: z.string().optional() },
192
+ outputSchema: {
193
+ count: z.number(),
194
+ characters: z.array(z.object({ id: z.string(), name: z.string(), visibility: z.string().optional(), originPortfolioId: z.string().optional() })),
195
+ },
196
+ annotations: { readOnlyHint: true, openWorldHint: true },
197
+ }, async (a) => {
198
+ const q = a.portfolio_id ? `?portfolioId=${encodeURIComponent(a.portfolio_id)}` : '';
199
+ const res = await api(ctx, `/api/portfolio/characters${q}`);
200
+ const raw = (await expectJson(res, 'portfolio_characters'));
201
+ const list = (Array.isArray(raw?.characters) ? raw.characters : Array.isArray(raw) ? raw : []);
202
+ const characters = list.map((c) => ({
203
+ id: String(c.id),
204
+ name: String(c.name ?? ''),
205
+ ...(c.visibility != null ? { visibility: String(c.visibility) } : {}),
206
+ ...(c.origin_portfolio_id != null ? { originPortfolioId: String(c.origin_portfolio_id) } : {}),
207
+ }));
208
+ const structured = { count: characters.length, characters };
209
+ return { content: [text(structured)], structuredContent: structured };
210
+ });
211
+ if (ctx.readonly)
212
+ return;
213
+ async function gatedReq(method, path, body, label) {
214
+ const init = { method };
215
+ if (body !== undefined)
216
+ init.body = JSON.stringify(body);
217
+ const res = await api(ctx, path, init);
218
+ if (res.status === 503) {
219
+ return { content: [{ type: 'text', text: `${label}: this endpoint is gated behind NONBOT_RUN_FEATURE_ENABLED on the server.` }], isError: true };
220
+ }
221
+ return { content: [text(await expectJson(res, label))] };
222
+ }
223
+ server.registerTool('portfolio_update', {
224
+ title: 'Replace a portfolio graph',
225
+ description: 'Whole-graph replace: write the complete nodes + edges for a portfolio. Use it for restructures and deletes; to ADD items use portfolio_add_items, to change one item portfolio_patch_item, to re-position portfolio_layout. Pre-flight validation rejects orphan parents, duplicate ids, and unresolvable epic links. The current server state is snapshotted (backed up) before the write. Pass expectedVersion from portfolio_get; if it no longer matches, the write is refused with a 409 instead of clobbering a concurrent edit.',
226
+ inputSchema: {
227
+ id: z.string().describe('Portfolio id.'),
228
+ nodes: z.array(z.any()).describe('The COMPLETE set of nodes (whole-graph replace).'),
229
+ edges: z.array(z.any()).describe('The COMPLETE set of edges (whole-graph replace).'),
230
+ name: z.string().optional().describe('Optional new portfolio name.'),
231
+ description: z.string().optional().describe('Optional new portfolio description.'),
232
+ viewport: z.any().optional().describe('Optional Vue Flow viewport { x, y, zoom }.'),
233
+ expectedVersion: z
234
+ .number()
235
+ .int()
236
+ .optional()
237
+ .describe('The version from your most recent portfolio_get. Optimistic-concurrency guard — the write is refused (409) if the portfolio changed since. Omit to use the version at write time.'),
238
+ },
239
+ outputSchema: {
240
+ ok: z.boolean(),
241
+ nodeCount: z.number(),
242
+ edgeCount: z.number(),
243
+ version: z.number().optional(),
244
+ backedUp: z.boolean().describe('Whether the prior server state was snapshotted before the write.'),
245
+ },
246
+ annotations: {
247
+ readOnlyHint: false,
248
+ destructiveHint: true,
249
+ idempotentHint: false,
250
+ openWorldHint: true,
251
+ },
252
+ }, async (args) => {
253
+ const nodes = (args.nodes ?? []);
254
+ const edges = (args.edges ?? []);
255
+ const problems = validateGraph(nodes, edges);
256
+ if (problems.length > 0) {
257
+ return {
258
+ content: [
259
+ {
260
+ type: 'text',
261
+ text: `Refusing to write — the graph has ${problems.length} problem(s):\n` + problems.map((e) => ` - ${e}`).join('\n'),
262
+ },
263
+ ],
264
+ isError: true,
265
+ };
266
+ }
267
+ const currentRes = await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(args.id)}`);
268
+ const current = await expectJson(currentRes, 'portfolio_update (pre-fetch)');
269
+ let backedUp = false;
270
+ if (ctx.backup) {
271
+ try {
272
+ await ctx.backup(args.id, current);
273
+ backedUp = true;
274
+ }
275
+ catch {
276
+ backedUp = false;
277
+ }
278
+ }
279
+ const currentGraph = (current.graph ?? current);
280
+ const curNodeCount = Array.isArray(currentGraph.nodes) ? currentGraph.nodes.length : 0;
281
+ const removed = curNodeCount - nodes.length;
282
+ if (curNodeCount > 0 && (removed >= 5 || nodes.length < curNodeCount / 2)) {
283
+ try {
284
+ const el = await server.server.elicitInput({
285
+ message: `This overwrites the portfolio graph: ${curNodeCount} → ${nodes.length} nodes (removing ${Math.max(removed, 0)}). Proceed?`,
286
+ requestedSchema: {
287
+ type: 'object',
288
+ properties: { confirm: { type: 'boolean', description: 'true to proceed with the overwrite' } },
289
+ required: ['confirm'],
290
+ },
291
+ });
292
+ if (el.action !== 'accept' || el.content?.confirm !== true) {
293
+ return { content: [{ type: 'text', text: 'Update cancelled — the overwrite was not confirmed. The prior state is unchanged.' }], isError: true };
294
+ }
295
+ }
296
+ catch {
297
+ }
298
+ }
299
+ const baselineVersion = typeof args.expectedVersion === 'number' ? args.expectedVersion : currentGraph.version;
300
+ const putRes = await api(ctx, '/api/portfolio/graph', {
301
+ method: 'PUT',
302
+ body: JSON.stringify({
303
+ id: args.id,
304
+ nodes,
305
+ edges,
306
+ ...(baselineVersion !== undefined ? { expectedVersion: baselineVersion } : {}),
307
+ name: args.name ?? String(current.name ?? current.graph?.name ?? ''),
308
+ description: args.description ?? String(current.description ?? current.graph?.description ?? ''),
309
+ ...(args.viewport !== undefined ? { viewport: args.viewport } : currentGraph.viewport !== undefined ? { viewport: currentGraph.viewport } : {}),
310
+ }),
311
+ });
312
+ if (putRes.status === 409) {
313
+ const conflict = (await putRes.json().catch(() => ({})));
314
+ return {
315
+ content: [
316
+ {
317
+ type: 'text',
318
+ text: `Update refused (409) — the portfolio changed since you read it ` +
319
+ `(current version ${conflict.currentVersion ?? '?'}). ` +
320
+ `Call portfolio_get again, re-apply your edits onto the latest, then retry.` +
321
+ (backedUp ? ' The prior state was backed up before this attempt.' : ''),
322
+ },
323
+ ],
324
+ isError: true,
325
+ };
326
+ }
327
+ const result = await expectJson(putRes, 'portfolio_update');
328
+ const resultGraph = (result.server ?? result.graph ?? result);
329
+ const structured = {
330
+ ok: true,
331
+ nodeCount: nodes.length,
332
+ edgeCount: edges.length,
333
+ ...(typeof resultGraph.version === 'number' ? { version: resultGraph.version } : {}),
334
+ backedUp,
335
+ };
336
+ return { content: [text(structured)], structuredContent: structured };
337
+ });
338
+ const planItem = z.object({
339
+ title: z.string(),
340
+ description: z.string().optional(),
341
+ acceptanceCriteria: z.string().optional(),
342
+ });
343
+ server.registerTool('portfolio_create_from_plan', {
344
+ title: 'Create a portfolio from a plan',
345
+ description: 'Create a DRAFT portfolio from an LLM-structured plan (epic + optional features/stories). Status stays "draft" until portfolio_publish_draft.',
346
+ inputSchema: {
347
+ suggested_name: z.string().optional(),
348
+ description: z.string().optional(),
349
+ epic: planItem,
350
+ features: z.array(planItem.extend({ stories: z.array(planItem).optional() })).optional(),
351
+ stories: z.array(planItem.extend({ parentFeatureIndex: z.number().int().optional() })).optional(),
352
+ inception_summary: z.string().optional(),
353
+ claude_session_id: z.string().optional(),
354
+ },
355
+ annotations: { readOnlyHint: false, openWorldHint: true },
356
+ }, async (a) => {
357
+ if (!a.epic?.title?.trim()) {
358
+ return { content: [{ type: 'text', text: 'epic.title is required.' }], isError: true };
359
+ }
360
+ return gatedReq('POST', '/api/portfolio/create-from-plan', a, 'portfolio_create_from_plan');
361
+ });
362
+ server.registerTool('portfolio_decompose', {
363
+ title: 'Decompose an item into children',
364
+ description: 'Ask the server AI to decompose an item (epic→features or feature→stories) into SUGGESTED children for review. Reads the parent (level/title/description) from the portfolio by item_id. Returns { items, reasoning } — it does NOT write them; apply via portfolio_update if you accept them.',
365
+ inputSchema: {
366
+ portfolio_id: z.string(),
367
+ item_id: z.string(),
368
+ count: z.number().int().min(1).max(10).optional().describe('How many children to suggest.'),
369
+ guidance: z.string().optional().describe('Extra guidance for the decomposition.'),
370
+ },
371
+ annotations: { readOnlyHint: false, openWorldHint: true },
372
+ }, async (a) => {
373
+ const gRes = await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(a.portfolio_id)}`);
374
+ const gData = await expectJson(gRes, 'portfolio_decompose (fetch)');
375
+ const graph = (gData.graph ?? gData);
376
+ const nodes = Array.isArray(graph.nodes)
377
+ ? graph.nodes
378
+ : [];
379
+ const node = nodes.find((n) => n?.id === a.item_id);
380
+ if (!node) {
381
+ return { content: [{ type: 'text', text: `No item "${a.item_id}" in portfolio "${a.portfolio_id}". Use portfolio_get to find the id.` }], isError: true };
382
+ }
383
+ const d = node.data ?? {};
384
+ return gatedReq('POST', '/api/portfolio/decompose', {
385
+ portfolioId: a.portfolio_id,
386
+ parentLevel: String(d.hierarchyLevel ?? 'epic'),
387
+ parentTitle: String(d.title ?? d.label ?? d.name ?? ''),
388
+ parentDescription: String(d.description ?? ''),
389
+ ...(a.count !== undefined ? { count: a.count } : {}),
390
+ ...(a.guidance !== undefined ? { guidance: a.guidance } : {}),
391
+ }, 'portfolio_decompose');
392
+ });
393
+ server.registerTool('portfolio_attach_brief', {
394
+ title: 'Attach a brief to an item',
395
+ description: 'Attach metadata to one node: a markdown brief, acceptance criteria, description, and/or hypothesis. At least one is required.',
396
+ inputSchema: {
397
+ portfolio_id: z.string(),
398
+ item_id: z.string(),
399
+ brief_md: z.string().optional(),
400
+ acceptance_criteria: z.string().optional(),
401
+ description: z.string().optional(),
402
+ hypothesis: z.string().optional(),
403
+ },
404
+ annotations: { readOnlyHint: false, openWorldHint: true },
405
+ }, async (a) => {
406
+ if (!a.brief_md && !a.acceptance_criteria && !a.description && !a.hypothesis) {
407
+ return { content: [{ type: 'text', text: 'Provide at least one of brief_md, acceptance_criteria, description, hypothesis.' }], isError: true };
408
+ }
409
+ return gatedReq('POST', `/api/portfolio/${encodeURIComponent(a.portfolio_id)}/attach-brief`, {
410
+ itemId: a.item_id,
411
+ briefMd: a.brief_md,
412
+ acceptanceCriteria: a.acceptance_criteria,
413
+ description: a.description,
414
+ hypothesis: a.hypothesis,
415
+ }, 'portfolio_attach_brief');
416
+ });
417
+ server.registerTool('portfolio_link_chat_session', {
418
+ title: 'Link a chat session to a portfolio',
419
+ description: 'Backlink a portfolio to the chat that birthed it. Provide a claude_session_id and/or an inception_summary.',
420
+ inputSchema: {
421
+ portfolio_id: z.string(),
422
+ claude_session_id: z.string().optional(),
423
+ inception_summary: z.string().optional(),
424
+ },
425
+ annotations: { readOnlyHint: false, openWorldHint: true },
426
+ }, async (a) => {
427
+ if (!a.claude_session_id && !a.inception_summary) {
428
+ return { content: [{ type: 'text', text: 'Provide at least one of claude_session_id, inception_summary.' }], isError: true };
429
+ }
430
+ return gatedReq('POST', `/api/portfolio/${encodeURIComponent(a.portfolio_id)}/link-chat-session`, {
431
+ claude_session_id: a.claude_session_id,
432
+ inception_summary: a.inception_summary,
433
+ }, 'portfolio_link_chat_session');
434
+ });
435
+ server.registerTool('portfolio_publish_draft', {
436
+ title: 'Publish a draft portfolio',
437
+ description: 'Flip a portfolio from "draft" → "active". Idempotent.',
438
+ inputSchema: { portfolio_id: z.string() },
439
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
440
+ }, async (a) => gatedReq('POST', `/api/portfolio/${encodeURIComponent(a.portfolio_id)}/publish-draft`, undefined, 'portfolio_publish_draft'));
441
+ server.registerTool('portfolio_mark_run_ready', {
442
+ title: 'Mark a story run-ready',
443
+ description: 'Tag a story as ready for CLI pickup (or clear it with run_ready=false).',
444
+ inputSchema: { portfolio_id: z.string(), item_id: z.string(), run_ready: z.boolean().optional() },
445
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
446
+ }, async (a) => gatedReq('POST', `/api/portfolio/${encodeURIComponent(a.portfolio_id)}/mark-run-ready`, {
447
+ itemId: a.item_id,
448
+ ...(a.run_ready === undefined ? {} : { runReady: a.run_ready }),
449
+ }, 'portfolio_mark_run_ready'));
450
+ server.registerTool('portfolio_repo_path', {
451
+ title: 'Get or set the portfolio repo path',
452
+ description: 'Your per-user local repo path for a portfolio — where ▶ Run activations cd before launching. Without repo_path: returns the current value. With repo_path: sets it (absolute POSIX path; pass empty string to clear). portfolio_create_activation fails with no_repo_path until this is set. (Added after a live dogfood hit exactly that wall.)',
453
+ inputSchema: {
454
+ portfolio_id: z.string(),
455
+ repo_path: z.string().optional().describe('Absolute local path to set, "" to clear; omit to read.'),
456
+ },
457
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
458
+ }, async (a) => {
459
+ const path = `/api/portfolio/${encodeURIComponent(a.portfolio_id)}/repo-path`;
460
+ if (a.repo_path === undefined)
461
+ return gatedReq('GET', path, undefined, 'portfolio_repo_path');
462
+ return gatedReq('PUT', path, { repoPath: a.repo_path === '' ? null : a.repo_path }, 'portfolio_repo_path');
463
+ });
464
+ server.registerTool('portfolio_create_activation', {
465
+ title: 'Create an activation',
466
+ description: 'Create an activation (a ▶ Run launch request) for a story. Targets the assignee or the caller.',
467
+ inputSchema: {
468
+ portfolio_id: z.string(),
469
+ item_id: z.string(),
470
+ target_operator_user_id: z.number().int().optional(),
471
+ },
472
+ annotations: { readOnlyHint: false, openWorldHint: true },
473
+ }, async (a) => gatedReq('POST', '/api/portfolio/activations', {
474
+ portfolioId: a.portfolio_id,
475
+ itemId: a.item_id,
476
+ ...(a.target_operator_user_id !== undefined ? { targetOperatorUserId: a.target_operator_user_id } : {}),
477
+ }, 'portfolio_create_activation'));
478
+ server.registerTool('portfolio_update_activation_status', {
479
+ title: 'Update activation status',
480
+ description: 'Transition an activation: pending → running | failed | cancelled. The caller must be the target operator.',
481
+ inputSchema: {
482
+ activation_id: z.string(),
483
+ status: z.string(),
484
+ terminal_app_used: z.string().optional(),
485
+ run_id: z.string().optional(),
486
+ failure_reason: z.string().optional(),
487
+ },
488
+ annotations: { readOnlyHint: false, openWorldHint: true },
489
+ }, async (a) => gatedReq('PATCH', `/api/portfolio/activations/${encodeURIComponent(a.activation_id)}`, {
490
+ status: a.status,
491
+ ...(a.terminal_app_used ? { terminalAppUsed: a.terminal_app_used } : {}),
492
+ ...(a.run_id ? { runId: a.run_id } : {}),
493
+ ...(a.failure_reason ? { failureReason: a.failure_reason } : {}),
494
+ }, 'portfolio_update_activation_status'));
495
+ server.registerTool('portfolio_snapshot', {
496
+ title: 'Snapshot a portfolio',
497
+ description: 'Save a labeled version snapshot of a portfolio’s CURRENT graph — a time-travel restore point. Reads the live graph and stores it. The server rate-limits snapshots (20/day).',
498
+ inputSchema: { portfolio_id: z.string(), label: z.string().optional().describe('Optional human label for the snapshot.') },
499
+ outputSchema: { ok: z.boolean(), versionId: z.string().optional(), label: z.string().optional() },
500
+ annotations: { readOnlyHint: false, openWorldHint: true },
501
+ }, async (a) => {
502
+ const gRes = await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(a.portfolio_id)}`);
503
+ const g = await expectJson(gRes, 'portfolio_snapshot (fetch)');
504
+ const graph = (g.graph ?? g);
505
+ const res = await api(ctx, '/api/portfolio/versions', {
506
+ method: 'POST',
507
+ body: JSON.stringify({
508
+ portfolioId: a.portfolio_id,
509
+ label: a.label,
510
+ nodes: graph.nodes ?? [],
511
+ edges: graph.edges ?? [],
512
+ viewport: graph.viewport,
513
+ }),
514
+ });
515
+ const data = await expectJson(res, 'portfolio_snapshot');
516
+ const ver = (data.version ?? data);
517
+ const versionId = ver.id != null ? String(ver.id) : data.versionId != null ? String(data.versionId) : undefined;
518
+ const structured = { ok: true, ...(versionId ? { versionId } : {}), ...(a.label ? { label: a.label } : {}) };
519
+ return { content: [text(structured)], structuredContent: structured };
520
+ });
521
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ApiContext } from '../config.js';
3
+ export declare function registerRunTools(server: McpServer, ctx: ApiContext): void;