stacks-ai 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.
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import Database from "better-sqlite3";
6
+ import { existsSync, mkdirSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ // Database setup
10
+ const dataDir = join(homedir(), ".stacks");
11
+ if (!existsSync(dataDir)) {
12
+ mkdirSync(dataDir, { recursive: true });
13
+ }
14
+ const db = new Database(join(dataDir, "canvas.db"));
15
+ // Initialize schema
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS spaces (
18
+ id TEXT PRIMARY KEY,
19
+ data JSON NOT NULL,
20
+ updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
21
+ )
22
+ `);
23
+ // Database helpers
24
+ const loadSpace = (spaceId) => {
25
+ const row = db.prepare("SELECT data FROM spaces WHERE id = ?").get(spaceId);
26
+ return row ? JSON.parse(row.data) : null;
27
+ };
28
+ const saveSpace = (space) => {
29
+ db.prepare("INSERT OR REPLACE INTO spaces (id, data, updatedAt) VALUES (?, ?, CURRENT_TIMESTAMP)")
30
+ .run(space.id, JSON.stringify(space));
31
+ };
32
+ const listSpaces = () => {
33
+ const rows = db.prepare("SELECT data FROM spaces").all();
34
+ return rows.map(row => JSON.parse(row.data));
35
+ };
36
+ const deleteSpace = (spaceId) => {
37
+ const result = db.prepare("DELETE FROM spaces WHERE id = ?").run(spaceId);
38
+ return result.changes > 0;
39
+ };
40
+ // Ensure root space exists
41
+ if (!loadSpace("root")) {
42
+ saveSpace({
43
+ id: "root",
44
+ name: "Scratchpad",
45
+ parentId: null,
46
+ items: [],
47
+ connections: [],
48
+ camera: { x: 0, y: 0, zoom: 1 }
49
+ });
50
+ }
51
+ // Item defaults
52
+ const ITEM_DEFAULTS = {
53
+ sticky: { w: 200, h: 200, color: "bg-yellow-200" },
54
+ note: { w: 300, h: 400 },
55
+ image: { w: 300, h: 200 },
56
+ video: { w: 300, h: 200 },
57
+ folder: { w: 200, h: 240 }
58
+ };
59
+ // Generate unique ID
60
+ const generateId = (prefix = "item") => {
61
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
62
+ };
63
+ // Create MCP Server
64
+ const server = new McpServer({
65
+ name: "stacks-canvas",
66
+ version: "1.0.0",
67
+ });
68
+ // ============================================
69
+ // TOOLS: Canvas Item Operations
70
+ // ============================================
71
+ server.tool("add_item", "Add a new item (sticky note, note, image, video, or folder) to a space", {
72
+ spaceId: z.string().describe("ID of the space to add item to (use 'root' for main space)"),
73
+ itemType: z.enum(["sticky", "note", "image", "video", "folder"]).describe("Type of item to create"),
74
+ x: z.number().describe("X coordinate on canvas"),
75
+ y: z.number().describe("Y coordinate on canvas"),
76
+ content: z.string().describe("Item content: text for sticky/note, URL for image/video, name for folder"),
77
+ width: z.number().optional().describe("Width (optional, uses default based on type)"),
78
+ height: z.number().optional().describe("Height (optional, uses default based on type)"),
79
+ rotation: z.number().optional().describe("Rotation in degrees (optional, default 0)"),
80
+ color: z.string().optional().describe("Tailwind color class e.g. 'bg-yellow-200' (optional)"),
81
+ }, async ({ spaceId, itemType, x, y, content, width, height, rotation, color }) => {
82
+ const space = loadSpace(spaceId);
83
+ if (!space) {
84
+ return { content: [{ type: "text", text: `Error: Space "${spaceId}" not found` }] };
85
+ }
86
+ const defaults = ITEM_DEFAULTS[itemType];
87
+ const maxZ = space.items.length > 0 ? Math.max(...space.items.map(i => i.zIndex)) : 0;
88
+ const newItem = {
89
+ id: generateId(itemType),
90
+ type: itemType,
91
+ x: x ?? 0,
92
+ y: y ?? 0,
93
+ w: width ?? defaults.w,
94
+ h: height ?? defaults.h,
95
+ zIndex: maxZ + 1,
96
+ rotation: rotation ?? (Math.random() - 0.5) * 6,
97
+ content,
98
+ color: color ?? defaults.color,
99
+ };
100
+ // If folder, create linked space
101
+ if (itemType === "folder") {
102
+ const linkedSpaceId = generateId("space");
103
+ newItem.linkedSpaceId = linkedSpaceId;
104
+ saveSpace({
105
+ id: linkedSpaceId,
106
+ name: content,
107
+ parentId: spaceId,
108
+ items: [],
109
+ connections: [],
110
+ camera: { x: 0, y: 0, zoom: 1 }
111
+ });
112
+ }
113
+ space.items.push(newItem);
114
+ saveSpace(space);
115
+ return {
116
+ content: [{
117
+ type: "text",
118
+ text: `Created ${itemType} "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}" at (${x}, ${y}) with ID: ${newItem.id}`
119
+ }]
120
+ };
121
+ });
122
+ server.tool("update_item", "Update an existing item's position, size, content, or properties", {
123
+ spaceId: z.string().describe("Space ID containing the item"),
124
+ itemId: z.string().describe("ID of item to update"),
125
+ x: z.number().optional().describe("New X position"),
126
+ y: z.number().optional().describe("New Y position"),
127
+ w: z.number().optional().describe("New width"),
128
+ h: z.number().optional().describe("New height"),
129
+ rotation: z.number().optional().describe("New rotation"),
130
+ content: z.string().optional().describe("New content"),
131
+ color: z.string().optional().describe("New color"),
132
+ zIndex: z.number().optional().describe("New z-index/layer"),
133
+ }, async ({ spaceId, itemId, ...updates }) => {
134
+ const space = loadSpace(spaceId);
135
+ if (!space) {
136
+ return { content: [{ type: "text", text: `Error: Space "${spaceId}" not found` }] };
137
+ }
138
+ const item = space.items.find(i => i.id === itemId);
139
+ if (!item) {
140
+ return { content: [{ type: "text", text: `Error: Item "${itemId}" not found` }] };
141
+ }
142
+ // Apply updates
143
+ const validUpdates = Object.entries(updates).filter(([_, v]) => v !== undefined);
144
+ for (const [key, value] of validUpdates) {
145
+ item[key] = value;
146
+ }
147
+ saveSpace(space);
148
+ return {
149
+ content: [{
150
+ type: "text",
151
+ text: `Updated item ${itemId}: ${validUpdates.map(([k, v]) => `${k}=${v}`).join(", ")}`
152
+ }]
153
+ };
154
+ });
155
+ server.tool("delete_item", "Delete an item from a space", {
156
+ spaceId: z.string().describe("Space ID"),
157
+ itemId: z.string().describe("Item ID to delete"),
158
+ }, async ({ spaceId, itemId }) => {
159
+ const space = loadSpace(spaceId);
160
+ if (!space) {
161
+ return { content: [{ type: "text", text: `Error: Space "${spaceId}" not found` }] };
162
+ }
163
+ const index = space.items.findIndex(i => i.id === itemId);
164
+ if (index === -1) {
165
+ return { content: [{ type: "text", text: `Error: Item "${itemId}" not found` }] };
166
+ }
167
+ const deleted = space.items.splice(index, 1)[0];
168
+ // Also remove any connections involving this item
169
+ space.connections = space.connections.filter(c => c.from !== itemId && c.to !== itemId);
170
+ // If folder, delete linked space
171
+ if (deleted.linkedSpaceId) {
172
+ deleteSpace(deleted.linkedSpaceId);
173
+ }
174
+ saveSpace(space);
175
+ return {
176
+ content: [{ type: "text", text: `Deleted ${deleted.type} "${deleted.content.substring(0, 30)}..."` }]
177
+ };
178
+ });
179
+ server.tool("move_item", "Move an item to a new position (convenience method)", {
180
+ spaceId: z.string().describe("Space ID"),
181
+ itemId: z.string().describe("Item ID"),
182
+ x: z.number().describe("New X position"),
183
+ y: z.number().describe("New Y position"),
184
+ }, async ({ spaceId, itemId, x, y }) => {
185
+ const space = loadSpace(spaceId);
186
+ if (!space) {
187
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
188
+ }
189
+ const item = space.items.find(i => i.id === itemId);
190
+ if (!item) {
191
+ return { content: [{ type: "text", text: `Error: Item not found` }] };
192
+ }
193
+ const oldPos = { x: item.x, y: item.y };
194
+ item.x = x;
195
+ item.y = y;
196
+ saveSpace(space);
197
+ return {
198
+ content: [{ type: "text", text: `Moved item from (${oldPos.x}, ${oldPos.y}) to (${x}, ${y})` }]
199
+ };
200
+ });
201
+ // ============================================
202
+ // TOOLS: Connection Operations
203
+ // ============================================
204
+ server.tool("create_connection", "Create a visual connection/link between two items", {
205
+ spaceId: z.string().describe("Space ID"),
206
+ fromItemId: z.string().describe("Source item ID"),
207
+ toItemId: z.string().describe("Target item ID"),
208
+ }, async ({ spaceId, fromItemId, toItemId }) => {
209
+ const space = loadSpace(spaceId);
210
+ if (!space) {
211
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
212
+ }
213
+ // Verify both items exist
214
+ const fromItem = space.items.find(i => i.id === fromItemId);
215
+ const toItem = space.items.find(i => i.id === toItemId);
216
+ if (!fromItem || !toItem) {
217
+ return { content: [{ type: "text", text: `Error: One or both items not found` }] };
218
+ }
219
+ // Check for duplicate
220
+ const exists = space.connections.some(c => (c.from === fromItemId && c.to === toItemId) || (c.from === toItemId && c.to === fromItemId));
221
+ if (exists) {
222
+ return { content: [{ type: "text", text: `Connection already exists between these items` }] };
223
+ }
224
+ const connection = {
225
+ id: generateId("conn"),
226
+ from: fromItemId,
227
+ to: toItemId,
228
+ };
229
+ space.connections.push(connection);
230
+ saveSpace(space);
231
+ return {
232
+ content: [{ type: "text", text: `Created connection: ${fromItem.content.substring(0, 20)} -> ${toItem.content.substring(0, 20)}` }]
233
+ };
234
+ });
235
+ server.tool("delete_connection", "Delete a connection between items", {
236
+ spaceId: z.string().describe("Space ID"),
237
+ connectionId: z.string().describe("Connection ID to delete"),
238
+ }, async ({ spaceId, connectionId }) => {
239
+ const space = loadSpace(spaceId);
240
+ if (!space) {
241
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
242
+ }
243
+ const index = space.connections.findIndex(c => c.id === connectionId);
244
+ if (index === -1) {
245
+ return { content: [{ type: "text", text: `Error: Connection not found` }] };
246
+ }
247
+ space.connections.splice(index, 1);
248
+ saveSpace(space);
249
+ return { content: [{ type: "text", text: `Deleted connection ${connectionId}` }] };
250
+ });
251
+ // ============================================
252
+ // TOOLS: Space Management
253
+ // ============================================
254
+ server.tool("list_spaces", "List all available spaces", {}, async () => {
255
+ const spaces = listSpaces();
256
+ const summary = spaces.map(s => `- ${s.name} (${s.id}): ${s.items.length} items, ${s.connections.length} connections`).join("\n");
257
+ return {
258
+ content: [{
259
+ type: "text",
260
+ text: `Found ${spaces.length} spaces:\n${summary}`
261
+ }]
262
+ };
263
+ });
264
+ server.tool("get_space", "Get detailed information about a specific space", {
265
+ spaceId: z.string().describe("Space ID to retrieve"),
266
+ }, async ({ spaceId }) => {
267
+ const space = loadSpace(spaceId);
268
+ if (!space) {
269
+ return { content: [{ type: "text", text: `Error: Space "${spaceId}" not found` }] };
270
+ }
271
+ const itemSummary = space.items.map(i => ` - [${i.type}] "${i.content.substring(0, 40)}${i.content.length > 40 ? '...' : ''}" at (${Math.round(i.x)}, ${Math.round(i.y)}) ID: ${i.id}`).join("\n");
272
+ const connSummary = space.connections.map(c => ` - ${c.from} -> ${c.to}`).join("\n");
273
+ return {
274
+ content: [{
275
+ type: "text",
276
+ text: `Space: ${space.name} (${space.id})
277
+ Parent: ${space.parentId || "none (root level)"}
278
+ Camera: x=${space.camera.x}, y=${space.camera.y}, zoom=${space.camera.zoom}
279
+
280
+ Items (${space.items.length}):
281
+ ${itemSummary || " (none)"}
282
+
283
+ Connections (${space.connections.length}):
284
+ ${connSummary || " (none)"}`
285
+ }]
286
+ };
287
+ });
288
+ server.tool("create_space", "Create a new top-level space", {
289
+ name: z.string().describe("Name for the new space"),
290
+ }, async ({ name }) => {
291
+ const newSpace = {
292
+ id: generateId("space"),
293
+ name,
294
+ parentId: null,
295
+ items: [],
296
+ connections: [],
297
+ camera: { x: 0, y: 0, zoom: 1 }
298
+ };
299
+ saveSpace(newSpace);
300
+ return {
301
+ content: [{ type: "text", text: `Created new space "${name}" with ID: ${newSpace.id}` }]
302
+ };
303
+ });
304
+ server.tool("delete_space", "Delete a space and all its contents (cannot delete root)", {
305
+ spaceId: z.string().describe("Space ID to delete"),
306
+ }, async ({ spaceId }) => {
307
+ if (spaceId === "root") {
308
+ return { content: [{ type: "text", text: `Error: Cannot delete root space` }] };
309
+ }
310
+ const space = loadSpace(spaceId);
311
+ if (!space) {
312
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
313
+ }
314
+ // Delete any linked sub-spaces from folders
315
+ for (const item of space.items) {
316
+ if (item.linkedSpaceId) {
317
+ deleteSpace(item.linkedSpaceId);
318
+ }
319
+ }
320
+ deleteSpace(spaceId);
321
+ return {
322
+ content: [{ type: "text", text: `Deleted space "${space.name}" and ${space.items.length} items` }]
323
+ };
324
+ });
325
+ server.tool("list_items", "List all items in a space with their positions and types", {
326
+ spaceId: z.string().describe("Space ID"),
327
+ }, async ({ spaceId }) => {
328
+ const space = loadSpace(spaceId);
329
+ if (!space) {
330
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
331
+ }
332
+ if (space.items.length === 0) {
333
+ return { content: [{ type: "text", text: `Space "${space.name}" is empty` }] };
334
+ }
335
+ const summary = space.items.map(i => {
336
+ const preview = i.content.substring(0, 50) + (i.content.length > 50 ? "..." : "");
337
+ return `- ${i.type} "${preview}" at (${Math.round(i.x)}, ${Math.round(i.y)}) [${i.id}]`;
338
+ }).join("\n");
339
+ return {
340
+ content: [{
341
+ type: "text",
342
+ text: `Items in "${space.name}" (${space.items.length}):\n${summary}`
343
+ }]
344
+ };
345
+ });
346
+ // ============================================
347
+ // TOOLS: Bulk Operations
348
+ // ============================================
349
+ server.tool("organize_items", "Auto-organize items in a grid layout", {
350
+ spaceId: z.string().describe("Space ID"),
351
+ columns: z.number().optional().default(3).describe("Number of columns (default 3)"),
352
+ spacing: z.number().optional().default(40).describe("Spacing between items (default 40)"),
353
+ }, async ({ spaceId, columns = 3, spacing = 40 }) => {
354
+ const space = loadSpace(spaceId);
355
+ if (!space) {
356
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
357
+ }
358
+ if (space.items.length === 0) {
359
+ return { content: [{ type: "text", text: `No items to organize` }] };
360
+ }
361
+ // Sort by current position for stability
362
+ const sorted = [...space.items].sort((a, b) => a.y - b.y || a.x - b.x);
363
+ // Calculate grid positions
364
+ let currentX = 0;
365
+ let currentY = 0;
366
+ let rowHeight = 0;
367
+ for (let i = 0; i < sorted.length; i++) {
368
+ const item = sorted[i];
369
+ const original = space.items.find(it => it.id === item.id);
370
+ if (i > 0 && i % columns === 0) {
371
+ currentX = 0;
372
+ currentY += rowHeight + spacing;
373
+ rowHeight = 0;
374
+ }
375
+ original.x = currentX;
376
+ original.y = currentY;
377
+ original.rotation = 0; // Reset rotation for clean grid
378
+ rowHeight = Math.max(rowHeight, original.h);
379
+ currentX += original.w + spacing;
380
+ }
381
+ saveSpace(space);
382
+ return {
383
+ content: [{ type: "text", text: `Organized ${space.items.length} items into ${columns}-column grid` }]
384
+ };
385
+ });
386
+ server.tool("clear_space", "Remove all items from a space (keeps the space itself)", {
387
+ spaceId: z.string().describe("Space ID to clear"),
388
+ }, async ({ spaceId }) => {
389
+ const space = loadSpace(spaceId);
390
+ if (!space) {
391
+ return { content: [{ type: "text", text: `Error: Space not found` }] };
392
+ }
393
+ const count = space.items.length;
394
+ // Delete linked spaces from folders
395
+ for (const item of space.items) {
396
+ if (item.linkedSpaceId) {
397
+ deleteSpace(item.linkedSpaceId);
398
+ }
399
+ }
400
+ space.items = [];
401
+ space.connections = [];
402
+ saveSpace(space);
403
+ return {
404
+ content: [{ type: "text", text: `Cleared ${count} items from "${space.name}"` }]
405
+ };
406
+ });
407
+ // ============================================
408
+ // RESOURCES: Canvas State
409
+ // ============================================
410
+ server.resource("canvas://spaces", "List of all spaces", async (uri) => {
411
+ const spaces = listSpaces();
412
+ return {
413
+ contents: [{
414
+ uri: uri.href,
415
+ mimeType: "application/json",
416
+ text: JSON.stringify(spaces.map(s => ({
417
+ id: s.id,
418
+ name: s.name,
419
+ parentId: s.parentId,
420
+ itemCount: s.items.length,
421
+ connectionCount: s.connections.length
422
+ })), null, 2)
423
+ }]
424
+ };
425
+ });
426
+ // ============================================
427
+ // PROMPTS: AI Workflows
428
+ // ============================================
429
+ server.prompt("brainstorm", "Generate ideas and add them as sticky notes to the canvas", {
430
+ topic: z.string().describe("Topic to brainstorm about"),
431
+ count: z.string().optional().default("5").describe("Number of ideas to generate"),
432
+ }, async ({ topic, count = "5" }) => {
433
+ return {
434
+ messages: [{
435
+ role: "user",
436
+ content: {
437
+ type: "text",
438
+ text: `Generate ${count} creative ideas about "${topic}".
439
+
440
+ For each idea, use the add_item tool to create a sticky note in the "root" space.
441
+ Space them out in a roughly circular pattern around center (0,0), about 300 pixels apart.
442
+ Use different pastel colors: bg-yellow-200, bg-pink-200, bg-green-200, bg-blue-200, bg-purple-200.
443
+
444
+ Make the ideas concise but insightful. Each sticky note content should be 1-2 sentences.`
445
+ }
446
+ }]
447
+ };
448
+ });
449
+ server.prompt("summarize_space", "Analyze and summarize the contents of a space", {
450
+ spaceId: z.string().describe("Space ID to analyze"),
451
+ }, async ({ spaceId }) => {
452
+ const space = loadSpace(spaceId);
453
+ if (!space) {
454
+ return {
455
+ messages: [{
456
+ role: "user",
457
+ content: { type: "text", text: `Space "${spaceId}" not found.` }
458
+ }]
459
+ };
460
+ }
461
+ const itemsJson = JSON.stringify(space.items, null, 2);
462
+ return {
463
+ messages: [{
464
+ role: "user",
465
+ content: {
466
+ type: "text",
467
+ text: `Analyze this canvas space and provide a summary:
468
+
469
+ Space: ${space.name}
470
+ Items: ${space.items.length}
471
+ Connections: ${space.connections.length}
472
+
473
+ Item details:
474
+ ${itemsJson}
475
+
476
+ Please provide:
477
+ 1. A brief overview of what this space contains
478
+ 2. Key themes or topics identified
479
+ 3. Suggestions for organization or additions`
480
+ }
481
+ }]
482
+ };
483
+ });
484
+ server.prompt("layout_suggestions", "Get suggestions for organizing canvas items", {
485
+ spaceId: z.string().describe("Space to analyze"),
486
+ }, async ({ spaceId }) => {
487
+ const space = loadSpace(spaceId);
488
+ if (!space) {
489
+ return {
490
+ messages: [{
491
+ role: "user",
492
+ content: { type: "text", text: `Space "${spaceId}" not found.` }
493
+ }]
494
+ };
495
+ }
496
+ return {
497
+ messages: [{
498
+ role: "user",
499
+ content: {
500
+ type: "text",
501
+ text: `I have ${space.items.length} items on my canvas. Here are their current positions:
502
+
503
+ ${space.items.map(i => `- ${i.type}: "${i.content.substring(0, 30)}" at (${i.x}, ${i.y})`).join("\n")}
504
+
505
+ Suggest a better visual arrangement. Consider:
506
+ - Grouping related items
507
+ - Creating visual hierarchy
508
+ - Using space effectively
509
+ - Making connections clear
510
+
511
+ You can use the move_item or organize_items tools to implement your suggestions.`
512
+ }
513
+ }]
514
+ };
515
+ });
516
+ // ============================================
517
+ // Server Startup
518
+ // ============================================
519
+ async function main() {
520
+ const transport = new StdioServerTransport();
521
+ await server.connect(transport);
522
+ console.error("Stacks Canvas MCP Server v1.0.0 running on stdio");
523
+ console.error(`Database: ${join(dataDir, "canvas.db")}`);
524
+ }
525
+ main().catch(error => {
526
+ console.error("Server startup error:", error);
527
+ process.exit(1);
528
+ });
529
+ //# sourceMappingURL=server.js.map