@mentagen/mcp 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import { formatError } from '../utils/errors.js';
3
- import { generateId } from './create.js';
3
+ import { applyExtension, generateId } from './create.js';
4
4
  import { calculateNodeSize } from './size-calc.js';
5
5
  const nodeInputSchema = z.object({
6
6
  tempId: z.string().describe('Temporary ID for referencing in edges'),
7
7
  name: z.string().describe('The node title/name'),
8
+ extension: z
9
+ .string()
10
+ .optional()
11
+ .describe('File extension to append to name (e.g., ".ts", ".py"). Only applies to code nodes. Defaults to .js.'),
8
12
  content: z.string().optional().describe('Node content'),
9
13
  type: z
10
14
  .enum(['text', 'markdown', 'code', 'url'])
@@ -43,8 +47,9 @@ const DEFAULT_NODE_COLORS = {
43
47
  async function createNode(client, boardId, nodeInput, idMap) {
44
48
  const realId = generateId();
45
49
  const type = nodeInput.type ?? 'text';
50
+ const finalName = applyExtension(nodeInput.name, type, nodeInput.extension);
46
51
  const color = nodeInput.color ?? DEFAULT_NODE_COLORS[type];
47
- const autoSize = calculateNodeSize(nodeInput.name, type);
52
+ const autoSize = calculateNodeSize(finalName, type);
48
53
  const x = nodeInput.x ?? 100;
49
54
  const y = nodeInput.y ?? 100;
50
55
  const width = nodeInput.width ?? autoSize.width;
@@ -57,7 +62,7 @@ async function createNode(client, boardId, nodeInput, idMap) {
57
62
  _id: realId,
58
63
  boardId,
59
64
  node: {
60
- name: nodeInput.name,
65
+ name: finalName,
61
66
  ...(isCodeNode ? { code: nodeContent } : { content: nodeContent }),
62
67
  type,
63
68
  color,
@@ -27,9 +27,27 @@ const DEFAULT_NODE_COLORS = {
27
27
  excalidraw: 'teal',
28
28
  teleprompter: 'rose',
29
29
  };
30
+ const DEFAULT_CODE_EXTENSION = '.js';
31
+ /**
32
+ * Append extension to name for code nodes only.
33
+ * Defaults to .js if no extension provided.
34
+ * Avoids double extensions if name already ends with the extension.
35
+ */
36
+ export function applyExtension(name, type, extension) {
37
+ if (type !== 'code')
38
+ return name;
39
+ const ext = extension ?? DEFAULT_CODE_EXTENSION;
40
+ if (name.endsWith(ext))
41
+ return name;
42
+ return `${name}${ext}`;
43
+ }
30
44
  export const createSchema = z.object({
31
45
  boardId: z.string().describe('The board ID to create the node on'),
32
46
  name: z.string().describe('The node title/name'),
47
+ extension: z
48
+ .string()
49
+ .optional()
50
+ .describe('File extension to append to name (e.g., ".ts", ".py"). Only applies to code nodes. Defaults to .js.'),
33
51
  content: z
34
52
  .string()
35
53
  .optional()
@@ -77,6 +95,7 @@ export function generateId() {
77
95
  }
78
96
  /**
79
97
  * Build node fields object with content mapped to the correct field based on type.
98
+ * Expects params.name to already have extension applied.
80
99
  */
81
100
  function buildNodeFields(params, color, position) {
82
101
  const nodeFields = {
@@ -105,15 +124,16 @@ function buildNodeFields(params, color, position) {
105
124
  export async function handleCreate(client, params, mentagenUrl) {
106
125
  try {
107
126
  const _id = generateId();
127
+ const finalName = applyExtension(params.name, params.type, params.extension);
108
128
  const color = params.color || DEFAULT_NODE_COLORS[params.type];
109
- const autoSize = calculateNodeSize(params.name, params.type);
129
+ const autoSize = calculateNodeSize(finalName, params.type);
110
130
  const position = {
111
131
  x: snapToGrid(params.x ?? 96),
112
132
  y: snapToGrid(params.y ?? 96),
113
133
  width: snapToGrid(params.width ?? autoSize.width),
114
134
  height: snapToGrid(params.height ?? autoSize.height),
115
135
  };
116
- const nodeFields = buildNodeFields(params, color, position);
136
+ const nodeFields = buildNodeFields({ ...params, name: finalName }, color, position);
117
137
  const node = await client.addNode({
118
138
  _id,
119
139
  boardId: params.boardId,
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod';
2
+ import { rectsIntersect } from '../utils/collision.js';
3
+ import { formatError } from '../utils/errors.js';
4
+ export const findOverlapsSchema = z.object({
5
+ boardId: z.string().describe('The board ID to check for overlapping nodes'),
6
+ });
7
+ function calculateOverlapArea(a, b) {
8
+ const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
9
+ const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
10
+ return xOverlap * yOverlap;
11
+ }
12
+ export async function handleFindOverlaps(client, params) {
13
+ try {
14
+ const nodes = await client.listNodes({
15
+ boardId: params.boardId,
16
+ limit: 1000,
17
+ });
18
+ if (nodes.length === 0) {
19
+ return {
20
+ content: [
21
+ {
22
+ type: 'text',
23
+ text: 'No nodes found in this board.',
24
+ },
25
+ ],
26
+ };
27
+ }
28
+ const overlaps = [];
29
+ for (let i = 0; i < nodes.length; i++) {
30
+ const node1 = nodes[i];
31
+ const rect1 = {
32
+ x: node1.x,
33
+ y: node1.y,
34
+ width: node1.width,
35
+ height: node1.height,
36
+ };
37
+ for (let j = i + 1; j < nodes.length; j++) {
38
+ const node2 = nodes[j];
39
+ const rect2 = {
40
+ x: node2.x,
41
+ y: node2.y,
42
+ width: node2.width,
43
+ height: node2.height,
44
+ };
45
+ if (rectsIntersect(rect1, rect2)) {
46
+ overlaps.push({
47
+ node1: { id: node1._id, name: node1.name, rect: rect1 },
48
+ node2: { id: node2._id, name: node2.name, rect: rect2 },
49
+ overlapArea: calculateOverlapArea(rect1, rect2),
50
+ });
51
+ }
52
+ }
53
+ }
54
+ if (overlaps.length === 0) {
55
+ return {
56
+ content: [
57
+ {
58
+ type: 'text',
59
+ text: `No overlapping nodes found. All ${nodes.length} nodes are properly positioned.`,
60
+ },
61
+ ],
62
+ };
63
+ }
64
+ overlaps.sort((a, b) => b.overlapArea - a.overlapArea);
65
+ const summary = `Found ${overlaps.length} overlapping node pair(s) among ${nodes.length} nodes.\n`;
66
+ const details = overlaps
67
+ .map((o, i) => {
68
+ return (`${i + 1}. ${o.node1.name} (${o.node1.id})\n` +
69
+ ` overlaps with\n` +
70
+ ` ${o.node2.name} (${o.node2.id})\n` +
71
+ ` Overlap area: ${o.overlapArea}px²`);
72
+ })
73
+ .join('\n\n');
74
+ return {
75
+ content: [
76
+ {
77
+ type: 'text',
78
+ text: summary + '\n' + details,
79
+ },
80
+ ],
81
+ };
82
+ }
83
+ catch (error) {
84
+ return {
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: `Failed to find overlaps: ${formatError(error)}`,
89
+ },
90
+ ],
91
+ isError: true,
92
+ };
93
+ }
94
+ }
@@ -13,6 +13,7 @@ import { createGraphSchema, handleCreateGraph } from './create-graph.js';
13
13
  import { deleteSchema, handleDelete } from './delete.js';
14
14
  import { deleteEdgeSchema, handleDeleteEdge } from './delete-edge.js';
15
15
  import { extractBoardContentSchema, handleExtractBoardContent, } from './extract-board-content.js';
16
+ import { findOverlapsSchema, handleFindOverlaps } from './find-overlaps.js';
16
17
  import { findPositionSchema, handleFindPosition } from './find-position.js';
17
18
  import { gridCalcSchema, handleGridCalc } from './grid-calc.js';
18
19
  import { handleLink, linkSchema } from './link.js';
@@ -29,6 +30,7 @@ import { handleSizeCalc, sizeCalcSchema } from './size-calc.js';
29
30
  import { handleUpdate, updateSchema } from './update.js';
30
31
  import { handleUpdateBoard, updateBoardSchema } from './update-board.js';
31
32
  import { handleUpdateEdge, updateEdgeSchema } from './update-edge.js';
33
+ import { handleVisualizeBoard, visualizeBoardSchema, } from './visualize-board.js';
32
34
  export function registerTools(server, client, config) {
33
35
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
34
36
  tools: [
@@ -229,7 +231,11 @@ export function registerTools(server, client, config) {
229
231
  },
230
232
  name: {
231
233
  type: 'string',
232
- description: 'The node title/name (used for auto-sizing). For code nodes, use a filename with extension (e.g., "utils.ts", "app.py") for syntax highlighting.',
234
+ description: 'The node title/name (used for auto-sizing). Extension is appended automatically via the extension parameter.',
235
+ },
236
+ extension: {
237
+ type: 'string',
238
+ description: 'File extension to append to name (e.g., ".ts", ".py"). Only applies to code nodes. Defaults to .js.',
233
239
  },
234
240
  content: {
235
241
  type: 'string',
@@ -238,7 +244,7 @@ export function registerTools(server, client, config) {
238
244
  type: {
239
245
  type: 'string',
240
246
  enum: ['text', 'markdown', 'code', 'url'],
241
- description: 'Node type: text (default), markdown, code, or url. Code nodes get syntax highlighting based on the file extension in the name.',
247
+ description: 'Node type: text (default), markdown, code, or url.',
242
248
  },
243
249
  color: {
244
250
  type: 'string',
@@ -476,7 +482,11 @@ export function registerTools(server, client, config) {
476
482
  },
477
483
  name: {
478
484
  type: 'string',
479
- description: 'Node title/name (used for auto-sizing). For code nodes, use a filename with extension for syntax highlighting.',
485
+ description: 'Node title/name (used for auto-sizing). Extension is appended automatically via the extension parameter.',
486
+ },
487
+ extension: {
488
+ type: 'string',
489
+ description: 'File extension to append to name (e.g., ".ts", ".py"). Only applies to code nodes. Defaults to .js.',
480
490
  },
481
491
  content: {
482
492
  type: 'string',
@@ -485,7 +495,7 @@ export function registerTools(server, client, config) {
485
495
  type: {
486
496
  type: 'string',
487
497
  enum: ['text', 'markdown', 'code', 'url'],
488
- description: 'Node type (default: text). Code nodes get syntax highlighting based on file extension in name.',
498
+ description: 'Node type (default: text).',
489
499
  },
490
500
  color: { type: 'string', description: 'Node color' },
491
501
  x: { type: 'number', description: 'X position' },
@@ -749,6 +759,20 @@ export function registerTools(server, client, config) {
749
759
  required: ['boardId', 'x', 'y', 'width', 'height'],
750
760
  },
751
761
  },
762
+ {
763
+ name: 'mentagen_find_overlaps',
764
+ description: 'Find all overlapping node pairs on a board. Returns a list of nodes that overlap with each other, sorted by overlap area. Use this to detect layout issues and identify nodes that need repositioning.',
765
+ inputSchema: {
766
+ type: 'object',
767
+ properties: {
768
+ boardId: {
769
+ type: 'string',
770
+ description: 'The board ID to check for overlapping nodes',
771
+ },
772
+ },
773
+ required: ['boardId'],
774
+ },
775
+ },
752
776
  {
753
777
  name: 'mentagen_find_position',
754
778
  description: 'Find a valid non-colliding position for a new node. IMPORTANT: Use gap=96 or more when nodes will be connected by edges, otherwise gap=16 is fine for unrelated nodes.',
@@ -810,6 +834,23 @@ export function registerTools(server, client, config) {
810
834
  required: ['name'],
811
835
  },
812
836
  },
837
+ {
838
+ name: 'mentagen_visualize_board',
839
+ description: 'Generate a visual PNG image of a board showing all nodes and their connections. ' +
840
+ 'Each node displays its ID in the center. Edges are drawn with arrows for directed connections. ' +
841
+ 'Image dimensions are automatically computed to fit all nodes at a readable size. ' +
842
+ 'Use this to understand the spatial layout and relationships between nodes.',
843
+ inputSchema: {
844
+ type: 'object',
845
+ properties: {
846
+ boardId: {
847
+ type: 'string',
848
+ description: 'The board ID to visualize',
849
+ },
850
+ },
851
+ required: ['boardId'],
852
+ },
853
+ },
813
854
  ],
814
855
  }));
815
856
  const toolHandlers = {
@@ -917,6 +958,10 @@ export function registerTools(server, client, config) {
917
958
  const params = checkCollisionSchema.parse(args);
918
959
  return handleCheckCollision(client, params);
919
960
  },
961
+ mentagen_find_overlaps: async (args) => {
962
+ const params = findOverlapsSchema.parse(args);
963
+ return handleFindOverlaps(client, params);
964
+ },
920
965
  mentagen_find_position: async (args) => {
921
966
  const params = findPositionSchema.parse(args);
922
967
  return handleFindPosition(client, params);
@@ -925,6 +970,10 @@ export function registerTools(server, client, config) {
925
970
  const params = sizeCalcSchema.parse(args);
926
971
  return handleSizeCalc(params);
927
972
  },
973
+ mentagen_visualize_board: async (args) => {
974
+ const params = visualizeBoardSchema.parse(args);
975
+ return handleVisualizeBoard(client, params);
976
+ },
928
977
  };
929
978
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
930
979
  const { name, arguments: args } = request.params;
@@ -0,0 +1,319 @@
1
+ import { Resvg } from '@resvg/resvg-js';
2
+ import { z } from 'zod';
3
+ import { EdgeDirection } from '../client/types.js';
4
+ import { formatError } from '../utils/errors.js';
5
+ export const visualizeBoardSchema = z.object({
6
+ boardId: z.string().describe('The board ID to visualize'),
7
+ });
8
+ const PADDING = 60;
9
+ const MIN_IMAGE_WIDTH = 1000;
10
+ const MAX_IMAGE_WIDTH = 1800;
11
+ const MIN_NODE_DISPLAY_SIZE = 100;
12
+ function computeBounds(nodes) {
13
+ if (nodes.length === 0) {
14
+ return { minX: 0, minY: 0, maxX: 400, maxY: 300, width: 400, height: 300 };
15
+ }
16
+ const minX = Math.min(...nodes.map(n => n.x));
17
+ const minY = Math.min(...nodes.map(n => n.y));
18
+ const maxX = Math.max(...nodes.map(n => n.x + n.width));
19
+ const maxY = Math.max(...nodes.map(n => n.y + n.height));
20
+ return {
21
+ minX,
22
+ minY,
23
+ maxX,
24
+ maxY,
25
+ width: maxX - minX,
26
+ height: maxY - minY,
27
+ };
28
+ }
29
+ function computeImageDimensions(bounds, nodeCount) {
30
+ if (nodeCount === 0) {
31
+ return { width: MIN_IMAGE_WIDTH, height: 600, scale: 1 };
32
+ }
33
+ const avgNodeSize = Math.min(bounds.width, bounds.height) / Math.sqrt(nodeCount);
34
+ const scale = avgNodeSize < MIN_NODE_DISPLAY_SIZE
35
+ ? MIN_NODE_DISPLAY_SIZE / avgNodeSize
36
+ : 1;
37
+ let width = (bounds.width + PADDING * 2) * scale;
38
+ let height = (bounds.height + PADDING * 2) * scale;
39
+ if (width > MAX_IMAGE_WIDTH) {
40
+ const ratio = MAX_IMAGE_WIDTH / width;
41
+ width = MAX_IMAGE_WIDTH;
42
+ height = height * ratio;
43
+ }
44
+ if (width < MIN_IMAGE_WIDTH) {
45
+ const ratio = MIN_IMAGE_WIDTH / width;
46
+ width = MIN_IMAGE_WIDTH;
47
+ height = height * ratio;
48
+ }
49
+ height = Math.max(height, 400);
50
+ height = Math.min(height, 1200);
51
+ return {
52
+ width: Math.round(width),
53
+ height: Math.round(height),
54
+ scale,
55
+ };
56
+ }
57
+ function escapeXml(str) {
58
+ return str
59
+ .replace(/&/g, '&amp;')
60
+ .replace(/</g, '&lt;')
61
+ .replace(/>/g, '&gt;')
62
+ .replace(/"/g, '&quot;')
63
+ .replace(/'/g, '&apos;');
64
+ }
65
+ function calculateFontSize(nodeWidth, textLength) {
66
+ const charWidth = 7;
67
+ const padding = 16;
68
+ const availableWidth = nodeWidth - padding;
69
+ const idealSize = availableWidth / (textLength * (charWidth / 12));
70
+ return Math.max(10, Math.min(14, idealSize));
71
+ }
72
+ function getNodeColor(type) {
73
+ const colors = {
74
+ text: '#f1f5f9',
75
+ markdown: '#ecfeff',
76
+ code: '#eef2ff',
77
+ image: '#fef2f2',
78
+ pdf: '#fef3c7',
79
+ url: '#dbeafe',
80
+ 'ai-chat': '#f3e8ff',
81
+ mermaid: '#dcfce7',
82
+ };
83
+ return colors[type] || '#f1f5f9';
84
+ }
85
+ function getNodeStrokeColor(type) {
86
+ const colors = {
87
+ text: '#64748b',
88
+ markdown: '#06b6d4',
89
+ code: '#6366f1',
90
+ image: '#ef4444',
91
+ pdf: '#f59e0b',
92
+ url: '#3b82f6',
93
+ 'ai-chat': '#a855f7',
94
+ mermaid: '#22c55e',
95
+ };
96
+ return colors[type] || '#64748b';
97
+ }
98
+ function renderNode(node) {
99
+ const fill = getNodeColor(node.type);
100
+ const stroke = getNodeStrokeColor(node.type);
101
+ const id = node._id;
102
+ const centerX = node.x + node.width / 2;
103
+ const centerY = node.y + node.height / 2;
104
+ const fontSize = calculateFontSize(node.width, id.length);
105
+ return `
106
+ <g>
107
+ <rect
108
+ x="${node.x}"
109
+ y="${node.y}"
110
+ width="${node.width}"
111
+ height="${node.height}"
112
+ fill="${fill}"
113
+ stroke="${stroke}"
114
+ stroke-width="2"
115
+ rx="6"
116
+ />
117
+ <text
118
+ x="${centerX}"
119
+ y="${centerY}"
120
+ text-anchor="middle"
121
+ dominant-baseline="middle"
122
+ font-family="ui-monospace, SFMono-Regular, monospace"
123
+ font-size="${fontSize}"
124
+ font-weight="500"
125
+ fill="#1e293b"
126
+ >${escapeXml(id)}</text>
127
+ </g>
128
+ `;
129
+ }
130
+ function findNodeCenter(nodes, nodeId) {
131
+ const node = nodes.find(n => n._id === nodeId);
132
+ if (!node)
133
+ return null;
134
+ return {
135
+ x: node.x + node.width / 2,
136
+ y: node.y + node.height / 2,
137
+ };
138
+ }
139
+ function findClosestSides(sourceNode, targetNode) {
140
+ const sourceCenterX = sourceNode.x + sourceNode.width / 2;
141
+ const sourceCenterY = sourceNode.y + sourceNode.height / 2;
142
+ const targetCenterX = targetNode.x + targetNode.width / 2;
143
+ const targetCenterY = targetNode.y + targetNode.height / 2;
144
+ const sourceSides = {
145
+ top: { x: sourceCenterX, y: sourceNode.y },
146
+ bottom: { x: sourceCenterX, y: sourceNode.y + sourceNode.height },
147
+ left: { x: sourceNode.x, y: sourceCenterY },
148
+ right: { x: sourceNode.x + sourceNode.width, y: sourceCenterY },
149
+ };
150
+ const targetSides = {
151
+ top: { x: targetCenterX, y: targetNode.y },
152
+ bottom: { x: targetCenterX, y: targetNode.y + targetNode.height },
153
+ left: { x: targetNode.x, y: targetCenterY },
154
+ right: { x: targetNode.x + targetNode.width, y: targetCenterY },
155
+ };
156
+ let minDist = Infinity;
157
+ let bestSource = sourceSides.right;
158
+ let bestTarget = targetSides.left;
159
+ for (const [, sp] of Object.entries(sourceSides)) {
160
+ for (const [, tp] of Object.entries(targetSides)) {
161
+ const dist = Math.sqrt((sp.x - tp.x) ** 2 + (sp.y - tp.y) ** 2);
162
+ if (dist < minDist) {
163
+ minDist = dist;
164
+ bestSource = sp;
165
+ bestTarget = tp;
166
+ }
167
+ }
168
+ }
169
+ return { source: bestSource, target: bestTarget };
170
+ }
171
+ function renderEdge(edge, nodes) {
172
+ const sourceNode = nodes.find(n => n._id === edge.source);
173
+ const targetNode = nodes.find(n => n._id === edge.target);
174
+ if (!sourceNode || !targetNode)
175
+ return '';
176
+ const { source, target } = findClosestSides(sourceNode, targetNode);
177
+ const hasArrow = edge.direction && edge.direction !== EdgeDirection.None;
178
+ const markerId = `arrow-${edge._id}`;
179
+ let path = '';
180
+ let marker = '';
181
+ if (hasArrow) {
182
+ const isToSource = edge.direction === EdgeDirection.Source;
183
+ const arrowStart = isToSource ? target : source;
184
+ const arrowEnd = isToSource ? source : target;
185
+ marker = `
186
+ <defs>
187
+ <marker
188
+ id="${markerId}"
189
+ markerWidth="10"
190
+ markerHeight="7"
191
+ refX="9"
192
+ refY="3.5"
193
+ orient="auto"
194
+ >
195
+ <polygon points="0 0, 10 3.5, 0 7" fill="#64748b" />
196
+ </marker>
197
+ </defs>
198
+ `;
199
+ path = `
200
+ <line
201
+ x1="${arrowStart.x}"
202
+ y1="${arrowStart.y}"
203
+ x2="${arrowEnd.x}"
204
+ y2="${arrowEnd.y}"
205
+ stroke="#64748b"
206
+ stroke-width="2"
207
+ marker-end="url(#${markerId})"
208
+ />
209
+ `;
210
+ }
211
+ else {
212
+ path = `
213
+ <line
214
+ x1="${source.x}"
215
+ y1="${source.y}"
216
+ x2="${target.x}"
217
+ y2="${target.y}"
218
+ stroke="#94a3b8"
219
+ stroke-width="1.5"
220
+ />
221
+ `;
222
+ }
223
+ return marker + path;
224
+ }
225
+ function generateSvg(nodes, edges, bounds, dimensions) {
226
+ const viewBoxX = bounds.minX - PADDING;
227
+ const viewBoxY = bounds.minY - PADDING;
228
+ const viewBoxWidth = bounds.width + PADDING * 2;
229
+ const viewBoxHeight = bounds.height + PADDING * 2;
230
+ const edgesSvg = edges.map(e => renderEdge(e, nodes)).join('\n');
231
+ const nodesSvg = nodes.map(n => renderNode(n)).join('\n');
232
+ return `<?xml version="1.0" encoding="UTF-8"?>
233
+ <svg
234
+ xmlns="http://www.w3.org/2000/svg"
235
+ viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}"
236
+ width="${dimensions.width}"
237
+ height="${dimensions.height}"
238
+ >
239
+ <rect
240
+ x="${viewBoxX}"
241
+ y="${viewBoxY}"
242
+ width="${viewBoxWidth}"
243
+ height="${viewBoxHeight}"
244
+ fill="#ffffff"
245
+ />
246
+ ${edgesSvg}
247
+ ${nodesSvg}
248
+ </svg>`;
249
+ }
250
+ export async function handleVisualizeBoard(client, params) {
251
+ try {
252
+ const [nodes, edges] = await Promise.all([
253
+ client.listNodes({ boardId: params.boardId, limit: 1000 }),
254
+ client.listEdges({ boardId: params.boardId, limit: 1000 }),
255
+ ]);
256
+ if (nodes.length === 0) {
257
+ return {
258
+ content: [
259
+ {
260
+ type: 'text',
261
+ text: 'No nodes found in this board.',
262
+ },
263
+ ],
264
+ };
265
+ }
266
+ const bounds = computeBounds(nodes);
267
+ const dimensions = computeImageDimensions(bounds, nodes.length);
268
+ const svg = generateSvg(nodes, edges, bounds, dimensions);
269
+ const resvg = new Resvg(svg, {
270
+ fitTo: {
271
+ mode: 'width',
272
+ value: dimensions.width,
273
+ },
274
+ });
275
+ const pngData = resvg.render();
276
+ const pngBuffer = pngData.asPng();
277
+ const base64 = pngBuffer.toString('base64');
278
+ const summary = `Board visualization: ${nodes.length} nodes, ${edges.length} edges (${dimensions.width}x${dimensions.height}px)`;
279
+ const nodeLegend = nodes
280
+ .map(n => `${n._id}\t${n.type}\t${n.name}`)
281
+ .join('\n');
282
+ const edgeLegend = edges.length > 0
283
+ ? edges
284
+ .map(e => {
285
+ const dir = e.direction === EdgeDirection.Target
286
+ ? '→'
287
+ : e.direction === EdgeDirection.Source
288
+ ? '←'
289
+ : '—';
290
+ return `${e.source} ${dir} ${e.target}${e.label ? ` (${e.label})` : ''}`;
291
+ })
292
+ .join('\n')
293
+ : 'No edges';
294
+ return {
295
+ content: [
296
+ {
297
+ type: 'text',
298
+ text: `${summary}\n\nNodes (id, type, name):\n${nodeLegend}\n\nEdges (source → target):\n${edgeLegend}`,
299
+ },
300
+ {
301
+ type: 'image',
302
+ data: base64,
303
+ mimeType: 'image/png',
304
+ },
305
+ ],
306
+ };
307
+ }
308
+ catch (error) {
309
+ return {
310
+ content: [
311
+ {
312
+ type: 'text',
313
+ text: `Failed to visualize board: ${formatError(error)}`,
314
+ },
315
+ ],
316
+ isError: true,
317
+ };
318
+ }
319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentagen/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for Mentagen knowledge base integration with Cursor",
5
5
  "type": "module",
6
6
  "bin": "dist/index.js",
@@ -29,6 +29,7 @@
29
29
  "license": "UNLICENSED",
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.0.0",
32
+ "@resvg/resvg-js": "^2.6.2",
32
33
  "ejson2": "^1.1.0",
33
34
  "papaparse": "^5.5.3",
34
35
  "zod": "^3.24.1"