roam-research-mcp 1.0.0 → 1.4.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.
@@ -0,0 +1,281 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseExistingBlock, parseExistingBlocks, flattenExistingBlocks, markdownToBlocks, getBlockDepth, } from './parser.js';
3
+ describe('parseExistingBlock', () => {
4
+ it('parses a simple block', () => {
5
+ const roamBlock = {
6
+ ':block/uid': 'abc123def',
7
+ ':block/string': 'Hello world',
8
+ ':block/order': 0,
9
+ ':block/heading': null,
10
+ };
11
+ const block = parseExistingBlock(roamBlock);
12
+ expect(block.uid).toBe('abc123def');
13
+ expect(block.text).toBe('Hello world');
14
+ expect(block.order).toBe(0);
15
+ expect(block.heading).toBeNull();
16
+ expect(block.children).toEqual([]);
17
+ expect(block.parentUid).toBeNull();
18
+ });
19
+ it('parses block with heading', () => {
20
+ const roamBlock = {
21
+ ':block/uid': 'heading1',
22
+ ':block/string': 'Title',
23
+ ':block/order': 0,
24
+ ':block/heading': 2,
25
+ };
26
+ const block = parseExistingBlock(roamBlock);
27
+ expect(block.heading).toBe(2);
28
+ });
29
+ it('parses nested children', () => {
30
+ const roamBlock = {
31
+ ':block/uid': 'parent',
32
+ ':block/string': 'Parent',
33
+ ':block/order': 0,
34
+ ':block/children': [
35
+ {
36
+ ':block/uid': 'child1',
37
+ ':block/string': 'Child 1',
38
+ ':block/order': 0,
39
+ },
40
+ {
41
+ ':block/uid': 'child2',
42
+ ':block/string': 'Child 2',
43
+ ':block/order': 1,
44
+ },
45
+ ],
46
+ };
47
+ const block = parseExistingBlock(roamBlock);
48
+ expect(block.children.length).toBe(2);
49
+ expect(block.children[0].uid).toBe('child1');
50
+ expect(block.children[0].parentUid).toBe('parent');
51
+ expect(block.children[1].uid).toBe('child2');
52
+ expect(block.children[1].parentUid).toBe('parent');
53
+ });
54
+ it('sorts children by order', () => {
55
+ const roamBlock = {
56
+ ':block/uid': 'parent',
57
+ ':block/string': 'Parent',
58
+ ':block/order': 0,
59
+ ':block/children': [
60
+ { ':block/uid': 'c', ':block/string': 'C', ':block/order': 2 },
61
+ { ':block/uid': 'a', ':block/string': 'A', ':block/order': 0 },
62
+ { ':block/uid': 'b', ':block/string': 'B', ':block/order': 1 },
63
+ ],
64
+ };
65
+ const block = parseExistingBlock(roamBlock);
66
+ expect(block.children.map((c) => c.uid)).toEqual(['a', 'b', 'c']);
67
+ });
68
+ it('handles missing properties gracefully', () => {
69
+ const roamBlock = {};
70
+ const block = parseExistingBlock(roamBlock);
71
+ expect(block.uid).toBe('');
72
+ expect(block.text).toBe('');
73
+ expect(block.order).toBe(0);
74
+ expect(block.heading).toBeNull();
75
+ });
76
+ });
77
+ describe('parseExistingBlocks', () => {
78
+ it('parses page children into blocks', () => {
79
+ const pageData = {
80
+ ':block/uid': 'page123',
81
+ ':block/children': [
82
+ { ':block/uid': 'b1', ':block/string': 'First', ':block/order': 0 },
83
+ { ':block/uid': 'b2', ':block/string': 'Second', ':block/order': 1 },
84
+ ],
85
+ };
86
+ const blocks = parseExistingBlocks(pageData);
87
+ expect(blocks.length).toBe(2);
88
+ expect(blocks[0].text).toBe('First');
89
+ expect(blocks[1].text).toBe('Second');
90
+ expect(blocks[0].parentUid).toBeNull();
91
+ });
92
+ it('returns empty array for page with no children', () => {
93
+ const pageData = { ':block/uid': 'page123' };
94
+ const blocks = parseExistingBlocks(pageData);
95
+ expect(blocks).toEqual([]);
96
+ });
97
+ });
98
+ describe('flattenExistingBlocks', () => {
99
+ it('flattens nested blocks into array', () => {
100
+ const blocks = [
101
+ {
102
+ uid: 'a',
103
+ text: 'A',
104
+ order: 0,
105
+ heading: null,
106
+ parentUid: null,
107
+ children: [
108
+ {
109
+ uid: 'a1',
110
+ text: 'A1',
111
+ order: 0,
112
+ heading: null,
113
+ parentUid: 'a',
114
+ children: [],
115
+ },
116
+ {
117
+ uid: 'a2',
118
+ text: 'A2',
119
+ order: 1,
120
+ heading: null,
121
+ parentUid: 'a',
122
+ children: [],
123
+ },
124
+ ],
125
+ },
126
+ {
127
+ uid: 'b',
128
+ text: 'B',
129
+ order: 1,
130
+ heading: null,
131
+ parentUid: null,
132
+ children: [],
133
+ },
134
+ ];
135
+ const flat = flattenExistingBlocks(blocks);
136
+ expect(flat.map((b) => b.uid)).toEqual(['a', 'a1', 'a2', 'b']);
137
+ });
138
+ it('preserves depth-first order', () => {
139
+ const blocks = [
140
+ {
141
+ uid: 'root',
142
+ text: 'Root',
143
+ order: 0,
144
+ heading: null,
145
+ parentUid: null,
146
+ children: [
147
+ {
148
+ uid: 'child1',
149
+ text: 'Child 1',
150
+ order: 0,
151
+ heading: null,
152
+ parentUid: 'root',
153
+ children: [
154
+ {
155
+ uid: 'grandchild',
156
+ text: 'Grandchild',
157
+ order: 0,
158
+ heading: null,
159
+ parentUid: 'child1',
160
+ children: [],
161
+ },
162
+ ],
163
+ },
164
+ {
165
+ uid: 'child2',
166
+ text: 'Child 2',
167
+ order: 1,
168
+ heading: null,
169
+ parentUid: 'root',
170
+ children: [],
171
+ },
172
+ ],
173
+ },
174
+ ];
175
+ const flat = flattenExistingBlocks(blocks);
176
+ expect(flat.map((b) => b.uid)).toEqual([
177
+ 'root',
178
+ 'child1',
179
+ 'grandchild',
180
+ 'child2',
181
+ ]);
182
+ });
183
+ it('returns empty array for empty input', () => {
184
+ expect(flattenExistingBlocks([])).toEqual([]);
185
+ });
186
+ });
187
+ describe('markdownToBlocks', () => {
188
+ const pageUid = 'page123';
189
+ it('converts simple markdown to blocks', () => {
190
+ const markdown = `- First item
191
+ - Second item`;
192
+ const blocks = markdownToBlocks(markdown, pageUid);
193
+ expect(blocks.length).toBe(2);
194
+ expect(blocks[0].text).toBe('First item');
195
+ expect(blocks[1].text).toBe('Second item');
196
+ expect(blocks[0].parentRef?.blockUid).toBe(pageUid);
197
+ expect(blocks[1].parentRef?.blockUid).toBe(pageUid);
198
+ });
199
+ it('handles nested markdown', () => {
200
+ const markdown = `- Parent
201
+ - Child`;
202
+ const blocks = markdownToBlocks(markdown, pageUid);
203
+ expect(blocks.length).toBe(2);
204
+ const parentBlock = blocks.find((b) => b.text === 'Parent');
205
+ const childBlock = blocks.find((b) => b.text === 'Child');
206
+ expect(parentBlock).toBeDefined();
207
+ expect(childBlock).toBeDefined();
208
+ expect(childBlock?.parentRef?.blockUid).toBe(parentBlock?.ref.blockUid);
209
+ });
210
+ it('preserves heading levels', () => {
211
+ const markdown = `# Heading 1
212
+ ## Heading 2
213
+ ### Heading 3`;
214
+ const blocks = markdownToBlocks(markdown, pageUid);
215
+ expect(blocks[0].heading).toBe(1);
216
+ expect(blocks[1].heading).toBe(2);
217
+ expect(blocks[2].heading).toBe(3);
218
+ });
219
+ it('generates unique UIDs', () => {
220
+ const markdown = `- Item 1
221
+ - Item 2
222
+ - Item 3`;
223
+ const blocks = markdownToBlocks(markdown, pageUid);
224
+ const uids = blocks.map((b) => b.ref.blockUid);
225
+ expect(new Set(uids).size).toBe(3); // All unique
226
+ });
227
+ it('sets order based on sibling position', () => {
228
+ const markdown = `- First
229
+ - Second
230
+ - Third`;
231
+ const blocks = markdownToBlocks(markdown, pageUid);
232
+ expect(blocks[0].order).toBe(0);
233
+ expect(blocks[1].order).toBe(1);
234
+ expect(blocks[2].order).toBe(2);
235
+ });
236
+ });
237
+ describe('getBlockDepth', () => {
238
+ it('returns 0 for root blocks', () => {
239
+ const blocks = [
240
+ {
241
+ ref: { blockUid: 'root' },
242
+ text: 'Root',
243
+ parentRef: { blockUid: 'page' },
244
+ order: 0,
245
+ open: true,
246
+ heading: null,
247
+ },
248
+ ];
249
+ expect(getBlockDepth(blocks[0], blocks)).toBe(0);
250
+ });
251
+ it('returns correct depth for nested blocks', () => {
252
+ const blocks = [
253
+ {
254
+ ref: { blockUid: 'parent' },
255
+ text: 'Parent',
256
+ parentRef: { blockUid: 'page' },
257
+ order: 0,
258
+ open: true,
259
+ heading: null,
260
+ },
261
+ {
262
+ ref: { blockUid: 'child' },
263
+ text: 'Child',
264
+ parentRef: { blockUid: 'parent' },
265
+ order: 0,
266
+ open: true,
267
+ heading: null,
268
+ },
269
+ {
270
+ ref: { blockUid: 'grandchild' },
271
+ text: 'Grandchild',
272
+ parentRef: { blockUid: 'child' },
273
+ order: 0,
274
+ open: true,
275
+ heading: null,
276
+ },
277
+ ];
278
+ expect(getBlockDepth(blocks[1], blocks)).toBe(1);
279
+ expect(getBlockDepth(blocks[2], blocks)).toBe(2);
280
+ });
281
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Diff Algorithm Types
3
+ *
4
+ * Type definitions for the smart diff algorithm that computes minimal
5
+ * update operations when syncing markdown content to Roam.
6
+ */
7
+ /**
8
+ * Extract statistics from a DiffResult.
9
+ */
10
+ export function getDiffStats(result) {
11
+ return {
12
+ creates: result.creates.length,
13
+ updates: result.updates.length,
14
+ moves: result.moves.length,
15
+ deletes: result.deletes.length,
16
+ preserved: result.preservedUids.size,
17
+ };
18
+ }
19
+ /**
20
+ * Check if a diff result contains no changes.
21
+ */
22
+ export function isDiffEmpty(result) {
23
+ return (result.creates.length === 0 &&
24
+ result.updates.length === 0 &&
25
+ result.moves.length === 0 &&
26
+ result.deletes.length === 0);
27
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getDiffStats, isDiffEmpty } from './types.js';
3
+ function createDiffResult(createCount = 0, updateCount = 0, moveCount = 0, deleteCount = 0, preservedCount = 0) {
4
+ return {
5
+ creates: Array(createCount).fill({ action: 'create-block' }),
6
+ updates: Array(updateCount).fill({ action: 'update-block' }),
7
+ moves: Array(moveCount).fill({ action: 'move-block' }),
8
+ deletes: Array(deleteCount).fill({ action: 'delete-block' }),
9
+ preservedUids: new Set(Array(preservedCount).fill(null).map((_, i) => `uid${i}`)),
10
+ };
11
+ }
12
+ describe('getDiffStats', () => {
13
+ it('returns correct counts for all operation types', () => {
14
+ const diff = createDiffResult(2, 3, 1, 4, 5);
15
+ const stats = getDiffStats(diff);
16
+ expect(stats.creates).toBe(2);
17
+ expect(stats.updates).toBe(3);
18
+ expect(stats.moves).toBe(1);
19
+ expect(stats.deletes).toBe(4);
20
+ expect(stats.preserved).toBe(5);
21
+ });
22
+ it('returns zeros for empty diff', () => {
23
+ const diff = createDiffResult();
24
+ const stats = getDiffStats(diff);
25
+ expect(stats.creates).toBe(0);
26
+ expect(stats.updates).toBe(0);
27
+ expect(stats.moves).toBe(0);
28
+ expect(stats.deletes).toBe(0);
29
+ expect(stats.preserved).toBe(0);
30
+ });
31
+ });
32
+ describe('isDiffEmpty', () => {
33
+ it('returns true when no operations exist', () => {
34
+ const diff = createDiffResult(0, 0, 0, 0, 5);
35
+ expect(isDiffEmpty(diff)).toBe(true);
36
+ });
37
+ it('returns false when creates exist', () => {
38
+ const diff = createDiffResult(1);
39
+ expect(isDiffEmpty(diff)).toBe(false);
40
+ });
41
+ it('returns false when updates exist', () => {
42
+ const diff = createDiffResult(0, 1);
43
+ expect(isDiffEmpty(diff)).toBe(false);
44
+ });
45
+ it('returns false when moves exist', () => {
46
+ const diff = createDiffResult(0, 0, 1);
47
+ expect(isDiffEmpty(diff)).toBe(false);
48
+ });
49
+ it('returns false when deletes exist', () => {
50
+ const diff = createDiffResult(0, 0, 0, 1);
51
+ expect(isDiffEmpty(diff)).toBe(false);
52
+ });
53
+ it('ignores preserved count when checking empty', () => {
54
+ const diff = createDiffResult(0, 0, 0, 0, 100);
55
+ expect(isDiffEmpty(diff)).toBe(true);
56
+ });
57
+ });
@@ -92,6 +92,51 @@ function parseMarkdown(markdown) {
92
92
  processedLines.push(line);
93
93
  }
94
94
  }
95
+ // First pass: collect all unique indentation values to build level mapping
96
+ const indentationSet = new Set();
97
+ indentationSet.add(0); // Always include level 0
98
+ let inCodeBlockFirstPass = false;
99
+ for (const line of processedLines) {
100
+ const trimmedLine = line.trimEnd();
101
+ if (trimmedLine.match(/^(\s*)```/)) {
102
+ inCodeBlockFirstPass = !inCodeBlockFirstPass;
103
+ if (!inCodeBlockFirstPass)
104
+ continue; // Skip closing ```
105
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
106
+ indentationSet.add(indent);
107
+ continue;
108
+ }
109
+ if (inCodeBlockFirstPass || trimmedLine === '')
110
+ continue;
111
+ const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
112
+ if (bulletMatch) {
113
+ indentationSet.add(bulletMatch[1].length);
114
+ }
115
+ else {
116
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
117
+ indentationSet.add(indent);
118
+ }
119
+ }
120
+ // Create sorted array of indentation values and map to sequential levels
121
+ const sortedIndents = Array.from(indentationSet).sort((a, b) => a - b);
122
+ const indentToLevel = new Map();
123
+ sortedIndents.forEach((indent, index) => {
124
+ indentToLevel.set(indent, index);
125
+ });
126
+ // Helper to get level from indentation, finding closest match
127
+ function getLevel(indent) {
128
+ if (indentToLevel.has(indent)) {
129
+ return indentToLevel.get(indent);
130
+ }
131
+ // Find the closest smaller indentation
132
+ let closestLevel = 0;
133
+ for (const [ind, lvl] of indentToLevel) {
134
+ if (ind <= indent && lvl > closestLevel) {
135
+ closestLevel = lvl;
136
+ }
137
+ }
138
+ return closestLevel;
139
+ }
95
140
  const rootNodes = [];
96
141
  const stack = [];
97
142
  let inCodeBlock = false;
@@ -133,7 +178,7 @@ function parseMarkdown(markdown) {
133
178
  }
134
179
  return codeLine.trimStart();
135
180
  });
136
- const level = Math.floor(codeBlockIndentation / 2);
181
+ const level = getLevel(codeBlockIndentation);
137
182
  const node = {
138
183
  content: processedCodeLines.join('\n'),
139
184
  level,
@@ -169,17 +214,18 @@ function parseMarkdown(markdown) {
169
214
  if (trimmedLine === '') {
170
215
  continue;
171
216
  }
172
- const indentation = line.match(/^\s*/)?.[0].length ?? 0;
173
- let level = Math.floor(indentation / 2);
217
+ let indentation;
174
218
  let contentToParse;
175
219
  const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
176
220
  if (bulletMatch) {
177
- level = Math.floor(bulletMatch[1].length / 2);
221
+ indentation = bulletMatch[1].length;
178
222
  contentToParse = trimmedLine.substring(bulletMatch[0].length);
179
223
  }
180
224
  else {
225
+ indentation = line.match(/^\s*/)?.[0].length ?? 0;
181
226
  contentToParse = trimmedLine;
182
227
  }
228
+ const level = getLevel(indentation);
183
229
  const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse);
184
230
  const node = {
185
231
  content: finalContent,
@@ -240,7 +286,7 @@ function parseTableRows(lines) {
240
286
  }
241
287
  return tableNodes;
242
288
  }
243
- function generateBlockUid() {
289
+ export function generateBlockUid() {
244
290
  // Generate a random string of 9 characters (Roam's format) using crypto for better randomness
245
291
  const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';
246
292
  // 64 chars, which divides 256 evenly (256 = 64 * 4), so simple modulo is unbiased
@@ -11,7 +11,7 @@ import { join, dirname } from 'node:path';
11
11
  import { createServer } from 'node:http';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { findAvailablePort } from '../utils/net.js';
14
- import { CORS_ORIGIN } from '../config/environment.js';
14
+ import { CORS_ORIGINS } from '../config/environment.js';
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  // Read package.json to get the version
@@ -211,6 +211,25 @@ export class RoamServer {
211
211
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
212
212
  };
213
213
  }
214
+ case 'roam_create_table': {
215
+ const { parent_uid, order, headers, rows } = request.params.arguments;
216
+ const result = await this.toolHandlers.createTable({
217
+ parent_uid,
218
+ order,
219
+ headers,
220
+ rows
221
+ });
222
+ return {
223
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
224
+ };
225
+ }
226
+ case 'roam_update_page_markdown': {
227
+ const { title, markdown, dry_run = false } = request.params.arguments;
228
+ const result = await this.toolHandlers.updatePageMarkdown(title, markdown, dry_run);
229
+ return {
230
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
231
+ };
232
+ }
214
233
  default:
215
234
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
216
235
  }
@@ -229,23 +248,70 @@ export class RoamServer {
229
248
  const stdioMcpServer = this.createMcpServer();
230
249
  const stdioTransport = new StdioServerTransport();
231
250
  await stdioMcpServer.connect(stdioTransport);
232
- const httpMcpServer = this.createMcpServer('-http');
233
- const httpStreamTransport = new StreamableHTTPServerTransport({
234
- sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
235
- });
236
- await httpMcpServer.connect(httpStreamTransport);
251
+ // Track active transports by session ID for proper session management
252
+ const activeSessions = new Map();
237
253
  const httpServer = createServer(async (req, res) => {
238
- // Set CORS headers
239
- res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
240
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
241
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
254
+ // Set CORS headers dynamically based on request origin
255
+ const requestOrigin = req.headers.origin;
256
+ if (requestOrigin && CORS_ORIGINS.includes(requestOrigin)) {
257
+ res.setHeader('Access-Control-Allow-Origin', requestOrigin);
258
+ }
259
+ else if (CORS_ORIGINS.includes('*')) {
260
+ res.setHeader('Access-Control-Allow-Origin', '*');
261
+ }
262
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
263
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
264
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
265
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
242
266
  // Handle preflight OPTIONS requests
243
267
  if (req.method === 'OPTIONS') {
244
268
  res.writeHead(204); // No Content
245
269
  res.end();
246
270
  return;
247
271
  }
272
+ // Check for existing session ID in header
273
+ const sessionId = req.headers['mcp-session-id'];
274
+ // Handle session termination (DELETE request)
275
+ if (req.method === 'DELETE' && sessionId) {
276
+ const transport = activeSessions.get(sessionId);
277
+ if (transport) {
278
+ await transport.close();
279
+ activeSessions.delete(sessionId);
280
+ res.writeHead(200);
281
+ res.end();
282
+ }
283
+ else {
284
+ res.writeHead(404, { 'Content-Type': 'application/json' });
285
+ res.end(JSON.stringify({ error: 'Session not found' }));
286
+ }
287
+ return;
288
+ }
248
289
  try {
290
+ // If we have an existing session, use that transport
291
+ if (sessionId && activeSessions.has(sessionId)) {
292
+ const transport = activeSessions.get(sessionId);
293
+ await transport.handleRequest(req, res);
294
+ return;
295
+ }
296
+ // Create new transport and server for new sessions
297
+ const httpMcpServer = this.createMcpServer('-http');
298
+ const httpStreamTransport = new StreamableHTTPServerTransport({
299
+ sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
300
+ onsessioninitialized: (newSessionId) => {
301
+ activeSessions.set(newSessionId, httpStreamTransport);
302
+ }
303
+ });
304
+ // Clean up session when transport closes
305
+ httpStreamTransport.onclose = () => {
306
+ const entries = activeSessions.entries();
307
+ for (const [key, value] of entries) {
308
+ if (value === httpStreamTransport) {
309
+ activeSessions.delete(key);
310
+ break;
311
+ }
312
+ }
313
+ };
314
+ await httpMcpServer.connect(httpStreamTransport);
249
315
  await httpStreamTransport.handleRequest(req, res);
250
316
  }
251
317
  catch (error) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Structured error types for the Roam MCP server.
3
+ * Provides consistent error handling across all tools.
4
+ */
5
+ /**
6
+ * Creates a structured validation error response.
7
+ */
8
+ export function createValidationError(message, details, recovery) {
9
+ return {
10
+ code: 'VALIDATION_ERROR',
11
+ message,
12
+ details,
13
+ recovery
14
+ };
15
+ }
16
+ /**
17
+ * Creates a structured rate limit error response.
18
+ */
19
+ export function createRateLimitError(retryAfterMs) {
20
+ return {
21
+ code: 'RATE_LIMIT',
22
+ message: 'Too many requests, please retry after backoff',
23
+ recovery: {
24
+ retry_after_ms: retryAfterMs ?? 60000,
25
+ suggestion: 'Wait for the specified duration before retrying'
26
+ }
27
+ };
28
+ }
29
+ /**
30
+ * Creates a structured API error response.
31
+ */
32
+ export function createApiError(message, details) {
33
+ return {
34
+ code: 'API_ERROR',
35
+ message,
36
+ details
37
+ };
38
+ }
39
+ /**
40
+ * Creates a structured transaction failed error response.
41
+ */
42
+ export function createTransactionFailedError(message, failedAtAction, committed) {
43
+ return {
44
+ success: false,
45
+ error: {
46
+ code: 'TRANSACTION_FAILED',
47
+ message,
48
+ details: failedAtAction !== undefined ? { action_index: failedAtAction } : undefined
49
+ },
50
+ committed
51
+ };
52
+ }
53
+ /**
54
+ * Checks if an error is a rate limit error based on error message.
55
+ */
56
+ export function isRateLimitError(error) {
57
+ if (error instanceof Error) {
58
+ const message = error.message.toLowerCase();
59
+ return message.includes('too many requests') ||
60
+ message.includes('rate limit') ||
61
+ message.includes('try again in');
62
+ }
63
+ if (typeof error === 'string') {
64
+ const message = error.toLowerCase();
65
+ return message.includes('too many requests') ||
66
+ message.includes('rate limit') ||
67
+ message.includes('try again in');
68
+ }
69
+ return false;
70
+ }
71
+ /**
72
+ * Checks if an error is a network error.
73
+ */
74
+ export function isNetworkError(error) {
75
+ if (error instanceof Error) {
76
+ const message = error.message.toLowerCase();
77
+ return message.includes('network') ||
78
+ message.includes('econnrefused') ||
79
+ message.includes('econnreset') ||
80
+ message.includes('etimedout') ||
81
+ message.includes('socket');
82
+ }
83
+ return false;
84
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared utilities for the Roam MCP server.
3
+ */
4
+ export * from './validation.js';
5
+ export * from './errors.js';