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.
- package/README.md +104 -17
- package/build/Roam_Markdown_Cheatsheet.md +528 -175
- package/build/cache/page-uid-cache.js +55 -0
- package/build/cli/import-markdown.js +98 -0
- package/build/config/environment.js +4 -2
- package/build/diff/actions.js +93 -0
- package/build/diff/actions.test.js +125 -0
- package/build/diff/diff.js +155 -0
- package/build/diff/diff.test.js +202 -0
- package/build/diff/index.js +43 -0
- package/build/diff/matcher.js +118 -0
- package/build/diff/matcher.test.js +198 -0
- package/build/diff/parser.js +114 -0
- package/build/diff/parser.test.js +281 -0
- package/build/diff/types.js +27 -0
- package/build/diff/types.test.js +57 -0
- package/build/markdown-utils.js +51 -5
- package/build/server/roam-server.js +76 -10
- package/build/shared/errors.js +84 -0
- package/build/shared/index.js +5 -0
- package/build/shared/validation.js +268 -0
- package/build/tools/operations/batch.js +165 -3
- package/build/tools/operations/memory.js +29 -19
- package/build/tools/operations/outline.js +110 -70
- package/build/tools/operations/pages.js +254 -60
- package/build/tools/operations/table.js +142 -0
- package/build/tools/schemas.js +110 -9
- package/build/tools/tool-handlers.js +12 -2
- package/package.json +9 -5
|
@@ -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
|
+
});
|
package/build/markdown-utils.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
233
|
-
const
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
}
|