roam-research-mcp 0.2.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,706 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { q, createPage, createBlock, batchActions, } from '@roam-research/roam-api-sdk';
3
+ import { formatRoamDate } from '../utils/helpers.js';
4
+ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable } from '../markdown-utils.js';
5
+ // Helper function to capitalize each word
6
+ const capitalizeWords = (str) => {
7
+ return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
8
+ };
9
+ // Helper function to collect all referenced block UIDs from text
10
+ const collectRefs = (text, depth = 0, refs = new Set()) => {
11
+ if (depth >= 4)
12
+ return refs; // Max recursion depth
13
+ const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
14
+ let match;
15
+ while ((match = refRegex.exec(text)) !== null) {
16
+ const [_, uid] = match;
17
+ refs.add(uid);
18
+ }
19
+ return refs;
20
+ };
21
+ // Helper function to resolve block references
22
+ const resolveRefs = async (graph, text, depth = 0) => {
23
+ if (depth >= 4)
24
+ return text; // Max recursion depth
25
+ const refs = collectRefs(text, depth);
26
+ if (refs.size === 0)
27
+ return text;
28
+ // Get referenced block contents
29
+ const refQuery = `[:find ?uid ?string
30
+ :in $ [?uid ...]
31
+ :where [?b :block/uid ?uid]
32
+ [?b :block/string ?string]]`;
33
+ const refResults = await q(graph, refQuery, [Array.from(refs)]);
34
+ // Create lookup map of uid -> string
35
+ const refMap = new Map();
36
+ refResults.forEach(([uid, string]) => {
37
+ refMap.set(uid, string);
38
+ });
39
+ // Replace references with their content
40
+ let resolvedText = text;
41
+ for (const uid of refs) {
42
+ const refContent = refMap.get(uid);
43
+ if (refContent) {
44
+ // Recursively resolve nested references
45
+ const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
46
+ resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
47
+ }
48
+ }
49
+ return resolvedText;
50
+ };
51
+ export class ToolHandlers {
52
+ graph;
53
+ constructor(graph) {
54
+ this.graph = graph;
55
+ }
56
+ async createOutline(outline, page_title_uid, block_text_uid) {
57
+ // Validate input
58
+ if (!Array.isArray(outline) || outline.length === 0) {
59
+ throw new McpError(ErrorCode.InvalidRequest, 'outline must be a non-empty array');
60
+ }
61
+ // Filter out items with undefined text
62
+ const validOutline = outline.filter(item => item.text !== undefined);
63
+ if (validOutline.length === 0) {
64
+ throw new McpError(ErrorCode.InvalidRequest, 'outline must contain at least one item with text');
65
+ }
66
+ // Validate outline structure
67
+ const invalidItems = validOutline.filter(item => typeof item.level !== 'number' ||
68
+ item.level < 1 ||
69
+ item.level > 10 ||
70
+ typeof item.text !== 'string' ||
71
+ item.text.trim().length === 0);
72
+ if (invalidItems.length > 0) {
73
+ throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
74
+ }
75
+ // Helper function to find or create page with retries
76
+ const findOrCreatePage = async (titleOrUid, maxRetries = 3, delayMs = 500) => {
77
+ // First try to find by title
78
+ const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
79
+ const variations = [
80
+ titleOrUid, // Original
81
+ capitalizeWords(titleOrUid), // Each word capitalized
82
+ titleOrUid.toLowerCase() // All lowercase
83
+ ];
84
+ for (let retry = 0; retry < maxRetries; retry++) {
85
+ // Try each case variation
86
+ for (const variation of variations) {
87
+ const findResults = await q(this.graph, titleQuery, [variation]);
88
+ if (findResults && findResults.length > 0) {
89
+ return findResults[0][0];
90
+ }
91
+ }
92
+ // If not found as title, try as UID
93
+ const uidQuery = `[:find ?uid
94
+ :where [?e :block/uid "${titleOrUid}"]
95
+ [?e :block/uid ?uid]]`;
96
+ const uidResult = await q(this.graph, uidQuery, []);
97
+ if (uidResult && uidResult.length > 0) {
98
+ return uidResult[0][0];
99
+ }
100
+ // If still not found and this is the first retry, try to create the page
101
+ if (retry === 0) {
102
+ const success = await createPage(this.graph, {
103
+ action: 'create-page',
104
+ page: { title: titleOrUid }
105
+ });
106
+ // Even if createPage returns false, the page might still have been created
107
+ // Wait a bit and continue to next retry
108
+ await new Promise(resolve => setTimeout(resolve, delayMs));
109
+ continue;
110
+ }
111
+ if (retry < maxRetries - 1) {
112
+ await new Promise(resolve => setTimeout(resolve, delayMs));
113
+ }
114
+ }
115
+ throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
116
+ };
117
+ // Get or create the target page
118
+ const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
119
+ // Helper function to find block with improved relationship checks
120
+ const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
121
+ // Try multiple query strategies
122
+ const queries = [
123
+ // Strategy 1: Direct page and string match
124
+ `[:find ?b-uid ?order
125
+ :where [?p :block/uid "${pageUid}"]
126
+ [?b :block/page ?p]
127
+ [?b :block/string "${blockString}"]
128
+ [?b :block/order ?order]
129
+ [?b :block/uid ?b-uid]]`,
130
+ // Strategy 2: Parent-child relationship
131
+ `[:find ?b-uid ?order
132
+ :where [?p :block/uid "${pageUid}"]
133
+ [?b :block/parents ?p]
134
+ [?b :block/string "${blockString}"]
135
+ [?b :block/order ?order]
136
+ [?b :block/uid ?b-uid]]`,
137
+ // Strategy 3: Broader page relationship
138
+ `[:find ?b-uid ?order
139
+ :where [?p :block/uid "${pageUid}"]
140
+ [?b :block/page ?page]
141
+ [?p :block/page ?page]
142
+ [?b :block/string "${blockString}"]
143
+ [?b :block/order ?order]
144
+ [?b :block/uid ?b-uid]]`
145
+ ];
146
+ for (let retry = 0; retry < maxRetries; retry++) {
147
+ // Try each query strategy
148
+ for (const queryStr of queries) {
149
+ const blockResults = await q(this.graph, queryStr, []);
150
+ if (blockResults && blockResults.length > 0) {
151
+ // Use the most recently created block
152
+ const sorted = blockResults.sort((a, b) => b[1] - a[1]);
153
+ return sorted[0][0];
154
+ }
155
+ }
156
+ // Exponential backoff
157
+ const delay = initialDelay * Math.pow(2, retry);
158
+ await new Promise(resolve => setTimeout(resolve, delay));
159
+ console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
160
+ }
161
+ throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
162
+ };
163
+ // Helper function to create and verify block with improved error handling
164
+ const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
165
+ try {
166
+ // Initial delay before any operations
167
+ if (!isRetry) {
168
+ await new Promise(resolve => setTimeout(resolve, initialDelay));
169
+ }
170
+ for (let retry = 0; retry < maxRetries; retry++) {
171
+ console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
172
+ // Create block
173
+ const success = await createBlock(this.graph, {
174
+ action: 'create-block',
175
+ location: {
176
+ 'parent-uid': parentUid,
177
+ order: 'last'
178
+ },
179
+ block: { string: content }
180
+ });
181
+ // Wait with exponential backoff
182
+ const delay = initialDelay * Math.pow(2, retry);
183
+ await new Promise(resolve => setTimeout(resolve, delay));
184
+ try {
185
+ // Try to find the block using our improved findBlockWithRetry
186
+ return await findBlockWithRetry(parentUid, content);
187
+ }
188
+ catch (error) {
189
+ const errorMessage = error instanceof Error ? error.message : String(error);
190
+ console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`);
191
+ if (retry === maxRetries - 1)
192
+ throw error;
193
+ }
194
+ }
195
+ throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
196
+ }
197
+ catch (error) {
198
+ // If this is already a retry, throw the error
199
+ if (isRetry)
200
+ throw error;
201
+ // Otherwise, try one more time with a clean slate
202
+ console.log(`Retrying block creation for "${content}" with fresh attempt`);
203
+ await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
204
+ return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
205
+ }
206
+ };
207
+ // Get or create the parent block
208
+ let targetParentUid;
209
+ if (!block_text_uid) {
210
+ targetParentUid = targetPageUid;
211
+ }
212
+ else {
213
+ try {
214
+ // Create header block and get its UID
215
+ targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
216
+ }
217
+ catch (error) {
218
+ const errorMessage = error instanceof Error ? error.message : String(error);
219
+ throw new McpError(ErrorCode.InternalError, `Failed to create header block "${block_text_uid}": ${errorMessage}`);
220
+ }
221
+ }
222
+ // Initialize result variable
223
+ let result;
224
+ try {
225
+ // Validate level sequence
226
+ let prevLevel = 0;
227
+ for (const item of validOutline) {
228
+ // Level should not increase by more than 1 at a time
229
+ if (item.level > prevLevel + 1) {
230
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}`);
231
+ }
232
+ prevLevel = item.level;
233
+ }
234
+ // Convert outline items to markdown-like structure
235
+ const markdownContent = validOutline
236
+ .map(item => {
237
+ const indent = ' '.repeat(item.level - 1);
238
+ return `${indent}- ${item.text?.trim()}`;
239
+ })
240
+ .join('\n');
241
+ // Convert to Roam markdown format
242
+ const convertedContent = convertToRoamMarkdown(markdownContent);
243
+ // Parse markdown into hierarchical structure
244
+ const nodes = parseMarkdown(convertedContent);
245
+ // Convert nodes to batch actions
246
+ const actions = convertToRoamActions(nodes, targetParentUid, 'last');
247
+ if (actions.length === 0) {
248
+ throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
249
+ }
250
+ // Execute batch actions to create the outline
251
+ result = await batchActions(this.graph, {
252
+ action: 'batch-actions',
253
+ actions
254
+ }).catch(error => {
255
+ throw new McpError(ErrorCode.InternalError, `Failed to create outline blocks: ${error.message}`);
256
+ });
257
+ if (!result) {
258
+ throw new McpError(ErrorCode.InternalError, 'Failed to create outline blocks - no result returned');
259
+ }
260
+ }
261
+ catch (error) {
262
+ if (error instanceof McpError)
263
+ throw error;
264
+ throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
265
+ }
266
+ // Get the created block UIDs
267
+ const createdUids = result?.created_uids || [];
268
+ return {
269
+ success: true,
270
+ page_uid: targetPageUid,
271
+ parent_uid: targetParentUid,
272
+ created_uids: createdUids
273
+ };
274
+ }
275
+ async fetchPageByTitle(title) {
276
+ if (!title) {
277
+ throw new McpError(ErrorCode.InvalidRequest, 'title is required');
278
+ }
279
+ // Try different case variations
280
+ const variations = [
281
+ title, // Original
282
+ capitalizeWords(title), // Each word capitalized
283
+ title.toLowerCase() // All lowercase
284
+ ];
285
+ let uid = null;
286
+ for (const variation of variations) {
287
+ const searchQuery = `[:find ?uid .
288
+ :where [?e :node/title "${variation}"]
289
+ [?e :block/uid ?uid]]`;
290
+ const result = await q(this.graph, searchQuery, []);
291
+ uid = (result === null || result === undefined) ? null : String(result);
292
+ if (uid)
293
+ break;
294
+ }
295
+ if (!uid) {
296
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
297
+ }
298
+ // Get all blocks under this page with their order and parent relationships
299
+ const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
300
+ :where [?p :block/uid "${uid}"]
301
+ [?b :block/page ?p]
302
+ [?b :block/uid ?block-uid]
303
+ [?b :block/string ?block-str]
304
+ [?b :block/order ?order]
305
+ [?b :block/parents ?parent]
306
+ [?parent :block/uid ?parent-uid]]`;
307
+ const blocks = await q(this.graph, blocksQuery, []);
308
+ if (blocks.length > 0) {
309
+ const blockMap = new Map();
310
+ for (const [uid, string, order] of blocks) {
311
+ if (!blockMap.has(uid)) {
312
+ const resolvedString = await resolveRefs(this.graph, string);
313
+ blockMap.set(uid, {
314
+ uid,
315
+ string: resolvedString,
316
+ order: order,
317
+ children: []
318
+ });
319
+ }
320
+ }
321
+ // Build parent-child relationships
322
+ blocks.forEach(([childUid, _, __, parentUid]) => {
323
+ const child = blockMap.get(childUid);
324
+ const parent = blockMap.get(parentUid);
325
+ if (child && parent && !parent.children.includes(child)) {
326
+ parent.children.push(child);
327
+ }
328
+ });
329
+ // Get top-level blocks
330
+ const topQuery = `[:find ?block-uid ?block-str ?order
331
+ :where [?p :block/uid "${uid}"]
332
+ [?b :block/page ?p]
333
+ [?b :block/uid ?block-uid]
334
+ [?b :block/string ?block-str]
335
+ [?b :block/order ?order]
336
+ (not-join [?b]
337
+ [?b :block/parents ?parent]
338
+ [?parent :block/page ?p])]`;
339
+ const topBlocks = await q(this.graph, topQuery, []);
340
+ // Create root blocks
341
+ const rootBlocks = topBlocks
342
+ .map(([uid, string, order]) => ({
343
+ uid,
344
+ string,
345
+ order: order,
346
+ children: blockMap.get(uid)?.children || []
347
+ }))
348
+ .sort((a, b) => a.order - b.order);
349
+ // Convert to markdown
350
+ const toMarkdown = (blocks, level = 0) => {
351
+ return blocks.map(block => {
352
+ const indent = ' '.repeat(level);
353
+ let md = `${indent}- ${block.string}\n`;
354
+ if (block.children.length > 0) {
355
+ md += toMarkdown(block.children.sort((a, b) => a.order - b.order), level + 1);
356
+ }
357
+ return md;
358
+ }).join('');
359
+ };
360
+ return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
361
+ }
362
+ return `${title} (no content found)`;
363
+ }
364
+ async createPage(title, content) {
365
+ // Ensure title is properly formatted
366
+ const pageTitle = String(title).trim();
367
+ // First try to find if the page exists
368
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
369
+ const findResults = await q(this.graph, findQuery, [pageTitle]);
370
+ let pageUid;
371
+ if (findResults && findResults.length > 0) {
372
+ // Page exists, use its UID
373
+ pageUid = findResults[0][0];
374
+ }
375
+ else {
376
+ // Create new page
377
+ const success = await createPage(this.graph, {
378
+ action: 'create-page',
379
+ page: {
380
+ title: pageTitle
381
+ }
382
+ });
383
+ if (!success) {
384
+ throw new Error('Failed to create page');
385
+ }
386
+ // Get the new page's UID
387
+ const results = await q(this.graph, findQuery, [pageTitle]);
388
+ if (!results || results.length === 0) {
389
+ throw new Error('Could not find created page');
390
+ }
391
+ pageUid = results[0][0];
392
+ }
393
+ // If content is provided, check if it looks like nested markdown
394
+ if (content) {
395
+ const isMultilined = content.includes('\n') || hasMarkdownTable(content);
396
+ if (isMultilined) {
397
+ // Use import_nested_markdown functionality
398
+ const convertedContent = convertToRoamMarkdown(content);
399
+ const nodes = parseMarkdown(convertedContent);
400
+ const actions = convertToRoamActions(nodes, pageUid, 'last');
401
+ const result = await batchActions(this.graph, {
402
+ action: 'batch-actions',
403
+ actions
404
+ });
405
+ if (!result) {
406
+ throw new Error('Failed to import nested markdown content');
407
+ }
408
+ }
409
+ else {
410
+ // Create a simple block for non-nested content
411
+ const blockSuccess = await createBlock(this.graph, {
412
+ action: 'create-block',
413
+ location: {
414
+ "parent-uid": pageUid,
415
+ "order": "last"
416
+ },
417
+ block: { string: content }
418
+ });
419
+ if (!blockSuccess) {
420
+ throw new Error('Failed to create content block');
421
+ }
422
+ }
423
+ }
424
+ return { success: true, uid: pageUid };
425
+ }
426
+ async createBlock(content, page_uid, title) {
427
+ // If page_uid provided, use it directly
428
+ let targetPageUid = page_uid;
429
+ // If no page_uid but title provided, search for page by title
430
+ if (!targetPageUid && title) {
431
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
432
+ const findResults = await q(this.graph, findQuery, [title]);
433
+ if (findResults && findResults.length > 0) {
434
+ targetPageUid = findResults[0][0];
435
+ }
436
+ else {
437
+ // Create page with provided title if it doesn't exist
438
+ const success = await createPage(this.graph, {
439
+ action: 'create-page',
440
+ page: { title }
441
+ });
442
+ if (!success) {
443
+ throw new Error('Failed to create page with provided title');
444
+ }
445
+ // Get the new page's UID
446
+ const results = await q(this.graph, findQuery, [title]);
447
+ if (!results || results.length === 0) {
448
+ throw new Error('Could not find created page');
449
+ }
450
+ targetPageUid = results[0][0];
451
+ }
452
+ }
453
+ // If neither page_uid nor title provided, use today's date page
454
+ if (!targetPageUid) {
455
+ const today = new Date();
456
+ const dateStr = formatRoamDate(today);
457
+ // Try to find today's page
458
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
459
+ const findResults = await q(this.graph, findQuery, [dateStr]);
460
+ if (findResults && findResults.length > 0) {
461
+ targetPageUid = findResults[0][0];
462
+ }
463
+ else {
464
+ // Create today's page if it doesn't exist
465
+ const success = await createPage(this.graph, {
466
+ action: 'create-page',
467
+ page: { title: dateStr }
468
+ });
469
+ if (!success) {
470
+ throw new Error('Failed to create today\'s page');
471
+ }
472
+ // Get the new page's UID
473
+ const results = await q(this.graph, findQuery, [dateStr]);
474
+ if (!results || results.length === 0) {
475
+ throw new Error('Could not find created today\'s page');
476
+ }
477
+ targetPageUid = results[0][0];
478
+ }
479
+ }
480
+ // If the content has multiple lines or is a table, use nested import
481
+ if (content.includes('\n')) {
482
+ // Parse and import the nested content
483
+ const convertedContent = convertToRoamMarkdown(content);
484
+ const nodes = parseMarkdown(convertedContent);
485
+ const actions = convertToRoamActions(nodes, targetPageUid, 'last');
486
+ // Execute batch actions to create the nested structure
487
+ const result = await batchActions(this.graph, {
488
+ action: 'batch-actions',
489
+ actions
490
+ });
491
+ if (!result) {
492
+ throw new Error('Failed to create nested blocks');
493
+ }
494
+ const blockUid = result.created_uids?.[0];
495
+ return {
496
+ success: true,
497
+ block_uid: blockUid,
498
+ parent_uid: targetPageUid
499
+ };
500
+ }
501
+ else {
502
+ // For non-table content, create a simple block
503
+ const result = await createBlock(this.graph, {
504
+ action: 'create-block',
505
+ location: {
506
+ "parent-uid": targetPageUid,
507
+ "order": "last"
508
+ },
509
+ block: { string: content }
510
+ });
511
+ if (!result) {
512
+ throw new Error('Failed to create block');
513
+ }
514
+ // Get the block's UID
515
+ const findBlockQuery = `[:find ?uid
516
+ :in $ ?parent ?string
517
+ :where [?b :block/uid ?uid]
518
+ [?b :block/string ?string]
519
+ [?b :block/parents ?p]
520
+ [?p :block/uid ?parent]]`;
521
+ const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
522
+ if (!blockResults || blockResults.length === 0) {
523
+ throw new Error('Could not find created block');
524
+ }
525
+ const blockUid = blockResults[0][0];
526
+ return {
527
+ success: true,
528
+ block_uid: blockUid,
529
+ parent_uid: targetPageUid
530
+ };
531
+ }
532
+ }
533
+ async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
534
+ // First get the page UID
535
+ let targetPageUid = page_uid;
536
+ if (!targetPageUid && page_title) {
537
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
538
+ const findResults = await q(this.graph, findQuery, [page_title]);
539
+ if (findResults && findResults.length > 0) {
540
+ targetPageUid = findResults[0][0];
541
+ }
542
+ else {
543
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
544
+ }
545
+ }
546
+ // If no page specified, use today's date page
547
+ if (!targetPageUid) {
548
+ const today = new Date();
549
+ const dateStr = formatRoamDate(today);
550
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
551
+ const findResults = await q(this.graph, findQuery, [dateStr]);
552
+ if (findResults && findResults.length > 0) {
553
+ targetPageUid = findResults[0][0];
554
+ }
555
+ else {
556
+ // Create today's page
557
+ const success = await createPage(this.graph, {
558
+ action: 'create-page',
559
+ page: { title: dateStr }
560
+ });
561
+ if (!success) {
562
+ throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
563
+ }
564
+ const results = await q(this.graph, findQuery, [dateStr]);
565
+ if (!results || results.length === 0) {
566
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
567
+ }
568
+ targetPageUid = results[0][0];
569
+ }
570
+ }
571
+ // Now get the parent block UID
572
+ let targetParentUid = parent_uid;
573
+ if (!targetParentUid && parent_string) {
574
+ if (!targetPageUid) {
575
+ throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
576
+ }
577
+ // Find block by exact string match within the page
578
+ const findBlockQuery = `[:find ?uid
579
+ :where [?p :block/uid "${targetPageUid}"]
580
+ [?b :block/page ?p]
581
+ [?b :block/string "${parent_string}"]]`;
582
+ const blockResults = await q(this.graph, findBlockQuery, []);
583
+ if (!blockResults || blockResults.length === 0) {
584
+ throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
585
+ }
586
+ targetParentUid = blockResults[0][0];
587
+ }
588
+ // If no parent specified, use page as parent
589
+ if (!targetParentUid) {
590
+ targetParentUid = targetPageUid;
591
+ }
592
+ // Always use parseMarkdown for content with multiple lines or any markdown formatting
593
+ const isMultilined = content.includes('\n');
594
+ if (isMultilined) {
595
+ // Parse markdown into hierarchical structure
596
+ const convertedContent = convertToRoamMarkdown(content);
597
+ const nodes = parseMarkdown(convertedContent);
598
+ // Convert markdown nodes to batch actions
599
+ const actions = convertToRoamActions(nodes, targetParentUid, order);
600
+ // Execute batch actions to add content
601
+ const result = await batchActions(this.graph, {
602
+ action: 'batch-actions',
603
+ actions
604
+ });
605
+ if (!result) {
606
+ throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
607
+ }
608
+ // Get the created block UIDs
609
+ const createdUids = result.created_uids || [];
610
+ return {
611
+ success: true,
612
+ page_uid: targetPageUid,
613
+ parent_uid: targetParentUid,
614
+ created_uids: createdUids
615
+ };
616
+ }
617
+ else {
618
+ // Create a simple block for non-nested content
619
+ const blockSuccess = await createBlock(this.graph, {
620
+ action: 'create-block',
621
+ location: {
622
+ "parent-uid": targetParentUid,
623
+ order
624
+ },
625
+ block: { string: content }
626
+ });
627
+ if (!blockSuccess) {
628
+ throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
629
+ }
630
+ return {
631
+ success: true,
632
+ page_uid: targetPageUid,
633
+ parent_uid: targetParentUid
634
+ };
635
+ }
636
+ }
637
+ async addTodos(todos) {
638
+ if (!Array.isArray(todos) || todos.length === 0) {
639
+ throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
640
+ }
641
+ // Get today's date
642
+ const today = new Date();
643
+ const dateStr = formatRoamDate(today);
644
+ // Try to find today's page
645
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
646
+ const findResults = await q(this.graph, findQuery, [dateStr]);
647
+ let targetPageUid;
648
+ if (findResults && findResults.length > 0) {
649
+ targetPageUid = findResults[0][0];
650
+ }
651
+ else {
652
+ // Create today's page if it doesn't exist
653
+ const success = await createPage(this.graph, {
654
+ action: 'create-page',
655
+ page: { title: dateStr }
656
+ });
657
+ if (!success) {
658
+ throw new Error('Failed to create today\'s page');
659
+ }
660
+ // Get the new page's UID
661
+ const results = await q(this.graph, findQuery, [dateStr]);
662
+ if (!results || results.length === 0) {
663
+ throw new Error('Could not find created today\'s page');
664
+ }
665
+ targetPageUid = results[0][0];
666
+ }
667
+ // If more than 10 todos, use batch actions
668
+ const todo_tag = "{{TODO}}";
669
+ if (todos.length > 10) {
670
+ const actions = todos.map((todo, index) => ({
671
+ action: 'create-block',
672
+ location: {
673
+ 'parent-uid': targetPageUid,
674
+ order: index
675
+ },
676
+ block: {
677
+ string: `${todo_tag} ${todo}`
678
+ }
679
+ }));
680
+ const result = await batchActions(this.graph, {
681
+ action: 'batch-actions',
682
+ actions
683
+ });
684
+ if (!result) {
685
+ throw new Error('Failed to create todo blocks');
686
+ }
687
+ }
688
+ else {
689
+ // Create todos sequentially
690
+ for (const todo of todos) {
691
+ const success = await createBlock(this.graph, {
692
+ action: 'create-block',
693
+ location: {
694
+ "parent-uid": targetPageUid,
695
+ "order": "last"
696
+ },
697
+ block: { string: `${todo_tag} ${todo}` }
698
+ });
699
+ if (!success) {
700
+ throw new Error('Failed to create todo block');
701
+ }
702
+ }
703
+ }
704
+ return { success: true };
705
+ }
706
+ }