@mentagen/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/dist/client/helene.js +178 -0
- package/dist/client/types.js +27 -0
- package/dist/index.js +26 -0
- package/dist/tools/batch-update.js +118 -0
- package/dist/tools/boards.js +50 -0
- package/dist/tools/check-collision.js +55 -0
- package/dist/tools/colors.js +35 -0
- package/dist/tools/create-board.js +50 -0
- package/dist/tools/create-edge.js +61 -0
- package/dist/tools/create-graph.js +174 -0
- package/dist/tools/create.js +152 -0
- package/dist/tools/delete-edge.js +36 -0
- package/dist/tools/delete.js +36 -0
- package/dist/tools/extract-board-content.js +207 -0
- package/dist/tools/find-position.js +117 -0
- package/dist/tools/grid-calc.js +205 -0
- package/dist/tools/index.js +940 -0
- package/dist/tools/link.js +22 -0
- package/dist/tools/list-edges.js +58 -0
- package/dist/tools/list-nodes.js +96 -0
- package/dist/tools/list-positions.js +64 -0
- package/dist/tools/node-types.js +65 -0
- package/dist/tools/patch-content.js +143 -0
- package/dist/tools/read.js +41 -0
- package/dist/tools/search-board.js +99 -0
- package/dist/tools/search-boards.js +50 -0
- package/dist/tools/search.js +89 -0
- package/dist/tools/size-calc.js +108 -0
- package/dist/tools/update-board.js +64 -0
- package/dist/tools/update-edge.js +63 -0
- package/dist/tools/update.js +157 -0
- package/dist/utils/collision.js +177 -0
- package/dist/utils/config.js +16 -0
- package/dist/utils/ejson.js +30 -0
- package/dist/utils/errors.js +16 -0
- package/dist/utils/formatting.js +15 -0
- package/dist/utils/urls.js +18 -0
- package/package.json +49 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { formatError } from '../utils/errors.js';
|
|
4
|
+
import { formatSearchResults } from '../utils/formatting.js';
|
|
5
|
+
import { getNodeUrl } from '../utils/urls.js';
|
|
6
|
+
export const searchSchema = z.object({
|
|
7
|
+
query: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe('The search query - can be a question or keywords'),
|
|
10
|
+
board: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Optional board ID to limit search scope'),
|
|
14
|
+
limit: z
|
|
15
|
+
.number()
|
|
16
|
+
.default(10)
|
|
17
|
+
.describe('Maximum number of results (default: 10)'),
|
|
18
|
+
semanticRatio: z
|
|
19
|
+
.number()
|
|
20
|
+
.default(0.5)
|
|
21
|
+
.describe('Balance between semantic (1.0) and keyword (0.0) search'),
|
|
22
|
+
});
|
|
23
|
+
const MAX_CONTENT_LENGTH = 200;
|
|
24
|
+
/**
|
|
25
|
+
* Truncate content to save tokens, preserving word boundaries.
|
|
26
|
+
*/
|
|
27
|
+
function truncateContent(content, maxLength) {
|
|
28
|
+
if (!content)
|
|
29
|
+
return '';
|
|
30
|
+
if (content.length <= maxLength)
|
|
31
|
+
return content;
|
|
32
|
+
const truncated = content.slice(0, maxLength);
|
|
33
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
34
|
+
return ((lastSpace > maxLength * 0.7 ? truncated.slice(0, lastSpace) : truncated) +
|
|
35
|
+
'...');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Format search results as TSV for compact output.
|
|
39
|
+
*/
|
|
40
|
+
function formatSearchTsv(results) {
|
|
41
|
+
const rows = results.map(r => ({
|
|
42
|
+
id: r.id,
|
|
43
|
+
name: r.name,
|
|
44
|
+
content: truncateContent(r.content, MAX_CONTENT_LENGTH),
|
|
45
|
+
type: r.type,
|
|
46
|
+
board: r.board || '',
|
|
47
|
+
score: Math.round(r.score * 100) / 100,
|
|
48
|
+
link: r.link,
|
|
49
|
+
}));
|
|
50
|
+
return Papa.unparse(rows, {
|
|
51
|
+
delimiter: '\t',
|
|
52
|
+
header: true,
|
|
53
|
+
newline: '\n',
|
|
54
|
+
columns: ['id', 'name', 'content', 'type', 'board', 'score', 'link'],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function handleSearch(client, params, baseUrl) {
|
|
58
|
+
try {
|
|
59
|
+
const results = await client.searchNodes({
|
|
60
|
+
query: params.query,
|
|
61
|
+
board: params.board,
|
|
62
|
+
semanticRatio: params.semanticRatio,
|
|
63
|
+
});
|
|
64
|
+
const formatted = formatSearchResults(results.hits.slice(0, params.limit));
|
|
65
|
+
const resultsWithLinks = formatted.results.map(r => ({
|
|
66
|
+
...r,
|
|
67
|
+
link: r.board ? getNodeUrl(baseUrl, r.board, r.id) : '',
|
|
68
|
+
}));
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: 'text',
|
|
73
|
+
text: formatSearchTsv(resultsWithLinks),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: 'text',
|
|
83
|
+
text: `Search failed: ${formatError(error)}`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
isError: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Grid and constraint constants (from src/common/boards/constraints.ts)
|
|
3
|
+
const GRID = 16;
|
|
4
|
+
const MIN_WIDTH = 128;
|
|
5
|
+
const MIN_HEIGHT = 64;
|
|
6
|
+
const MAX_WIDTH = 512; // Reasonable max for auto-sizing (not the 4096 absolute max)
|
|
7
|
+
const MAX_HEIGHT = 512;
|
|
8
|
+
// Text measurement constants (from measure-node-size.ts)
|
|
9
|
+
const MAX_AUTO_WIDTH = 400;
|
|
10
|
+
const PADDING_X = 12;
|
|
11
|
+
const PADDING_Y = 12;
|
|
12
|
+
const ICON_WIDTH = 16;
|
|
13
|
+
const GAP = 8;
|
|
14
|
+
const LINE_HEIGHT = 24;
|
|
15
|
+
// Average character width at 16px font (approximation)
|
|
16
|
+
const AVG_CHAR_WIDTH = 8.5;
|
|
17
|
+
// Node types that display an icon
|
|
18
|
+
const NODE_TYPES_WITH_ICONS = new Set([
|
|
19
|
+
'ai-chat',
|
|
20
|
+
'board',
|
|
21
|
+
'contact',
|
|
22
|
+
'pdf',
|
|
23
|
+
'file',
|
|
24
|
+
'mermaid',
|
|
25
|
+
'code',
|
|
26
|
+
'markdown',
|
|
27
|
+
'teleprompter',
|
|
28
|
+
'excalidraw',
|
|
29
|
+
'url',
|
|
30
|
+
'voice',
|
|
31
|
+
'audio',
|
|
32
|
+
'ai-quiz',
|
|
33
|
+
]);
|
|
34
|
+
export const sizeCalcSchema = z.object({
|
|
35
|
+
name: z.string().describe('The node name/title to measure'),
|
|
36
|
+
type: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.default('text')
|
|
40
|
+
.describe('Node type (affects icon space calculation)'),
|
|
41
|
+
});
|
|
42
|
+
function roundToGrid(value) {
|
|
43
|
+
const result = Math.round(value / GRID) * GRID;
|
|
44
|
+
return Object.is(result, -0) ? 0 : result;
|
|
45
|
+
}
|
|
46
|
+
function hasIcon(type) {
|
|
47
|
+
return NODE_TYPES_WITH_ICONS.has(type);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Estimate text dimensions without DOM access.
|
|
51
|
+
* Uses average character width approximation.
|
|
52
|
+
*/
|
|
53
|
+
function estimateTextSize(text) {
|
|
54
|
+
if (!text || text.trim() === '') {
|
|
55
|
+
return { width: 0, lines: 1 };
|
|
56
|
+
}
|
|
57
|
+
// Split by newlines first
|
|
58
|
+
const explicitLines = text.split('\n');
|
|
59
|
+
let maxLineWidth = 0;
|
|
60
|
+
let totalLines = 0;
|
|
61
|
+
for (const line of explicitLines) {
|
|
62
|
+
const lineWidth = line.length * AVG_CHAR_WIDTH;
|
|
63
|
+
// Check if line needs wrapping
|
|
64
|
+
const maxContentWidth = MAX_AUTO_WIDTH - PADDING_X;
|
|
65
|
+
if (lineWidth > maxContentWidth) {
|
|
66
|
+
// Estimate wrapped lines
|
|
67
|
+
const wrappedLines = Math.ceil(lineWidth / maxContentWidth);
|
|
68
|
+
totalLines += wrappedLines;
|
|
69
|
+
maxLineWidth = Math.max(maxLineWidth, maxContentWidth);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
totalLines += 1;
|
|
73
|
+
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { width: maxLineWidth, lines: totalLines };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Calculate recommended node size based on name and type only.
|
|
80
|
+
* Content is intentionally ignored — nodes should be sized to fit their title,
|
|
81
|
+
* with content scrollable inside.
|
|
82
|
+
*/
|
|
83
|
+
export function calculateNodeSize(name, type) {
|
|
84
|
+
const withIcon = hasIcon(type);
|
|
85
|
+
const iconSpace = withIcon ? ICON_WIDTH + GAP : 0;
|
|
86
|
+
// Measure the title
|
|
87
|
+
const titleSize = estimateTextSize(name);
|
|
88
|
+
// Calculate width based on title
|
|
89
|
+
const contentWidth = Math.min(titleSize.width, MAX_AUTO_WIDTH - PADDING_X - iconSpace);
|
|
90
|
+
const totalWidth = contentWidth + PADDING_X + iconSpace;
|
|
91
|
+
// Calculate height from title only
|
|
92
|
+
const totalHeight = titleSize.lines * LINE_HEIGHT + PADDING_Y;
|
|
93
|
+
// Apply grid snapping and constraints
|
|
94
|
+
const width = roundToGrid(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, totalWidth)));
|
|
95
|
+
const height = roundToGrid(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, totalHeight)));
|
|
96
|
+
return { width, height };
|
|
97
|
+
}
|
|
98
|
+
export function handleSizeCalc(params) {
|
|
99
|
+
const { width, height } = calculateNodeSize(params.name, params.type);
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: JSON.stringify({ width, height }, null, 2),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { formatError } from '../utils/errors.js';
|
|
3
|
+
import { getBoardUrl } from '../utils/urls.js';
|
|
4
|
+
export const updateBoardSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
boardId: z.string().describe('The board ID to update'),
|
|
7
|
+
name: z.string().min(1).optional().describe('New name for the board'),
|
|
8
|
+
description: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('New description for the board'),
|
|
12
|
+
pullRequestUrl: z
|
|
13
|
+
.string()
|
|
14
|
+
.url()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Pull request URL for this board'),
|
|
17
|
+
})
|
|
18
|
+
.refine(data => data.name !== undefined ||
|
|
19
|
+
data.description !== undefined ||
|
|
20
|
+
data.pullRequestUrl !== undefined, {
|
|
21
|
+
message: 'At least one field (name, description, or pullRequestUrl) must be provided',
|
|
22
|
+
});
|
|
23
|
+
export async function handleUpdateBoard(client, params, baseUrl) {
|
|
24
|
+
try {
|
|
25
|
+
const data = {};
|
|
26
|
+
if (params.name !== undefined)
|
|
27
|
+
data.name = params.name;
|
|
28
|
+
if (params.description !== undefined)
|
|
29
|
+
data.description = params.description;
|
|
30
|
+
if (params.pullRequestUrl !== undefined)
|
|
31
|
+
data.pullRequestUrl = params.pullRequestUrl;
|
|
32
|
+
const board = await client.updateBoard({
|
|
33
|
+
boardId: params.boardId,
|
|
34
|
+
data,
|
|
35
|
+
});
|
|
36
|
+
const result = {
|
|
37
|
+
success: true,
|
|
38
|
+
board: {
|
|
39
|
+
id: board._id,
|
|
40
|
+
name: board.name,
|
|
41
|
+
link: getBoardUrl(baseUrl, board._id),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: 'text',
|
|
48
|
+
text: JSON.stringify(result, null, 2),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: `Failed to update board: ${formatError(error)}`,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { EdgeDirection } from '../client/types.js';
|
|
3
|
+
import { formatError } from '../utils/errors.js';
|
|
4
|
+
export const updateEdgeSchema = z.object({
|
|
5
|
+
boardId: z.string().describe('The board ID containing the edge'),
|
|
6
|
+
edgeId: z.string().describe('The edge ID to update'),
|
|
7
|
+
direction: z
|
|
8
|
+
.enum(['none', 'source', 'target'])
|
|
9
|
+
.optional()
|
|
10
|
+
.describe('Edge direction: none (no arrow), source (arrow to source), target (arrow to target)'),
|
|
11
|
+
label: z.string().optional().describe('Optional label text for the edge'),
|
|
12
|
+
});
|
|
13
|
+
export async function handleUpdateEdge(client, params) {
|
|
14
|
+
try {
|
|
15
|
+
// Check if any updates provided
|
|
16
|
+
if (params.direction === undefined && params.label === undefined) {
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
{
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: 'No updates provided. Specify direction and/or label to update.',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
isError: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const edge = await client.updateEdge({
|
|
28
|
+
boardId: params.boardId,
|
|
29
|
+
edgeId: params.edgeId,
|
|
30
|
+
direction: params.direction,
|
|
31
|
+
label: params.label,
|
|
32
|
+
});
|
|
33
|
+
const result = {
|
|
34
|
+
success: true,
|
|
35
|
+
edge: {
|
|
36
|
+
id: edge._id,
|
|
37
|
+
source: edge.source,
|
|
38
|
+
target: edge.target,
|
|
39
|
+
direction: edge.direction || EdgeDirection.None,
|
|
40
|
+
label: edge.label || null,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: JSON.stringify(result, null, 2),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: 'text',
|
|
57
|
+
text: `Failed to update edge: ${formatError(error)}`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
isError: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NodeType } from '../client/types.js';
|
|
3
|
+
import { formatError } from '../utils/errors.js';
|
|
4
|
+
import { snapToGrid } from './grid-calc.js';
|
|
5
|
+
import { calculateNodeSize } from './size-calc.js';
|
|
6
|
+
export const updateSchema = z.object({
|
|
7
|
+
boardId: z.string().describe('The board ID containing the node'),
|
|
8
|
+
nodeId: z.string().describe('The node ID to update'),
|
|
9
|
+
name: z.string().optional().describe('New name for the node'),
|
|
10
|
+
content: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Content for text/markdown nodes. Use "url" field for url-type nodes.'),
|
|
14
|
+
url: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('The URL for url-type nodes (e.g., https://example.com). Only for type="url".'),
|
|
18
|
+
color: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Node color name (use mentagen_colors to see available colors)'),
|
|
22
|
+
x: z
|
|
23
|
+
.number()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('X position on the board canvas (must be multiple of 16)'),
|
|
26
|
+
y: z
|
|
27
|
+
.number()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Y position on the board canvas (must be multiple of 16)'),
|
|
30
|
+
width: z
|
|
31
|
+
.number()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('Node width in pixels (must be multiple of 16)'),
|
|
34
|
+
height: z
|
|
35
|
+
.number()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('Node height in pixels (must be multiple of 16)'),
|
|
38
|
+
autoSize: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.default(true)
|
|
42
|
+
.describe('Auto-resize node when name changes (default: true). Set to false to keep current size.'),
|
|
43
|
+
});
|
|
44
|
+
const POSITION_FIELDS = ['x', 'y', 'width', 'height'];
|
|
45
|
+
/**
|
|
46
|
+
* Add content fields to update data based on node type.
|
|
47
|
+
*/
|
|
48
|
+
function addContentFields(data, params, nodeType) {
|
|
49
|
+
if (nodeType === NodeType.Code && params.content !== undefined) {
|
|
50
|
+
data.code = params.content;
|
|
51
|
+
}
|
|
52
|
+
else if (nodeType === NodeType.URL) {
|
|
53
|
+
if (params.url !== undefined) {
|
|
54
|
+
data.url = params.url;
|
|
55
|
+
}
|
|
56
|
+
if (params.content !== undefined) {
|
|
57
|
+
data.content = params.content;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (params.content !== undefined) {
|
|
61
|
+
data.content = params.content;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Builds the update data object, mapping content/url to the correct field based on node type.
|
|
66
|
+
* Code nodes store their content in `code`, url nodes use `url`, others use `content`.
|
|
67
|
+
*/
|
|
68
|
+
function buildUpdateData(params, nodeType) {
|
|
69
|
+
const data = {};
|
|
70
|
+
if (params.name !== undefined)
|
|
71
|
+
data.name = params.name;
|
|
72
|
+
if (params.color !== undefined)
|
|
73
|
+
data.color = params.color;
|
|
74
|
+
addContentFields(data, params, nodeType);
|
|
75
|
+
for (const field of POSITION_FIELDS) {
|
|
76
|
+
const value = params[field];
|
|
77
|
+
if (value !== undefined) {
|
|
78
|
+
data[field] = snapToGrid(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Object.keys(data).length > 0 ? data : null;
|
|
82
|
+
}
|
|
83
|
+
function shouldAutoSize(params) {
|
|
84
|
+
return (params.autoSize !== false &&
|
|
85
|
+
params.name !== undefined &&
|
|
86
|
+
params.width === undefined &&
|
|
87
|
+
params.height === undefined);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fetches the node and calculates auto-size if needed.
|
|
91
|
+
* Returns both the node (for type detection) and optional auto-size dimensions.
|
|
92
|
+
*/
|
|
93
|
+
async function getNodeContext(client, params) {
|
|
94
|
+
const node = await client.getNode({
|
|
95
|
+
boardId: params.boardId,
|
|
96
|
+
nodeId: params.nodeId,
|
|
97
|
+
});
|
|
98
|
+
const autoSize = shouldAutoSize(params) && params.name
|
|
99
|
+
? calculateNodeSize(params.name, node.type)
|
|
100
|
+
: null;
|
|
101
|
+
return { node, autoSize };
|
|
102
|
+
}
|
|
103
|
+
function formatSuccessResponse(node) {
|
|
104
|
+
const result = {
|
|
105
|
+
success: true,
|
|
106
|
+
node: {
|
|
107
|
+
id: node._id,
|
|
108
|
+
name: node.name,
|
|
109
|
+
x: node.x,
|
|
110
|
+
y: node.y,
|
|
111
|
+
width: node.width,
|
|
112
|
+
height: node.height,
|
|
113
|
+
updatedAt: node.updatedAt || new Date().toISOString(),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export async function handleUpdate(client, params) {
|
|
121
|
+
try {
|
|
122
|
+
const { node: currentNode, autoSize } = await getNodeContext(client, params);
|
|
123
|
+
const data = buildUpdateData({
|
|
124
|
+
...params,
|
|
125
|
+
width: params.width ?? autoSize?.width,
|
|
126
|
+
height: params.height ?? autoSize?.height,
|
|
127
|
+
}, currentNode.type);
|
|
128
|
+
if (!data) {
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: `At least one field to update must be provided (name, content, url, color, x, y, width, height).`,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
isError: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const node = await client.updateNode({
|
|
140
|
+
boardId: params.boardId,
|
|
141
|
+
nodeId: params.nodeId,
|
|
142
|
+
data,
|
|
143
|
+
});
|
|
144
|
+
return formatSuccessResponse(node);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return {
|
|
148
|
+
content: [
|
|
149
|
+
{
|
|
150
|
+
type: 'text',
|
|
151
|
+
text: `Failed to update node: ${formatError(error)}`,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collision detection utilities for board nodes.
|
|
3
|
+
*
|
|
4
|
+
* @see specs/mcp-collision-system.md
|
|
5
|
+
*/
|
|
6
|
+
const GRID_SIZE = 16;
|
|
7
|
+
/**
|
|
8
|
+
* Check if two rectangles intersect (overlap).
|
|
9
|
+
* Edge-touching rectangles do NOT intersect.
|
|
10
|
+
*/
|
|
11
|
+
export function rectsIntersect(a, b) {
|
|
12
|
+
return (a.x < b.x + b.width &&
|
|
13
|
+
a.x + a.width > b.x &&
|
|
14
|
+
a.y < b.y + b.height &&
|
|
15
|
+
a.y + a.height > b.y);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Snap a value to the nearest grid multiple.
|
|
19
|
+
*/
|
|
20
|
+
export function snapToGrid(value) {
|
|
21
|
+
const result = Math.round(value / GRID_SIZE) * GRID_SIZE;
|
|
22
|
+
return Object.is(result, -0) ? 0 : result;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert node data to NodeInfo format for collision results.
|
|
26
|
+
*/
|
|
27
|
+
export function nodeToNodeInfo(node) {
|
|
28
|
+
return {
|
|
29
|
+
id: node._id,
|
|
30
|
+
name: node.name,
|
|
31
|
+
rect: {
|
|
32
|
+
x: node.x,
|
|
33
|
+
y: node.y,
|
|
34
|
+
width: node.width,
|
|
35
|
+
height: node.height,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Find all nodes that collide with a given rectangle.
|
|
41
|
+
*/
|
|
42
|
+
export function findCollidingNodes(rect, nodes, excludeNodeId) {
|
|
43
|
+
const collidingNodes = [];
|
|
44
|
+
for (const node of nodes) {
|
|
45
|
+
if (excludeNodeId && node._id === excludeNodeId) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const nodeRect = {
|
|
49
|
+
x: node.x,
|
|
50
|
+
y: node.y,
|
|
51
|
+
width: node.width,
|
|
52
|
+
height: node.height,
|
|
53
|
+
};
|
|
54
|
+
if (rectsIntersect(rect, nodeRect)) {
|
|
55
|
+
collidingNodes.push(nodeToNodeInfo(node));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
collides: collidingNodes.length > 0,
|
|
60
|
+
collidingNodes,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Check if a rectangle collides with any nodes (early exit on first collision).
|
|
65
|
+
*/
|
|
66
|
+
export function hasCollision(rect, nodes, excludeNodeId) {
|
|
67
|
+
for (const node of nodes) {
|
|
68
|
+
if (excludeNodeId && node._id === excludeNodeId) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const nodeRect = {
|
|
72
|
+
x: node.x,
|
|
73
|
+
y: node.y,
|
|
74
|
+
width: node.width,
|
|
75
|
+
height: node.height,
|
|
76
|
+
};
|
|
77
|
+
if (rectsIntersect(rect, nodeRect)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Calculate starting position based on anchor and direction.
|
|
85
|
+
*/
|
|
86
|
+
function getStartPositionFromAnchor(anchor, direction, width, height, gap) {
|
|
87
|
+
const positions = {
|
|
88
|
+
right: { x: anchor.x + anchor.width + gap, y: anchor.y },
|
|
89
|
+
below: { x: anchor.x, y: anchor.y + anchor.height + gap },
|
|
90
|
+
left: { x: anchor.x - width - gap, y: anchor.y },
|
|
91
|
+
above: { x: anchor.x, y: anchor.y - height - gap },
|
|
92
|
+
};
|
|
93
|
+
return positions[direction];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get the increment for searching in a direction.
|
|
97
|
+
*/
|
|
98
|
+
function getDirectionIncrement(direction) {
|
|
99
|
+
const increments = {
|
|
100
|
+
right: { dx: GRID_SIZE, dy: 0 },
|
|
101
|
+
below: { dx: 0, dy: GRID_SIZE },
|
|
102
|
+
left: { dx: -GRID_SIZE, dy: 0 },
|
|
103
|
+
above: { dx: 0, dy: -GRID_SIZE },
|
|
104
|
+
};
|
|
105
|
+
return increments[direction];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check if direction uses horizontal (x) axis for search.
|
|
109
|
+
*/
|
|
110
|
+
function isHorizontalDirection(direction) {
|
|
111
|
+
return direction === 'right' || direction === 'left';
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get the initial starting position for search.
|
|
115
|
+
*/
|
|
116
|
+
function getInitialPosition(params, direction) {
|
|
117
|
+
if (params.anchor) {
|
|
118
|
+
return getStartPositionFromAnchor(params.anchor, direction, params.width, params.height, params.gap ?? GRID_SIZE);
|
|
119
|
+
}
|
|
120
|
+
return { x: params.startX ?? 0, y: params.startY ?? 0 };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Execute the search loop to find a free position.
|
|
124
|
+
*/
|
|
125
|
+
function searchForFreePosition(startX, startY, width, height, nodes, direction, maxSearchDistance, excludeNodeId) {
|
|
126
|
+
let x = startX;
|
|
127
|
+
let y = startY;
|
|
128
|
+
const startPosition = isHorizontalDirection(direction) ? x : y;
|
|
129
|
+
const { dx, dy } = getDirectionIncrement(direction);
|
|
130
|
+
let searchSteps = 0;
|
|
131
|
+
while (true) {
|
|
132
|
+
searchSteps++;
|
|
133
|
+
if (!hasCollision({ x, y, width, height }, nodes, excludeNodeId)) {
|
|
134
|
+
return { found: true, position: { x, y }, searchSteps };
|
|
135
|
+
}
|
|
136
|
+
x += dx;
|
|
137
|
+
y += dy;
|
|
138
|
+
const currentPosition = isHorizontalDirection(direction) ? x : y;
|
|
139
|
+
if (Math.abs(currentPosition - startPosition) > maxSearchDistance) {
|
|
140
|
+
return {
|
|
141
|
+
found: false,
|
|
142
|
+
searchSteps,
|
|
143
|
+
error: `No valid position found within ${maxSearchDistance}px search limit`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Find the first non-colliding position for a rectangle.
|
|
150
|
+
*/
|
|
151
|
+
export function findFreePosition(params) {
|
|
152
|
+
const direction = params.direction ?? 'right';
|
|
153
|
+
const maxSearchDistance = params.maxSearchDistance ?? 10000;
|
|
154
|
+
const startPos = getInitialPosition(params, direction);
|
|
155
|
+
return searchForFreePosition(snapToGrid(startPos.x), snapToGrid(startPos.y), params.width, params.height, params.nodes, direction, maxSearchDistance, params.excludeNodeId);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Find a suggested position when a collision is detected.
|
|
159
|
+
* Searches in the given direction from the original position.
|
|
160
|
+
*/
|
|
161
|
+
export function findSuggestedPosition(originalX, originalY, width, height, nodes, direction, excludeNodeId) {
|
|
162
|
+
const searchDirection = direction === 'x' ? 'right' : 'below';
|
|
163
|
+
const result = findFreePosition({
|
|
164
|
+
width,
|
|
165
|
+
height,
|
|
166
|
+
nodes,
|
|
167
|
+
startX: originalX,
|
|
168
|
+
startY: originalY,
|
|
169
|
+
direction: searchDirection,
|
|
170
|
+
excludeNodeId,
|
|
171
|
+
maxSearchDistance: 5000,
|
|
172
|
+
});
|
|
173
|
+
if (result.found && result.position) {
|
|
174
|
+
return direction === 'x' ? result.position.x : result.position.y;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function loadConfig() {
|
|
2
|
+
const token = process.env.MENTAGEN_TOKEN;
|
|
3
|
+
if (!token) {
|
|
4
|
+
throw new Error('MENTAGEN_TOKEN environment variable is required.\n' +
|
|
5
|
+
'Generate a development token in Mentagen Settings > Developer.');
|
|
6
|
+
}
|
|
7
|
+
if (!token.startsWith('sd:')) {
|
|
8
|
+
throw new Error('Invalid MENTAGEN_TOKEN format.\n' +
|
|
9
|
+
'Development tokens must start with "sd:" prefix.');
|
|
10
|
+
}
|
|
11
|
+
const mentagenUrl = process.env.MENTAGEN_URL || 'https://mentagen.com';
|
|
12
|
+
return {
|
|
13
|
+
mentagenUrl,
|
|
14
|
+
token,
|
|
15
|
+
};
|
|
16
|
+
}
|