@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.
Files changed (39) hide show
  1. package/README.md +100 -0
  2. package/dist/client/helene.js +178 -0
  3. package/dist/client/types.js +27 -0
  4. package/dist/index.js +26 -0
  5. package/dist/tools/batch-update.js +118 -0
  6. package/dist/tools/boards.js +50 -0
  7. package/dist/tools/check-collision.js +55 -0
  8. package/dist/tools/colors.js +35 -0
  9. package/dist/tools/create-board.js +50 -0
  10. package/dist/tools/create-edge.js +61 -0
  11. package/dist/tools/create-graph.js +174 -0
  12. package/dist/tools/create.js +152 -0
  13. package/dist/tools/delete-edge.js +36 -0
  14. package/dist/tools/delete.js +36 -0
  15. package/dist/tools/extract-board-content.js +207 -0
  16. package/dist/tools/find-position.js +117 -0
  17. package/dist/tools/grid-calc.js +205 -0
  18. package/dist/tools/index.js +940 -0
  19. package/dist/tools/link.js +22 -0
  20. package/dist/tools/list-edges.js +58 -0
  21. package/dist/tools/list-nodes.js +96 -0
  22. package/dist/tools/list-positions.js +64 -0
  23. package/dist/tools/node-types.js +65 -0
  24. package/dist/tools/patch-content.js +143 -0
  25. package/dist/tools/read.js +41 -0
  26. package/dist/tools/search-board.js +99 -0
  27. package/dist/tools/search-boards.js +50 -0
  28. package/dist/tools/search.js +89 -0
  29. package/dist/tools/size-calc.js +108 -0
  30. package/dist/tools/update-board.js +64 -0
  31. package/dist/tools/update-edge.js +63 -0
  32. package/dist/tools/update.js +157 -0
  33. package/dist/utils/collision.js +177 -0
  34. package/dist/utils/config.js +16 -0
  35. package/dist/utils/ejson.js +30 -0
  36. package/dist/utils/errors.js +16 -0
  37. package/dist/utils/formatting.js +15 -0
  38. package/dist/utils/urls.js +18 -0
  39. package/package.json +49 -0
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ import { formatError } from '../utils/errors.js';
3
+ export const createEdgeSchema = z.object({
4
+ boardId: z.string().describe('The board ID where the edge will be created'),
5
+ source: z.string().describe('The source node ID'),
6
+ target: z.string().describe('The target node ID'),
7
+ direction: z
8
+ .enum(['none', 'source', 'target'])
9
+ .optional()
10
+ .describe('Arrow direction: "none" (no arrow, default), "source" (arrow points to source), "target" (arrow points to target)'),
11
+ });
12
+ /**
13
+ * Generate a simple ObjectId-like string.
14
+ */
15
+ export function generateId() {
16
+ const timestamp = Math.floor(Date.now() / 1000)
17
+ .toString(16)
18
+ .padStart(8, '0');
19
+ const random = Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
20
+ return timestamp + random;
21
+ }
22
+ export async function handleCreateEdge(client, params) {
23
+ try {
24
+ const _id = generateId();
25
+ const edge = await client.addEdge({
26
+ _id,
27
+ boardId: params.boardId,
28
+ source: params.source,
29
+ target: params.target,
30
+ direction: params.direction,
31
+ });
32
+ const result = {
33
+ success: true,
34
+ edge: {
35
+ id: edge._id,
36
+ source: edge.source,
37
+ target: edge.target,
38
+ board: params.boardId,
39
+ },
40
+ };
41
+ return {
42
+ content: [
43
+ {
44
+ type: 'text',
45
+ text: JSON.stringify(result, null, 2),
46
+ },
47
+ ],
48
+ };
49
+ }
50
+ catch (error) {
51
+ return {
52
+ content: [
53
+ {
54
+ type: 'text',
55
+ text: `Failed to create edge: ${formatError(error)}`,
56
+ },
57
+ ],
58
+ isError: true,
59
+ };
60
+ }
61
+ }
@@ -0,0 +1,174 @@
1
+ import { z } from 'zod';
2
+ import { formatError } from '../utils/errors.js';
3
+ import { generateId } from './create.js';
4
+ import { calculateNodeSize } from './size-calc.js';
5
+ const nodeInputSchema = z.object({
6
+ tempId: z.string().describe('Temporary ID for referencing in edges'),
7
+ name: z.string().describe('The node title/name'),
8
+ content: z.string().optional().describe('Node content'),
9
+ type: z
10
+ .enum(['text', 'markdown', 'code', 'url'])
11
+ .optional()
12
+ .default('text')
13
+ .describe('Node type'),
14
+ color: z.string().optional().describe('Node color'),
15
+ x: z.number().optional().describe('X position'),
16
+ y: z.number().optional().describe('Y position'),
17
+ width: z.number().optional().describe('Width'),
18
+ height: z.number().optional().describe('Height'),
19
+ });
20
+ const edgeInputSchema = z.object({
21
+ source: z.string().describe('Source node tempId'),
22
+ target: z.string().describe('Target node tempId'),
23
+ direction: z
24
+ .enum(['none', 'source', 'target'])
25
+ .optional()
26
+ .describe('Arrow direction'),
27
+ label: z.string().optional().describe('Edge label'),
28
+ });
29
+ export const createGraphSchema = z.object({
30
+ boardId: z.string().describe('The board ID to create the graph on'),
31
+ nodes: z.array(nodeInputSchema).min(1).describe('Array of nodes to create'),
32
+ edges: z
33
+ .array(edgeInputSchema)
34
+ .optional()
35
+ .describe('Array of edges connecting nodes by tempId'),
36
+ });
37
+ const DEFAULT_NODE_COLORS = {
38
+ text: undefined,
39
+ markdown: 'cyan',
40
+ code: 'indigo',
41
+ url: 'blue',
42
+ };
43
+ async function createNode(client, boardId, nodeInput, idMap) {
44
+ const realId = generateId();
45
+ const type = nodeInput.type ?? 'text';
46
+ const color = nodeInput.color ?? DEFAULT_NODE_COLORS[type];
47
+ const autoSize = calculateNodeSize(nodeInput.name, type);
48
+ const x = nodeInput.x ?? 100;
49
+ const y = nodeInput.y ?? 100;
50
+ const width = nodeInput.width ?? autoSize.width;
51
+ const height = nodeInput.height ?? autoSize.height;
52
+ try {
53
+ // For code nodes, store content in the `code` field
54
+ const isCodeNode = type === 'code';
55
+ const nodeContent = nodeInput.content ?? '';
56
+ const node = await client.addNode({
57
+ _id: realId,
58
+ boardId,
59
+ node: {
60
+ name: nodeInput.name,
61
+ ...(isCodeNode ? { code: nodeContent } : { content: nodeContent }),
62
+ type,
63
+ color,
64
+ x,
65
+ y,
66
+ width,
67
+ height,
68
+ },
69
+ });
70
+ idMap[nodeInput.tempId] = node._id;
71
+ return {
72
+ success: true,
73
+ result: {
74
+ tempId: nodeInput.tempId,
75
+ id: node._id,
76
+ name: node.name,
77
+ x,
78
+ y,
79
+ width,
80
+ height,
81
+ },
82
+ };
83
+ }
84
+ catch (error) {
85
+ return {
86
+ success: false,
87
+ tempId: nodeInput.tempId,
88
+ error: formatError(error),
89
+ };
90
+ }
91
+ }
92
+ async function createEdge(client, boardId, edgeInput, idMap) {
93
+ const sourceId = idMap[edgeInput.source];
94
+ const targetId = idMap[edgeInput.target];
95
+ if (!sourceId || !targetId) {
96
+ return {
97
+ success: false,
98
+ error: `Invalid edge: source=${edgeInput.source} target=${edgeInput.target}`,
99
+ };
100
+ }
101
+ try {
102
+ const realId = generateId();
103
+ const edge = await client.addEdge({
104
+ _id: realId,
105
+ boardId,
106
+ source: sourceId,
107
+ target: targetId,
108
+ direction: edgeInput.direction,
109
+ });
110
+ return {
111
+ success: true,
112
+ result: {
113
+ id: edge._id,
114
+ source: sourceId,
115
+ target: targetId,
116
+ },
117
+ };
118
+ }
119
+ catch (error) {
120
+ return {
121
+ success: false,
122
+ error: formatError(error),
123
+ };
124
+ }
125
+ }
126
+ function processNodeOutcomes(outcomes) {
127
+ const results = [];
128
+ const errors = [];
129
+ for (const outcome of outcomes) {
130
+ if (outcome.success && outcome.result) {
131
+ results.push(outcome.result);
132
+ }
133
+ else if (!outcome.success && outcome.tempId) {
134
+ errors.push({ tempId: outcome.tempId, error: outcome.error });
135
+ }
136
+ }
137
+ return { results, errors };
138
+ }
139
+ function processEdgeOutcomes(outcomes) {
140
+ return outcomes
141
+ .filter((o) => o.success && !!o.result)
142
+ .map(o => o.result);
143
+ }
144
+ export async function handleCreateGraph(client, params) {
145
+ try {
146
+ const idMap = {};
147
+ const nodeOutcomes = await Promise.all(params.nodes.map(n => createNode(client, params.boardId, n, idMap)));
148
+ const { results: nodeResults, errors } = processNodeOutcomes(nodeOutcomes);
149
+ let edgeResults = [];
150
+ if (errors.length === 0 && params.edges && params.edges.length > 0) {
151
+ const edgeOutcomes = await Promise.all(params.edges.map(e => createEdge(client, params.boardId, e, idMap)));
152
+ edgeResults = processEdgeOutcomes(edgeOutcomes);
153
+ }
154
+ const response = {
155
+ success: errors.length === 0,
156
+ idMap,
157
+ nodes: nodeResults,
158
+ edges: edgeResults,
159
+ ...(errors.length > 0 && { errors }),
160
+ };
161
+ return {
162
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
163
+ ...(errors.length === params.nodes.length && { isError: true }),
164
+ };
165
+ }
166
+ catch (error) {
167
+ return {
168
+ content: [
169
+ { type: 'text', text: `Failed to create graph: ${formatError(error)}` },
170
+ ],
171
+ isError: true,
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,152 @@
1
+ import { z } from 'zod';
2
+ import { formatError } from '../utils/errors.js';
3
+ import { getNodeUrl } from '../utils/urls.js';
4
+ import { snapToGrid } from './grid-calc.js';
5
+ import { calculateNodeSize } from './size-calc.js';
6
+ /**
7
+ * Default colors for node types, matching the context menu behavior.
8
+ *
9
+ * Keep in sync with: src/client/features/boards/inner-board-context-menu.tsx
10
+ *
11
+ * When adding new node types or changing default colors in the context menu,
12
+ * update this mapping to match.
13
+ */
14
+ const DEFAULT_NODE_COLORS = {
15
+ // Currently supported in MCP
16
+ text: undefined,
17
+ markdown: 'cyan',
18
+ code: 'indigo',
19
+ url: 'blue',
20
+ // Future node types (from context menu)
21
+ image: undefined,
22
+ 'ai-chat': 'blue',
23
+ 'ai-quiz': 'green',
24
+ board: 'fuchsia',
25
+ mermaid: 'violet',
26
+ voice: undefined,
27
+ excalidraw: 'teal',
28
+ teleprompter: 'rose',
29
+ };
30
+ export const createSchema = z.object({
31
+ boardId: z.string().describe('The board ID to create the node on'),
32
+ name: z.string().describe('The node title/name'),
33
+ content: z
34
+ .string()
35
+ .optional()
36
+ .describe('Content for text/markdown nodes. Use "url" field for url-type nodes.'),
37
+ url: z
38
+ .string()
39
+ .optional()
40
+ .describe('The URL for url-type nodes (e.g., https://example.com). Required for type="url".'),
41
+ type: z
42
+ .enum(['text', 'markdown', 'code', 'url'])
43
+ .optional()
44
+ .default('text')
45
+ .describe('Node type: text (default), markdown, code, or url'),
46
+ color: z
47
+ .string()
48
+ .optional()
49
+ .describe('Optional color override. If not specified, uses default for type (markdown=cyan, code=indigo, url=blue)'),
50
+ x: z
51
+ .number()
52
+ .optional()
53
+ .describe('X position (must be multiple of 16, default: 96)'),
54
+ y: z
55
+ .number()
56
+ .optional()
57
+ .describe('Y position (must be multiple of 16, default: 96)'),
58
+ width: z
59
+ .number()
60
+ .optional()
61
+ .describe('Width (must be multiple of 16, default: 256)'),
62
+ height: z
63
+ .number()
64
+ .optional()
65
+ .describe('Height (must be multiple of 16, default: 128)'),
66
+ });
67
+ /**
68
+ * Generate a simple ObjectId-like string.
69
+ * Uses timestamp + random hex for uniqueness.
70
+ */
71
+ export function generateId() {
72
+ const timestamp = Math.floor(Date.now() / 1000)
73
+ .toString(16)
74
+ .padStart(8, '0');
75
+ const random = Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
76
+ return timestamp + random;
77
+ }
78
+ /**
79
+ * Build node fields object with content mapped to the correct field based on type.
80
+ */
81
+ function buildNodeFields(params, color, position) {
82
+ const nodeFields = {
83
+ name: params.name,
84
+ type: params.type,
85
+ color,
86
+ ...position,
87
+ };
88
+ if (params.type === 'code') {
89
+ nodeFields.code = params.content || '';
90
+ }
91
+ else if (params.type === 'url') {
92
+ if (!params.url) {
93
+ throw new Error('URL field is required for url-type nodes');
94
+ }
95
+ nodeFields.url = params.url;
96
+ if (params.content) {
97
+ nodeFields.content = params.content;
98
+ }
99
+ }
100
+ else {
101
+ nodeFields.content = params.content || '';
102
+ }
103
+ return nodeFields;
104
+ }
105
+ export async function handleCreate(client, params, mentagenUrl) {
106
+ try {
107
+ const _id = generateId();
108
+ const color = params.color || DEFAULT_NODE_COLORS[params.type];
109
+ const autoSize = calculateNodeSize(params.name, params.type);
110
+ const position = {
111
+ x: snapToGrid(params.x ?? 96),
112
+ y: snapToGrid(params.y ?? 96),
113
+ width: snapToGrid(params.width ?? autoSize.width),
114
+ height: snapToGrid(params.height ?? autoSize.height),
115
+ };
116
+ const nodeFields = buildNodeFields(params, color, position);
117
+ const node = await client.addNode({
118
+ _id,
119
+ boardId: params.boardId,
120
+ node: nodeFields,
121
+ });
122
+ const result = {
123
+ success: true,
124
+ node: {
125
+ id: node._id,
126
+ name: node.name,
127
+ board: params.boardId,
128
+ ...position,
129
+ link: getNodeUrl(mentagenUrl, params.boardId, node._id),
130
+ },
131
+ };
132
+ return {
133
+ content: [
134
+ {
135
+ type: 'text',
136
+ text: JSON.stringify(result, null, 2),
137
+ },
138
+ ],
139
+ };
140
+ }
141
+ catch (error) {
142
+ return {
143
+ content: [
144
+ {
145
+ type: 'text',
146
+ text: `Failed to create node: ${formatError(error)}`,
147
+ },
148
+ ],
149
+ isError: true,
150
+ };
151
+ }
152
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { formatError } from '../utils/errors.js';
3
+ export const deleteEdgeSchema = z.object({
4
+ boardId: z.string().describe('The board ID containing the edge'),
5
+ edgeId: z.string().describe('The edge ID to delete'),
6
+ });
7
+ export async function handleDeleteEdge(client, params) {
8
+ try {
9
+ await client.deleteEdge({
10
+ boardId: params.boardId,
11
+ edgeId: params.edgeId,
12
+ });
13
+ return {
14
+ content: [
15
+ {
16
+ type: 'text',
17
+ text: JSON.stringify({
18
+ success: true,
19
+ message: 'Edge deleted successfully. This is a soft delete and can be undone in Mentagen.',
20
+ }, null, 2),
21
+ },
22
+ ],
23
+ };
24
+ }
25
+ catch (error) {
26
+ return {
27
+ content: [
28
+ {
29
+ type: 'text',
30
+ text: `Failed to delete edge: ${formatError(error)}`,
31
+ },
32
+ ],
33
+ isError: true,
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { formatError } from '../utils/errors.js';
3
+ export const deleteSchema = z.object({
4
+ boardId: z.string().describe('The board ID containing the node'),
5
+ nodeId: z.string().describe('The node ID to delete'),
6
+ });
7
+ export async function handleDelete(client, params) {
8
+ try {
9
+ await client.deleteNode({
10
+ boardId: params.boardId,
11
+ nodeId: params.nodeId,
12
+ });
13
+ return {
14
+ content: [
15
+ {
16
+ type: 'text',
17
+ text: JSON.stringify({
18
+ success: true,
19
+ message: 'Node deleted successfully. This is a soft delete and can be undone in Mentagen.',
20
+ }, null, 2),
21
+ },
22
+ ],
23
+ };
24
+ }
25
+ catch (error) {
26
+ return {
27
+ content: [
28
+ {
29
+ type: 'text',
30
+ text: `Failed to delete node: ${formatError(error)}`,
31
+ },
32
+ ],
33
+ isError: true,
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,207 @@
1
+ import { z } from 'zod';
2
+ import { NodeType } from '../client/types.js';
3
+ import { formatError } from '../utils/errors.js';
4
+ import { getNodeUrl } from '../utils/urls.js';
5
+ export const extractBoardContentSchema = z.object({
6
+ boardId: z.string().describe('The board ID to extract content from'),
7
+ includeEmpty: z
8
+ .boolean()
9
+ .default(false)
10
+ .describe('Include nodes with no content (default: false)'),
11
+ });
12
+ /**
13
+ * Content extractors by node type.
14
+ * Returns the appropriate text content from a node based on its type.
15
+ */
16
+ const contentExtractors = {
17
+ [NodeType.Code]: node => node.code || '',
18
+ [NodeType.AIChat]: node => node.chatConcatenated || '',
19
+ [NodeType.Image]: node => node.textDetectionsConcatenated || '',
20
+ [NodeType.PDF]: node => node.pdfText || node.content || '',
21
+ [NodeType.URL]: node => node.article || node.content || '',
22
+ };
23
+ /**
24
+ * Get the text content from a node, handling different field locations by type.
25
+ */
26
+ function getNodeContent(node) {
27
+ const extractor = contentExtractors[node.type];
28
+ return extractor ? extractor(node) : node.content || '';
29
+ }
30
+ /**
31
+ * Strip HTML tags from content for cleaner output.
32
+ */
33
+ function stripHtml(html) {
34
+ return html
35
+ .replace(/<[^>]*>/g, '')
36
+ .replace(/&nbsp;/g, ' ')
37
+ .replace(/&amp;/g, '&')
38
+ .replace(/&lt;/g, '<')
39
+ .replace(/&gt;/g, '>')
40
+ .replace(/&quot;/g, '"')
41
+ .replace(/&#39;/g, "'")
42
+ .trim();
43
+ }
44
+ /**
45
+ * Infer programming language from file extension.
46
+ */
47
+ function inferLanguage(filename) {
48
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
49
+ const langMap = {
50
+ ts: 'typescript',
51
+ tsx: 'typescript',
52
+ js: 'javascript',
53
+ jsx: 'javascript',
54
+ py: 'python',
55
+ rb: 'ruby',
56
+ go: 'go',
57
+ rs: 'rust',
58
+ java: 'java',
59
+ kt: 'kotlin',
60
+ swift: 'swift',
61
+ c: 'c',
62
+ cpp: 'cpp',
63
+ h: 'c',
64
+ hpp: 'cpp',
65
+ cs: 'csharp',
66
+ php: 'php',
67
+ sql: 'sql',
68
+ sh: 'bash',
69
+ bash: 'bash',
70
+ zsh: 'bash',
71
+ yml: 'yaml',
72
+ yaml: 'yaml',
73
+ json: 'json',
74
+ xml: 'xml',
75
+ html: 'html',
76
+ css: 'css',
77
+ scss: 'scss',
78
+ md: 'markdown',
79
+ };
80
+ return langMap[ext] || '';
81
+ }
82
+ /**
83
+ * Format node content based on its type.
84
+ */
85
+ function formatContent(node) {
86
+ const raw = getNodeContent(node);
87
+ if (!raw)
88
+ return '';
89
+ // Code nodes: wrap in code block
90
+ if (node.type === NodeType.Code) {
91
+ const lang = inferLanguage(node.name);
92
+ return '```' + lang + '\n' + raw + '\n```';
93
+ }
94
+ // Markdown nodes: keep as-is (already markdown)
95
+ if (node.type === NodeType.Markdown) {
96
+ return raw;
97
+ }
98
+ // AI Chat nodes: convert server format to markdown
99
+ if (node.type === NodeType.AIChat) {
100
+ // Server returns "User: text\n\nAssistant: text", convert to markdown bold
101
+ return raw
102
+ .replace(/^User:/gm, '**User:**')
103
+ .replace(/^Assistant:/gm, '**Assistant:**');
104
+ }
105
+ // Image nodes with OCR: prefix with context
106
+ if (node.type === NodeType.Image && node.textDetectionsConcatenated) {
107
+ return `*Text detected in image:*\n\n${raw}`;
108
+ }
109
+ // PDF nodes: prefix with context
110
+ if (node.type === NodeType.PDF) {
111
+ return `*Extracted from PDF:*\n\n${raw}`;
112
+ }
113
+ // Text nodes with HTML: strip tags
114
+ if (raw.includes('<')) {
115
+ return stripHtml(raw);
116
+ }
117
+ return raw;
118
+ }
119
+ /**
120
+ * Format a single node as a markdown section.
121
+ */
122
+ function formatNodeSection(node, baseUrl, boardId) {
123
+ const content = formatContent(node);
124
+ const link = getNodeUrl(baseUrl, boardId, node._id);
125
+ const lines = [
126
+ `## ${node.name}`,
127
+ `**ID:** \`${node._id}\` | **Type:** ${node.type} | [Open](${link})`,
128
+ '',
129
+ content,
130
+ ];
131
+ return lines.join('\n');
132
+ }
133
+ export async function handleExtractBoardContent(client, params, baseUrl) {
134
+ try {
135
+ // Fetch board metadata and nodes in parallel
136
+ const [board, nodes] = await Promise.all([
137
+ client.getBoard({ boardId: params.boardId }),
138
+ client.listNodesWithContent({
139
+ boardId: params.boardId,
140
+ limit: 1000,
141
+ }),
142
+ ]);
143
+ if (nodes.length === 0) {
144
+ return {
145
+ content: [
146
+ {
147
+ type: 'text',
148
+ text: 'No nodes found in this board.',
149
+ },
150
+ ],
151
+ };
152
+ }
153
+ // Filter nodes based on content
154
+ const nodesWithContent = params.includeEmpty
155
+ ? nodes
156
+ : nodes.filter(n => {
157
+ const content = getNodeContent(n);
158
+ return content && content.trim().length > 0;
159
+ });
160
+ if (nodesWithContent.length === 0) {
161
+ return {
162
+ content: [
163
+ {
164
+ type: 'text',
165
+ text: `Board has ${nodes.length} nodes but none contain extractable content.`,
166
+ },
167
+ ],
168
+ };
169
+ }
170
+ // Build the markdown output
171
+ const sections = nodesWithContent.map(n => formatNodeSection(n, baseUrl, params.boardId));
172
+ // Build board link
173
+ const boardLink = `${baseUrl}/b/${params.boardId}`;
174
+ // Add summary header with board metadata
175
+ const headerLines = [
176
+ `# ${board.name}`,
177
+ '',
178
+ `**Board ID:** \`${params.boardId}\` | [Open Board](${boardLink})`,
179
+ ];
180
+ // Add description if present
181
+ if (board.description) {
182
+ headerLines.push('', board.description);
183
+ }
184
+ headerLines.push('', '---', '', `**Total nodes:** ${nodesWithContent.length}${nodes.length !== nodesWithContent.length ? ` (${nodes.length - nodesWithContent.length} empty nodes excluded)` : ''}`, '', '---', '');
185
+ const header = headerLines.join('\n');
186
+ const output = header + sections.join('\n\n---\n\n');
187
+ return {
188
+ content: [
189
+ {
190
+ type: 'text',
191
+ text: output,
192
+ },
193
+ ],
194
+ };
195
+ }
196
+ catch (error) {
197
+ return {
198
+ content: [
199
+ {
200
+ type: 'text',
201
+ text: `Failed to extract board content: ${formatError(error)}`,
202
+ },
203
+ ],
204
+ isError: true,
205
+ };
206
+ }
207
+ }