flowpad-mcp 0.1.0 → 0.1.1
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/dist/index.js +26 -26
- package/package.json +11 -11
- package/dist/client.js +0 -281
- package/dist/client.js.map +0 -1
- package/dist/diff.js +0 -58
- package/dist/diff.js.map +0 -1
- package/dist/http.js +0 -174
- package/dist/http.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lib.js +0 -10
- package/dist/lib.js.map +0 -1
- package/dist/local-engine.js +0 -82
- package/dist/local-engine.js.map +0 -1
- package/dist/server.js +0 -956
- package/dist/server.js.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/version.js +0 -8
- package/dist/version.js.map +0 -1
package/dist/server.js
DELETED
|
@@ -1,956 +0,0 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { isProposedEdit } from './client.js';
|
|
4
|
-
import { buildSimpleNode, diffIsEmpty, newNodeId, rescueTextContent, } from './diff.js';
|
|
5
|
-
import { canConnectNodes } from 'flowpad-core';
|
|
6
|
-
import { describeEngine, previewLayout, validateLocal, } from './local-engine.js';
|
|
7
|
-
import { MCP_VERSION } from './version.js';
|
|
8
|
-
const ok = (text) => ({
|
|
9
|
-
content: [{ type: 'text', text }],
|
|
10
|
-
});
|
|
11
|
-
// The owner was viewing the project, so the edit became a proposal in their
|
|
12
|
-
// accept/reject preview rather than a direct save. Tell the agent plainly so it
|
|
13
|
-
// never claims a save that hasn't landed.
|
|
14
|
-
const proposed = (summary) => ok(`Proposed for the user's review: ${summary}. They are viewing this project, so the change is waiting in their accept/reject preview — nothing is saved until they approve it.`);
|
|
15
|
-
const fail = (e) => ({
|
|
16
|
-
content: [
|
|
17
|
-
{
|
|
18
|
-
type: 'text',
|
|
19
|
-
text: `Error: ${e instanceof Error ? e.message : String(e)}`,
|
|
20
|
-
},
|
|
21
|
-
],
|
|
22
|
-
isError: true,
|
|
23
|
-
});
|
|
24
|
-
const boundsSchema = z
|
|
25
|
-
.object({
|
|
26
|
-
x: z.number().optional(),
|
|
27
|
-
y: z.number().optional(),
|
|
28
|
-
width: z.number().optional(),
|
|
29
|
-
height: z.number().optional(),
|
|
30
|
-
})
|
|
31
|
-
.describe('Position/size. Missing fields default to x:0 y:0 w:240 h:120.');
|
|
32
|
-
const edgePortEnum = z.enum(['top', 'right', 'bottom', 'left']);
|
|
33
|
-
const edgeSchema = z
|
|
34
|
-
.object({
|
|
35
|
-
id: z
|
|
36
|
-
.string()
|
|
37
|
-
.optional()
|
|
38
|
-
.describe('Edge id; a fresh one is minted if omitted.'),
|
|
39
|
-
to: z.string().describe('Target node id this edge points to.'),
|
|
40
|
-
fromPort: edgePortEnum.optional(),
|
|
41
|
-
toPort: edgePortEnum.optional(),
|
|
42
|
-
})
|
|
43
|
-
.describe('A connection FROM this node TO another node. Direction rule: you may NOT point an edge INTO a task node — connect task→regular, never regular→task or task→task (invalid edges are dropped server-side).');
|
|
44
|
-
const layoutSchema = z
|
|
45
|
-
.object({
|
|
46
|
-
flexDirection: z
|
|
47
|
-
.enum(['row', 'row-reverse', 'column', 'column-reverse'])
|
|
48
|
-
.optional(),
|
|
49
|
-
flexWrap: z.enum(['nowrap', 'wrap', 'wrap-reverse']).optional(),
|
|
50
|
-
justifyContent: z
|
|
51
|
-
.enum([
|
|
52
|
-
'flex-start',
|
|
53
|
-
'center',
|
|
54
|
-
'flex-end',
|
|
55
|
-
'space-between',
|
|
56
|
-
'space-around',
|
|
57
|
-
'space-evenly',
|
|
58
|
-
])
|
|
59
|
-
.optional(),
|
|
60
|
-
alignItems: z
|
|
61
|
-
.enum(['flex-start', 'center', 'flex-end', 'stretch', 'baseline'])
|
|
62
|
-
.optional(),
|
|
63
|
-
alignSelf: z
|
|
64
|
-
.enum(['flex-start', 'center', 'flex-end', 'stretch', 'baseline', 'auto'])
|
|
65
|
-
.optional(),
|
|
66
|
-
gap: z.number().optional(),
|
|
67
|
-
padding: z.number().optional(),
|
|
68
|
-
paddingTop: z.number().optional(),
|
|
69
|
-
paddingRight: z.number().optional(),
|
|
70
|
-
paddingBottom: z.number().optional(),
|
|
71
|
-
paddingLeft: z.number().optional(),
|
|
72
|
-
margin: z.number().optional(),
|
|
73
|
-
marginTop: z.number().optional(),
|
|
74
|
-
marginRight: z.number().optional(),
|
|
75
|
-
marginBottom: z.number().optional(),
|
|
76
|
-
marginLeft: z.number().optional(),
|
|
77
|
-
flexGrow: z.number().optional(),
|
|
78
|
-
flexShrink: z.number().optional(),
|
|
79
|
-
})
|
|
80
|
-
.describe("Flexbox layout (standard CSS semantics). CONTAINER props position this node's children: flexDirection, justifyContent (main axis), alignItems (cross axis), gap, padding. PER-CHILD props control how THIS node behaves inside its parent: flexGrow, flexShrink, alignSelf, margin. Engine defaults are browser-parity — flexShrink 1, flexWrap nowrap, alignItems stretch — so set flexShrink:0 / a fixed bound only when you want NO shrinking. Use flexGrow:1 on siblings to share a row/column equally without computing widths. A node is a flex container only when isAutoLayout:true.");
|
|
81
|
-
// STYLE fields the canvas renderer actually paints today (mirror, not a CSS
|
|
82
|
-
// universe). These are FLAT on the node — matching the client node model exactly
|
|
83
|
-
// (flowpad/src/engine/types/node.ts) — so they pass straight through to the
|
|
84
|
-
// backend's JSONB latestData and the renderer reads them as-is. Each field is
|
|
85
|
-
// type-specific (a textColor on a view node is simply ignored); the doc tells
|
|
86
|
-
// the agent which goes where. Colors are raw hex, sizes raw px — the app has no
|
|
87
|
-
// design-token scale for these, so mirroring it = raw values. Anything NOT here
|
|
88
|
-
// (fontWeight, fontFamily, …) is unpainted, so it is intentionally absent.
|
|
89
|
-
const styleShape = {
|
|
90
|
-
// text nodes
|
|
91
|
-
textColor: z
|
|
92
|
-
.string()
|
|
93
|
-
.optional()
|
|
94
|
-
.describe('Text fill color as hex, e.g. "#FFFFFF" (text nodes).'),
|
|
95
|
-
fontSize: z
|
|
96
|
-
.number()
|
|
97
|
-
.min(6)
|
|
98
|
-
.max(200)
|
|
99
|
-
.optional()
|
|
100
|
-
.describe('Font size in px, 6–200 (text nodes).'),
|
|
101
|
-
textAlign: z
|
|
102
|
-
.enum(['left', 'center', 'right'])
|
|
103
|
-
.optional()
|
|
104
|
-
.describe('Horizontal text alignment within the node (text nodes).'),
|
|
105
|
-
// view / container nodes
|
|
106
|
-
backgroundColor: z
|
|
107
|
-
.string()
|
|
108
|
-
.optional()
|
|
109
|
-
.describe('Fill color as hex (view/container nodes).'),
|
|
110
|
-
borderColor: z
|
|
111
|
-
.string()
|
|
112
|
-
.optional()
|
|
113
|
-
.describe('Border color as hex (view nodes).'),
|
|
114
|
-
borderWidth: z
|
|
115
|
-
.number()
|
|
116
|
-
.min(0)
|
|
117
|
-
.optional()
|
|
118
|
-
.describe('Border thickness in px (view nodes).'),
|
|
119
|
-
borderRadius: z
|
|
120
|
-
.number()
|
|
121
|
-
.min(0)
|
|
122
|
-
.optional()
|
|
123
|
-
.describe('Corner radius in px (view/container nodes).'),
|
|
124
|
-
borderStyle: z
|
|
125
|
-
.enum(['solid', 'dashed', 'dotted'])
|
|
126
|
-
.optional()
|
|
127
|
-
.describe('Border line style (view nodes).'),
|
|
128
|
-
// shared renderer-level clip (view + text)
|
|
129
|
-
overflow: z
|
|
130
|
-
.enum(['hidden', 'visible'])
|
|
131
|
-
.optional()
|
|
132
|
-
.describe('Clip content to the node bounds (view & text nodes).'),
|
|
133
|
-
// image nodes
|
|
134
|
-
iconColor: z
|
|
135
|
-
.string()
|
|
136
|
-
.optional()
|
|
137
|
-
.describe('Tint for FontAwesome icon sources as hex; ignored for raster/SVG (image nodes).'),
|
|
138
|
-
resizeMode: z
|
|
139
|
-
.enum(['cover', 'contain', 'fill', 'none'])
|
|
140
|
-
.optional()
|
|
141
|
-
.describe('How the image fits its bounds (image nodes).'),
|
|
142
|
-
// sticky nodes
|
|
143
|
-
stickyColor: z
|
|
144
|
-
.enum(['yellow', 'pink', 'green', 'blue', 'orange', 'purple'])
|
|
145
|
-
.optional()
|
|
146
|
-
.describe('Sticky note color (sticky nodes).'),
|
|
147
|
-
stickyVariant: z
|
|
148
|
-
.enum(['note', 'node'])
|
|
149
|
-
.optional()
|
|
150
|
-
.describe('Sticky note presentation (sticky nodes): "note" = folded sticky-paper look (default), "node" = clean flat card with the color as a thin border accent.'),
|
|
151
|
-
};
|
|
152
|
-
// CONTENT fields the renderer paints as the node's VISIBLE BODY — distinct from
|
|
153
|
-
// name/description (metadata, NOT painted). A text node renders `content`; a
|
|
154
|
-
// sticky renders `title` (+ description). These were unwritable before, so an
|
|
155
|
-
// agent-made text node showed blank on canvas. Flat strings, same passthrough
|
|
156
|
-
// as styleShape.
|
|
157
|
-
const contentShape = {
|
|
158
|
-
content: z
|
|
159
|
-
.string()
|
|
160
|
-
.optional()
|
|
161
|
-
.describe("A TEXT node's visible body — THIS is what renders on canvas. name/description are metadata and are NOT painted; set `content` for visible text."),
|
|
162
|
-
title: z
|
|
163
|
-
.string()
|
|
164
|
-
.optional()
|
|
165
|
-
.describe("A STICKY note's heading text (sticky nodes)."),
|
|
166
|
-
};
|
|
167
|
-
// Both content + style flow through one picker into the node (flat fields).
|
|
168
|
-
const WRITABLE_EXTRA_KEYS = [
|
|
169
|
-
...Object.keys(contentShape),
|
|
170
|
-
...Object.keys(styleShape),
|
|
171
|
-
];
|
|
172
|
-
/** Copy the defined content + style fields off a validated tool input. */
|
|
173
|
-
function pickStyle(src) {
|
|
174
|
-
const out = {};
|
|
175
|
-
for (const key of WRITABLE_EXTRA_KEYS) {
|
|
176
|
-
if (src[key] !== undefined)
|
|
177
|
-
out[key] = src[key];
|
|
178
|
-
}
|
|
179
|
-
return out;
|
|
180
|
-
}
|
|
181
|
-
/** Mint ids for edges that omit one (the canvas requires a stable edge id). */
|
|
182
|
-
function mintEdges(edges) {
|
|
183
|
-
return (edges ?? []).map((e) => ({
|
|
184
|
-
id: e.id ?? newNodeId(),
|
|
185
|
-
to: e.to,
|
|
186
|
-
...(e.fromPort ? { fromPort: e.fromPort } : {}),
|
|
187
|
-
...(e.toPort ? { toPort: e.toPort } : {}),
|
|
188
|
-
}));
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Build a fully-wired McpServer for ONE acting user. The `client` carries that
|
|
192
|
-
* user's credential, so every tool here operates on that user's canvas.
|
|
193
|
-
*
|
|
194
|
-
* stdio runs a single process-wide server (one user). The HTTP transport calls
|
|
195
|
-
* this once PER SESSION (multi-tenant) so each connection edits its own user's
|
|
196
|
-
* canvas — there is no shared, env-baked key. Keep all tool registrations here;
|
|
197
|
-
* `index.ts` only chooses the transport.
|
|
198
|
-
*/
|
|
199
|
-
// Guidance is a static backend constant; fetch it once per process and reuse it
|
|
200
|
-
// across sessions (the HTTP transport builds a fresh server per session, so without
|
|
201
|
-
// this every session would re-login + re-fetch the same string on its init path).
|
|
202
|
-
let cachedGuidance;
|
|
203
|
-
export async function createServer(client) {
|
|
204
|
-
// The design contract is fetched from be at runtime (GET /mcp/guidance) so it is
|
|
205
|
-
// never bundled into the published public tool, and can change without a republish.
|
|
206
|
-
// Best-effort: a fetch failure degrades to a minimal fallback, never a crash.
|
|
207
|
-
let instructions;
|
|
208
|
-
if (cachedGuidance !== undefined) {
|
|
209
|
-
instructions = cachedGuidance;
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
try {
|
|
213
|
-
instructions = await client.guidance();
|
|
214
|
-
cachedGuidance = instructions; // cache only the successful fetch
|
|
215
|
-
}
|
|
216
|
-
catch (err) {
|
|
217
|
-
console.error('flowpad-mcp: could not fetch guidance from backend; using minimal fallback.', err);
|
|
218
|
-
// Do NOT cache the fallback — a transient failure should retry next session.
|
|
219
|
-
instructions =
|
|
220
|
-
'Flowpad is a visual canvas of nodes. Read a project (get_project / ' +
|
|
221
|
-
'get_outline) before editing, then write changes with apply_edit / add_node / ' +
|
|
222
|
-
'connect_nodes. (Full design guidance was unavailable.)';
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
// Soft version gate (Faz 2): if the backend flagged a newer tool during the
|
|
226
|
-
// guidance fetch, fold it into the instructions so the agent nudges the user.
|
|
227
|
-
// Appended to the per-session string, never to the shared cachedGuidance.
|
|
228
|
-
const advisory = client.getUpdateAdvisory();
|
|
229
|
-
if (advisory) {
|
|
230
|
-
instructions +=
|
|
231
|
-
`\n\n⚠ UPDATE AVAILABLE: a newer flowpad-mcp (v${advisory}) is out. ` +
|
|
232
|
-
`Tell the user to update with: npx flowpad-mcp@latest.`;
|
|
233
|
-
}
|
|
234
|
-
const server = new McpServer({ name: 'flowpad-mcp', version: MCP_VERSION }, { instructions });
|
|
235
|
-
// Capture the initializing host (Cursor / Claude Desktop / CLI / …) from the
|
|
236
|
-
// protocol `clientInfo` and hand it to the client, which forwards it to the
|
|
237
|
-
// backend on every call for audit + presence attribution. Fires once per
|
|
238
|
-
// session, after `initialize` — works for both stdio and HTTP transports.
|
|
239
|
-
server.server.oninitialized = () => {
|
|
240
|
-
client.setClientName(server.server.getClientVersion()?.name);
|
|
241
|
-
};
|
|
242
|
-
// -------------------------------------------------------------------------
|
|
243
|
-
// list_projects
|
|
244
|
-
// -------------------------------------------------------------------------
|
|
245
|
-
server.tool('list_projects', 'List Flowpad projects for the logged-in user. Optionally filter by a substring of the project name. Returns id + name for each match.', {
|
|
246
|
-
query: z
|
|
247
|
-
.string()
|
|
248
|
-
.optional()
|
|
249
|
-
.describe('Case-insensitive substring to match against project names.'),
|
|
250
|
-
}, async ({ query }) => {
|
|
251
|
-
try {
|
|
252
|
-
const { data } = await client.listProjects(query);
|
|
253
|
-
if (data.length === 0) {
|
|
254
|
-
return ok(query ? `No projects matching "${query}".` : 'No projects found.');
|
|
255
|
-
}
|
|
256
|
-
const lines = data.map((p) => `- ${p.name} (id: ${p.id})`);
|
|
257
|
-
return ok(`${data.length} project(s):\n${lines.join('\n')}`);
|
|
258
|
-
}
|
|
259
|
-
catch (e) {
|
|
260
|
-
return fail(e);
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
// -------------------------------------------------------------------------
|
|
264
|
-
// get_project
|
|
265
|
-
// -------------------------------------------------------------------------
|
|
266
|
-
server.tool('get_project', 'Get a single Flowpad project: its current revision and a summary of its canvas nodes (id, type, name, parent). Use this to understand the canvas before editing.', {
|
|
267
|
-
projectId: z.string().describe('The project UUID.'),
|
|
268
|
-
}, async ({ projectId }) => {
|
|
269
|
-
try {
|
|
270
|
-
const project = await client.getProjectRaw(projectId);
|
|
271
|
-
const nodes = project.latestData ?? [];
|
|
272
|
-
const header = `Project "${project.name}" (id: ${project.id})\nrevision: ${project.version}\nnodes: ${nodes.length}`;
|
|
273
|
-
if (nodes.length === 0)
|
|
274
|
-
return ok(`${header}\n(empty canvas)`);
|
|
275
|
-
const lines = nodes
|
|
276
|
-
.slice(0, 200)
|
|
277
|
-
.map((n) => `- #${n.id} ${n.type ?? 'node'}${n.name ? ` "${n.name}"` : ''}${n.parent !== null && n.parent !== undefined
|
|
278
|
-
? ` (parent #${n.parent})`
|
|
279
|
-
: ''}`);
|
|
280
|
-
return ok(`${header}\n${lines.join('\n')}`);
|
|
281
|
-
}
|
|
282
|
-
catch (e) {
|
|
283
|
-
return fail(e);
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
// -------------------------------------------------------------------------
|
|
287
|
-
// Agentic retrieval tools — let an external agent (Cursor/Claude/Antigravity)
|
|
288
|
-
// navigate a large canvas without ever loading the whole thing into context.
|
|
289
|
-
// The MCP fetches the full node list once (cached by revision) and returns only
|
|
290
|
-
// compact slices. Analogy: get_outline = `ls -R`, search_nodes = `grep`,
|
|
291
|
-
// get_node = `cat`, apply_edit = targeted edit.
|
|
292
|
-
// -------------------------------------------------------------------------
|
|
293
|
-
server.tool('get_outline', "Get a compact, depth-limited outline of a project's canvas (node ids, types, names, child counts). Like `ls -R` for the canvas — use it to understand structure before drilling in. Scales to large canvases.", {
|
|
294
|
-
projectId: z.string().describe('The project UUID.'),
|
|
295
|
-
maxDepth: z
|
|
296
|
-
.number()
|
|
297
|
-
.int()
|
|
298
|
-
.min(0)
|
|
299
|
-
.max(4)
|
|
300
|
-
.optional()
|
|
301
|
-
.describe('How deep to expand the tree. Default 2.'),
|
|
302
|
-
}, async ({ projectId, maxDepth }) => {
|
|
303
|
-
try {
|
|
304
|
-
return ok(await client.outline(projectId, maxDepth));
|
|
305
|
-
}
|
|
306
|
-
catch (e) {
|
|
307
|
-
return fail(e);
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
server.tool('search_nodes', "Search a project's canvas for nodes by name/description substring, type, parent id, and/or faceted labels (category bucket, tag). Paginated. Like `grep` for the canvas — use it to locate the nodes you need to read or edit. Run get_outline first to see which categories/tags exist.", {
|
|
311
|
-
projectId: z.string().describe('The project UUID.'),
|
|
312
|
-
query: z
|
|
313
|
-
.string()
|
|
314
|
-
.optional()
|
|
315
|
-
.describe('Case-insensitive substring matched against name/description.'),
|
|
316
|
-
type: z
|
|
317
|
-
.string()
|
|
318
|
-
.optional()
|
|
319
|
-
.describe("Restrict to a node type, e.g. 'text', 'view', 'container'."),
|
|
320
|
-
parentId: z
|
|
321
|
-
.string()
|
|
322
|
-
.optional()
|
|
323
|
-
.describe('Restrict to direct children of this node id.'),
|
|
324
|
-
category: z
|
|
325
|
-
.string()
|
|
326
|
-
.optional()
|
|
327
|
-
.describe("Exact-match a node's coarse `category` bucket, e.g. 'auth'."),
|
|
328
|
-
tag: z
|
|
329
|
-
.string()
|
|
330
|
-
.optional()
|
|
331
|
-
.describe("Match nodes whose `tags` include this tag, e.g. 'platform:ios'."),
|
|
332
|
-
limit: z
|
|
333
|
-
.number()
|
|
334
|
-
.int()
|
|
335
|
-
.min(1)
|
|
336
|
-
.max(100)
|
|
337
|
-
.optional()
|
|
338
|
-
.describe('Default 20.'),
|
|
339
|
-
offset: z.number().int().min(0).optional().describe('For pagination.'),
|
|
340
|
-
}, async ({ projectId, query, type, parentId, category, tag, limit, offset, }) => {
|
|
341
|
-
try {
|
|
342
|
-
const out = await client.searchNodes(projectId, {
|
|
343
|
-
query,
|
|
344
|
-
type,
|
|
345
|
-
parentId,
|
|
346
|
-
category,
|
|
347
|
-
tag,
|
|
348
|
-
limit,
|
|
349
|
-
offset,
|
|
350
|
-
});
|
|
351
|
-
return ok(out);
|
|
352
|
-
}
|
|
353
|
-
catch (e) {
|
|
354
|
-
return fail(e);
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
server.tool('get_node', "Get a single node's full fields plus its subtree to a given depth. Like `cat` for a canvas node — use after get_outline/search_nodes to read just the part you care about.", {
|
|
358
|
-
projectId: z.string().describe('The project UUID.'),
|
|
359
|
-
nodeId: z.string().describe('The node id to inspect.'),
|
|
360
|
-
depth: z
|
|
361
|
-
.number()
|
|
362
|
-
.int()
|
|
363
|
-
.min(0)
|
|
364
|
-
.max(4)
|
|
365
|
-
.optional()
|
|
366
|
-
.describe('How many child levels to include. Default 1.'),
|
|
367
|
-
}, async ({ projectId, nodeId, depth }) => {
|
|
368
|
-
try {
|
|
369
|
-
return ok(await client.getNode(projectId, nodeId, depth));
|
|
370
|
-
}
|
|
371
|
-
catch (e) {
|
|
372
|
-
return fail(e);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
server.tool('analyze_project', "Analyze a project's canvas: structural stats (node/type counts, root count, max nesting depth) plus deterministic layout-overflow warnings (flex containers whose children can't fit). Lead with this to gauge the shape and health of a project before editing.", {
|
|
376
|
-
projectId: z.string().describe('The project UUID.'),
|
|
377
|
-
}, async ({ projectId }) => {
|
|
378
|
-
try {
|
|
379
|
-
return ok(await client.analyze(projectId));
|
|
380
|
-
}
|
|
381
|
-
catch (e) {
|
|
382
|
-
return fail(e);
|
|
383
|
-
}
|
|
384
|
-
});
|
|
385
|
-
server.tool('query_connections', 'Answer relationship/rollup questions about a project canvas using edges + work items — e.g. "how many bugs are attached to the header", "what is connected to this feature", project-wide work-item counts by type/status. Pass nodeId OR nodeName to focus on one node; omit both for a project-wide rollup. Use this instead of inferring relationships from the raw node tree. (To CREATE work items or connections, use apply_edit: add a `type:"task"` node — the backend creates its task record automatically — and put an edge in the source node\'s `edges` array.)', {
|
|
386
|
-
projectId: z.string().describe('The project UUID.'),
|
|
387
|
-
nodeId: z
|
|
388
|
-
.string()
|
|
389
|
-
.optional()
|
|
390
|
-
.describe('Focus node id. Omit for a project-wide rollup.'),
|
|
391
|
-
nodeName: z
|
|
392
|
-
.string()
|
|
393
|
-
.optional()
|
|
394
|
-
.describe('Focus node by name. Omit for a project-wide rollup.'),
|
|
395
|
-
}, async ({ projectId, nodeId, nodeName }) => {
|
|
396
|
-
try {
|
|
397
|
-
return ok(await client.connections(projectId, { nodeId, nodeName }));
|
|
398
|
-
}
|
|
399
|
-
catch (e) {
|
|
400
|
-
return fail(e);
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
// -------------------------------------------------------------------------
|
|
404
|
-
// describe_engine (local engine knowledge — no network)
|
|
405
|
-
// -------------------------------------------------------------------------
|
|
406
|
-
server.tool('describe_engine', 'Describe the Flowpad layout engine you are designing for: allowed node types, bounds/layout/edge rules and limits. Read this BEFORE building a non-trivial design so what you produce is valid on the first try. Answered locally from the bundled engine (no network, instant).', {}, async () => {
|
|
407
|
-
try {
|
|
408
|
-
return ok(describeEngine());
|
|
409
|
-
}
|
|
410
|
-
catch (e) {
|
|
411
|
-
return fail(e);
|
|
412
|
-
}
|
|
413
|
-
});
|
|
414
|
-
// -------------------------------------------------------------------------
|
|
415
|
-
// validate_design (local pre-flight — check a diff BEFORE apply_edit)
|
|
416
|
-
// -------------------------------------------------------------------------
|
|
417
|
-
server.tool('validate_design', "Check a canvas diff against the engine rules LOCALLY, before sending it. Pass the same added/updated/removedIds you intend to give apply_edit; get back either OK or a precise, fixable reason. Runs on your machine via the bundled engine (no network) — use it to self-correct before committing a change. apply_edit runs this same check, so a passing result means apply_edit won't reject the shape.", {
|
|
418
|
-
added: z
|
|
419
|
-
.array(z.record(z.unknown()))
|
|
420
|
-
.optional()
|
|
421
|
-
.describe('Nodes you plan to add (same shape as apply_edit.added).'),
|
|
422
|
-
updated: z
|
|
423
|
-
.array(z.record(z.unknown()))
|
|
424
|
-
.optional()
|
|
425
|
-
.describe('Node patches you plan to apply (same shape as apply_edit.updated).'),
|
|
426
|
-
removedIds: z
|
|
427
|
-
.array(z.string())
|
|
428
|
-
.optional()
|
|
429
|
-
.describe('Node ids you plan to remove.'),
|
|
430
|
-
existingNodeCount: z
|
|
431
|
-
.number()
|
|
432
|
-
.int()
|
|
433
|
-
.min(0)
|
|
434
|
-
.optional()
|
|
435
|
-
.describe('Current node count in the project (for size-limit checks). Omit if unknown.'),
|
|
436
|
-
}, async ({ added, updated, removedIds, existingNodeCount, }) => {
|
|
437
|
-
try {
|
|
438
|
-
const result = validateLocal({ added, updated, removedIds }, existingNodeCount ?? 0);
|
|
439
|
-
if (result.valid) {
|
|
440
|
-
return ok('✓ Valid — this design satisfies the engine rules. Safe to apply_edit.');
|
|
441
|
-
}
|
|
442
|
-
return ok(`✗ Invalid (rule: ${result.rule}): ${result.message}\nFix this and re-check before apply_edit.`);
|
|
443
|
-
}
|
|
444
|
-
catch (e) {
|
|
445
|
-
return fail(e);
|
|
446
|
-
}
|
|
447
|
-
});
|
|
448
|
-
// -------------------------------------------------------------------------
|
|
449
|
-
// preview_layout (local — compute where auto-layout children will land)
|
|
450
|
-
// -------------------------------------------------------------------------
|
|
451
|
-
server.tool('preview_layout', "Preview where an auto-layout container's children will be positioned, using the REAL layout engine — locally, no network. Pass the container + its children (same node shape as apply_edit, with bounds/layout/isAutoLayout/children/parent). Returns each node's computed bounds so you can verify a frame tree renders as intended BEFORE apply_edit, instead of hand-computing x/y. Only nodes inside an isAutoLayout container are repositioned.", {
|
|
452
|
-
nodes: z
|
|
453
|
-
.array(z.record(z.unknown()))
|
|
454
|
-
.describe('The container + children to lay out (apply_edit node shape: bounds, layout, isAutoLayout, children, parent).'),
|
|
455
|
-
}, async ({ nodes }) => {
|
|
456
|
-
try {
|
|
457
|
-
const { nodes: laid, overflows } = previewLayout(nodes);
|
|
458
|
-
const lines = laid.map((n) => `- #${n.id}: x=${Math.round(n.bounds.x)} y=${Math.round(n.bounds.y)} w=${Math.round(n.bounds.width)} h=${Math.round(n.bounds.height)}`);
|
|
459
|
-
const overflowText = overflows.length
|
|
460
|
-
? `\n\n⚠ Overflow (${overflows.length}):\n${overflows.map((w) => `- ${w}`).join('\n')}`
|
|
461
|
-
: '\n\n✓ No overflow — children fit their containers.';
|
|
462
|
-
return ok(`Computed layout (${laid.length} node(s)):\n${lines.join('\n')}${overflowText}`);
|
|
463
|
-
}
|
|
464
|
-
catch (e) {
|
|
465
|
-
return fail(e);
|
|
466
|
-
}
|
|
467
|
-
});
|
|
468
|
-
server.tool('apply_edit', "Apply a targeted edit to a project's canvas: add, update, and/or remove nodes in one diff. Build the edit yourself from what you read via get_outline/search_nodes/get_node. New nodes get ids assigned automatically if omitted. On a revision conflict the edit is NOT force-written — re-read the latest and reapply.", {
|
|
469
|
-
projectId: z.string().describe('The project UUID.'),
|
|
470
|
-
added: z
|
|
471
|
-
.array(z.object({
|
|
472
|
-
id: z.string().optional(),
|
|
473
|
-
type: z.string().optional(),
|
|
474
|
-
name: z.string().optional(),
|
|
475
|
-
description: z
|
|
476
|
-
.string()
|
|
477
|
-
.optional()
|
|
478
|
-
.describe('Metadata, NOT painted. A TEXT node’s visible string lives in `content`.'),
|
|
479
|
-
parent: z.string().nullable().optional(),
|
|
480
|
-
children: z.array(z.string()).optional(),
|
|
481
|
-
bounds: boundsSchema.optional(),
|
|
482
|
-
category: z
|
|
483
|
-
.string()
|
|
484
|
-
.optional()
|
|
485
|
-
.describe("Coarse discovery bucket, e.g. 'auth'."),
|
|
486
|
-
tags: z
|
|
487
|
-
.array(z.string())
|
|
488
|
-
.optional()
|
|
489
|
-
.describe("Faceted labels, e.g. ['platform:ios','type:dialog']."),
|
|
490
|
-
edges: z
|
|
491
|
-
.array(edgeSchema)
|
|
492
|
-
.optional()
|
|
493
|
-
.describe('Connections FROM this node to other nodes. For a task node, use this to attach it to what it relates to.'),
|
|
494
|
-
isAutoLayout: z
|
|
495
|
-
.boolean()
|
|
496
|
-
.optional()
|
|
497
|
-
.describe('Set true to make this node a flexbox container that auto-positions its children (then give it a `layout`). A container WITHOUT isAutoLayout leaves children at their absolute bounds — almost always wrong for real UI.'),
|
|
498
|
-
layout: layoutSchema.optional(),
|
|
499
|
-
...contentShape,
|
|
500
|
-
...styleShape,
|
|
501
|
-
}))
|
|
502
|
-
.optional()
|
|
503
|
-
.describe('New nodes to add.'),
|
|
504
|
-
updated: z
|
|
505
|
-
.array(z.object({
|
|
506
|
-
id: z.string().describe('Existing node id to update.'),
|
|
507
|
-
type: z.string().optional(),
|
|
508
|
-
name: z.string().optional(),
|
|
509
|
-
description: z
|
|
510
|
-
.string()
|
|
511
|
-
.optional()
|
|
512
|
-
.describe('Metadata, NOT painted. A TEXT node’s visible string lives in `content`.'),
|
|
513
|
-
parent: z.string().nullable().optional(),
|
|
514
|
-
bounds: boundsSchema.optional(),
|
|
515
|
-
category: z
|
|
516
|
-
.string()
|
|
517
|
-
.optional()
|
|
518
|
-
.describe('Coarse discovery bucket.'),
|
|
519
|
-
tags: z
|
|
520
|
-
.array(z.string())
|
|
521
|
-
.optional()
|
|
522
|
-
.describe("Replaces the node's tags."),
|
|
523
|
-
edges: z
|
|
524
|
-
.array(edgeSchema)
|
|
525
|
-
.optional()
|
|
526
|
-
.describe("Replaces the node's outgoing connections."),
|
|
527
|
-
isAutoLayout: z
|
|
528
|
-
.boolean()
|
|
529
|
-
.optional()
|
|
530
|
-
.describe('Toggle whether this node is a flexbox container.'),
|
|
531
|
-
layout: layoutSchema
|
|
532
|
-
.optional()
|
|
533
|
-
.describe("Merged onto the node's existing layout."),
|
|
534
|
-
...contentShape,
|
|
535
|
-
...styleShape,
|
|
536
|
-
}))
|
|
537
|
-
.optional()
|
|
538
|
-
.describe('Patches to existing nodes (merged onto current values).'),
|
|
539
|
-
removedIds: z
|
|
540
|
-
.array(z.string())
|
|
541
|
-
.optional()
|
|
542
|
-
.describe('Ids of nodes to delete.'),
|
|
543
|
-
}, async ({ projectId, added, updated, removedIds }) => {
|
|
544
|
-
try {
|
|
545
|
-
const project = await client.getProjectRawCached(projectId);
|
|
546
|
-
const existing = project.latestData ?? [];
|
|
547
|
-
// Key by String(id): legacy projects store NUMERIC node ids, but tool
|
|
548
|
-
// inputs are strings — a raw `n.id` map would miss `"1"` vs `1`. We look
|
|
549
|
-
// up by string, then write back each node's ORIGINAL id (number or
|
|
550
|
-
// string) so the backend's diff-merge matches it.
|
|
551
|
-
const byId = new Map(existing.map((n) => [String(n.id), n]));
|
|
552
|
-
// Reserve ids: existing + any explicit ids on the new nodes.
|
|
553
|
-
const usedIds = new Set(byId.keys());
|
|
554
|
-
for (const a of added ?? [])
|
|
555
|
-
if (a.id != null)
|
|
556
|
-
usedIds.add(a.id);
|
|
557
|
-
const nextFree = () => {
|
|
558
|
-
let id = newNodeId();
|
|
559
|
-
while (usedIds.has(id))
|
|
560
|
-
id = newNodeId();
|
|
561
|
-
usedIds.add(id);
|
|
562
|
-
return id;
|
|
563
|
-
};
|
|
564
|
-
const addedNodes = (added ?? []).map((a) => rescueTextContent({
|
|
565
|
-
id: a.id ?? nextFree(),
|
|
566
|
-
bounds: {
|
|
567
|
-
x: a.bounds?.x ?? 0,
|
|
568
|
-
y: a.bounds?.y ?? 0,
|
|
569
|
-
width: a.bounds?.width ?? 240,
|
|
570
|
-
height: a.bounds?.height ?? 120,
|
|
571
|
-
},
|
|
572
|
-
children: a.children ?? [],
|
|
573
|
-
parent: a.parent ?? null,
|
|
574
|
-
edges: mintEdges(a.edges),
|
|
575
|
-
...(a.type ? { type: a.type } : {}),
|
|
576
|
-
...(a.name ? { name: a.name } : {}),
|
|
577
|
-
...(a.description ? { description: a.description } : {}),
|
|
578
|
-
...(a.category ? { category: a.category } : {}),
|
|
579
|
-
...(a.tags ? { tags: a.tags } : {}),
|
|
580
|
-
...(a.isAutoLayout != null ? { isAutoLayout: a.isAutoLayout } : {}),
|
|
581
|
-
...(a.layout ? { layout: a.layout } : {}),
|
|
582
|
-
...pickStyle(a),
|
|
583
|
-
}));
|
|
584
|
-
const updatedNodes = [];
|
|
585
|
-
const unknownUpdates = [];
|
|
586
|
-
for (const u of updated ?? []) {
|
|
587
|
-
const base = byId.get(String(u.id));
|
|
588
|
-
if (!base) {
|
|
589
|
-
unknownUpdates.push(u.id);
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
|
-
const merged = { ...base };
|
|
593
|
-
if (u.name !== undefined)
|
|
594
|
-
merged.name = u.name;
|
|
595
|
-
if (u.description !== undefined)
|
|
596
|
-
merged.description = u.description;
|
|
597
|
-
if (u.type !== undefined)
|
|
598
|
-
merged.type = u.type;
|
|
599
|
-
if (u.parent !== undefined)
|
|
600
|
-
merged.parent = u.parent;
|
|
601
|
-
if (u.bounds)
|
|
602
|
-
merged.bounds = { ...base.bounds, ...u.bounds };
|
|
603
|
-
if (u.category !== undefined)
|
|
604
|
-
merged.category = u.category;
|
|
605
|
-
if (u.tags !== undefined)
|
|
606
|
-
merged.tags = u.tags;
|
|
607
|
-
if (u.edges !== undefined)
|
|
608
|
-
merged.edges = mintEdges(u.edges);
|
|
609
|
-
if (u.isAutoLayout !== undefined)
|
|
610
|
-
merged.isAutoLayout = u.isAutoLayout;
|
|
611
|
-
if (u.layout !== undefined) {
|
|
612
|
-
const baseLayout = (base.layout ?? {});
|
|
613
|
-
merged.layout = { ...baseLayout, ...u.layout };
|
|
614
|
-
}
|
|
615
|
-
Object.assign(merged, pickStyle(u));
|
|
616
|
-
updatedNodes.push(merged);
|
|
617
|
-
}
|
|
618
|
-
// Resolve each requested id to the node's ORIGINAL id (preserves numeric
|
|
619
|
-
// legacy ids) so the backend matches them; unknown ids are dropped.
|
|
620
|
-
const removed = (removedIds ?? [])
|
|
621
|
-
.map((id) => byId.get(String(id))?.id)
|
|
622
|
-
.filter((id) => id !== undefined);
|
|
623
|
-
const diff = {};
|
|
624
|
-
if (addedNodes.length)
|
|
625
|
-
diff.added = addedNodes;
|
|
626
|
-
if (updatedNodes.length)
|
|
627
|
-
diff.updated = updatedNodes;
|
|
628
|
-
if (removed.length)
|
|
629
|
-
diff.removedIds = removed;
|
|
630
|
-
const notes = unknownUpdates.length
|
|
631
|
-
? ` (skipped unknown update ids: ${unknownUpdates.join(', ')})`
|
|
632
|
-
: '';
|
|
633
|
-
if (diffIsEmpty(diff))
|
|
634
|
-
return ok(`Nothing to apply.${notes}`);
|
|
635
|
-
// Local pre-flight: validate with the bundled engine BEFORE the network
|
|
636
|
-
// round-trip — same check validate_design exposes and the web client runs.
|
|
637
|
-
// Catch a malformed diff on the user's machine and hand back a fixable
|
|
638
|
-
// reason instead of spending a request to have the backend reject it.
|
|
639
|
-
const verdict = validateLocal(diff, existing.length);
|
|
640
|
-
if (!verdict.valid) {
|
|
641
|
-
return ok(`Edit not sent — local validation failed (rule: ${verdict.rule}): ${verdict.message}. ` +
|
|
642
|
-
`This was caught on your machine before any save. Fix the nodes and retry; ` +
|
|
643
|
-
`run validate_design to re-check.`);
|
|
644
|
-
}
|
|
645
|
-
try {
|
|
646
|
-
const summary = [
|
|
647
|
-
addedNodes.length
|
|
648
|
-
? `${addedNodes.length} added (#${addedNodes.map((n) => n.id).join(', #')})`
|
|
649
|
-
: null,
|
|
650
|
-
updatedNodes.length ? `${updatedNodes.length} updated` : null,
|
|
651
|
-
removed.length ? `${removed.length} removed` : null,
|
|
652
|
-
]
|
|
653
|
-
.filter(Boolean)
|
|
654
|
-
.join(', ');
|
|
655
|
-
const saved = await client.updateProject(projectId, {
|
|
656
|
-
diff,
|
|
657
|
-
baseRevision: project.version,
|
|
658
|
-
});
|
|
659
|
-
if (isProposedEdit(saved))
|
|
660
|
-
return proposed(summary);
|
|
661
|
-
return ok(`Applied: ${summary}. New revision: ${saved.version}.${notes}`);
|
|
662
|
-
}
|
|
663
|
-
catch (e) {
|
|
664
|
-
if (e instanceof Error && /409|conflict|revision/i.test(e.message)) {
|
|
665
|
-
client.invalidate(projectId);
|
|
666
|
-
return ok(`Revision conflict: the project changed since you last read it (your baseRevision ${project.version} is stale). Re-read with get_outline/get_node and reapply your edit. (Non-overlapping edits will merge automatically once server-side node-merge lands.)`);
|
|
667
|
-
}
|
|
668
|
-
throw e;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
catch (e) {
|
|
672
|
-
return fail(e);
|
|
673
|
-
}
|
|
674
|
-
});
|
|
675
|
-
// -------------------------------------------------------------------------
|
|
676
|
-
// add_node (deterministic — no AI, builds a single node directly)
|
|
677
|
-
// -------------------------------------------------------------------------
|
|
678
|
-
server.tool('add_node', 'Add a single node to a Flowpad project directly. Use for a deterministic single-node addition when you know its content. For multi-node structures build the tree yourself and use apply_edit. To make this node lay its children out with flexbox, pass isAutoLayout:true + layout.', {
|
|
679
|
-
projectId: z.string().describe('The project UUID.'),
|
|
680
|
-
name: z
|
|
681
|
-
.string()
|
|
682
|
-
.optional()
|
|
683
|
-
.describe('Node title (metadata, NOT painted).'),
|
|
684
|
-
description: z
|
|
685
|
-
.string()
|
|
686
|
-
.optional()
|
|
687
|
-
.describe('Node description (metadata, NOT painted on canvas). For a TEXT node’s VISIBLE string use `content`, not this.'),
|
|
688
|
-
type: z
|
|
689
|
-
.string()
|
|
690
|
-
.optional()
|
|
691
|
-
.describe("Node type, e.g. 'view'. Defaults to a plain node."),
|
|
692
|
-
parentId: z
|
|
693
|
-
.string()
|
|
694
|
-
.optional()
|
|
695
|
-
.describe('Existing node id to attach this node under.'),
|
|
696
|
-
x: z.number().optional().describe('Top-left x. Default 0.'),
|
|
697
|
-
y: z.number().optional().describe('Top-left y. Default 0.'),
|
|
698
|
-
width: z.number().optional().describe('Width. Default 240.'),
|
|
699
|
-
height: z.number().optional().describe('Height. Default 120.'),
|
|
700
|
-
isAutoLayout: z
|
|
701
|
-
.boolean()
|
|
702
|
-
.optional()
|
|
703
|
-
.describe('Make this node a flexbox container (pair with `layout`).'),
|
|
704
|
-
layout: layoutSchema.optional(),
|
|
705
|
-
...contentShape,
|
|
706
|
-
...styleShape,
|
|
707
|
-
}, async (args) => {
|
|
708
|
-
const { projectId, name, description, type, parentId, x, y, width, height, isAutoLayout, layout, } = args;
|
|
709
|
-
try {
|
|
710
|
-
const project = await client.getProjectRaw(projectId);
|
|
711
|
-
const id = newNodeId();
|
|
712
|
-
const node = buildSimpleNode({
|
|
713
|
-
id,
|
|
714
|
-
type,
|
|
715
|
-
name,
|
|
716
|
-
description,
|
|
717
|
-
parentId: parentId ?? null,
|
|
718
|
-
bounds: { x, y, width, height },
|
|
719
|
-
isAutoLayout,
|
|
720
|
-
layout,
|
|
721
|
-
style: pickStyle(args),
|
|
722
|
-
});
|
|
723
|
-
const saved = await client.updateProject(projectId, {
|
|
724
|
-
diff: { added: [node] },
|
|
725
|
-
baseRevision: project.version,
|
|
726
|
-
});
|
|
727
|
-
if (isProposedEdit(saved)) {
|
|
728
|
-
return proposed(`1 node added${name ? ` ("${name}")` : ''}`);
|
|
729
|
-
}
|
|
730
|
-
return ok(`Added node #${id}${name ? ` "${name}"` : ''}. New revision: ${saved.version}.`);
|
|
731
|
-
}
|
|
732
|
-
catch (e) {
|
|
733
|
-
return fail(e);
|
|
734
|
-
}
|
|
735
|
-
});
|
|
736
|
-
// -------------------------------------------------------------------------
|
|
737
|
-
// create_project (a fresh, empty project to design a NEW thing into)
|
|
738
|
-
// -------------------------------------------------------------------------
|
|
739
|
-
server.tool('create_project', 'Create a NEW, empty project (a fresh canvas) for the user. Use this when the request is a new app/design that does NOT belong in an existing project — never dump unrelated screens into whatever project happens to be open. Returns the new project id; pass it as projectId to add_node/apply_edit to design into it. Applies immediately (a new empty project is non-destructive).', {
|
|
740
|
-
name: z
|
|
741
|
-
.string()
|
|
742
|
-
.describe('The project name, e.g. "Mobile App — Onboarding".'),
|
|
743
|
-
isTemplateProject: z
|
|
744
|
-
.boolean()
|
|
745
|
-
.optional()
|
|
746
|
-
.describe('Mark as a reusable template project. Default false.'),
|
|
747
|
-
}, async ({ name, isTemplateProject }) => {
|
|
748
|
-
try {
|
|
749
|
-
const res = await client.createProject({
|
|
750
|
-
name,
|
|
751
|
-
...(isTemplateProject != null ? { isTemplateProject } : {}),
|
|
752
|
-
});
|
|
753
|
-
if ('proposed' in res) {
|
|
754
|
-
return proposed(`create project "${name}" (the user confirms/edits the name on their screen)`);
|
|
755
|
-
}
|
|
756
|
-
return ok(`Created project "${name}" (#${res.id}). Design into it by passing projectId:"${res.id}" to add_node / apply_edit.`);
|
|
757
|
-
}
|
|
758
|
-
catch (err) {
|
|
759
|
-
return fail(err instanceof Error ? err : new Error(String(err)));
|
|
760
|
-
}
|
|
761
|
-
});
|
|
762
|
-
// -------------------------------------------------------------------------
|
|
763
|
-
// delete_project (DESTRUCTIVE — confirm-gated when the owner is viewing)
|
|
764
|
-
// -------------------------------------------------------------------------
|
|
765
|
-
server.tool('delete_project', 'Delete a project by id. DESTRUCTIVE. If the owner is currently viewing that project, the deletion is routed to their accept/reject confirm — nothing is deleted until they approve. Otherwise it deletes immediately. Use the UUID from list_projects; never guess.', {
|
|
766
|
-
projectId: z.string().describe('The project UUID to delete.'),
|
|
767
|
-
}, async ({ projectId }) => {
|
|
768
|
-
try {
|
|
769
|
-
const res = await client.deleteProject(projectId);
|
|
770
|
-
if (res.proposed)
|
|
771
|
-
return proposed(`delete project ${projectId}`);
|
|
772
|
-
return ok(`Deleted project ${projectId}.`);
|
|
773
|
-
}
|
|
774
|
-
catch (err) {
|
|
775
|
-
return fail(err instanceof Error ? err : new Error(String(err)));
|
|
776
|
-
}
|
|
777
|
-
});
|
|
778
|
-
// -------------------------------------------------------------------------
|
|
779
|
-
// bind_template (install a template project; confirm-gated when viewing)
|
|
780
|
-
// -------------------------------------------------------------------------
|
|
781
|
-
server.tool('bind_template', 'Install a template project INTO a project so its template nodes become available there. If the owner is viewing the target project, the bind is routed to their confirm dialog; otherwise it applies. Find template project ids via list_projects (template projects) or the project catalog. Both ids are UUIDs.', {
|
|
782
|
-
projectId: z
|
|
783
|
-
.string()
|
|
784
|
-
.describe('The project UUID to install the template into.'),
|
|
785
|
-
templateProjectId: z
|
|
786
|
-
.string()
|
|
787
|
-
.describe('The template project UUID to install.'),
|
|
788
|
-
}, async ({ projectId, templateProjectId }) => {
|
|
789
|
-
try {
|
|
790
|
-
const res = await client.bindTemplate(projectId, templateProjectId);
|
|
791
|
-
if (res.proposed) {
|
|
792
|
-
return proposed(`install template ${templateProjectId} into ${projectId}`);
|
|
793
|
-
}
|
|
794
|
-
return ok(`Installed template ${templateProjectId} into project ${projectId}.`);
|
|
795
|
-
}
|
|
796
|
-
catch (err) {
|
|
797
|
-
return fail(err instanceof Error ? err : new Error(String(err)));
|
|
798
|
-
}
|
|
799
|
-
});
|
|
800
|
-
// -------------------------------------------------------------------------
|
|
801
|
-
// list_templates (the BINDABLE catalog — pick one for bind_template)
|
|
802
|
-
// -------------------------------------------------------------------------
|
|
803
|
-
server.tool('list_templates', "List the BINDABLE templates you can install into a project: bundled built-ins + the user's own template projects. Returns id + name + source for each — pick one and pass its id as bind_template's templateProjectId. (Templates already INSTALLED on a project are shown by get_project; THIS is the catalog of what you CAN add.)", {}, async () => {
|
|
804
|
-
try {
|
|
805
|
-
const { data } = await client.listTemplates();
|
|
806
|
-
if (!data.length)
|
|
807
|
-
return ok('No bindable templates available.');
|
|
808
|
-
const lines = data.map((t) => `- ${t.name} (id: ${t.id}, ${t.source})${t.description ? ` — ${t.description}` : ''}`);
|
|
809
|
-
return ok(`${data.length} template(s) you can bind:\n${lines.join('\n')}`);
|
|
810
|
-
}
|
|
811
|
-
catch (err) {
|
|
812
|
-
return fail(err instanceof Error ? err : new Error(String(err)));
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
// -------------------------------------------------------------------------
|
|
816
|
-
// create_task (deterministic — work item + auto-linked task record)
|
|
817
|
-
// -------------------------------------------------------------------------
|
|
818
|
-
server.tool('create_task', "Create a work item (task / feature / bug) on a project canvas. Adds a first-class task node; the backend auto-creates the backing task record and resolves taskType/taskStatus to the workspace's tag ids by name. Optionally connect it to an existing node in one shot. This is the deterministic equivalent of the in-app AI's <AddTask> — use it to plan/track work, not just draw UI.", {
|
|
819
|
-
projectId: z.string().describe('The project UUID.'),
|
|
820
|
-
title: z.string().describe('Short imperative summary (the task title).'),
|
|
821
|
-
description: z
|
|
822
|
-
.string()
|
|
823
|
-
.optional()
|
|
824
|
-
.describe('More detail about the work.'),
|
|
825
|
-
taskType: z
|
|
826
|
-
.string()
|
|
827
|
-
.optional()
|
|
828
|
-
.describe('Work-item kind, e.g. "bug" / "feat" / "chore". Resolved to a workspace task-type tag by name; omit if unknown (run analyze_project / get_outline to learn the workspace vocabulary).'),
|
|
829
|
-
taskStatus: z
|
|
830
|
-
.string()
|
|
831
|
-
.optional()
|
|
832
|
-
.describe('Progress, e.g. "todo" / "in-progress" / "done". Resolved to a workspace progress tag by name; omit if unknown.'),
|
|
833
|
-
connectTo: z
|
|
834
|
-
.string()
|
|
835
|
-
.optional()
|
|
836
|
-
.describe('Existing node id to attach this task to. Creates an edge FROM the task TO that node (e.g. a bug attached to the header it affects).'),
|
|
837
|
-
parentId: z
|
|
838
|
-
.string()
|
|
839
|
-
.optional()
|
|
840
|
-
.describe('Existing node id to nest under.'),
|
|
841
|
-
x: z.number().optional().describe('Top-left x. Default 0.'),
|
|
842
|
-
y: z.number().optional().describe('Top-left y. Default 0.'),
|
|
843
|
-
}, async ({ projectId, title, description, taskType, taskStatus, connectTo, parentId, x, y, }) => {
|
|
844
|
-
try {
|
|
845
|
-
const project = await client.getProjectRaw(projectId);
|
|
846
|
-
const connectTarget = connectTo
|
|
847
|
-
? (project.latestData ?? []).find((n) => String(n.id) === connectTo)
|
|
848
|
-
: undefined;
|
|
849
|
-
if (connectTo && !connectTarget) {
|
|
850
|
-
return fail(new Error(`connectTo node ${connectTo} not found in project.`));
|
|
851
|
-
}
|
|
852
|
-
// EDGE_RULES.NO_EDGE_INTO_TASK (flowpad-core canConnectNodes): the edge is
|
|
853
|
-
// FROM this new task TO connectTo, so connectTo may not itself be a task.
|
|
854
|
-
// Catch it locally with a clear message instead of letting the backend
|
|
855
|
-
// silently drop the edge (dropInvalidTaskEdges).
|
|
856
|
-
if (connectTarget &&
|
|
857
|
-
!canConnectNodes({ type: 'task' }, connectTarget)) {
|
|
858
|
-
return fail(new Error(`Cannot connect task "${title}" to ${connectTo}: an edge may not point INTO a task node. Connect a task to a regular node, not another task.`));
|
|
859
|
-
}
|
|
860
|
-
const id = newNodeId();
|
|
861
|
-
// taskId:null → the backend reconciler creates + links the Task record and
|
|
862
|
-
// resolves the aiTaskType/aiTaskStatus hints to tag ids, then strips them.
|
|
863
|
-
const node = {
|
|
864
|
-
id,
|
|
865
|
-
type: 'task',
|
|
866
|
-
name: title,
|
|
867
|
-
bounds: { x: x ?? 0, y: y ?? 0, width: 100, height: 100 },
|
|
868
|
-
children: [],
|
|
869
|
-
parent: parentId ?? null,
|
|
870
|
-
edges: connectTo ? [{ id: newNodeId(), to: connectTo }] : [],
|
|
871
|
-
taskId: null,
|
|
872
|
-
taskVersionNumber: null,
|
|
873
|
-
...(description ? { description } : {}),
|
|
874
|
-
...(taskType ? { aiTaskType: taskType } : {}),
|
|
875
|
-
...(taskStatus ? { aiTaskStatus: taskStatus } : {}),
|
|
876
|
-
};
|
|
877
|
-
const saved = await client.updateProject(projectId, {
|
|
878
|
-
diff: { added: [node] },
|
|
879
|
-
baseRevision: project.version,
|
|
880
|
-
});
|
|
881
|
-
const conn = connectTo ? `, connected to ${connectTo}` : '';
|
|
882
|
-
if (isProposedEdit(saved)) {
|
|
883
|
-
return proposed(`task "${title}" created${conn}`);
|
|
884
|
-
}
|
|
885
|
-
return ok(`Created task "${title}" (#${id}${taskType ? `, ${taskType}` : ''}${taskStatus ? `, ${taskStatus}` : ''}${conn}). New revision: ${saved.version}.`);
|
|
886
|
-
}
|
|
887
|
-
catch (e) {
|
|
888
|
-
return fail(e);
|
|
889
|
-
}
|
|
890
|
-
});
|
|
891
|
-
// -------------------------------------------------------------------------
|
|
892
|
-
// connect_nodes (deterministic — draw an edge between two existing nodes)
|
|
893
|
-
// -------------------------------------------------------------------------
|
|
894
|
-
server.tool('connect_nodes', 'Draw a connection (edge) between two EXISTING nodes — e.g. attach a bug to the feature it affects, or link a task to a design region. The edge points FROM fromId TO toId. Direction rule: you may NOT point an edge INTO a task node (connect task→regular, never regular→task or task→task). Idempotent: re-running with the same pair is a no-op.', {
|
|
895
|
-
projectId: z.string().describe('The project UUID.'),
|
|
896
|
-
fromId: z.string().describe('Source node id (the edge starts here).'),
|
|
897
|
-
toId: z.string().describe('Target node id (the edge points here).'),
|
|
898
|
-
fromPort: edgePortEnum.optional(),
|
|
899
|
-
toPort: edgePortEnum.optional(),
|
|
900
|
-
}, async ({ projectId, fromId, toId, fromPort, toPort, }) => {
|
|
901
|
-
try {
|
|
902
|
-
if (fromId === toId) {
|
|
903
|
-
return fail(new Error('Cannot connect a node to itself.'));
|
|
904
|
-
}
|
|
905
|
-
const project = await client.getProjectRaw(projectId);
|
|
906
|
-
const nodes = project.latestData ?? [];
|
|
907
|
-
const source = nodes.find((n) => String(n.id) === fromId);
|
|
908
|
-
if (!source)
|
|
909
|
-
return fail(new Error(`Source node ${fromId} not found.`));
|
|
910
|
-
const target = nodes.find((n) => String(n.id) === toId);
|
|
911
|
-
if (!target) {
|
|
912
|
-
return fail(new Error(`Target node ${toId} not found.`));
|
|
913
|
-
}
|
|
914
|
-
// EDGE_RULES.NO_EDGE_INTO_TASK (flowpad-core canConnectNodes): an edge may
|
|
915
|
-
// not point INTO a task node. Catch it locally with a clear message rather
|
|
916
|
-
// than letting the backend silently drop it (dropInvalidTaskEdges).
|
|
917
|
-
if (!canConnectNodes(source, target)) {
|
|
918
|
-
return fail(new Error(`Cannot connect ${fromId} → ${toId}: an edge may not point INTO a task node. Connect a task to a regular node, not another task.`));
|
|
919
|
-
}
|
|
920
|
-
const existing = Array.isArray(source.edges) ? source.edges : [];
|
|
921
|
-
const dup = existing.some((e) => {
|
|
922
|
-
const edge = e;
|
|
923
|
-
return (edge.to === toId &&
|
|
924
|
-
edge.fromPort === fromPort &&
|
|
925
|
-
edge.toPort === toPort);
|
|
926
|
-
});
|
|
927
|
-
if (dup)
|
|
928
|
-
return ok(`Edge ${fromId} → ${toId} already exists.`);
|
|
929
|
-
const merged = {
|
|
930
|
-
...source,
|
|
931
|
-
edges: [
|
|
932
|
-
...existing,
|
|
933
|
-
{
|
|
934
|
-
id: newNodeId(),
|
|
935
|
-
to: toId,
|
|
936
|
-
...(fromPort ? { fromPort } : {}),
|
|
937
|
-
...(toPort ? { toPort } : {}),
|
|
938
|
-
},
|
|
939
|
-
],
|
|
940
|
-
};
|
|
941
|
-
const saved = await client.updateProject(projectId, {
|
|
942
|
-
diff: { updated: [merged] },
|
|
943
|
-
baseRevision: project.version,
|
|
944
|
-
});
|
|
945
|
-
if (isProposedEdit(saved)) {
|
|
946
|
-
return proposed(`edge ${fromId} → ${toId}`);
|
|
947
|
-
}
|
|
948
|
-
return ok(`Connected ${fromId} → ${toId}. New revision: ${saved.version}.`);
|
|
949
|
-
}
|
|
950
|
-
catch (e) {
|
|
951
|
-
return fail(e);
|
|
952
|
-
}
|
|
953
|
-
});
|
|
954
|
-
return server;
|
|
955
|
-
}
|
|
956
|
-
//# sourceMappingURL=server.js.map
|