@nonbot/mcp 0.1.0 → 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.
package/README.md CHANGED
@@ -100,7 +100,7 @@ Ask your agent to "list my non.bot portfolios", "show what rules apply before I
100
100
  ## Tools
101
101
 
102
102
  <!-- tool-table:start — generated by `npm run gen:tools`; tests/readme-tool-table.test.ts fails if this drifts from the code -->
103
- ### Portfolio (18)
103
+ ### Portfolio (24)
104
104
 
105
105
  The SAFe portfolio canvas: epics, features, stories, snapshots, personas, and the Run launch surface.
106
106
 
@@ -124,13 +124,20 @@ The SAFe portfolio canvas: epics, features, stories, snapshots, personas, and th
124
124
  | `portfolio_create_activation` | Create an activation (a ▶ Run launch request) for a story. | write | `portfolio:write` + `runs:fire` |
125
125
  | `portfolio_update_activation_status` | Transition an activation: pending → running \| failed \| cancelled. | write | `portfolio:write` |
126
126
  | `portfolio_snapshot` | Save a labeled version snapshot of a portfolio’s CURRENT graph — a time-travel restore point. | write | `portfolio:write` |
127
+ | `portfolio_create` | Create a new, empty portfolio owned by the caller and return its id + version. | write | `portfolio:write` |
128
+ | `portfolio_add_items` | Add up to 200 items of any level (objective, key-result, epic, feature, story, enabler, milestone) in one versioned write. | write | `portfolio:write` |
129
+ | `portfolio_patch_item` | Change fields of ONE portfolio item without rewriting the graph (PATCH /api/portfolio/graph/node): `set` writes allowlisted PortfolioItemData fields (title, de… | write | `portfolio:write` |
130
+ | `portfolio_layout` | 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… | write | `portfolio:write` |
131
+ | `portfolio_set_meta` | Graph-level settings that travel with the portfolio (the `__graph_meta__` record): `context_text` (the portfolio's context notes — what a newcomer reads first)… | write | `portfolio:write` |
132
+ | `portfolio_asset_upload` | 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, f… | write | `portfolio:write` |
127
133
 
128
- ### IP node (10)
134
+ ### IP node (11)
129
135
 
130
136
  Patent and trademark disclosures on the canvas: read the bag, edit it as an op list, add server-sealed evidence, export the hash list.
131
137
 
132
138
  | Tool | What it does | Kind | PAT scope |
133
139
  |---|---|---|---|
140
+ | `ip_create` | 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, descript… | write | `portfolio:write` |
134
141
  | `ip_list` | Every IP node (patent or trademark disclosure) in a portfolio: id, title, track, kind, started, live source count, live evidence count, filing date. | read | `portfolio:read` |
135
142
  | `ip_get` | 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_… | read | `portfolio:read` |
136
143
  | `ip_evidence_list` | 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 w… | read | `portfolio:read` |
@@ -4,5 +4,6 @@ export interface ApiContext {
4
4
  getToken: () => string;
5
5
  readonly: boolean;
6
6
  backup?: (id: string, json: unknown) => Promise<void>;
7
+ readLocalImage?: (path: string) => Promise<string>;
7
8
  fetchImpl?: typeof fetch;
8
9
  }
@@ -0,0 +1,7 @@
1
+ export type IpTrack = 'patent' | 'trademark';
2
+ export declare const IP_KINDS: Record<IpTrack, string[]>;
3
+ export declare const IP_NODE_SIZE: {
4
+ readonly width: 340;
5
+ readonly height: 184;
6
+ };
7
+ export declare function emptyIp(track: IpTrack, kind?: string): Record<string, unknown>;
@@ -0,0 +1,45 @@
1
+ const UNIT_SHAPES = {
2
+ patent: [
3
+ { id: 'u1', editor: 'text' }, { id: 'u2', editor: 'textarea' }, { id: 'u3', editor: 'steps' }, { id: 'u4', editor: 'proposals' },
4
+ { id: 'u5', editor: 'people' }, { id: 'u6', editor: 'where' }, { id: 'u7', editor: 'events' }, { id: 'u8', editor: 'date' },
5
+ { id: 'u9', editor: 'list' }, { id: 'u10', editor: 'figures' }, { id: 'u11', editor: 'owner' }, { id: 'u12', editor: 'text' },
6
+ ],
7
+ trademark: [
8
+ { id: 't1', editor: 'tm-mark' }, { id: 't2', editor: 'tm-type' }, { id: 't3', editor: 'tm-owner' }, { id: 't4', editor: 'tm-classes' },
9
+ { id: 't5', editor: 'tm-basis' }, { id: 't6', editor: 'tm-specimen' }, { id: 't7', editor: 'tm-signatory' }, { id: 't8', editor: 'tm-search' },
10
+ ],
11
+ };
12
+ export const IP_KINDS = { patent: ['provisional', 'nonprovisional', 'design'], trademark: ['application'] };
13
+ export const IP_NODE_SIZE = { width: 340, height: 184 };
14
+ const TM_EMPTY = {
15
+ 'tm-mark': { k: 'tm', mark: '' },
16
+ 'tm-type': { k: 'tm', type: 'standard', colour: false },
17
+ 'tm-owner': { k: 'tm', rows: [] },
18
+ 'tm-classes': { k: 'tm', answers: { downloadable: null, hosted: null, broker: null }, wording: {} },
19
+ 'tm-basis': { k: 'tm', perClass: {} },
20
+ 'tm-specimen': { k: 'tm', perClass: {} },
21
+ 'tm-signatory': { k: 'tm', name: '', email: '' },
22
+ 'tm-search': { k: 'tm', rows: [] },
23
+ };
24
+ function emptyValue(editor) {
25
+ switch (editor) {
26
+ case 'text':
27
+ case 'textarea': return { k: 'text', t: '' };
28
+ case 'steps': return { k: 'steps', rows: [] };
29
+ case 'proposals': return { k: 'proposals', cards: [] };
30
+ case 'people': return { k: 'people', rows: [] };
31
+ case 'where': return { k: 'where', side: null, files: [] };
32
+ case 'events': return { k: 'events', none: false };
33
+ case 'date': return { k: 'date', date: null, evIds: [] };
34
+ case 'list': return { k: 'list', rows: [] };
35
+ case 'figures': return { k: 'figures', rows: [] };
36
+ case 'owner': return { k: 'owner', entity: '', signed: {} };
37
+ default: return structuredClone(TM_EMPTY[editor] ?? { k: 'tm' });
38
+ }
39
+ }
40
+ export function emptyIp(track, kind) {
41
+ const units = {};
42
+ for (const s of UNIT_SHAPES[track])
43
+ units[s.id] = { id: s.id, status: 'empty', rev: 0, value: emptyValue(s.editor), provenance: null, history: [] };
44
+ return { v: 1, track, kind: kind ?? (track === 'patent' ? 'provisional' : 'application'), title: '', blurb: '', started: false, sourceTicks: {}, units, evidence: [], events: [], filing: null, prefill: null };
45
+ }
@@ -0,0 +1,38 @@
1
+ export declare const PX_PER_WEEK = 120;
2
+ export declare const LEVELS: readonly ["objective", "key-result", "epic", "feature", "story", "enabler", "milestone"];
3
+ export type Level = (typeof LEVELS)[number];
4
+ export interface LayoutNode {
5
+ id: string;
6
+ type?: string;
7
+ position?: {
8
+ x: number;
9
+ y: number;
10
+ };
11
+ data?: Record<string, any>;
12
+ }
13
+ export interface LayoutOptions {
14
+ now?: Date;
15
+ heights?: Record<string, number>;
16
+ }
17
+ export interface LayoutResult {
18
+ positions: Map<string, {
19
+ x: number;
20
+ y: number;
21
+ }>;
22
+ widthOverride: Map<string, number | null>;
23
+ }
24
+ export declare function calendarStart(now: Date): Date;
25
+ export declare const isoDate: (d: Date) => string;
26
+ export declare function columnOf(date: Date, now: Date): number;
27
+ export declare function dateAtColumn(col: number, now: Date): Date;
28
+ export declare function todayColumn(now: Date): number;
29
+ export declare function sprintAtColumn(col: number): {
30
+ pi: string;
31
+ sprint: string;
32
+ };
33
+ export declare function scheduleFields(col: number, weeks: number, now: Date): Record<string, unknown>;
34
+ export declare const levelOf: (n: LayoutNode) => Level;
35
+ export declare const isItem: (n: LayoutNode) => boolean;
36
+ export declare const isIpNode: (n: LayoutNode) => boolean;
37
+ export declare function layoutTimeline(nodes: LayoutNode[], opts?: LayoutOptions): LayoutResult;
38
+ export declare function applyLayout<T extends LayoutNode>(nodes: T[], res: LayoutResult): T[];
@@ -0,0 +1,193 @@
1
+ import { IP_NODE_SIZE } from './ipShape.js';
2
+ export const PX_PER_WEEK = 120;
3
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
4
+ const ITEM_TOP_HANDLE_X = 8;
5
+ const LANE_GAP_X = 24;
6
+ const ROW_GAP = 32;
7
+ const BLOCK_GAP = 120;
8
+ export const LEVELS = ['objective', 'key-result', 'epic', 'feature', 'story', 'enabler', 'milestone'];
9
+ const DEFAULT_DURATION = { epic: 8, feature: 4, story: 1, enabler: 2, milestone: 0, objective: 12, 'key-result': 12 };
10
+ const DEFAULT_WIDTH = { epic: 280, feature: 260, story: 240, enabler: 240, milestone: 120, objective: 280, 'key-result': 260 };
11
+ const MIN_PAINT = { epic: 360, feature: 360, story: 352, enabler: 352 };
12
+ const HEIGHT = { epic: 265, feature: 265, story: 220, enabler: 220, milestone: 54, objective: 200, 'key-result': 220 };
13
+ const HERO_EXTRA = 150;
14
+ export function calendarStart(now) {
15
+ const s = new Date(now.getFullYear(), now.getMonth() - 3, 1);
16
+ const d = s.getDay();
17
+ s.setDate(s.getDate() + (d === 0 ? -6 : 1 - d));
18
+ return s;
19
+ }
20
+ const pad = (n) => String(n).padStart(2, '0');
21
+ export const isoDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
22
+ function parseIso(s) {
23
+ if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(s))
24
+ return null;
25
+ const out = new Date(Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)));
26
+ return Number.isNaN(out.getTime()) ? null : out;
27
+ }
28
+ export function columnOf(date, now) {
29
+ return Math.round((date.getTime() - calendarStart(now).getTime()) / WEEK_MS);
30
+ }
31
+ export function dateAtColumn(col, now) {
32
+ const d = calendarStart(now);
33
+ d.setDate(d.getDate() + Math.round(col * 7));
34
+ return d;
35
+ }
36
+ export function todayColumn(now) {
37
+ return Math.max(0, Math.floor((now.getTime() - calendarStart(now).getTime()) / WEEK_MS));
38
+ }
39
+ export function sprintAtColumn(col) {
40
+ const c = Math.max(0, col);
41
+ const L = ['S1', 'S2', 'S3', 'S4', 'S5', 'IP'];
42
+ return { pi: `PI-${Math.floor(c / 12) + 1}`, sprint: L[Math.floor((c % 12) / 2)] ?? 'S1' };
43
+ }
44
+ export function scheduleFields(col, weeks, now) {
45
+ const s = sprintAtColumn(col);
46
+ const e = sprintAtColumn(col + Math.round(weeks));
47
+ return {
48
+ durationWeeks: weeks,
49
+ startDate: isoDate(dateAtColumn(col, now)),
50
+ endDate: isoDate(dateAtColumn(col + weeks, now)),
51
+ pi: s.pi, sprint: s.sprint, endPi: e.pi, endSprint: e.sprint,
52
+ };
53
+ }
54
+ export const levelOf = (n) => LEVELS.includes(n.data?.hierarchyLevel) ? n.data.hierarchyLevel : 'story';
55
+ export const isItem = (n) => n.type === 'portfolio-item';
56
+ export const isIpNode = (n) => n.type === 'rad-module' && n.data?.subtype === 'ip';
57
+ export function layoutTimeline(nodes, opts = {}) {
58
+ const now = opts.now ?? new Date();
59
+ const heights = opts.heights ?? {};
60
+ const items = nodes.filter(isItem);
61
+ const byId = new Map(items.map(n => [n.id, n]));
62
+ const positions = new Map();
63
+ const widthOverride = new Map();
64
+ const geo = new Map();
65
+ for (const n of items) {
66
+ const level = levelOf(n);
67
+ const start = parseIso(n.data?.startDate);
68
+ const col = start ? columnOf(start, now) : Math.round((n.position?.x ?? 0) / PX_PER_WEEK);
69
+ const weeks = typeof n.data?.durationWeeks === 'number' ? n.data.durationWeeks : DEFAULT_DURATION[level];
70
+ const natural = weeks > 0 ? weeks * PX_PER_WEEK : DEFAULT_WIDTH[level];
71
+ const min = MIN_PAINT[level] ?? 0;
72
+ widthOverride.set(n.id, natural < min ? min : null);
73
+ const hero = Array.isArray(n.data?.pastedImages) && n.data.pastedImages.length > 0;
74
+ geo.set(n.id, { x: col * PX_PER_WEEK, width: Math.max(natural, min), height: heights[n.id] ?? HEIGHT[level] + (hero && (level === 'epic' || level === 'feature') ? HERO_EXTRA : 0) });
75
+ }
76
+ const kids = new Map();
77
+ for (const n of items) {
78
+ const p = n.data?.parentItemId;
79
+ if (p && geo.has(p))
80
+ kids.set(p, [...(kids.get(p) ?? []), n.id]);
81
+ }
82
+ const right = new Map();
83
+ const paintedRight = (id, depth = 0) => {
84
+ if (right.has(id))
85
+ return right.get(id);
86
+ const g = geo.get(id);
87
+ let r = g.x + g.width;
88
+ if (depth < 12)
89
+ for (const k of kids.get(id) ?? [])
90
+ r = Math.max(r, paintedRight(k, depth + 1) + 20);
91
+ right.set(id, r);
92
+ return r;
93
+ };
94
+ for (const [id, g] of geo)
95
+ g.width = paintedRight(id) - g.x;
96
+ const epicOf = (n) => {
97
+ let cur = n;
98
+ for (let i = 0; cur && i < 12; i++) {
99
+ if (levelOf(cur) === 'epic')
100
+ return cur.id;
101
+ cur = cur.data?.parentItemId ? byId.get(cur.data.parentItemId) : undefined;
102
+ }
103
+ return null;
104
+ };
105
+ const placeBand = (slots, top) => {
106
+ const lanes = [];
107
+ const laneOf = new Map();
108
+ for (const s of slots) {
109
+ const free = (i) => !lanes[i] || lanes[i].spans.every(([a, b]) => s.x + s.width + LANE_GAP_X <= a || b + LANE_GAP_X <= s.x);
110
+ const parentLane = s.from !== undefined ? laneOf.get(s.from) : undefined;
111
+ let l = 0;
112
+ if (parentLane !== undefined && free(parentLane))
113
+ l = parentLane;
114
+ else
115
+ while (!free(l))
116
+ l++;
117
+ if (!lanes[l])
118
+ lanes[l] = { spans: [], height: 0 };
119
+ lanes[l].spans.push([s.x, s.x + s.width]);
120
+ lanes[l].height = Math.max(lanes[l].height, s.height);
121
+ laneOf.set(s.id, l);
122
+ }
123
+ const laneY = [];
124
+ let y = top;
125
+ lanes.forEach((lane, i) => { laneY[i] = y; y += lane.height + 24; });
126
+ for (const s of slots)
127
+ positions.set(s.id, { x: s.x, y: laneY[laneOf.get(s.id)] });
128
+ return lanes.length ? y - 24 + ROW_GAP : top;
129
+ };
130
+ const slot = (id, from) => ({ id, ...geo.get(id), ...(from ? { from } : {}) });
131
+ const byX = (a, b) => geo.get(a.id).x - geo.get(b.id).x || a.id.localeCompare(b.id);
132
+ let y = 0;
133
+ for (const o of items.filter(n => levelOf(n) === 'objective').sort(byX))
134
+ y = placeBand([slot(o.id)], y);
135
+ y = placeBand(items.filter(n => levelOf(n) === 'key-result').sort(byX).map(n => slot(n.id)), y);
136
+ const ipNodes = nodes.filter(isIpNode);
137
+ const ipFirstSource = new Map();
138
+ const ipUnplaced = [];
139
+ for (const n of ipNodes) {
140
+ const src = (n.data?.attachedItemIds ?? []).find((id) => geo.has(id));
141
+ if (src)
142
+ ipFirstSource.set(n.id, src);
143
+ else
144
+ ipUnplaced.push(n.id);
145
+ }
146
+ const epics = items.filter(n => levelOf(n) === 'epic').sort(byX);
147
+ const rest = items.filter(n => !['objective', 'key-result', 'epic'].includes(levelOf(n)));
148
+ for (const epic of [...epics.map(e => e.id), null]) {
149
+ const inBlock = rest.filter(n => epicOf(n) === epic);
150
+ const blockIds = new Set([...(epic ? [epic] : []), ...inBlock.map(n => n.id)]);
151
+ const ips = ipNodes.filter(n => blockIds.has(ipFirstSource.get(n.id) ?? ''));
152
+ if (!epic && !inBlock.length && !ips.length)
153
+ continue;
154
+ if (y > 0)
155
+ y += BLOCK_GAP - ROW_GAP;
156
+ if (epic)
157
+ y = placeBand([slot(epic)], y);
158
+ if (ips.length) {
159
+ const src = (id) => geo.get(ipFirstSource.get(id));
160
+ y = placeBand(ips.map(n => ({ id: n.id, x: src(n.id).x + ITEM_TOP_HANDLE_X - IP_NODE_SIZE.width / 2, width: IP_NODE_SIZE.width, height: IP_NODE_SIZE.height })).sort((a, b) => a.x - b.x), y) - ROW_GAP + 16;
161
+ }
162
+ const features = inBlock.filter(n => levelOf(n) === 'feature').sort(byX);
163
+ const leaves = inBlock.filter(n => levelOf(n) === 'story' || levelOf(n) === 'enabler').sort(byX);
164
+ const underFeature = (f) => leaves.filter(n => n.data?.parentItemId === f);
165
+ const pool = features.map(f => slot(f.id));
166
+ for (const f of features)
167
+ pool.push(...underFeature(f.id).map(n => slot(n.id, f.id)));
168
+ const featureIds = new Set(features.map(f => f.id));
169
+ pool.push(...leaves.filter(n => !featureIds.has(n.data?.parentItemId)).map(n => slot(n.id)));
170
+ pool.push(...inBlock.filter(n => levelOf(n) === 'milestone').sort(byX).map(n => slot(n.id)));
171
+ y = placeBand(pool, y);
172
+ }
173
+ ipUnplaced.forEach((id, k) => positions.set(id, { x: k * (IP_NODE_SIZE.width + LANE_GAP_X), y: y + BLOCK_GAP }));
174
+ return { positions, widthOverride };
175
+ }
176
+ export function applyLayout(nodes, res) {
177
+ return nodes.map(n => {
178
+ const p = res.positions.get(n.id);
179
+ if (!p)
180
+ return n;
181
+ const out = { ...n, position: { x: Math.round(p.x), y: Math.round(p.y) } };
182
+ if (res.widthOverride.has(n.id)) {
183
+ const data = { ...(n.data ?? {}) };
184
+ const w = res.widthOverride.get(n.id);
185
+ if (w)
186
+ data.manualWidthOverride = w;
187
+ else
188
+ delete data.manualWidthOverride;
189
+ out.data = data;
190
+ }
191
+ return out;
192
+ });
193
+ }
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { ApiContext } from './config.js';
3
3
  export declare const SERVER_NAME = "nonbot-mcp";
4
- export declare const SERVER_VERSION = "0.1.0";
4
+ export declare const SERVER_VERSION = "0.2.0";
5
5
  export declare const INSTRUCTIONS: string;
6
6
  export declare function registerAll(server: McpServer, ctx: ApiContext): void;
7
7
  export declare function createNonbotMcpServer(ctx: ApiContext): McpServer;
@@ -1,21 +1,25 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { registerPortfolioTools } from './toolsets/portfolio.js';
3
3
  import { registerIpTools } from './toolsets/ip.js';
4
+ import { registerBuildTools } from './toolsets/build.js';
4
5
  import { registerWhygraphTools } from './toolsets/whygraph.js';
5
6
  import { registerRunTools } from './toolsets/run.js';
6
7
  import { registerMonitorTools } from './toolsets/monitor.js';
7
8
  import { registerResources } from './resources.js';
8
9
  import { registerPrompts } from './prompts.js';
9
10
  export const SERVER_NAME = 'nonbot-mcp';
10
- export const SERVER_VERSION = '0.1.0';
11
+ export const SERVER_VERSION = '0.2.0';
11
12
  export const INSTRUCTIONS = [
12
13
  'non.bot MCP — work against the caller’s real product-management data, not a copy.',
13
14
  'Two domains: PORTFOLIOS (the SAFe portfolio canvas — epics → features → stories) and',
14
15
  'WHYGRAPH (the PM decision graph — the rules and decisions the team has committed to).',
15
16
  'Read before you write: call the *_list / *_get / *_search tools first.',
16
- 'Portfolio edits are whole-graph: portfolio_get to load the current graph + version,',
17
- 'edit the full node/edge set, then portfolio_update (pass expectedVersion for safe',
18
- 'concurrent editing). There are no per-node mutators by design.',
17
+ 'BUILDING: portfolio_create makes an empty portfolio; portfolio_add_items adds items of any level',
18
+ '(parents / OKR links / dependencies by id or by a key from the same call) and lays the Timeline out;',
19
+ 'portfolio_patch_item changes one item; portfolio_layout re-lays everything; portfolio_set_meta sets',
20
+ 'context text and palette; portfolio_asset_upload stores an image; ip_create adds an IP node over its sources.',
21
+ 'Whole-graph rewrites (restructures, deletes): portfolio_get for the graph + version, then portfolio_update',
22
+ '(pass expectedVersion for safe concurrent editing).',
19
23
  'Before a non-trivial decision, call whygraph_context to surface the LAWs and prior',
20
24
  'decisions that apply. WhyGraph is read/reasoning-only over this transport.',
21
25
  'MONITORING: run_feed / run_daemon_status / run_machines / run_prompts_list answer',
@@ -27,6 +31,7 @@ export const INSTRUCTIONS = [
27
31
  ].join(' ');
28
32
  export function registerAll(server, ctx) {
29
33
  registerPortfolioTools(server, ctx);
34
+ registerBuildTools(server, ctx);
30
35
  registerIpTools(server, ctx);
31
36
  registerWhygraphTools(server, ctx);
32
37
  registerRunTools(server, ctx);
@@ -0,0 +1,105 @@
1
+ import { z } from 'zod';
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import type { ApiContext } from '../config.js';
4
+ import { type LayoutNode } from '../layout.js';
5
+ type Node = LayoutNode & {
6
+ id: string;
7
+ type?: string;
8
+ data?: Record<string, any>;
9
+ };
10
+ type Edge = {
11
+ id: string;
12
+ source: string;
13
+ target: string;
14
+ [k: string]: unknown;
15
+ };
16
+ interface Graph {
17
+ name: string;
18
+ description: string;
19
+ nodes: Node[];
20
+ edges: Edge[];
21
+ viewport: unknown;
22
+ version?: number;
23
+ }
24
+ export declare const toHtml: (s: string) => string;
25
+ export declare function newItemId(now?: number): string;
26
+ declare const itemSpec: z.ZodObject<{
27
+ key: z.ZodOptional<z.ZodString>;
28
+ level: z.ZodEnum<["objective", "key-result", "epic", "feature", "story", "enabler", "milestone"]>;
29
+ title: z.ZodString;
30
+ description: z.ZodOptional<z.ZodString>;
31
+ bluf: z.ZodOptional<z.ZodString>;
32
+ acceptance_criteria: z.ZodOptional<z.ZodString>;
33
+ hypothesis: z.ZodOptional<z.ZodString>;
34
+ state: z.ZodOptional<z.ZodEnum<["funnel", "reviewing", "analyzing", "ready", "implementing", "done"]>>;
35
+ parent: z.ZodOptional<z.ZodString>;
36
+ okr: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
37
+ depends_on: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
38
+ start_week: z.ZodOptional<z.ZodNumber>;
39
+ start_date: z.ZodOptional<z.ZodString>;
40
+ duration_weeks: z.ZodOptional<z.ZodNumber>;
41
+ tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
42
+ wsjf: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
43
+ owner: z.ZodOptional<z.ZodString>;
44
+ kr_target: z.ZodOptional<z.ZodString>;
45
+ kr_current: z.ZodOptional<z.ZodString>;
46
+ okr_status: z.ZodOptional<z.ZodEnum<["on-track", "at-risk", "behind"]>>;
47
+ images: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
48
+ fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
49
+ }, "strip", z.ZodTypeAny, {
50
+ title: string;
51
+ level: "epic" | "objective" | "key-result" | "feature" | "story" | "enabler" | "milestone";
52
+ parent?: string | undefined;
53
+ description?: string | undefined;
54
+ acceptance_criteria?: string | undefined;
55
+ hypothesis?: string | undefined;
56
+ owner?: string | undefined;
57
+ key?: string | undefined;
58
+ bluf?: string | undefined;
59
+ state?: "done" | "funnel" | "reviewing" | "analyzing" | "ready" | "implementing" | undefined;
60
+ okr?: string[] | undefined;
61
+ depends_on?: string[] | undefined;
62
+ start_week?: number | undefined;
63
+ start_date?: string | undefined;
64
+ duration_weeks?: number | undefined;
65
+ tags?: string[] | undefined;
66
+ wsjf?: [number, number, number, number] | undefined;
67
+ kr_target?: string | undefined;
68
+ kr_current?: string | undefined;
69
+ okr_status?: "on-track" | "at-risk" | "behind" | undefined;
70
+ images?: string[] | undefined;
71
+ fields?: Record<string, unknown> | undefined;
72
+ }, {
73
+ title: string;
74
+ level: "epic" | "objective" | "key-result" | "feature" | "story" | "enabler" | "milestone";
75
+ parent?: string | undefined;
76
+ description?: string | undefined;
77
+ acceptance_criteria?: string | undefined;
78
+ hypothesis?: string | undefined;
79
+ owner?: string | undefined;
80
+ key?: string | undefined;
81
+ bluf?: string | undefined;
82
+ state?: "done" | "funnel" | "reviewing" | "analyzing" | "ready" | "implementing" | undefined;
83
+ okr?: string[] | undefined;
84
+ depends_on?: string[] | undefined;
85
+ start_week?: number | undefined;
86
+ start_date?: string | undefined;
87
+ duration_weeks?: number | undefined;
88
+ tags?: string[] | undefined;
89
+ wsjf?: [number, number, number, number] | undefined;
90
+ kr_target?: string | undefined;
91
+ kr_current?: string | undefined;
92
+ okr_status?: "on-track" | "at-risk" | "behind" | undefined;
93
+ images?: string[] | undefined;
94
+ fields?: Record<string, unknown> | undefined;
95
+ }>;
96
+ type ItemSpec = z.infer<typeof itemSpec>;
97
+ export declare function buildItems(g: Graph, specs: ItemSpec[], now?: Date): {
98
+ nodes: Node[];
99
+ edges: Edge[];
100
+ ids: Record<string, string>;
101
+ errors: string[];
102
+ patched: Map<string, Record<string, any>>;
103
+ };
104
+ export declare function registerBuildTools(server: McpServer, ctx: ApiContext): void;
105
+ export {};
@@ -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
+ }
@@ -222,7 +222,7 @@ export function registerPortfolioTools(server, ctx) {
222
222
  }
223
223
  server.registerTool('portfolio_update', {
224
224
  title: 'Replace a portfolio graph',
225
- description: 'Whole-graph replace: write the complete nodes + edges for a portfolio. This is the ONLY graph writer by design — there are no per-node mutators, so the model always composes the full intended state. 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.',
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
226
  inputSchema: {
227
227
  id: z.string().describe('Portfolio id.'),
228
228
  nodes: z.array(z.any()).describe('The COMPLETE set of nodes (whole-graph replace).'),
@@ -304,9 +304,9 @@ export function registerPortfolioTools(server, ctx) {
304
304
  nodes,
305
305
  edges,
306
306
  ...(baselineVersion !== undefined ? { expectedVersion: baselineVersion } : {}),
307
- ...(args.name !== undefined ? { name: args.name } : {}),
308
- ...(args.description !== undefined ? { description: args.description } : {}),
309
- ...(args.viewport !== undefined ? { viewport: args.viewport } : {}),
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
310
  }),
311
311
  });
312
312
  if (putRes.status === 409) {
package/dist/stdio.js CHANGED
@@ -32,13 +32,35 @@ async function fileBackup(id, json) {
32
32
  }
33
33
  await writeFile(file, JSON.stringify(json, null, 2), 'utf8');
34
34
  }
35
+ const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
36
+ async function readLocalImage(path) {
37
+ const ext = path.toLowerCase().split('.').pop() ?? '';
38
+ const mime = IMAGE_MIME[ext];
39
+ if (!mime)
40
+ throw new Error('file_path must be a .png, .jpg, .jpeg, .webp or .gif image');
41
+ const buf = await readFile(resolve(path));
42
+ if (buf.length > 2_200_000)
43
+ throw new Error(`The image is ${(buf.length / 1e6).toFixed(1)} MB; the limit is 2.2 MB — downscale it first`);
44
+ const magic = buf.subarray(0, 12);
45
+ const ok = mime === 'image/png' ? magic[0] === 0x89 && magic[1] === 0x50
46
+ : mime === 'image/jpeg' ? magic[0] === 0xff && magic[1] === 0xd8
47
+ : mime === 'image/gif' ? magic.toString('latin1', 0, 3) === 'GIF'
48
+ : magic.toString('latin1', 0, 4) === 'RIFF' && magic.toString('latin1', 8, 12) === 'WEBP';
49
+ if (!ok)
50
+ throw new Error('file_path is not the image its extension says it is');
51
+ return `data:${mime};base64,${buf.toString('base64')}`;
52
+ }
35
53
  async function main() {
36
- const token = process.env.NON_BOT_API_TOKEN || (await tokenFromCliAuth());
54
+ const inRun = typeof process.env.NONBOT_RUN_ID === 'string' && process.env.NONBOT_RUN_ID.length > 0;
55
+ const token = inRun
56
+ ? (process.env.NONBOT_PAT || '')
57
+ : (process.env.NON_BOT_API_TOKEN || (await tokenFromCliAuth()));
37
58
  const ctx = {
38
59
  apiBase: resolveApiBase(process.env.NON_BOT_API_BASE),
39
60
  getToken: () => token,
40
61
  readonly: process.env.NON_BOT_MCP_READONLY === '1',
41
62
  backup: fileBackup,
63
+ readLocalImage,
42
64
  };
43
65
  const server = createNonbotMcpServer(ctx);
44
66
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "The non.bot MCP server — one server for your portfolios, decision graph, and Runs. Runs over stdio (Claude Code, Cursor, VS Code) with a pat_ token. Built on MCP protocol 2025-11-25.",
6
6
  "keywords": [