@mentagen/mcp 0.5.0 → 0.6.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,14 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { formatError } from '../utils/errors.js';
3
+ import { unitsToPixels } from '../utils/units.js';
4
+ import { snapToGrid } from './grid-calc.js';
3
5
  const nodeUpdateSchema = z.object({
4
6
  nodeId: z.string().describe('The node ID to update'),
5
7
  name: z.string().optional().describe('New name for the node'),
6
8
  content: z.string().optional().describe('New content for the node'),
7
9
  color: z.string().optional().describe('Node color'),
8
- x: z.number().optional().describe('X position'),
9
- y: z.number().optional().describe('Y position'),
10
- width: z.number().optional().describe('Node width'),
11
- height: z.number().optional().describe('Node height'),
10
+ x: z.number().optional().describe('X position in grid units'),
11
+ y: z.number().optional().describe('Y position in grid units'),
12
+ width: z.number().optional().describe('Node width in grid units'),
13
+ height: z.number().optional().describe('Node height in grid units'),
12
14
  });
13
15
  const edgeUpdateSchema = z.object({
14
16
  edgeId: z.string().describe('The edge ID to update'),
@@ -44,8 +46,18 @@ export async function handleBatchUpdate(client, params) {
44
46
  // Process node updates in parallel
45
47
  const nodePromises = nodeUpdates.map(async (update) => {
46
48
  const { nodeId, ...data } = update;
47
- // Filter out undefined values
48
- const cleanData = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
49
+ // Filter out undefined values and convert position fields from units to pixels
50
+ const cleanData = {};
51
+ for (const [key, value] of Object.entries(data)) {
52
+ if (value !== undefined) {
53
+ if (key === 'x' || key === 'y' || key === 'width' || key === 'height') {
54
+ cleanData[key] = snapToGrid(unitsToPixels(value));
55
+ }
56
+ else {
57
+ cleanData[key] = value;
58
+ }
59
+ }
60
+ }
49
61
  if (Object.keys(cleanData).length === 0) {
50
62
  return { id: nodeId, success: false, error: 'No fields to update' };
51
63
  }
@@ -1,12 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import { findCollidingNodes, snapToGrid } from '../utils/collision.js';
3
3
  import { formatError } from '../utils/errors.js';
4
+ import { pixelsToUnits, unitsToPixels } from '../utils/units.js';
4
5
  export const checkCollisionSchema = z.object({
5
6
  boardId: z.string().describe('The board ID to check against'),
6
- x: z.number().describe('Left position of the rectangle'),
7
- y: z.number().describe('Top position of the rectangle'),
8
- width: z.number().describe('Width of the rectangle'),
9
- height: z.number().describe('Height of the rectangle'),
7
+ x: z.number().describe('Left position of the rectangle in grid units'),
8
+ y: z.number().describe('Top position of the rectangle in grid units'),
9
+ width: z.number().describe('Width of the rectangle in grid units'),
10
+ height: z.number().describe('Height of the rectangle in grid units'),
10
11
  excludeNodeId: z
11
12
  .string()
12
13
  .optional()
@@ -19,15 +20,32 @@ export async function handleCheckCollision(client, params) {
19
20
  boardId: params.boardId,
20
21
  limit: 500,
21
22
  });
22
- // Snap position to grid
23
- const rect = {
24
- x: snapToGrid(params.x),
25
- y: snapToGrid(params.y),
26
- width: params.width,
27
- height: params.height,
23
+ // Convert units to pixels and snap position to grid
24
+ // Note: width/height don't need snapToGrid since unitsToPixels already produces grid-aligned values
25
+ const rectPixels = {
26
+ x: snapToGrid(unitsToPixels(params.x)),
27
+ y: snapToGrid(unitsToPixels(params.y)),
28
+ width: unitsToPixels(params.width),
29
+ height: unitsToPixels(params.height),
28
30
  };
29
31
  // Check for collisions
30
- const result = findCollidingNodes(rect, nodes, params.excludeNodeId);
32
+ const result = findCollidingNodes(rectPixels, nodes, params.excludeNodeId);
33
+ // Convert results back to units
34
+ const rect = {
35
+ x: pixelsToUnits(rectPixels.x),
36
+ y: pixelsToUnits(rectPixels.y),
37
+ width: pixelsToUnits(rectPixels.width),
38
+ height: pixelsToUnits(rectPixels.height),
39
+ };
40
+ const collidingNodes = result.collidingNodes.map(node => ({
41
+ ...node,
42
+ rect: {
43
+ x: pixelsToUnits(node.rect.x),
44
+ y: pixelsToUnits(node.rect.y),
45
+ width: pixelsToUnits(node.rect.width),
46
+ height: pixelsToUnits(node.rect.height),
47
+ },
48
+ }));
31
49
  return {
32
50
  content: [
33
51
  {
@@ -35,7 +53,7 @@ export async function handleCheckCollision(client, params) {
35
53
  text: JSON.stringify({
36
54
  collides: result.collides,
37
55
  rect,
38
- collidingNodes: result.collidingNodes,
56
+ collidingNodes,
39
57
  }, null, 2),
40
58
  },
41
59
  ],
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { formatError } from '../utils/errors.js';
3
+ import { pixelsToUnits, unitsToPixels } from '../utils/units.js';
3
4
  import { applyExtension, generateId } from './create.js';
5
+ import { snapToGrid } from './grid-calc.js';
4
6
  import { calculateNodeSize } from './size-calc.js';
5
7
  const nodeInputSchema = z.object({
6
8
  tempId: z.string().describe('Temporary ID for referencing in edges'),
@@ -16,10 +18,10 @@ const nodeInputSchema = z.object({
16
18
  .default('text')
17
19
  .describe('Node type'),
18
20
  color: z.string().optional().describe('Node color'),
19
- x: z.number().optional().describe('X position'),
20
- y: z.number().optional().describe('Y position'),
21
- width: z.number().optional().describe('Width'),
22
- height: z.number().optional().describe('Height'),
21
+ x: z.number().optional().describe('X position in grid units'),
22
+ y: z.number().optional().describe('Y position in grid units'),
23
+ width: z.number().optional().describe('Width in grid units'),
24
+ height: z.number().optional().describe('Height in grid units'),
23
25
  });
24
26
  const edgeInputSchema = z.object({
25
27
  source: z.string().describe('Source node tempId'),
@@ -50,10 +52,14 @@ async function createNode(client, boardId, nodeInput, idMap) {
50
52
  const finalName = applyExtension(nodeInput.name, type, nodeInput.extension);
51
53
  const color = nodeInput.color ?? DEFAULT_NODE_COLORS[type];
52
54
  const autoSize = calculateNodeSize(finalName, type);
53
- const x = nodeInput.x ?? 100;
54
- const y = nodeInput.y ?? 100;
55
- const width = nodeInput.width ?? autoSize.width;
56
- const height = nodeInput.height ?? autoSize.height;
55
+ const xPixels = snapToGrid(nodeInput.x !== undefined ? unitsToPixels(nodeInput.x) : unitsToPixels(6));
56
+ const yPixels = snapToGrid(nodeInput.y !== undefined ? unitsToPixels(nodeInput.y) : unitsToPixels(6));
57
+ const widthPixels = snapToGrid(nodeInput.width !== undefined
58
+ ? unitsToPixels(nodeInput.width)
59
+ : autoSize.width);
60
+ const heightPixels = snapToGrid(nodeInput.height !== undefined
61
+ ? unitsToPixels(nodeInput.height)
62
+ : autoSize.height);
57
63
  try {
58
64
  // For code nodes, store content in the `code` field
59
65
  const isCodeNode = type === 'code';
@@ -66,10 +72,10 @@ async function createNode(client, boardId, nodeInput, idMap) {
66
72
  ...(isCodeNode ? { code: nodeContent } : { content: nodeContent }),
67
73
  type,
68
74
  color,
69
- x,
70
- y,
71
- width,
72
- height,
75
+ x: xPixels,
76
+ y: yPixels,
77
+ width: widthPixels,
78
+ height: heightPixels,
73
79
  },
74
80
  });
75
81
  idMap[nodeInput.tempId] = node._id;
@@ -79,10 +85,10 @@ async function createNode(client, boardId, nodeInput, idMap) {
79
85
  tempId: nodeInput.tempId,
80
86
  id: node._id,
81
87
  name: node.name,
82
- x,
83
- y,
84
- width,
85
- height,
88
+ x: pixelsToUnits(xPixels),
89
+ y: pixelsToUnits(yPixels),
90
+ width: pixelsToUnits(widthPixels),
91
+ height: pixelsToUnits(heightPixels),
86
92
  },
87
93
  };
88
94
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { formatError } from '../utils/errors.js';
3
+ import { pixelsToUnits, unitsToPixels } from '../utils/units.js';
3
4
  import { getNodeUrl } from '../utils/urls.js';
4
5
  import { snapToGrid } from './grid-calc.js';
5
6
  import { calculateNodeSize } from './size-calc.js';
@@ -66,22 +67,16 @@ export const createSchema = z.object({
66
67
  .string()
67
68
  .optional()
68
69
  .describe('Optional color override. If not specified, uses default for type (markdown=cyan, code=indigo, url=blue)'),
69
- x: z
70
- .number()
71
- .optional()
72
- .describe('X position (must be multiple of 16, default: 96)'),
73
- y: z
74
- .number()
75
- .optional()
76
- .describe('Y position (must be multiple of 16, default: 96)'),
70
+ x: z.number().optional().describe('X position in grid units (default: 6)'),
71
+ y: z.number().optional().describe('Y position in grid units (default: 6)'),
77
72
  width: z
78
73
  .number()
79
74
  .optional()
80
- .describe('Width (must be multiple of 16, default: 256)'),
75
+ .describe('Width in grid units (default: auto-calculated)'),
81
76
  height: z
82
77
  .number()
83
78
  .optional()
84
- .describe('Height (must be multiple of 16, default: 128)'),
79
+ .describe('Height in grid units (default: auto-calculated)'),
85
80
  });
86
81
  /**
87
82
  * Generate a simple ObjectId-like string.
@@ -129,10 +124,14 @@ export async function handleCreate(client, params, mentagenUrl) {
129
124
  const color = params.color || DEFAULT_NODE_COLORS[params.type];
130
125
  const autoSize = calculateNodeSize(finalName, params.type);
131
126
  const position = {
132
- x: snapToGrid(params.x ?? 96),
133
- y: snapToGrid(params.y ?? 96),
134
- width: snapToGrid(params.width ?? autoSize.width),
135
- height: snapToGrid(params.height ?? autoSize.height),
127
+ x: snapToGrid(unitsToPixels(params.x ?? 6)),
128
+ y: snapToGrid(unitsToPixels(params.y ?? 6)),
129
+ width: snapToGrid(params.width !== undefined
130
+ ? unitsToPixels(params.width)
131
+ : autoSize.width),
132
+ height: snapToGrid(params.height !== undefined
133
+ ? unitsToPixels(params.height)
134
+ : autoSize.height),
136
135
  };
137
136
  const nodeFields = buildNodeFields({ ...params, name: finalName }, color, position);
138
137
  const node = await client.addNode({
@@ -146,7 +145,10 @@ export async function handleCreate(client, params, mentagenUrl) {
146
145
  id: node._id,
147
146
  name: node.name,
148
147
  board: params.boardId,
149
- ...position,
148
+ x: pixelsToUnits(position.x),
149
+ y: pixelsToUnits(position.y),
150
+ width: pixelsToUnits(position.width),
151
+ height: pixelsToUnits(position.height),
150
152
  link: getNodeUrl(mentagenUrl, params.boardId, node._id),
151
153
  },
152
154
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { rectsIntersect } from '../utils/collision.js';
3
3
  import { formatError } from '../utils/errors.js';
4
+ import { pixelsToUnits } from '../utils/units.js';
4
5
  export const findOverlapsSchema = z.object({
5
6
  boardId: z.string().describe('The board ID to check for overlapping nodes'),
6
7
  });
@@ -43,10 +44,29 @@ export async function handleFindOverlaps(client, params) {
43
44
  height: node2.height,
44
45
  };
45
46
  if (rectsIntersect(rect1, rect2)) {
47
+ const overlapAreaPixels = calculateOverlapArea(rect1, rect2);
46
48
  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),
49
+ node1: {
50
+ id: node1._id,
51
+ name: node1.name,
52
+ rect: {
53
+ x: pixelsToUnits(rect1.x),
54
+ y: pixelsToUnits(rect1.y),
55
+ width: pixelsToUnits(rect1.width),
56
+ height: pixelsToUnits(rect1.height),
57
+ },
58
+ },
59
+ node2: {
60
+ id: node2._id,
61
+ name: node2.name,
62
+ rect: {
63
+ x: pixelsToUnits(rect2.x),
64
+ y: pixelsToUnits(rect2.y),
65
+ width: pixelsToUnits(rect2.width),
66
+ height: pixelsToUnits(rect2.height),
67
+ },
68
+ },
69
+ overlapArea: overlapAreaPixels,
50
70
  });
51
71
  }
52
72
  }
@@ -68,7 +88,7 @@ export async function handleFindOverlaps(client, params) {
68
88
  return (`${i + 1}. ${o.node1.name} (${o.node1.id})\n` +
69
89
  ` overlaps with\n` +
70
90
  ` ${o.node2.name} (${o.node2.id})\n` +
71
- ` Overlap area: ${o.overlapArea}px²`);
91
+ ` Overlap area: ${o.overlapArea}px² (${Math.round(o.overlapArea / 256)} units²)`);
72
92
  })
73
93
  .join('\n\n');
74
94
  return {
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { findFreePosition, } from '../utils/collision.js';
3
3
  import { formatError } from '../utils/errors.js';
4
+ import { pixelsToUnits, unitsToPixels } from '../utils/units.js';
4
5
  export const findPositionSchema = z.object({
5
6
  boardId: z.string().describe('The board ID to search'),
6
- width: z.number().describe('Width of the node to place'),
7
- height: z.number().describe('Height of the node to place'),
7
+ width: z.number().describe('Width of the node to place in grid units'),
8
+ height: z.number().describe('Height of the node to place in grid units'),
8
9
  anchorNodeId: z
9
10
  .string()
10
11
  .optional()
@@ -16,92 +17,116 @@ export const findPositionSchema = z.object({
16
17
  gap: z
17
18
  .number()
18
19
  .optional()
19
- .describe('Gap between nodes in pixels (default: 16)'),
20
+ .describe('Gap between nodes in grid units. Use 6+ for connected nodes, 1 for unrelated (default: 1)'),
20
21
  startX: z
21
22
  .number()
22
23
  .optional()
23
- .describe('Explicit start X (ignored if anchorNodeId provided)'),
24
+ .describe('Explicit start X in grid units (ignored if anchorNodeId provided)'),
24
25
  startY: z
25
26
  .number()
26
27
  .optional()
27
- .describe('Explicit start Y (ignored if anchorNodeId provided)'),
28
+ .describe('Explicit start Y in grid units (ignored if anchorNodeId provided)'),
28
29
  });
30
+ function findAnchorNode(nodes, anchorNodeId) {
31
+ const anchorNode = nodes.find(n => n._id === anchorNodeId);
32
+ if (!anchorNode)
33
+ return null;
34
+ return {
35
+ anchor: {
36
+ x: anchorNode.x,
37
+ y: anchorNode.y,
38
+ width: anchorNode.width,
39
+ height: anchorNode.height,
40
+ },
41
+ anchorInfo: {
42
+ id: anchorNode._id,
43
+ name: anchorNode.name,
44
+ },
45
+ };
46
+ }
47
+ function formatSuccessResponse(position, anchorInfo, direction, searchSteps) {
48
+ return {
49
+ content: [
50
+ {
51
+ type: 'text',
52
+ text: JSON.stringify({
53
+ found: true,
54
+ position: {
55
+ x: pixelsToUnits(position.x),
56
+ y: pixelsToUnits(position.y),
57
+ },
58
+ anchor: anchorInfo || null,
59
+ direction: direction || 'right',
60
+ searchSteps,
61
+ }, null, 2),
62
+ },
63
+ ],
64
+ };
65
+ }
66
+ function formatErrorResponse(error, searchSteps) {
67
+ return {
68
+ content: [
69
+ {
70
+ type: 'text',
71
+ text: JSON.stringify({
72
+ found: false,
73
+ error: error || 'No valid position found',
74
+ searchSteps,
75
+ }, null, 2),
76
+ },
77
+ ],
78
+ isError: true,
79
+ };
80
+ }
81
+ async function prepareSearchParams(client, params) {
82
+ const nodes = await client.listNodes({
83
+ boardId: params.boardId,
84
+ limit: 500,
85
+ });
86
+ if (!params.anchorNodeId) {
87
+ return { nodes };
88
+ }
89
+ const anchorData = findAnchorNode(nodes, params.anchorNodeId);
90
+ if (!anchorData) {
91
+ return {
92
+ error: {
93
+ content: [
94
+ {
95
+ type: 'text',
96
+ text: `Anchor node not found: ${params.anchorNodeId}`,
97
+ },
98
+ ],
99
+ isError: true,
100
+ },
101
+ };
102
+ }
103
+ return {
104
+ nodes,
105
+ anchor: anchorData.anchor,
106
+ anchorInfo: anchorData.anchorInfo,
107
+ };
108
+ }
29
109
  export async function handleFindPosition(client, params) {
30
110
  try {
31
- // Fetch nodes from board
32
- const nodes = await client.listNodes({
33
- boardId: params.boardId,
34
- limit: 500,
35
- });
36
- // Find anchor node if specified
37
- let anchor;
38
- let anchorInfo;
39
- if (params.anchorNodeId) {
40
- const anchorNode = nodes.find(n => n._id === params.anchorNodeId);
41
- if (!anchorNode) {
42
- return {
43
- content: [
44
- {
45
- type: 'text',
46
- text: `Anchor node not found: ${params.anchorNodeId}`,
47
- },
48
- ],
49
- isError: true,
50
- };
51
- }
52
- anchor = {
53
- x: anchorNode.x,
54
- y: anchorNode.y,
55
- width: anchorNode.width,
56
- height: anchorNode.height,
57
- };
58
- anchorInfo = {
59
- id: anchorNode._id,
60
- name: anchorNode.name,
61
- };
111
+ const prepared = await prepareSearchParams(client, params);
112
+ if ('error' in prepared) {
113
+ return prepared.error;
62
114
  }
63
- // Find free position
115
+ const { nodes, anchor, anchorInfo } = prepared;
64
116
  const result = findFreePosition({
65
- width: params.width,
66
- height: params.height,
67
- nodes,
117
+ width: unitsToPixels(params.width),
118
+ height: unitsToPixels(params.height),
119
+ nodes: nodes,
68
120
  anchor,
69
121
  direction: params.direction,
70
- gap: params.gap,
71
- startX: params.startX,
72
- startY: params.startY,
122
+ gap: params.gap !== undefined ? unitsToPixels(params.gap) : undefined,
123
+ startX: params.startX !== undefined ? unitsToPixels(params.startX) : undefined,
124
+ startY: params.startY !== undefined ? unitsToPixels(params.startY) : undefined,
73
125
  });
74
126
  if (result.found && result.position) {
75
- return {
76
- content: [
77
- {
78
- type: 'text',
79
- text: JSON.stringify({
80
- found: true,
81
- position: result.position,
82
- anchor: anchorInfo || null,
83
- direction: params.direction || 'right',
84
- searchSteps: result.searchSteps,
85
- }, null, 2),
86
- },
87
- ],
88
- };
89
- }
90
- else {
91
- return {
92
- content: [
93
- {
94
- type: 'text',
95
- text: JSON.stringify({
96
- found: false,
97
- error: result.error || 'No valid position found',
98
- searchSteps: result.searchSteps,
99
- }, null, 2),
100
- },
101
- ],
102
- isError: true,
103
- };
127
+ return formatSuccessResponse(result.position, anchorInfo, params.direction, result.searchSteps);
104
128
  }
129
+ return formatErrorResponse(result.error, result.searchSteps);
105
130
  }
106
131
  catch (error) {
107
132
  return {
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { findCollidingNodes, findSuggestedPosition, } from '../utils/collision.js';
3
+ import { pixelsToUnits, unitsToPixels } from '../utils/units.js';
3
4
  const GRID_SIZE = 16;
4
5
  export const gridCalcSchema = z
5
6
  .object({
@@ -9,23 +10,23 @@ export const gridCalcSchema = z
9
10
  value: z
10
11
  .number()
11
12
  .optional()
12
- .describe('Value to snap to the grid (for snap operation)'),
13
+ .describe('Value to snap to the grid in units (for snap operation)'),
13
14
  position: z
14
15
  .number()
15
16
  .optional()
16
- .describe('Current x or y position (for next_x/next_y operations)'),
17
+ .describe('Current x or y position in units (for next_x/next_y operations)'),
17
18
  size: z
18
19
  .number()
19
20
  .optional()
20
- .describe('Width or height of the current node (for next_x/next_y)'),
21
+ .describe('Width or height of the current node in units (for next_x/next_y)'),
21
22
  gap: z
22
23
  .number()
23
24
  .optional()
24
- .describe('Gap between nodes in pixels (default: 16)'),
25
+ .describe('Gap between nodes in grid units. Use 6+ for connected nodes, 1 for unrelated (default: 1)'),
25
26
  units: z
26
27
  .number()
27
28
  .optional()
28
- .describe('Number of grid units to convert to pixels (for grid_units)'),
29
+ .describe('Number of grid units (for grid_units operation - deprecated, returns same value)'),
29
30
  // Collision-aware params (optional)
30
31
  boardId: z
31
32
  .string()
@@ -34,19 +35,19 @@ export const gridCalcSchema = z
34
35
  nodeWidth: z
35
36
  .number()
36
37
  .optional()
37
- .describe('Width of node being placed (required with boardId)'),
38
+ .describe('Width of node being placed in grid units (required with boardId)'),
38
39
  nodeHeight: z
39
40
  .number()
40
41
  .optional()
41
- .describe('Height of node being placed (required with boardId)'),
42
+ .describe('Height of node being placed in grid units (required with boardId)'),
42
43
  nodeY: z
43
44
  .number()
44
45
  .optional()
45
- .describe('Y position of node (required for next_x collision check)'),
46
+ .describe('Y position of node in grid units (required for next_x collision check)'),
46
47
  nodeX: z
47
48
  .number()
48
49
  .optional()
49
- .describe('X position of node (required for next_y collision check)'),
50
+ .describe('X position of node in grid units (required for next_y collision check)'),
50
51
  excludeNodeId: z
51
52
  .string()
52
53
  .optional()
@@ -85,30 +86,26 @@ export function snapToGrid(value) {
85
86
  }
86
87
  /**
87
88
  * Calculate the next position after a node, with a gap.
88
- * Result is snapped to the grid.
89
+ * All inputs and output are in grid units.
89
90
  */
90
91
  export function calculateNextPosition(position, size, gap) {
91
- return snapToGrid(position + size + gap);
92
- }
93
- /**
94
- * Convert grid units to pixels.
95
- * 1 grid unit = 16 pixels.
96
- */
97
- export function gridUnitsToPixels(units) {
98
- return units * GRID_SIZE;
92
+ return position + size + gap;
99
93
  }
100
94
  /**
101
95
  * Calculate result based on operation type.
96
+ * All operations now work with grid units.
102
97
  */
103
98
  function calculateResult(params) {
104
99
  if (params.operation === 'snap') {
105
- return snapToGrid(params.value);
100
+ // Snap to nearest integer unit
101
+ return Math.round(params.value);
106
102
  }
107
103
  if (params.operation === 'grid_units') {
108
- return gridUnitsToPixels(params.units);
104
+ // Deprecated: just return the units value (identity operation)
105
+ return params.units;
109
106
  }
110
107
  // next_x or next_y
111
- return calculateNextPosition(params.position, params.size, params.gap ?? GRID_SIZE);
108
+ return calculateNextPosition(params.position, params.size, params.gap ?? 1);
112
109
  }
113
110
  /**
114
111
  * Build input object for result output.
@@ -156,18 +153,38 @@ function supportsCollisionCheck(operation) {
156
153
  */
157
154
  async function checkPositionCollision(params, baseResult, client) {
158
155
  const nodes = await client.listNodes({ boardId: params.boardId, limit: 500 });
159
- const x = params.operation === 'next_x' ? baseResult.result : (params.nodeX ?? 0);
160
- const y = params.operation === 'next_y' ? baseResult.result : (params.nodeY ?? 0);
161
- const rect = { x, y, width: params.nodeWidth, height: params.nodeHeight };
162
- const collisionResult = findCollidingNodes(rect, nodes, params.excludeNodeId);
156
+ // Convert units to pixels for collision checking
157
+ const xPixels = params.operation === 'next_x'
158
+ ? unitsToPixels(baseResult.result)
159
+ : unitsToPixels(params.nodeX ?? 0);
160
+ const yPixels = params.operation === 'next_y'
161
+ ? unitsToPixels(baseResult.result)
162
+ : unitsToPixels(params.nodeY ?? 0);
163
+ const rectPixels = {
164
+ x: xPixels,
165
+ y: yPixels,
166
+ width: unitsToPixels(params.nodeWidth),
167
+ height: unitsToPixels(params.nodeHeight),
168
+ };
169
+ const collisionResult = findCollidingNodes(rectPixels, nodes, params.excludeNodeId);
163
170
  const collision = {
164
171
  checked: true,
165
172
  collides: collisionResult.collides,
166
- collidingNodes: collisionResult.collidingNodes,
173
+ collidingNodes: collisionResult.collidingNodes.map(node => ({
174
+ ...node,
175
+ rect: {
176
+ x: pixelsToUnits(node.rect.x),
177
+ y: pixelsToUnits(node.rect.y),
178
+ width: pixelsToUnits(node.rect.width),
179
+ height: pixelsToUnits(node.rect.height),
180
+ },
181
+ })),
167
182
  };
168
183
  if (collisionResult.collides) {
169
184
  const direction = params.operation === 'next_x' ? 'x' : 'y';
170
- collision.suggestedPosition = findSuggestedPosition(x, y, params.nodeWidth, params.nodeHeight, nodes, direction, params.excludeNodeId);
185
+ const suggestedPixels = findSuggestedPosition(xPixels, yPixels, rectPixels.width, rectPixels.height, nodes, direction, params.excludeNodeId);
186
+ collision.suggestedPosition =
187
+ suggestedPixels !== null ? pixelsToUnits(suggestedPixels) : null;
171
188
  }
172
189
  return collision;
173
190
  }
@@ -313,11 +313,11 @@ export function registerTools(server, client, config) {
313
313
  },
314
314
  x: {
315
315
  type: 'number',
316
- description: 'X position (snapped to 16px grid, default: 96)',
316
+ description: 'X position in grid units (1 unit = 16px, default: 6)',
317
317
  },
318
318
  y: {
319
319
  type: 'number',
320
- description: 'Y position (snapped to 16px grid, default: 96)',
320
+ description: 'Y position in grid units (1 unit = 16px, default: 6)',
321
321
  },
322
322
  width: {
323
323
  type: 'number',
@@ -363,19 +363,19 @@ export function registerTools(server, client, config) {
363
363
  },
364
364
  x: {
365
365
  type: 'number',
366
- description: 'X position on the board canvas (multiple of 16)',
366
+ description: 'X position in grid units (1 unit = 16px)',
367
367
  },
368
368
  y: {
369
369
  type: 'number',
370
- description: 'Y position on the board canvas (multiple of 16)',
370
+ description: 'Y position in grid units (1 unit = 16px)',
371
371
  },
372
372
  width: {
373
373
  type: 'number',
374
- description: 'Node width in pixels (multiple of 16)',
374
+ description: 'Node width in grid units (1 unit = 16px)',
375
375
  },
376
376
  height: {
377
377
  type: 'number',
378
- description: 'Node height in pixels (multiple of 16)',
378
+ description: 'Node height in grid units (1 unit = 16px)',
379
379
  },
380
380
  autoSize: {
381
381
  type: 'boolean',
@@ -423,10 +423,22 @@ export function registerTools(server, client, config) {
423
423
  name: { type: 'string', description: 'New name' },
424
424
  content: { type: 'string', description: 'New content' },
425
425
  color: { type: 'string', description: 'Node color' },
426
- x: { type: 'number', description: 'X position' },
427
- y: { type: 'number', description: 'Y position' },
428
- width: { type: 'number', description: 'Width' },
429
- height: { type: 'number', description: 'Height' },
426
+ x: {
427
+ type: 'number',
428
+ description: 'X position in grid units (1 unit = 16px)',
429
+ },
430
+ y: {
431
+ type: 'number',
432
+ description: 'Y position in grid units (1 unit = 16px)',
433
+ },
434
+ width: {
435
+ type: 'number',
436
+ description: 'Width in grid units (1 unit = 16px)',
437
+ },
438
+ height: {
439
+ type: 'number',
440
+ description: 'Height in grid units (1 unit = 16px)',
441
+ },
430
442
  },
431
443
  required: ['nodeId'],
432
444
  },
@@ -563,15 +575,21 @@ export function registerTools(server, client, config) {
563
575
  description: 'Node type (default: text).',
564
576
  },
565
577
  color: { type: 'string', description: 'Node color' },
566
- x: { type: 'number', description: 'X position' },
567
- y: { type: 'number', description: 'Y position' },
578
+ x: {
579
+ type: 'number',
580
+ description: 'X position in grid units (1 unit = 16px)',
581
+ },
582
+ y: {
583
+ type: 'number',
584
+ description: 'Y position in grid units (1 unit = 16px)',
585
+ },
568
586
  width: {
569
587
  type: 'number',
570
- description: 'DO NOT USE - auto-calculated from name',
588
+ description: 'Width in grid units (1 unit = 16px, DO NOT USE - auto-calculated from name)',
571
589
  },
572
590
  height: {
573
591
  type: 'number',
574
- description: 'DO NOT USE - auto-calculated from name',
592
+ description: 'Height in grid units (1 unit = 16px, DO NOT USE - auto-calculated from name)',
575
593
  },
576
594
  },
577
595
  required: ['tempId', 'name'],
@@ -733,7 +751,7 @@ export function registerTools(server, client, config) {
733
751
  },
734
752
  {
735
753
  name: 'mentagen_grid_calc',
736
- description: 'Math helper for calculating node positions on the 16px grid. Use gap=96+ for connected nodes, gap=16 for unrelated nodes. Operations: "snap" rounds to 16px, "next_x"/"next_y" calculate position after a node, "grid_units" converts units to pixels.',
754
+ description: 'Math helper for calculating node positions in grid units (1 unit = 16px). Use gap=6+ for connected nodes, gap=1 for unrelated nodes. Operations: "snap" rounds to nearest integer unit, "next_x"/"next_y" calculate position after a node, "grid_units" is deprecated (returns same value).',
737
755
  inputSchema: {
738
756
  type: 'object',
739
757
  properties: {
@@ -744,23 +762,23 @@ export function registerTools(server, client, config) {
744
762
  },
745
763
  value: {
746
764
  type: 'number',
747
- description: 'Value to snap to the grid (for snap operation)',
765
+ description: 'Value to snap to the grid in units (for snap operation)',
748
766
  },
749
767
  position: {
750
768
  type: 'number',
751
- description: 'Current x or y position (for next_x/next_y operations)',
769
+ description: 'Current x or y position in units (for next_x/next_y operations)',
752
770
  },
753
771
  size: {
754
772
  type: 'number',
755
- description: 'Width or height of the current node (for next_x/next_y)',
773
+ description: 'Width or height of the current node in units (for next_x/next_y)',
756
774
  },
757
775
  gap: {
758
776
  type: 'number',
759
- description: 'Gap in pixels. Use 96+ for connected nodes, 16 for unrelated (default: 16)',
777
+ description: 'Gap in grid units (1 unit = 16px). Use 6+ for connected nodes, 1 for unrelated (default: 1)',
760
778
  },
761
779
  units: {
762
780
  type: 'number',
763
- description: 'Number of grid units to convert to pixels (for grid_units)',
781
+ description: 'Number of grid units (1 unit = 16px, for grid_units operation - deprecated, returns same value)',
764
782
  },
765
783
  boardId: {
766
784
  type: 'string',
@@ -768,19 +786,19 @@ export function registerTools(server, client, config) {
768
786
  },
769
787
  nodeWidth: {
770
788
  type: 'number',
771
- description: 'Width of node being placed (required with boardId)',
789
+ description: 'Width of node being placed in grid units (1 unit = 16px, required with boardId)',
772
790
  },
773
791
  nodeHeight: {
774
792
  type: 'number',
775
- description: 'Height of node being placed (required with boardId)',
793
+ description: 'Height of node being placed in grid units (1 unit = 16px, required with boardId)',
776
794
  },
777
795
  nodeY: {
778
796
  type: 'number',
779
- description: 'Y position of node (for next_x collision check)',
797
+ description: 'Y position of node in grid units (1 unit = 16px, for next_x collision check)',
780
798
  },
781
799
  nodeX: {
782
800
  type: 'number',
783
- description: 'X position of node (for next_y collision check)',
801
+ description: 'X position of node in grid units (1 unit = 16px, for next_y collision check)',
784
802
  },
785
803
  excludeNodeId: {
786
804
  type: 'string',
@@ -802,19 +820,19 @@ export function registerTools(server, client, config) {
802
820
  },
803
821
  x: {
804
822
  type: 'number',
805
- description: 'Left position of the rectangle',
823
+ description: 'Left position of the rectangle in grid units (1 unit = 16px)',
806
824
  },
807
825
  y: {
808
826
  type: 'number',
809
- description: 'Top position of the rectangle',
827
+ description: 'Top position of the rectangle in grid units (1 unit = 16px)',
810
828
  },
811
829
  width: {
812
830
  type: 'number',
813
- description: 'Width of the rectangle',
831
+ description: 'Width of the rectangle in grid units (1 unit = 16px)',
814
832
  },
815
833
  height: {
816
834
  type: 'number',
817
- description: 'Height of the rectangle',
835
+ description: 'Height of the rectangle in grid units (1 unit = 16px)',
818
836
  },
819
837
  excludeNodeId: {
820
838
  type: 'string',
@@ -840,7 +858,7 @@ export function registerTools(server, client, config) {
840
858
  },
841
859
  {
842
860
  name: 'mentagen_find_position',
843
- 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.',
861
+ description: 'Find a valid non-colliding position for a new node. IMPORTANT: Use gap=6+ (96px+) when nodes will be connected by edges, otherwise gap=1 (16px) is fine for unrelated nodes. All positions are in grid units (1 unit = 16px).',
844
862
  inputSchema: {
845
863
  type: 'object',
846
864
  properties: {
@@ -850,11 +868,11 @@ export function registerTools(server, client, config) {
850
868
  },
851
869
  width: {
852
870
  type: 'number',
853
- description: 'Width of the node to place',
871
+ description: 'Width of the node to place in grid units (1 unit = 16px)',
854
872
  },
855
873
  height: {
856
874
  type: 'number',
857
- description: 'Height of the node to place',
875
+ description: 'Height of the node to place in grid units (1 unit = 16px)',
858
876
  },
859
877
  anchorNodeId: {
860
878
  type: 'string',
@@ -867,15 +885,15 @@ export function registerTools(server, client, config) {
867
885
  },
868
886
  gap: {
869
887
  type: 'number',
870
- description: 'Gap between nodes in pixels. Use 96+ for connected nodes (edges), 16 for unrelated nodes',
888
+ description: 'Gap between nodes in grid units (1 unit = 16px). Use 6+ for connected nodes (edges), 1 for unrelated nodes',
871
889
  },
872
890
  startX: {
873
891
  type: 'number',
874
- description: 'Explicit start X (ignored if anchorNodeId provided)',
892
+ description: 'Explicit start X in grid units (1 unit = 16px, ignored if anchorNodeId provided)',
875
893
  },
876
894
  startY: {
877
895
  type: 'number',
878
- description: 'Explicit start Y (ignored if anchorNodeId provided)',
896
+ description: 'Explicit start Y in grid units (1 unit = 16px, ignored if anchorNodeId provided)',
879
897
  },
880
898
  },
881
899
  required: ['boardId', 'width', 'height'],
@@ -883,7 +901,7 @@ export function registerTools(server, client, config) {
883
901
  },
884
902
  {
885
903
  name: 'mentagen_size_calc',
886
- description: 'Calculate recommended node dimensions based on name and type. Returns width and height values (multiples of 16) sized to fit the title. Content is intentionally ignored — nodes are sized for their title with content scrollable inside.',
904
+ description: 'Calculate recommended node dimensions based on name and type. Returns width and height values in grid units (1 unit = 16px) sized to fit the title. Content is intentionally ignored — nodes are sized for their title with content scrollable inside.',
887
905
  inputSchema: {
888
906
  type: 'object',
889
907
  properties: {
@@ -2,6 +2,7 @@ import Papa from 'papaparse';
2
2
  import { z } from 'zod';
3
3
  import { formatDate } from '../utils/ejson.js';
4
4
  import { formatError } from '../utils/errors.js';
5
+ import { pixelsToUnits } from '../utils/units.js';
5
6
  import { getNodeUrl } from '../utils/urls.js';
6
7
  export const listNodesSchema = z.object({
7
8
  boardId: z.string().describe('The board ID to list nodes from'),
@@ -27,10 +28,10 @@ function formatNodesTsv(nodes) {
27
28
  id: n._id,
28
29
  name: n.name,
29
30
  type: n.type,
30
- x: n.x,
31
- y: n.y,
32
- width: n.width,
33
- height: n.height,
31
+ x: pixelsToUnits(n.x),
32
+ y: pixelsToUnits(n.y),
33
+ width: pixelsToUnits(n.width),
34
+ height: pixelsToUnits(n.height),
34
35
  content: truncateContent(n.content ?? null, MAX_CONTENT_LENGTH),
35
36
  updatedAt: formatDate(n.updatedAt),
36
37
  link: n.link,
@@ -1,6 +1,7 @@
1
1
  import Papa from 'papaparse';
2
2
  import { z } from 'zod';
3
3
  import { formatError } from '../utils/errors.js';
4
+ import { pixelsToUnits } from '../utils/units.js';
4
5
  export const listPositionsSchema = z.object({
5
6
  boardId: z.string().describe('The board ID to list node positions from'),
6
7
  limit: z
@@ -13,10 +14,10 @@ function formatPositionsTsv(nodes) {
13
14
  const rows = nodes.map(n => ({
14
15
  id: n._id,
15
16
  name: n.name,
16
- x: n.x,
17
- y: n.y,
18
- w: n.width,
19
- h: n.height,
17
+ x: pixelsToUnits(n.x),
18
+ y: pixelsToUnits(n.y),
19
+ w: pixelsToUnits(n.width),
20
+ h: pixelsToUnits(n.height),
20
21
  }));
21
22
  return Papa.unparse(rows, {
22
23
  delimiter: '\t',
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { formatError } from '../utils/errors.js';
3
+ import { pixelsToUnits } from '../utils/units.js';
3
4
  import { getNodeUrl } from '../utils/urls.js';
4
5
  export const readSchema = z.object({
5
6
  boardId: z.string().describe('The board ID containing the node'),
@@ -13,9 +14,21 @@ export async function handleRead(client, params, baseUrl) {
13
14
  });
14
15
  // Exclude large fields that aren't useful for AI consumption
15
16
  const { _vectors, __v, textDetections, ...nodeData } = node;
16
- // Add link to the response
17
+ // Convert position fields from pixels to units
17
18
  const response = {
18
19
  ...nodeData,
20
+ x: 'x' in nodeData && typeof nodeData.x === 'number'
21
+ ? pixelsToUnits(nodeData.x)
22
+ : nodeData.x,
23
+ y: 'y' in nodeData && typeof nodeData.y === 'number'
24
+ ? pixelsToUnits(nodeData.y)
25
+ : nodeData.y,
26
+ width: 'width' in nodeData && typeof nodeData.width === 'number'
27
+ ? pixelsToUnits(nodeData.width)
28
+ : nodeData.width,
29
+ height: 'height' in nodeData && typeof nodeData.height === 'number'
30
+ ? pixelsToUnits(nodeData.height)
31
+ : nodeData.height,
19
32
  link: getNodeUrl(baseUrl, params.boardId, params.nodeId),
20
33
  };
21
34
  return {
@@ -1,10 +1,12 @@
1
1
  import { z } from 'zod';
2
+ import { pixelsToUnits } from '../utils/units.js';
2
3
  // Grid and constraint constants (from src/common/boards/constraints.ts)
4
+ // Values in pixels (internal calculations)
3
5
  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;
6
+ const MIN_WIDTH = 128; // 8 units
7
+ const MIN_HEIGHT = 64; // 4 units
8
+ const MAX_WIDTH = 512; // 32 units - reasonable max for auto-sizing (not the 4096 absolute max)
9
+ const MAX_HEIGHT = 512; // 32 units
8
10
  // Text measurement constants (from measure-node-size.ts)
9
11
  const MAX_AUTO_WIDTH = 400;
10
12
  const PADDING_X = 12;
@@ -96,7 +98,10 @@ export function calculateNodeSize(name, type) {
96
98
  return { width, height };
97
99
  }
98
100
  export function handleSizeCalc(params) {
99
- const { width, height } = calculateNodeSize(params.name, params.type);
101
+ const { width: widthPixels, height: heightPixels } = calculateNodeSize(params.name, params.type);
102
+ // Convert output to units
103
+ const width = pixelsToUnits(widthPixels);
104
+ const height = pixelsToUnits(heightPixels);
100
105
  return {
101
106
  content: [
102
107
  {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { NodeType } from '../client/types.js';
3
3
  import { formatError } from '../utils/errors.js';
4
+ import { pixelsToUnits, unitsToPixels } from '../utils/units.js';
4
5
  import { snapToGrid } from './grid-calc.js';
5
6
  import { calculateNodeSize } from './size-calc.js';
6
7
  export const updateSchema = z.object({
@@ -24,22 +25,10 @@ export const updateSchema = z.object({
24
25
  .string()
25
26
  .optional()
26
27
  .describe('Node color name (use mentagen_colors to see available colors)'),
27
- x: z
28
- .number()
29
- .optional()
30
- .describe('X position on the board canvas (must be multiple of 16)'),
31
- y: z
32
- .number()
33
- .optional()
34
- .describe('Y position on the board canvas (must be multiple of 16)'),
35
- width: z
36
- .number()
37
- .optional()
38
- .describe('Node width in pixels (must be multiple of 16)'),
39
- height: z
40
- .number()
41
- .optional()
42
- .describe('Node height in pixels (must be multiple of 16)'),
28
+ x: z.number().optional().describe('X position in grid units'),
29
+ y: z.number().optional().describe('Y position in grid units'),
30
+ width: z.number().optional().describe('Node width in grid units'),
31
+ height: z.number().optional().describe('Node height in grid units'),
43
32
  autoSize: z
44
33
  .boolean()
45
34
  .optional()
@@ -85,7 +74,7 @@ function buildUpdateData(params, nodeType) {
85
74
  for (const field of POSITION_FIELDS) {
86
75
  const value = params[field];
87
76
  if (value !== undefined) {
88
- data[field] = snapToGrid(value);
77
+ data[field] = snapToGrid(unitsToPixels(value));
89
78
  }
90
79
  }
91
80
  return Object.keys(data).length > 0 ? data : null;
@@ -116,10 +105,10 @@ function formatSuccessResponse(node) {
116
105
  node: {
117
106
  id: node._id,
118
107
  name: node.name,
119
- x: node.x,
120
- y: node.y,
121
- width: node.width,
122
- height: node.height,
108
+ x: pixelsToUnits(node.x),
109
+ y: pixelsToUnits(node.y),
110
+ width: pixelsToUnits(node.width),
111
+ height: pixelsToUnits(node.height),
123
112
  updatedAt: node.updatedAt || new Date().toISOString(),
124
113
  },
125
114
  };
@@ -132,8 +121,17 @@ export async function handleUpdate(client, params) {
132
121
  const { node: currentNode, autoSize } = await getNodeContext(client, params);
133
122
  const data = buildUpdateData({
134
123
  ...params,
135
- width: params.width ?? autoSize?.width,
136
- height: params.height ?? autoSize?.height,
124
+ // Convert autoSize from pixels to units since buildUpdateData expects units
125
+ width: params.width !== undefined
126
+ ? params.width
127
+ : autoSize?.width !== undefined
128
+ ? pixelsToUnits(autoSize.width)
129
+ : undefined,
130
+ height: params.height !== undefined
131
+ ? params.height
132
+ : autoSize?.height !== undefined
133
+ ? pixelsToUnits(autoSize.height)
134
+ : undefined,
137
135
  }, currentNode.type);
138
136
  if (!data) {
139
137
  return {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Unit conversion utilities for grid-based positioning.
3
+ * 1 grid unit = 16 pixels
4
+ */
5
+ const GRID_SIZE = 16;
6
+ /**
7
+ * Convert grid units to pixels.
8
+ * Rounds the input to the nearest integer before conversion.
9
+ */
10
+ export function unitsToPixels(units) {
11
+ return Math.round(units) * GRID_SIZE;
12
+ }
13
+ /**
14
+ * Convert pixels to grid units.
15
+ * Rounds the result to the nearest integer unit.
16
+ */
17
+ export function pixelsToUnits(pixels) {
18
+ return Math.round(pixels / GRID_SIZE);
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentagen/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for Mentagen knowledge base integration with Cursor",
5
5
  "type": "module",
6
6
  "bin": "dist/index.js",