@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,367 @@
1
+ import { z } from 'zod';
2
+ import { api, expectJson } from '../http.js';
3
+ import { validateGraph } from '../validate.js';
4
+ import { LEVELS, applyLayout, layoutTimeline, scheduleFields, todayColumn, columnOf, isItem, PX_PER_WEEK } from '../layout.js';
5
+ import { emptyIp, IP_KINDS, IP_NODE_SIZE } from '../ipShape.js';
6
+ const STATES = ['funnel', 'reviewing', 'analyzing', 'ready', 'implementing', 'done'];
7
+ const OKR_STATUSES = ['on-track', 'at-risk', 'behind'];
8
+ const RESERVED = new Set(['hierarchyLevel', 'parentItemId', 'linkedEpicIds', 'id', '__proto__', 'constructor', 'prototype']);
9
+ const MAX_ITEMS = 200;
10
+ const MAX_ASSET_BYTES = 2_200_000;
11
+ const text = (obj) => ({ type: 'text', text: JSON.stringify(obj, null, 2) });
12
+ const fail = (msg) => ({ content: [{ type: 'text', text: msg }], isError: true });
13
+ const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
14
+ 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
+ export function newItemId(now = Date.now()) { return `pf-${now}-${Math.random().toString(36).slice(2, 11)}`; }
16
+ async function loadGraph(ctx, id) {
17
+ const data = await expectJson(await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(id)}`), 'load portfolio');
18
+ const g = (data.graph ?? data);
19
+ return {
20
+ name: String(data.name ?? g.name ?? ''),
21
+ description: String(data.description ?? g.description ?? ''),
22
+ nodes: Array.isArray(g.nodes) ? g.nodes : [],
23
+ edges: Array.isArray(g.edges) ? g.edges : [],
24
+ viewport: g.viewport ?? { x: 0, y: 0, zoom: 1 },
25
+ version: typeof g.version === 'number' ? g.version : undefined,
26
+ };
27
+ }
28
+ async function saveGraph(ctx, id, before, after, expectedVersion) {
29
+ const problems = validateGraph(after.nodes, after.edges);
30
+ if (problems.length)
31
+ return { ok: false, message: `Refusing to write — ${problems.length} problem(s):\n` + problems.map(p => ` - ${p}`).join('\n') };
32
+ if (ctx.backup) {
33
+ try {
34
+ await ctx.backup(id, before);
35
+ }
36
+ catch { }
37
+ }
38
+ const res = await api(ctx, '/api/portfolio/graph', {
39
+ method: 'PUT',
40
+ body: JSON.stringify({ id, name: after.name, description: after.description, nodes: after.nodes, edges: after.edges, viewport: after.viewport, expectedVersion: expectedVersion ?? before.version }),
41
+ });
42
+ if (res.status === 409) {
43
+ const c = (await res.json().catch(() => ({})));
44
+ return { ok: false, message: `Refused (409) — the portfolio changed since it was read (now version ${c.currentVersion ?? '?'}). Nothing was written; call again.` };
45
+ }
46
+ const out = await expectJson(res, 'save portfolio');
47
+ return { ok: true, version: typeof out.version === 'number' ? out.version : undefined };
48
+ }
49
+ const itemSpec = z.object({
50
+ key: z.string().max(64).optional().describe('A local name for this item, so other items in the SAME call can name it as parent / okr / depends_on before it has an id.'),
51
+ level: z.enum(LEVELS),
52
+ title: z.string().min(1).max(200),
53
+ description: z.string().max(20000).optional().describe('Plain text (blank lines = paragraphs) or HTML.'),
54
+ bluf: z.string().max(4000).optional().describe('Bottom line up front — the sentence the card shows.'),
55
+ acceptance_criteria: z.string().max(8000).optional(),
56
+ hypothesis: z.string().max(4000).optional().describe('Hypothesis statement ("We believe … will … measured by …").'),
57
+ state: z.enum(STATES).optional().describe('Default funnel.'),
58
+ parent: z.string().optional().describe('Parent item id, or a key from this call. Epic → feature/enabler/milestone, feature → story/enabler/milestone, objective → key-result.'),
59
+ okr: z.array(z.string()).max(10).optional().describe('Epics only: key-result ids/keys this epic contributes to (an okr-link edge + the KR roll-up).'),
60
+ depends_on: z.array(z.string()).max(20).optional().describe('Finish-to-start predecessors (ids or keys).'),
61
+ start_week: z.number().int().min(-52).max(156).optional().describe('Start, in weeks from the Monday of the current week (negative = past). Ignored when start_date is given.'),
62
+ start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
63
+ 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
+ tags: z.array(z.string().max(40)).max(20).optional(),
65
+ 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(),
67
+ kr_target: z.string().max(200).optional(),
68
+ kr_current: z.string().max(200).optional(),
69
+ okr_status: z.enum(OKR_STATUSES).optional(),
70
+ images: z.array(z.string().max(2048)).max(8).optional().describe('Image URLs from portfolio_asset_upload — the first is the card hero.'),
71
+ fields: z.record(z.unknown()).optional().describe('Any other PortfolioItemData fields, merged last (structural keys are refused).'),
72
+ });
73
+ const DEFAULT_WEEKS = { epic: 8, feature: 4, story: 1, enabler: 2, milestone: 0, objective: 12, 'key-result': 12 };
74
+ const LEGAL_PARENT = {
75
+ epic: [], objective: [], 'key-result': ['objective'], feature: ['epic'], story: ['feature', 'epic'], enabler: ['epic', 'feature'], milestone: ['epic', 'feature'],
76
+ };
77
+ export function buildItems(g, specs, now = new Date()) {
78
+ const errors = [];
79
+ const ids = {};
80
+ const t0 = now.getTime();
81
+ const idOf = specs.map((s, i) => { const id = newItemId(t0 + i); ids[s.key ?? `#${i}`] = id; return id; });
82
+ const levelById = new Map(g.nodes.filter(isItem).map(n => [n.id, String(n.data?.hierarchyLevel)]));
83
+ specs.forEach((s, i) => levelById.set(idOf[i], s.level));
84
+ const resolve = (ref) => ids[ref] ?? (levelById.has(ref) ? ref : null);
85
+ const base = todayColumn(now);
86
+ const nodes = [];
87
+ const edges = [];
88
+ const krLinks = new Map();
89
+ specs.forEach((s, i) => {
90
+ const self = idOf[i];
91
+ const label = s.key ?? s.title;
92
+ const d = { hierarchyLevel: s.level, title: s.title, description: s.description ? toHtml(s.description) : '', state: s.state ?? 'funnel', createdAt: t0, updatedAt: t0 };
93
+ if (s.parent) {
94
+ const p = resolve(s.parent);
95
+ if (!p)
96
+ errors.push(`${label}: parent "${s.parent}" is neither an id in the portfolio nor a key in this call`);
97
+ else if (!(LEGAL_PARENT[s.level] ?? []).includes(levelById.get(p) ?? ''))
98
+ errors.push(`${label}: a ${s.level} cannot sit under a ${levelById.get(p)} (allowed: ${(LEGAL_PARENT[s.level] ?? []).join(', ') || 'none — top level'})`);
99
+ else {
100
+ d.parentItemId = p;
101
+ edges.push({ id: `edge-${p}-${self}`, source: p, target: self, dependencyType: 'hierarchy' });
102
+ }
103
+ }
104
+ const weeks = s.duration_weeks ?? DEFAULT_WEEKS[s.level] ?? 1;
105
+ const col = s.start_date ? columnOf(new Date(Number(s.start_date.slice(0, 4)), Number(s.start_date.slice(5, 7)) - 1, Number(s.start_date.slice(8, 10))), now) : base + (s.start_week ?? 0);
106
+ Object.assign(d, scheduleFields(col, weeks, now));
107
+ if (s.bluf)
108
+ d.bluf = toHtml(s.bluf);
109
+ if (s.acceptance_criteria)
110
+ d.acceptanceCriteria = toHtml(s.acceptance_criteria);
111
+ if (s.hypothesis)
112
+ d.hypothesisStatement = s.hypothesis;
113
+ if (s.tags)
114
+ d.tags = s.tags;
115
+ if (s.owner)
116
+ d.owner = s.owner;
117
+ if (s.wsjf)
118
+ Object.assign(d, { wsjfBusinessValue: s.wsjf[0], wsjfTimeCriticality: s.wsjf[1], wsjfRiskReduction: s.wsjf[2], wsjfJobSize: s.wsjf[3] });
119
+ if (s.kr_target)
120
+ d.krTarget = s.kr_target;
121
+ if (s.kr_current)
122
+ d.krCurrent = s.kr_current;
123
+ if (s.okr_status)
124
+ d.okrStatus = s.okr_status;
125
+ if (s.images?.length)
126
+ d.pastedImages = s.images;
127
+ if (s.level === 'epic')
128
+ d.innerLoopExpanded = false;
129
+ for (const [k, v] of Object.entries(s.fields ?? {})) {
130
+ if (RESERVED.has(k))
131
+ errors.push(`${label}: fields.${k} is structural — use the tool's own parameter`);
132
+ else
133
+ d[k] = v;
134
+ }
135
+ for (const kr of s.okr ?? []) {
136
+ const k = resolve(kr);
137
+ if (s.level !== 'epic')
138
+ errors.push(`${label}: okr links are for epics`);
139
+ else if (!k || levelById.get(k) !== 'key-result')
140
+ errors.push(`${label}: okr "${kr}" is not a key result`);
141
+ else {
142
+ edges.push({ id: `edge-${k}-${self}`, source: k, target: self, dependencyType: 'okr-link' });
143
+ krLinks.set(k, [...(krLinks.get(k) ?? []), self]);
144
+ }
145
+ }
146
+ for (const dep of s.depends_on ?? []) {
147
+ const p = resolve(dep);
148
+ if (!p)
149
+ errors.push(`${label}: depends_on "${dep}" not found`);
150
+ else
151
+ edges.push({ id: `edge-${p}-${self}`, source: p, target: self, dependencyType: 'finish-to-start', label: 'FS' });
152
+ }
153
+ nodes.push({ id: self, type: 'portfolio-item', position: { x: col * PX_PER_WEEK, y: 0 }, data: d });
154
+ });
155
+ const patched = new Map();
156
+ for (const [kr, epics] of krLinks) {
157
+ const fresh = nodes.find(n => n.id === kr);
158
+ const old = g.nodes.find(n => n.id === kr);
159
+ const data = { ...((fresh ?? old)?.data ?? {}) };
160
+ data.linkedEpicIds = [...new Set([...(data.linkedEpicIds ?? []), ...epics])];
161
+ if (fresh)
162
+ fresh.data = data;
163
+ else if (old)
164
+ patched.set(kr, data);
165
+ }
166
+ return { nodes, edges, ids, errors, patched };
167
+ }
168
+ export function registerBuildTools(server, ctx) {
169
+ if (ctx.readonly)
170
+ return;
171
+ server.registerTool('portfolio_create', {
172
+ title: 'Create an empty portfolio',
173
+ description: 'Create a new, empty portfolio owned by the caller and return its id + version. Fill it with portfolio_add_items (any level) and ip_create; set its context text and palette with portfolio_set_meta.',
174
+ inputSchema: {
175
+ name: z.string().min(1).max(200),
176
+ description: z.string().max(2000).optional(),
177
+ status: z.enum(['active', 'draft']).optional().describe('Default active (the CREATE path alone leaves a new row as draft).'),
178
+ },
179
+ outputSchema: { id: z.string(), version: z.number().optional(), url: z.string(), status: z.string() },
180
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
181
+ }, async (a) => {
182
+ const out = await expectJson(await api(ctx, '/api/portfolio/graph', {
183
+ method: 'PUT', body: JSON.stringify({ name: a.name, description: a.description ?? '', nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
184
+ }), 'portfolio_create');
185
+ const id = String(out.id ?? '');
186
+ if (!id)
187
+ return fail('portfolio_create: the server did not return an id.');
188
+ const status = a.status ?? 'active';
189
+ if (status !== 'draft')
190
+ await expectJson(await api(ctx, '/api/portfolio/graph', { method: 'PUT', body: JSON.stringify({ id, metaOnly: true, status }) }), 'portfolio_create (status)');
191
+ const structured = { id, ...(typeof out.version === 'number' ? { version: out.version } : {}), url: `${ctx.apiBase}/portfolio/${id}`, status };
192
+ return { content: [text(structured)], structuredContent: structured };
193
+ });
194
+ server.registerTool('portfolio_add_items', {
195
+ title: 'Add items to a portfolio',
196
+ description: 'Add up to 200 items of any level (objective, key-result, epic, feature, story, enabler, milestone) in one versioned write. Parents, OKR links and dependencies may name existing ids OR `key`s of other items in the same call, so a whole tree goes in at once. The tool makes the ids, the hierarchy / okr-link / finish-to-start edges, the dates and sprint labels from start_week + duration_weeks, and (unless layout is "none") re-lays the whole portfolio on the Timeline so nothing overlaps. Returns key → id. Rich text fields accept plain text or HTML.',
197
+ inputSchema: {
198
+ portfolio_id: z.string(),
199
+ items: z.array(itemSpec).min(1).max(MAX_ITEMS),
200
+ layout: z.enum(['timeline', 'none']).optional().describe('Default timeline: re-lay every item after adding. "none": new items get their date x and y 0.'),
201
+ expected_version: z.number().int().optional().describe('Optional OCC guard; default = the version read at call time.'),
202
+ },
203
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
204
+ }, async (a) => {
205
+ const g = await loadGraph(ctx, a.portfolio_id);
206
+ const keys = a.items.map(s => s.key).filter(Boolean);
207
+ if (new Set(keys).size !== keys.length)
208
+ return fail('Two items share a key — keys must be unique within one call.');
209
+ const built = buildItems(g, a.items);
210
+ if (built.errors.length)
211
+ return fail(`Nothing was written — ${built.errors.length} problem(s):\n` + built.errors.map(e => ` - ${e}`).join('\n'));
212
+ let nodes = [...g.nodes.map(n => built.patched.has(n.id) ? { ...n, data: built.patched.get(n.id) } : n), ...built.nodes];
213
+ if ((a.layout ?? 'timeline') === 'timeline')
214
+ nodes = applyLayout(nodes, layoutTimeline(nodes));
215
+ const after = { ...g, nodes, edges: [...g.edges, ...built.edges] };
216
+ const r = await saveGraph(ctx, a.portfolio_id, g, after, a.expected_version);
217
+ if (!r.ok)
218
+ return fail(r.message);
219
+ const structured = { ok: true, added: built.nodes.length, edges: built.edges.length, ids: built.ids, ...(r.version !== undefined ? { version: r.version } : {}) };
220
+ return { content: [text(structured)], structuredContent: structured };
221
+ });
222
+ server.registerTool('portfolio_patch_item', {
223
+ 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.',
225
+ inputSchema: {
226
+ portfolio_id: z.string(),
227
+ item_id: z.string(),
228
+ set: z.record(z.unknown()).optional(),
229
+ unset: z.array(z.string()).max(40).optional(),
230
+ expected_version: z.number().int().optional(),
231
+ },
232
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
233
+ }, async (a) => {
234
+ if (!a.set && !a.unset)
235
+ return fail('Provide set and/or unset.');
236
+ let version = a.expected_version;
237
+ if (version === undefined)
238
+ version = (await loadGraph(ctx, a.portfolio_id)).version;
239
+ const set = {};
240
+ for (const [k, v] of Object.entries(a.set ?? {}))
241
+ set[k] = typeof v === 'string' && ['description', 'bluf', 'acceptanceCriteria'].includes(k) ? toHtml(v) : v;
242
+ const res = await api(ctx, '/api/portfolio/graph/node', { method: 'PATCH', body: JSON.stringify({ graphId: a.portfolio_id, nodeId: a.item_id, expectedVersion: version, ...(a.set ? { set } : {}), ...(a.unset ? { unset: a.unset } : {}) }) });
243
+ if (res.status === 409)
244
+ return fail('Refused (409) — the portfolio changed since it was read. Call again.');
245
+ const out = await expectJson(res, 'portfolio_patch_item');
246
+ const structured = { ok: true, ...(typeof out.version === 'number' ? { version: out.version } : {}) };
247
+ return { content: [text(structured)], structuredContent: structured };
248
+ });
249
+ server.registerTool('portfolio_layout', {
250
+ 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.',
252
+ inputSchema: {
253
+ portfolio_id: z.string(),
254
+ heights: z.record(z.number().min(20).max(2000)).optional(),
255
+ expected_version: z.number().int().optional(),
256
+ },
257
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
258
+ }, async (a) => {
259
+ const g = await loadGraph(ctx, a.portfolio_id);
260
+ const res = layoutTimeline(g.nodes, { heights: a.heights });
261
+ const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyLayout(g.nodes, res) }, a.expected_version);
262
+ if (!r.ok)
263
+ return fail(r.message);
264
+ const structured = { ok: true, positioned: res.positions.size, ...(r.version !== undefined ? { version: r.version } : {}) };
265
+ return { content: [text(structured)], structuredContent: structured };
266
+ });
267
+ server.registerTool('portfolio_set_meta', {
268
+ title: 'Set portfolio settings',
269
+ 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.',
270
+ inputSchema: {
271
+ portfolio_id: z.string(),
272
+ context_text: z.string().max(20000).optional(),
273
+ palette: z.object({ id: z.string().min(1).max(64), name: z.string().min(1).max(64), swatches: z.array(z.object({ hex: z.string().regex(/^#[0-9a-fA-F]{6}$/), label: z.string().max(32).optional() })).max(10), roles: z.record(z.string()).optional() }).optional(),
274
+ canvas_style: z.record(z.unknown()).optional(),
275
+ sprint_settings: z.record(z.unknown()).optional(),
276
+ expected_version: z.number().int().optional(),
277
+ },
278
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
279
+ }, async (a) => {
280
+ const g = await loadGraph(ctx, a.portfolio_id);
281
+ const cur = g.nodes.find(n => n.id === '__graph_meta__' || n.type === 'graph-meta');
282
+ const data = { ...(cur?.data ?? {}) };
283
+ if (a.context_text !== undefined)
284
+ data.contextText = a.context_text;
285
+ if (a.palette)
286
+ data.palette = { ...a.palette, swatches: a.palette.swatches.map(s => ({ ...s, hex: s.hex.toUpperCase() })), roles: a.palette.roles ?? {} };
287
+ if (a.canvas_style)
288
+ data.canvasStyle = { ...(data.canvasStyle ?? {}), ...a.canvas_style };
289
+ if (a.sprint_settings)
290
+ data.sprintSettings = { ...(data.sprintSettings ?? {}), ...a.sprint_settings };
291
+ const meta = { id: '__graph_meta__', type: 'graph-meta', position: { x: 0, y: 0 }, data };
292
+ const nodes = cur ? g.nodes.map(n => n === cur ? meta : n) : [...g.nodes, meta];
293
+ const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
294
+ if (!r.ok)
295
+ return fail(r.message);
296
+ const structured = { ok: true, keys: Object.keys(data), ...(r.version !== undefined ? { version: r.version } : {}) };
297
+ return { content: [text(structured)], structuredContent: structured };
298
+ });
299
+ server.registerTool('portfolio_asset_upload', {
300
+ title: 'Upload an image',
301
+ description: 'Store one image (PNG / JPEG / WebP / GIF, ≤ 2.2 MB — downscale first; the app keeps images at 1024 px JPEG) in your private asset store and get back its URL, for an item\'s `images` (card hero) or IP evidence (`ip_evidence_add` kind image with {asset: key}). Pass `data_url`, or `file_path` when this server runs locally (stdio).',
302
+ inputSchema: {
303
+ data_url: z.string().regex(/^data:image\/(png|jpeg|webp|gif);base64,/).optional(),
304
+ file_path: z.string().max(1024).optional(),
305
+ },
306
+ outputSchema: { url: z.string(), key: z.string().optional(), bytes: z.number() },
307
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
308
+ }, async (a) => {
309
+ let dataUrl = a.data_url;
310
+ if (!dataUrl && a.file_path) {
311
+ if (!ctx.readLocalImage)
312
+ return fail('file_path is only available when the MCP server runs locally — pass data_url instead.');
313
+ dataUrl = await ctx.readLocalImage(a.file_path);
314
+ }
315
+ if (!dataUrl)
316
+ return fail('Provide data_url or file_path.');
317
+ const bytes = Math.floor((dataUrl.length - dataUrl.indexOf(',') - 1) * 3 / 4);
318
+ if (bytes > MAX_ASSET_BYTES)
319
+ return fail(`The image is ${(bytes / 1e6).toFixed(1)} MB; the limit is 2.2 MB. Downscale it (1024 px JPEG is what the app stores) and retry.`);
320
+ const out = await expectJson(await api(ctx, '/api/portfolio/storyboard-assets', { method: 'POST', body: JSON.stringify({ dataUrl }) }), 'portfolio_asset_upload');
321
+ const url = out.url ? String(out.url) : out.key ? `/api/portfolio/asset?key=${encodeURIComponent(String(out.key))}` : '';
322
+ if (!url)
323
+ return fail('portfolio_asset_upload: the server returned no url.');
324
+ const structured = { url, ...(out.key ? { key: String(out.key) } : {}), bytes };
325
+ return { content: [text(structured)], structuredContent: structured };
326
+ });
327
+ server.registerTool('ip_create', {
328
+ title: 'Create an IP node',
329
+ description: 'Add an IP node — a patent or trademark disclosure — to a portfolio, wired to its source items (the cards the disclosure is prepared FROM: their title, description, BLUF, acceptance criteria and hypothesis). Placed above its first source like the canvas does, with an empty disclosure bag; then fill it in the app (Continue → prefills from the sources on your key) or with ip_update. Requires the IP node feature to be on for the caller.',
330
+ inputSchema: {
331
+ portfolio_id: z.string(),
332
+ track: z.enum(['patent', 'trademark']),
333
+ kind: z.enum(['provisional', 'nonprovisional', 'design', 'application']).optional().describe('patent: provisional (default) / nonprovisional / design; trademark: application.'),
334
+ title: z.string().max(200).optional().describe('The filing\'s working title shown on the card.'),
335
+ sources: z.array(z.string()).min(1).max(20).describe('Item ids the disclosure reads from (first = the one it sits above).'),
336
+ layout: z.enum(['timeline', 'none']).optional().describe('Default timeline: re-lay the portfolio so the node gets its own row above its source. "none": place it 40 px above the source and move nothing else.'),
337
+ expected_version: z.number().int().optional(),
338
+ },
339
+ outputSchema: { nodeId: z.string(), version: z.number().optional() },
340
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
341
+ }, async (a) => {
342
+ const track = a.track;
343
+ if (a.kind && !IP_KINDS[track].includes(a.kind))
344
+ return fail(`kind ${a.kind} is not a ${track} kind (${IP_KINDS[track].join(', ')}).`);
345
+ const g = await loadGraph(ctx, a.portfolio_id);
346
+ const items = new Map(g.nodes.filter(isItem).map(n => [n.id, n]));
347
+ const missing = a.sources.filter(s => !items.has(s));
348
+ if (missing.length)
349
+ return fail(`Not items in this portfolio: ${missing.join(', ')}`);
350
+ const src = items.get(a.sources[0]);
351
+ const nodeId = `radmod-ip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
352
+ const bag = { ...emptyIp(track, a.kind), title: a.title ?? '' };
353
+ const node = {
354
+ id: nodeId, type: 'rad-module',
355
+ position: { x: (src.position?.x ?? 0) + 8 - IP_NODE_SIZE.width / 2, y: (src.position?.y ?? 0) - IP_NODE_SIZE.height - 40 },
356
+ data: { subtype: 'ip', moduleData: bag, width: IP_NODE_SIZE.width, height: IP_NODE_SIZE.height, attachedItemIds: [...new Set(a.sources)] },
357
+ };
358
+ let nodes = [...g.nodes, node];
359
+ if ((a.layout ?? 'timeline') === 'timeline')
360
+ nodes = applyLayout(nodes, layoutTimeline(nodes));
361
+ const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
362
+ if (!r.ok)
363
+ return fail(r.message);
364
+ const structured = { nodeId, ...(r.version !== undefined ? { version: r.version } : {}) };
365
+ return { content: [text(structured)], structuredContent: structured };
366
+ });
367
+ }
@@ -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 registerIpTools(server: McpServer, ctx: ApiContext): void;
@@ -0,0 +1,130 @@
1
+ import { z } from 'zod';
2
+ import { api, expectJson } from '../http.js';
3
+ const READ = { readOnlyHint: true, openWorldHint: true };
4
+ const WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
5
+ const SUPPORTS = ['problem', 'mechanism', 'novelty', 'RTP', 'disclosure'];
6
+ const SECTIONS = ['whatsnew', 'spec', 'numerals', 'search'];
7
+ const KINDS = ['note', 'email', 'commit', 'field', 'doc', 'meeting', 'image'];
8
+ const textResult = (data) => ({ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] });
9
+ const q = (o) => new URLSearchParams(o).toString();
10
+ const ids = { portfolio_id: z.string().min(1).max(128).describe('Portfolio id.'), node_id: z.string().min(1).max(128).describe('The IP node id (radmod-ip-…).') };
11
+ async function getJson(ctx, path) {
12
+ return expectJson(await api(ctx, path), `GET ${path}`);
13
+ }
14
+ async function patchNode(ctx, body) {
15
+ return expectJson(await api(ctx, '/api/ip/node', { method: 'PATCH', body: JSON.stringify(body) }), 'PATCH /api/ip/node');
16
+ }
17
+ export function registerIpTools(server, ctx) {
18
+ server.registerTool('ip_list', {
19
+ title: 'List IP nodes',
20
+ description: 'Every IP node (patent or trademark disclosure) in a portfolio: id, title, track, kind, started, live source count, live evidence count, filing date. Read before ip_get.',
21
+ inputSchema: { portfolio_id: ids.portfolio_id },
22
+ annotations: READ,
23
+ }, async (a) => textResult(await getJson(ctx, `/api/ip/nodes?${q({ portfolioId: a.portfolio_id })}`)));
24
+ server.registerTool('ip_get', {
25
+ title: 'Read one IP node',
26
+ description: 'One IP node\'s full disclosure bag (units with status, value, provenance; evidence incl. tombstones; events; filing) plus the graph version to pass as expected_version, and its live sources.',
27
+ inputSchema: ids,
28
+ annotations: READ,
29
+ }, async (a) => textResult(await getJson(ctx, `/api/ip/nodes?${q({ portfolioId: a.portfolio_id, nodeId: a.node_id })}`)));
30
+ server.registerTool('ip_evidence_list', {
31
+ title: 'List IP evidence with verdicts',
32
+ description: 'Evidence rows from the saved graph, tombstones included, with a seal verdict per row (true = sealed by this server, false = not, null = no signing key) and a witness verdict per row carrying a request.',
33
+ inputSchema: ids,
34
+ annotations: READ,
35
+ }, async (a) => textResult(await getJson(ctx, `/api/ip/evidence?${q({ portfolioId: a.portfolio_id, nodeId: a.node_id })}`)));
36
+ server.registerTool('ip_evidence_hashes', {
37
+ title: 'Export the evidence hash list',
38
+ description: 'The Export-hash-list file: one `id · sha256 · added(ISO)` line per row (never changed once written) plus a `#` trailer naming removed, missing, unsealed or altered rows.',
39
+ inputSchema: ids,
40
+ annotations: READ,
41
+ }, async (a) => {
42
+ const path = `/api/ip/evidence/hashes?${q({ portfolioId: a.portfolio_id, nodeId: a.node_id })}`;
43
+ const res = await api(ctx, path);
44
+ if (!res.ok)
45
+ await expectJson(res, `GET ${path}`);
46
+ return textResult(await res.text());
47
+ });
48
+ server.registerTool('ip_packet', {
49
+ title: 'List the packet documents',
50
+ description: 'The filing packet of an IP node as the Packet step shows it, rendered by the server from the saved graph: each document\'s id, name, status (queued / ready / accepted / needs_you) and formats, plus how many evidence copies the zip would bundle. Read a document with ip_packet_document. The .docx and .zip files are assembled in the browser (Packet step) — not available here.',
51
+ inputSchema: ids,
52
+ annotations: READ,
53
+ }, async (a) => {
54
+ const p = await getJson(ctx, `/api/ip/packet?${q({ portfolioId: a.portfolio_id, nodeId: a.node_id })}`);
55
+ return textResult({
56
+ title: p.title,
57
+ documents: (p.docs ?? []).map(d => ({ id: d.id, name: d.packetName ?? d.name, status: d.status, formats: d.formats })),
58
+ bundle: { stored: p.bundle?.stored?.length ?? 0, text: p.bundle?.text?.length ?? 0, notBundled: p.bundle?.skipped?.length ?? 0 },
59
+ });
60
+ });
61
+ server.registerTool('ip_packet_document', {
62
+ title: 'Read one packet document',
63
+ description: 'One packet document\'s full text (markdown) and its text files (md / csv / txt / svg), rendered by the server from the saved graph. `document` is an id from ip_packet (disclosure, evidence, clocks, risk, inventorship, tradesecret, spec, figures, priorart, cover — or teas, ids, specimens, firstuse, owner, knockout for a trademark).',
64
+ inputSchema: { ...ids, document: z.string().min(1).max(32).regex(/^[a-z]+$/).describe('A document id from ip_packet.') },
65
+ annotations: READ,
66
+ }, async (a) => {
67
+ const p = await getJson(ctx, `/api/ip/packet?${q({ portfolioId: a.portfolio_id, nodeId: a.node_id, doc: a.document })}`);
68
+ const d = p.docs?.[0];
69
+ if (!d)
70
+ return textResult('That document does not exist on this node.');
71
+ return textResult([d.text, ...d.files.filter(f => !f.name.endsWith('.md')).map(f => `\n--- ${f.name} ---\n${f.text}`)].join('\n') + `\n\nfiles: ${d.files.map(f => f.name).join(', ')}`);
72
+ });
73
+ if (ctx.readonly)
74
+ return;
75
+ server.registerTool('ip_update', {
76
+ title: 'Edit an IP node (op list)',
77
+ description: 'Apply an op list to one IP node in ONE versioned write: unit.set {unitId, value, expectedRev} · unit.confirm · unit.unconfirm · evidence.update {evId, patch} · evidence.remove {evId} (a tombstone) · evidence.witness {evId, witness:{for,name,line}|null} · evidence.add (max 5 per request; identical adds collapse into one row) · event.upsert · event.remove · filing.set · meta.set. A stale expected_version is HTTP 409 version_conflict with currentVersion — re-read with ip_get and retry.',
78
+ inputSchema: { ...ids, expected_version: z.number().int().describe('The graph version from ip_get.'), ops: z.array(z.record(z.unknown())).min(1).max(50).refine(o => o.filter(x => x.op === 'evidence.add').length <= 5, 'At most 5 evidence.add ops per request.').describe('The op list.') },
79
+ annotations: WRITE,
80
+ }, async (a) => textResult(await patchNode(ctx, { portfolioId: a.portfolio_id, nodeId: a.node_id, expectedVersion: a.expected_version, ops: a.ops })));
81
+ server.registerTool('ip_evidence_add', {
82
+ title: 'Add evidence (server-sealed)',
83
+ description: 'Add one evidence row the SERVER hashes (SHA-256 of the stored copy), timestamps, seals and numbers. `from` is one source and must match `kind`: kind note or email takes {text} (at most 2000 characters), commit takes {commit:{sha?,repo?,pr?}}, field takes {field:{itemId,field}} for a source item\'s field, doc or meeting takes {module:{moduleId}} for an attached Document or radMeeting, image takes {asset} for your own uploaded image key. Returns the new row id in evIds. Two adds with identical bytes (same content, time and author) collapse into ONE row and report the same id, so add each distinct piece once; the server accepts at most 5 evidence.add ops per request (ip_update).',
84
+ inputSchema: {
85
+ ...ids,
86
+ expected_version: z.number().int().describe('The graph version from ip_get.'),
87
+ kind: z.enum(KINDS),
88
+ from: z.record(z.unknown()).describe('Exactly one source (see description).'),
89
+ title: z.string().min(1).max(300),
90
+ describes: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('The date the evidence describes (YYYY-MM-DD).'),
91
+ supports: z.array(z.enum(SUPPORTS)).max(5).optional(),
92
+ visibility: z.enum(['private', 'team', 'nda', 'public']).optional().describe('Who could see it at the time.'),
93
+ shows: z.string().max(600).optional(),
94
+ },
95
+ annotations: WRITE,
96
+ }, async (a) => {
97
+ const op = { op: 'evidence.add', kind: a.kind, from: a.from, title: a.title };
98
+ for (const k of ['describes', 'supports', 'visibility', 'shows'])
99
+ if (a[k] !== undefined)
100
+ op[k] = a[k];
101
+ return textResult(await patchNode(ctx, { portfolioId: a.portfolio_id, nodeId: a.node_id, expectedVersion: a.expected_version, ops: [op] }));
102
+ });
103
+ server.registerTool('ip_witness_candidates', {
104
+ title: 'List possible witnesses',
105
+ description: 'Members who may witness evidence on this node (id + name only): editors of the portfolio other than the caller, never someone listed as an inventor in field 5. Needs a write token (it is a step of asking for a witness), so it is absent in read-only mode. Use an id as `for` in an evidence.witness op.',
106
+ inputSchema: ids,
107
+ annotations: WRITE,
108
+ }, async (a) => textResult(await getJson(ctx, `/api/ip/witness-candidates?${q({ portfolioId: a.portfolio_id, nodeId: a.node_id })}`)));
109
+ server.registerTool('ip_build', {
110
+ title: 'Build the draft (one model pass)',
111
+ description: 'Runs `Build draft →` for a PATENT IP node on YOUR OWN model key (the token owner\'s Anthropic or Gemini key — non.bot never pays for it; no key = HTTP 402 no_key). Each call spends YOUR OWN model usage (draft_mode best = the larger, costlier model): one model pass that writes the What\'s-new elements, the specification paragraphs (each citing the disclosure / code / evidence it rests on, or shown unverified), the reference numerals and prior-art SEARCH SEEDS, then saves them to the node with OCC on expected_version (HTTP 409 version_conflict with currentVersion → re-read with ip_get). Every ★ disclosure field must be confirmed first (HTTP 400 gate). A trademark packet needs no model (HTTP 400 trademark_no_model). `sections` limits the pass (default all four). During a live collaborative session the saved draft can be overwritten by that session — prefer running when no one has the canvas open.',
112
+ inputSchema: {
113
+ ...ids,
114
+ expected_version: z.number().int().describe('The graph version from ip_get.'),
115
+ sections: z.array(z.enum(SECTIONS)).min(1).max(4).optional(),
116
+ steer: z.string().max(300).optional().describe('A short steer, e.g. "shorter background".'),
117
+ draft_mode: z.enum(['fast', 'best']).optional(),
118
+ },
119
+ annotations: WRITE,
120
+ }, async (a) => {
121
+ const body = { portfolioId: a.portfolio_id, nodeId: a.node_id, persist: true, expectedVersion: a.expected_version };
122
+ if (a.sections)
123
+ body.sections = a.sections;
124
+ if (a.steer)
125
+ body.steer = a.steer;
126
+ if (a.draft_mode)
127
+ body.draftMode = a.draft_mode;
128
+ return textResult(await expectJson(await api(ctx, '/api/ip/build', { method: 'POST', body: JSON.stringify(body) }), 'POST /api/ip/build'));
129
+ });
130
+ }
@@ -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 registerMonitorTools(server: McpServer, ctx: ApiContext): void;