@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.
- package/dist/tools/create-graph.js +8 -3
- package/dist/tools/create.js +22 -2
- package/dist/tools/find-overlaps.js +94 -0
- package/dist/tools/index.js +53 -4
- package/dist/tools/visualize-board.js +319 -0
- package/package.json +2 -1
|
@@ -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(
|
|
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:
|
|
65
|
+
name: finalName,
|
|
61
66
|
...(isCodeNode ? { code: nodeContent } : { content: nodeContent }),
|
|
62
67
|
type,
|
|
63
68
|
color,
|
package/dist/tools/create.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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).
|
|
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.
|
|
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).
|
|
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).
|
|
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, '&')
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/g, ''');
|
|
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.
|
|
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"
|