@nonbot/mcp 0.2.3 → 0.3.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.
- package/README.md +21 -6
- package/dist/core/completedWork.d.ts +127 -0
- package/dist/core/completedWork.js +287 -0
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.js +11 -0
- package/dist/core/layout.d.ts +51 -0
- package/dist/core/layout.js +325 -6
- package/dist/core/mapHeaders.d.ts +13 -0
- package/dist/core/mapHeaders.js +64 -0
- package/dist/core/preview.d.ts +17 -0
- package/dist/core/preview.js +74 -0
- package/dist/core/releases.d.ts +127 -0
- package/dist/core/releases.js +280 -0
- package/dist/core/server.d.ts +1 -1
- package/dist/core/server.js +12 -2
- package/dist/core/style.d.ts +31 -0
- package/dist/core/style.js +64 -0
- package/dist/core/toolsets/build.d.ts +16 -3
- package/dist/core/toolsets/build.js +117 -11
- package/dist/core/toolsets/portfolio.js +70 -0
- package/dist/core/toolsets/radbugs.d.ts +27 -0
- package/dist/core/toolsets/radbugs.js +55 -0
- package/dist/core/toolsets/release.d.ts +3 -0
- package/dist/core/toolsets/release.js +329 -0
- package/dist/gitLog.d.ts +3 -0
- package/dist/gitLog.js +46 -0
- package/dist/stdio.js +2 -0
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { api, expectJson } from '../http.js';
|
|
3
3
|
import { validateGraph } from '../validate.js';
|
|
4
|
-
import { LEVELS, applyLayout, layoutTimeline, scheduleFields, todayColumn, columnOf, isItem, PX_PER_WEEK } from '../layout.js';
|
|
4
|
+
import { LEVELS, applyLayout, layoutTimeline, layoutMapDetailed, applyMapLayout, scheduleFields, todayColumn, columnOf, isItem, PX_PER_WEEK } from '../layout.js';
|
|
5
5
|
import { emptyIp, IP_KINDS, IP_NODE_SIZE } from '../ipShape.js';
|
|
6
|
+
import { withMapHeaders } from '../mapHeaders.js';
|
|
7
|
+
import { renderPreviewSvg } from '../preview.js';
|
|
8
|
+
import { applyStyleRules, CARD_STYLES, TITLE_SIZES, ACCENTS, SHOW_KEYS, STATES as STYLE_STATES } from '../style.js';
|
|
6
9
|
const STATES = ['funnel', 'reviewing', 'analyzing', 'ready', 'implementing', 'done'];
|
|
7
10
|
const OKR_STATUSES = ['on-track', 'at-risk', 'behind'];
|
|
8
11
|
const RESERVED = new Set(['hierarchyLevel', 'parentItemId', 'linkedEpicIds', 'id', '__proto__', 'constructor', 'prototype']);
|
|
@@ -13,7 +16,7 @@ const fail = (msg) => ({ content: [{ type: 'text', text: msg }], isError: true }
|
|
|
13
16
|
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
14
17
|
export const toHtml = (s) => /^\s*</.test(s) ? s : s.split(/\n{2,}/).map(p => `<p>${escHtml(p.trim()).replace(/\n/g, '<br>')}</p>`).join('');
|
|
15
18
|
export function newItemId(now = Date.now()) { return `pf-${now}-${Math.random().toString(36).slice(2, 11)}`; }
|
|
16
|
-
async function loadGraph(ctx, id) {
|
|
19
|
+
export async function loadGraph(ctx, id) {
|
|
17
20
|
const data = await expectJson(await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(id)}`), 'load portfolio');
|
|
18
21
|
const g = (data.graph ?? data);
|
|
19
22
|
return {
|
|
@@ -25,7 +28,7 @@ async function loadGraph(ctx, id) {
|
|
|
25
28
|
version: typeof g.version === 'number' ? g.version : undefined,
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
|
-
async function saveGraph(ctx, id, before, after, expectedVersion) {
|
|
31
|
+
export async function saveGraph(ctx, id, before, after, expectedVersion) {
|
|
29
32
|
const problems = validateGraph(after.nodes, after.edges);
|
|
30
33
|
if (problems.length)
|
|
31
34
|
return { ok: false, message: `Refusing to write — ${problems.length} problem(s):\n` + problems.map(p => ` - ${p}`).join('\n') };
|
|
@@ -63,7 +66,7 @@ const itemSpec = z.object({
|
|
|
63
66
|
duration_weeks: z.number().min(0).max(156).optional().describe('Default: epic 8, feature 4, story 1, enabler 2, milestone 0, objective / key-result 12.'),
|
|
64
67
|
tags: z.array(z.string().max(40)).max(20).optional(),
|
|
65
68
|
wsjf: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe('[business value, time criticality, risk reduction, job size].'),
|
|
66
|
-
owner: z.string().max(80).optional(),
|
|
69
|
+
owner: z.string().max(80).optional().describe(`Who does this item — the Program view's lanes group by it. Set it on every feature / story / enabler / milestone: the human's name (as they appear in the portfolio, e.g. "Conrad") for work only a person can do — review or approve a plan, decide, buy, sign up, sign in, hand over a key, test on a real device, talk to a customer; "AI" for work an agent delivers — code, tests, migrations, docs, research, drafts. Spell it exactly "AI" so all agent work shares one lane.`),
|
|
67
70
|
kr_target: z.string().max(200).optional(),
|
|
68
71
|
kr_current: z.string().max(200).optional(),
|
|
69
72
|
okr_status: z.enum(OKR_STATUSES).optional(),
|
|
@@ -74,6 +77,20 @@ const DEFAULT_WEEKS = { epic: 8, feature: 4, story: 1, enabler: 2, milestone: 0,
|
|
|
74
77
|
const LEGAL_PARENT = {
|
|
75
78
|
epic: [], objective: [], 'key-result': ['objective'], feature: ['epic'], story: ['feature', 'epic'], enabler: ['epic', 'feature'], milestone: ['epic', 'feature'],
|
|
76
79
|
};
|
|
80
|
+
export const AI_OWNER = 'AI';
|
|
81
|
+
const WORK_LEVELS = new Set(['feature', 'story', 'enabler', 'milestone']);
|
|
82
|
+
export function ownerReport(specs) {
|
|
83
|
+
const owners = {};
|
|
84
|
+
const unassigned = [];
|
|
85
|
+
for (const s of specs) {
|
|
86
|
+
const who = s.owner?.trim();
|
|
87
|
+
if (who)
|
|
88
|
+
owners[who] = (owners[who] ?? 0) + 1;
|
|
89
|
+
else if (WORK_LEVELS.has(s.level))
|
|
90
|
+
unassigned.push(s.key ?? s.title);
|
|
91
|
+
}
|
|
92
|
+
return { owners, unassigned };
|
|
93
|
+
}
|
|
77
94
|
export function buildItems(g, specs, now = new Date()) {
|
|
78
95
|
const errors = [];
|
|
79
96
|
const ids = {};
|
|
@@ -112,8 +129,8 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
112
129
|
d.hypothesisStatement = s.hypothesis;
|
|
113
130
|
if (s.tags)
|
|
114
131
|
d.tags = s.tags;
|
|
115
|
-
if (s.owner)
|
|
116
|
-
d.owner = s.owner;
|
|
132
|
+
if (s.owner?.trim())
|
|
133
|
+
d.owner = s.owner.trim();
|
|
117
134
|
if (s.wsjf)
|
|
118
135
|
Object.assign(d, { wsjfBusinessValue: s.wsjf[0], wsjfTimeCriticality: s.wsjf[1], wsjfRiskReduction: s.wsjf[2], wsjfJobSize: s.wsjf[3] });
|
|
119
136
|
if (s.kr_target)
|
|
@@ -166,6 +183,21 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
166
183
|
return { nodes, edges, ids, errors, patched };
|
|
167
184
|
}
|
|
168
185
|
export function registerBuildTools(server, ctx) {
|
|
186
|
+
server.registerTool('portfolio_preview', {
|
|
187
|
+
title: 'Picture a portfolio layout',
|
|
188
|
+
description: 'Draw the portfolio as an SVG schematic — every card at its stored Map (default) or Timeline position and size, with its level, state and title (done cards pale green), section-header notes as headings — so you can LOOK at a layout before calling it done. Returns the SVG text plus the card count and extent. `sizes` (px per id) overrides the stored / estimated card sizes; `width` sets the output width.',
|
|
189
|
+
inputSchema: {
|
|
190
|
+
portfolio_id: z.string(),
|
|
191
|
+
view: z.enum(['map', 'timeline']).optional(),
|
|
192
|
+
sizes: z.record(z.string(), z.object({ width: z.number().min(1).max(8000), height: z.number().min(1).max(8000) })).optional(),
|
|
193
|
+
width: z.number().int().min(200).max(8000).optional(),
|
|
194
|
+
},
|
|
195
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
196
|
+
}, async (a) => {
|
|
197
|
+
const g = await loadGraph(ctx, a.portfolio_id);
|
|
198
|
+
const r = renderPreviewSvg(g.nodes, { view: a.view, sizes: a.sizes, width: a.width });
|
|
199
|
+
return { content: [{ type: 'text', text: JSON.stringify({ cards: r.cards, extent: r.extent }) }, { type: 'text', text: r.svg }] };
|
|
200
|
+
});
|
|
169
201
|
if (ctx.readonly)
|
|
170
202
|
return;
|
|
171
203
|
server.registerTool('portfolio_create', {
|
|
@@ -216,12 +248,16 @@ export function registerBuildTools(server, ctx) {
|
|
|
216
248
|
const r = await saveGraph(ctx, a.portfolio_id, g, after, a.expected_version);
|
|
217
249
|
if (!r.ok)
|
|
218
250
|
return fail(r.message);
|
|
219
|
-
const
|
|
220
|
-
|
|
251
|
+
const who = ownerReport(a.items);
|
|
252
|
+
const structured = { ok: true, added: built.nodes.length, edges: built.edges.length, ids: built.ids, owners: who.owners, unassigned: who.unassigned, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
253
|
+
const content = [text(structured)];
|
|
254
|
+
if (who.unassigned.length)
|
|
255
|
+
content.push({ type: 'text', text: `${who.unassigned.length} work item(s) have no owner: ${who.unassigned.join(', ')}. Give each one with portfolio_patch_item (set: { owner }) — the human's name for work only a person can do (review, approve, decide, buy, sign in, test on a device), "${AI_OWNER}" for work an agent delivers — so the human can see at a glance what is theirs.` });
|
|
256
|
+
return { content, structuredContent: structured };
|
|
221
257
|
});
|
|
222
258
|
server.registerTool('portfolio_patch_item', {
|
|
223
259
|
title: 'Patch one item',
|
|
224
|
-
description: 'Change fields of ONE portfolio item without rewriting the graph (PATCH /api/portfolio/graph/node): `set` writes allowlisted PortfolioItemData fields (title, description, bluf, acceptanceCriteria, hypothesisStatement, state, tags, wsjf*, krTarget, pastedImages, durationWeeks, startDate, …), `unset` deletes them. Rich text is stored as given (pass HTML, or plain text which is wrapped in <p>). A stale version answers 409 — nothing is written.',
|
|
260
|
+
description: 'Change fields of ONE portfolio item without rewriting the graph (PATCH /api/portfolio/graph/node): `set` writes allowlisted PortfolioItemData fields (title, description, bluf, acceptanceCriteria, hypothesisStatement, state, owner — the human\'s name or "AI", tags, wsjf*, krTarget, pastedImages, durationWeeks, startDate, …), `unset` deletes them. Rich text is stored as given (pass HTML, or plain text which is wrapped in <p>). A stale version answers 409 — nothing is written.',
|
|
225
261
|
inputSchema: {
|
|
226
262
|
portfolio_id: z.string(),
|
|
227
263
|
item_id: z.string(),
|
|
@@ -248,22 +284,92 @@ export function registerBuildTools(server, ctx) {
|
|
|
248
284
|
});
|
|
249
285
|
server.registerTool('portfolio_layout', {
|
|
250
286
|
title: 'Lay out a portfolio on the Timeline',
|
|
251
|
-
description: 'Re-position every item so the Timeline reads cleanly: x from each item\'s start date (the canvas reads dates off x), y top-down — objectives, key results, then one block per epic (epic, features, stories + enablers, milestones), each row lane-packed so overlapping items stack, IP nodes directly above their first source. Short items get a minimum painted width so titles fit. Content is untouched. `heights` overrides the estimated card height per id (px) when a card measures taller on screen.',
|
|
287
|
+
description: 'Re-position every item so the Timeline reads cleanly: x from each item\'s start date (the canvas reads dates off x), y top-down — objectives, key results, then one block per epic (epic, features, stories + enablers, milestones), each row lane-packed so overlapping items stack, IP nodes directly above their first source. Short items get a minimum painted width so titles fit. Content is untouched. `heights` overrides the estimated card height per id (px) when a card measures taller on screen. `group: "objective"` reads top-down by goal instead: each objective, its key results, then the blocks of the epics those key results link to, before the next objective (`objective_order` lists objective ids to put first); unlinked epics follow. Grouping changes rows only, never x or dates. `view: "map"` lays out the Map instead (the free layout, `freePosition` only — the Timeline positions and dates are untouched): one band per objective (`objective_order` first), the objective + key results leading it, then one column per linked epic with its features and a 3-wide grid of stories; unlinked epics, loose items and modules follow.',
|
|
252
288
|
inputSchema: {
|
|
253
289
|
portfolio_id: z.string(),
|
|
254
290
|
heights: z.record(z.string(), z.number().min(20).max(2000)).optional(),
|
|
291
|
+
sizes: z.record(z.string(), z.object({ width: z.number().min(1).max(8000), height: z.number().min(1).max(8000) })).optional().describe('With view "map": measured card sizes (px) per id — Map cards are as wide as their content.'),
|
|
292
|
+
column_height: z.number().min(400).max(20000).optional().describe('With view "map": an epic column taller than this (px) wraps into another column beside it, at feature boundaries.'),
|
|
293
|
+
pack: z.enum(['stack', 'grid']).optional().describe('With view "map": "grid" packs the objective bands into rows shaped like a screen (default "stack": top to bottom).'),
|
|
294
|
+
headers: z.boolean().optional().describe('With view "map": a section-header note above each objective band (title + epics + % done). Re-running updates the same notes; notes you wrote are never touched.'),
|
|
295
|
+
view: z.enum(['timeline', 'map']).optional().describe('Default timeline. "map": write the Map (free) positions only, grouped by objective.'),
|
|
296
|
+
group: z.enum(['objective']).optional().describe('"objective": group epic blocks under the objective whose key results link to them.'),
|
|
297
|
+
objective_order: z.array(z.string()).max(100).optional().describe('With group "objective": objective ids to place first, in this order.'),
|
|
255
298
|
expected_version: z.number().int().optional(),
|
|
256
299
|
},
|
|
257
300
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
258
301
|
}, async (a) => {
|
|
259
302
|
const g = await loadGraph(ctx, a.portfolio_id);
|
|
260
|
-
|
|
303
|
+
if (a.view === 'map') {
|
|
304
|
+
const HEADER_H = 220;
|
|
305
|
+
const cards = g.nodes.filter(n => !(n.type === 'rad-module' && n.data?.moduleData?.__layoutHeader));
|
|
306
|
+
const d = layoutMapDetailed(cards, { edges: g.edges, objectiveOrder: a.objective_order, heights: a.heights, sizes: a.sizes, pack: a.pack, columnHeight: a.column_height, headerHeight: a.headers ? HEADER_H : 0 });
|
|
307
|
+
let nodes = applyMapLayout(g.nodes, d.positions);
|
|
308
|
+
let headers = { added: 0, updated: 0, removed: 0 };
|
|
309
|
+
if (a.headers !== undefined) {
|
|
310
|
+
const h = withMapHeaders(nodes, a.headers ? d.bands : [], HEADER_H);
|
|
311
|
+
nodes = h.nodes;
|
|
312
|
+
headers = { added: h.added, updated: h.updated, removed: h.removed };
|
|
313
|
+
}
|
|
314
|
+
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
|
|
315
|
+
if (!r.ok)
|
|
316
|
+
return fail(r.message);
|
|
317
|
+
const structured = { ok: true, view: 'map', positioned: d.positions.size, bands: d.bands.length, headers, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
318
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
319
|
+
}
|
|
320
|
+
const res = layoutTimeline(g.nodes, { heights: a.heights, ...(a.group ? { group: a.group, edges: g.edges, objectiveOrder: a.objective_order } : {}) });
|
|
261
321
|
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyLayout(g.nodes, res) }, a.expected_version);
|
|
262
322
|
if (!r.ok)
|
|
263
323
|
return fail(r.message);
|
|
264
324
|
const structured = { ok: true, positioned: res.positions.size, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
265
325
|
return { content: [text(structured)], structuredContent: structured };
|
|
266
326
|
});
|
|
327
|
+
server.registerTool('portfolio_style', {
|
|
328
|
+
title: 'Restyle many cards at once',
|
|
329
|
+
description: 'Give a portfolio one consistent look in ONE versioned write: rules, applied in order, each matching cards by levels / states / ids / hasImage and setting how they are drawn — display (card_style plain|poster|split|image-top|cover|grid-2|grid-4, title_size sm|md|lg, accent tint|bar|plain, show {bluf, description, acceptanceCriteria, tags, dates, wsjf}), size (Map width × height, or "auto" to size to content) and satellites "close" (fold the card\'s inner-loop panels). Content is untouched. Artifact child cards (hypothesis, NFR…) are skipped unless a rule sets artifacts: true.',
|
|
330
|
+
inputSchema: {
|
|
331
|
+
portfolio_id: z.string(),
|
|
332
|
+
rules: z.array(z.object({
|
|
333
|
+
levels: z.array(z.enum(LEVELS)).optional(),
|
|
334
|
+
states: z.array(z.enum(STYLE_STATES)).optional(),
|
|
335
|
+
ids: z.array(z.string()).max(1000).optional(),
|
|
336
|
+
has_image: z.boolean().optional(),
|
|
337
|
+
artifacts: z.boolean().optional(),
|
|
338
|
+
display: z.object({
|
|
339
|
+
card_style: z.enum(CARD_STYLES).optional(),
|
|
340
|
+
title_size: z.enum(TITLE_SIZES).optional(),
|
|
341
|
+
accent: z.enum(ACCENTS).optional(),
|
|
342
|
+
show: z.object(Object.fromEntries(SHOW_KEYS.map(k => [k, z.boolean().optional()]))).optional(),
|
|
343
|
+
}).optional(),
|
|
344
|
+
size: z.union([z.object({ width: z.number().min(60).max(4000), height: z.number().min(40).max(4000) }), z.literal('auto')]).optional(),
|
|
345
|
+
satellites: z.literal('close').optional(),
|
|
346
|
+
})).min(1).max(40),
|
|
347
|
+
expected_version: z.number().int().optional(),
|
|
348
|
+
},
|
|
349
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
350
|
+
}, async (a) => {
|
|
351
|
+
const g = await loadGraph(ctx, a.portfolio_id);
|
|
352
|
+
const rules = a.rules.map(r => ({
|
|
353
|
+
...(r.levels ? { levels: r.levels } : {}),
|
|
354
|
+
...(r.states ? { states: r.states } : {}),
|
|
355
|
+
...(r.ids ? { ids: r.ids } : {}),
|
|
356
|
+
...(r.has_image !== undefined ? { hasImage: r.has_image } : {}),
|
|
357
|
+
...(r.artifacts ? { artifacts: true } : {}),
|
|
358
|
+
...(r.display ? { display: { cardStyle: r.display.card_style, titleSize: r.display.title_size, accent: r.display.accent, show: r.display.show } } : {}),
|
|
359
|
+
...(r.size ? { size: r.size } : {}),
|
|
360
|
+
...(r.satellites ? { satellites: r.satellites } : {}),
|
|
361
|
+
}));
|
|
362
|
+
const res = applyStyleRules(g.nodes, rules);
|
|
363
|
+
if (res.changed === 0) {
|
|
364
|
+
const structured = { ok: true, matched: res.matched, changed: 0, perRule: res.perRule };
|
|
365
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
366
|
+
}
|
|
367
|
+
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: res.nodes }, a.expected_version);
|
|
368
|
+
if (!r.ok)
|
|
369
|
+
return fail(r.message);
|
|
370
|
+
const structured = { ok: true, matched: res.matched, changed: res.changed, perRule: res.perRule, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
371
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
372
|
+
});
|
|
267
373
|
server.registerTool('portfolio_set_meta', {
|
|
268
374
|
title: 'Set portfolio settings',
|
|
269
375
|
description: 'Graph-level settings that travel with the portfolio (the `__graph_meta__` record): `context_text` (the portfolio\'s context notes — what a newcomer reads first), `palette` ({ id, name, swatches: [{hex, label?}] ≤ 10, roles?: {epic, feature, story, …: hex | "p:N"} }), `canvas_style`, `sprint_settings`. Only the keys you pass change; everything else in the record is kept.',
|
|
@@ -208,6 +208,15 @@ export function registerPortfolioTools(server, ctx) {
|
|
|
208
208
|
const structured = { count: characters.length, characters };
|
|
209
209
|
return { content: [text(structured)], structuredContent: structured };
|
|
210
210
|
});
|
|
211
|
+
server.registerTool('portfolio_comments_list', {
|
|
212
|
+
title: 'List comments on an item',
|
|
213
|
+
description: 'List the threaded comments on one portfolio item (author, content, resolved flag, parent comment).',
|
|
214
|
+
inputSchema: { portfolio_id: z.string(), item_id: z.string() },
|
|
215
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
216
|
+
}, async (a) => {
|
|
217
|
+
const res = await api(ctx, `/api/portfolio/comments?portfolioId=${encodeURIComponent(a.portfolio_id)}&itemId=${encodeURIComponent(a.item_id)}`);
|
|
218
|
+
return { content: [text(await expectJson(res, 'portfolio_comments_list'))] };
|
|
219
|
+
});
|
|
211
220
|
if (ctx.readonly)
|
|
212
221
|
return;
|
|
213
222
|
async function gatedReq(method, path, body, label) {
|
|
@@ -518,4 +527,65 @@ export function registerPortfolioTools(server, ctx) {
|
|
|
518
527
|
const structured = { ok: true, ...(versionId ? { versionId } : {}), ...(a.label ? { label: a.label } : {}) };
|
|
519
528
|
return { content: [text(structured)], structuredContent: structured };
|
|
520
529
|
});
|
|
530
|
+
async function currentVersion(portfolioId, label) {
|
|
531
|
+
const res = await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(portfolioId)}`);
|
|
532
|
+
const g = await expectJson(res, label);
|
|
533
|
+
const graph = (g.graph ?? g);
|
|
534
|
+
const v = Number(graph.version);
|
|
535
|
+
if (!Number.isFinite(v))
|
|
536
|
+
throw new Error(`${label}: portfolio ${portfolioId} returned no version`);
|
|
537
|
+
return v;
|
|
538
|
+
}
|
|
539
|
+
server.registerTool('portfolio_move_subtree', {
|
|
540
|
+
title: 'Move an item and its descendants to another portfolio',
|
|
541
|
+
description: 'Move one item AND every descendant (by parentItemId) out of a portfolio into another one — an existing portfolio (target_portfolio_id) or a brand-new one (new_portfolio_name). Atomic: both portfolios are written together or not at all. Hierarchy edges that cross the boundary are severed (the moved root becomes a root); dependency edges are kept as cross-portfolio links. Versions are read automatically when omitted.',
|
|
542
|
+
inputSchema: {
|
|
543
|
+
portfolio_id: z.string().describe('Source portfolio.'),
|
|
544
|
+
root_item_id: z.string().describe('The item to move; its whole subtree travels with it.'),
|
|
545
|
+
target_portfolio_id: z.string().optional().describe('Existing destination portfolio.'),
|
|
546
|
+
new_portfolio_name: z.string().optional().describe('Create a new destination portfolio with this name instead.'),
|
|
547
|
+
expected_version: z.number().int().optional().describe('Source version (OCC). Read automatically when omitted.'),
|
|
548
|
+
target_expected_version: z.number().int().optional().describe('Target version (OCC). Read automatically when omitted.'),
|
|
549
|
+
},
|
|
550
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
551
|
+
}, async (a) => {
|
|
552
|
+
if (!!a.target_portfolio_id === !!a.new_portfolio_name) {
|
|
553
|
+
return { content: [{ type: 'text', text: 'Provide exactly one of target_portfolio_id or new_portfolio_name.' }], isError: true };
|
|
554
|
+
}
|
|
555
|
+
const expectedVersion = a.expected_version ?? await currentVersion(a.portfolio_id, 'portfolio_move_subtree (source)');
|
|
556
|
+
const target = a.target_portfolio_id
|
|
557
|
+
? {
|
|
558
|
+
kind: 'existing',
|
|
559
|
+
portfolioId: a.target_portfolio_id,
|
|
560
|
+
expectedVersion: a.target_expected_version ?? await currentVersion(a.target_portfolio_id, 'portfolio_move_subtree (target)'),
|
|
561
|
+
}
|
|
562
|
+
: { kind: 'new', name: a.new_portfolio_name };
|
|
563
|
+
const res = await api(ctx, `/api/portfolio/${encodeURIComponent(a.portfolio_id)}/move-subtree`, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
body: JSON.stringify({ rootNodeId: a.root_item_id, expectedVersion, target }),
|
|
566
|
+
});
|
|
567
|
+
return { content: [text(await expectJson(res, 'portfolio_move_subtree'))] };
|
|
568
|
+
});
|
|
569
|
+
server.registerTool('portfolio_comment_post', {
|
|
570
|
+
title: 'Comment on an item',
|
|
571
|
+
description: 'Post a comment on one portfolio item (optionally as a reply via parent_comment_id). Max 10 KB; the server allows 20 comments per minute.',
|
|
572
|
+
inputSchema: {
|
|
573
|
+
portfolio_id: z.string(),
|
|
574
|
+
item_id: z.string(),
|
|
575
|
+
content: z.string().min(1).max(10240),
|
|
576
|
+
parent_comment_id: z.string().optional(),
|
|
577
|
+
},
|
|
578
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
579
|
+
}, async (a) => {
|
|
580
|
+
const res = await api(ctx, '/api/portfolio/comments', {
|
|
581
|
+
method: 'POST',
|
|
582
|
+
body: JSON.stringify({
|
|
583
|
+
portfolioId: a.portfolio_id,
|
|
584
|
+
itemId: a.item_id,
|
|
585
|
+
content: a.content,
|
|
586
|
+
...(a.parent_comment_id ? { parentCommentId: a.parent_comment_id } : {}),
|
|
587
|
+
}),
|
|
588
|
+
});
|
|
589
|
+
return { content: [text(await expectJson(res, 'portfolio_comment_post'))] };
|
|
590
|
+
});
|
|
521
591
|
}
|
|
@@ -1,3 +1,30 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { ApiContext } from '../config.js';
|
|
3
|
+
declare const LIST_STATUSES: readonly ["open", "done", "wontfix", "ai_flagged"];
|
|
4
|
+
type ListStatus = (typeof LIST_STATUSES)[number];
|
|
5
|
+
export interface RadbugNote {
|
|
6
|
+
id: string;
|
|
7
|
+
status: string;
|
|
8
|
+
priority: string | null;
|
|
9
|
+
area: string | null;
|
|
10
|
+
pageUrl: string | null;
|
|
11
|
+
description: string;
|
|
12
|
+
createdAt: number | null;
|
|
13
|
+
updatedAt: number | null;
|
|
14
|
+
hasScreenshot: boolean;
|
|
15
|
+
rawMessage?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function fetchRadbugs(ctx: ApiContext, opts?: {
|
|
18
|
+
status?: ListStatus;
|
|
19
|
+
area?: string;
|
|
20
|
+
priority?: 'low' | 'med' | 'high';
|
|
21
|
+
limit?: number;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
forbidden: true;
|
|
24
|
+
reason: string;
|
|
25
|
+
} | {
|
|
26
|
+
forbidden: false;
|
|
27
|
+
notes: RadbugNote[];
|
|
28
|
+
}>;
|
|
3
29
|
export declare function registerRadbugsTools(server: McpServer, ctx: ApiContext): void;
|
|
30
|
+
export {};
|
|
@@ -16,7 +16,62 @@ async function forbiddenText(res) {
|
|
|
16
16
|
function textResult(data) {
|
|
17
17
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
18
18
|
}
|
|
19
|
+
const LIST_STATUSES = ['open', 'done', 'wontfix', 'ai_flagged'];
|
|
20
|
+
const LIST_FORBIDDEN = 'radbug_list is owner-only: it needs the site owner\'s account and a token with the radbugs:read scope (or an unscoped token).';
|
|
21
|
+
const ms = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
22
|
+
export async function fetchRadbugs(ctx, opts = {}) {
|
|
23
|
+
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 500);
|
|
24
|
+
const q = [`limit=${encodeURIComponent(String(opts.status === 'done' ? 500 : limit))}`];
|
|
25
|
+
if (opts.status && opts.status !== 'done')
|
|
26
|
+
q.push(`status=${encodeURIComponent(opts.status)}`);
|
|
27
|
+
if (opts.area)
|
|
28
|
+
q.push(`area=${encodeURIComponent(opts.area)}`);
|
|
29
|
+
if (opts.priority)
|
|
30
|
+
q.push(`priority=${encodeURIComponent(opts.priority)}`);
|
|
31
|
+
const res = await api(ctx, `/api/admin/radbugs?${q.join('&')}`);
|
|
32
|
+
if (res.status === 403 || res.status === 401)
|
|
33
|
+
return { forbidden: true, reason: LIST_FORBIDDEN };
|
|
34
|
+
const body = await expectJson(res, 'GET /api/admin/radbugs');
|
|
35
|
+
const raw = Array.isArray(body.notes) ? body.notes : [];
|
|
36
|
+
const notes = [];
|
|
37
|
+
for (const r of raw) {
|
|
38
|
+
if (!r || typeof r.id !== 'string')
|
|
39
|
+
continue;
|
|
40
|
+
const status = String(r.status ?? '');
|
|
41
|
+
if (opts.status && status !== opts.status)
|
|
42
|
+
continue;
|
|
43
|
+
notes.push({
|
|
44
|
+
id: r.id, status,
|
|
45
|
+
priority: typeof r.priority === 'string' ? r.priority : null,
|
|
46
|
+
area: typeof r.area === 'string' ? r.area : null,
|
|
47
|
+
pageUrl: typeof r.pageUrl === 'string' ? r.pageUrl.slice(0, 2048) : null,
|
|
48
|
+
description: String(r.description ?? '').slice(0, 2000),
|
|
49
|
+
createdAt: ms(r.createdAt), updatedAt: ms(r.updatedAt),
|
|
50
|
+
hasScreenshot: !!(r.screenshot || (Array.isArray(r.screenshots) && r.screenshots.length)),
|
|
51
|
+
rawMessage: typeof r.rawMessage === 'string' ? r.rawMessage.slice(0, 500) : null,
|
|
52
|
+
});
|
|
53
|
+
if (notes.length >= limit)
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
return { forbidden: false, notes };
|
|
57
|
+
}
|
|
19
58
|
export function registerRadbugsTools(server, ctx) {
|
|
59
|
+
server.registerTool('radbug_list', {
|
|
60
|
+
title: 'List radBUGS',
|
|
61
|
+
description: 'List radBUGS from the non.bot inbox, newest first (open ones lead): id (ti_…), status (open | ai_flagged | done | wontfix), priority, area, page URL, description (clipped to 2 000 chars), created / updated time (epoch ms). `done` includes bugs closed from the inbox. Screenshots are left out. Owner-only: the site owner\'s account with a radbugs:read (or unscoped) token.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
status: z.enum(LIST_STATUSES).optional().describe('Only this status; "done" = closed.'),
|
|
64
|
+
area: z.string().max(80).optional(),
|
|
65
|
+
priority: z.enum(['low', 'med', 'high']).optional(),
|
|
66
|
+
limit: z.number().int().min(1).max(500).optional().describe('Default 200.'),
|
|
67
|
+
},
|
|
68
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
69
|
+
}, async (a) => {
|
|
70
|
+
const r = await fetchRadbugs(ctx, a);
|
|
71
|
+
if (r.forbidden)
|
|
72
|
+
return { content: [{ type: 'text', text: r.reason }], isError: true };
|
|
73
|
+
return textResult({ count: r.notes.length, notes: r.notes });
|
|
74
|
+
});
|
|
20
75
|
if (ctx.readonly)
|
|
21
76
|
return;
|
|
22
77
|
server.registerTool('radbug_file', {
|