roam-research-mcp 0.12.3 → 0.17.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,972 @@
1
+ import { q, createPage, createBlock, batchActions, updateBlock } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
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 findPagesModifiedToday() {
57
+ // Define ancestor rule for traversing block hierarchy
58
+ const ancestorRule = `[
59
+ [ (ancestor ?b ?a)
60
+ [?a :block/children ?b] ]
61
+ [ (ancestor ?b ?a)
62
+ [?parent :block/children ?b]
63
+ (ancestor ?parent ?a) ]
64
+ ]`;
65
+ // Get start of today
66
+ const startOfDay = new Date();
67
+ startOfDay.setHours(0, 0, 0, 0);
68
+ try {
69
+ // Query for pages modified today
70
+ const results = await q(this.graph, `[:find ?title
71
+ :in $ ?start_of_day %
72
+ :where
73
+ [?page :node/title ?title]
74
+ (ancestor ?block ?page)
75
+ [?block :edit/time ?time]
76
+ [(> ?time ?start_of_day)]]`, [startOfDay.getTime(), ancestorRule]);
77
+ if (!results || results.length === 0) {
78
+ return {
79
+ success: true,
80
+ pages: [],
81
+ message: 'No pages have been modified today'
82
+ };
83
+ }
84
+ // Extract unique page titles
85
+ const uniquePages = [...new Set(results.map(([title]) => title))];
86
+ return {
87
+ success: true,
88
+ pages: uniquePages,
89
+ message: `Found ${uniquePages.length} page(s) modified today`
90
+ };
91
+ }
92
+ catch (error) {
93
+ throw new McpError(ErrorCode.InternalError, `Failed to find modified pages: ${error.message}`);
94
+ }
95
+ }
96
+ async createOutline(outline, page_title_uid, block_text_uid) {
97
+ // Validate input
98
+ if (!Array.isArray(outline) || outline.length === 0) {
99
+ throw new McpError(ErrorCode.InvalidRequest, 'outline must be a non-empty array');
100
+ }
101
+ // Filter out items with undefined text
102
+ const validOutline = outline.filter(item => item.text !== undefined);
103
+ if (validOutline.length === 0) {
104
+ throw new McpError(ErrorCode.InvalidRequest, 'outline must contain at least one item with text');
105
+ }
106
+ // Validate outline structure
107
+ const invalidItems = validOutline.filter(item => typeof item.level !== 'number' ||
108
+ item.level < 1 ||
109
+ item.level > 10 ||
110
+ typeof item.text !== 'string' ||
111
+ item.text.trim().length === 0);
112
+ if (invalidItems.length > 0) {
113
+ throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
114
+ }
115
+ // Helper function to find or create page with retries
116
+ const findOrCreatePage = async (titleOrUid, maxRetries = 3, delayMs = 500) => {
117
+ // First try to find by title
118
+ const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
119
+ const variations = [
120
+ titleOrUid, // Original
121
+ capitalizeWords(titleOrUid), // Each word capitalized
122
+ titleOrUid.toLowerCase() // All lowercase
123
+ ];
124
+ for (let retry = 0; retry < maxRetries; retry++) {
125
+ // Try each case variation
126
+ for (const variation of variations) {
127
+ const findResults = await q(this.graph, titleQuery, [variation]);
128
+ if (findResults && findResults.length > 0) {
129
+ return findResults[0][0];
130
+ }
131
+ }
132
+ // If not found as title, try as UID
133
+ const uidQuery = `[:find ?uid
134
+ :where [?e :block/uid "${titleOrUid}"]
135
+ [?e :block/uid ?uid]]`;
136
+ const uidResult = await q(this.graph, uidQuery, []);
137
+ if (uidResult && uidResult.length > 0) {
138
+ return uidResult[0][0];
139
+ }
140
+ // If still not found and this is the first retry, try to create the page
141
+ if (retry === 0) {
142
+ const success = await createPage(this.graph, {
143
+ action: 'create-page',
144
+ page: { title: titleOrUid }
145
+ });
146
+ // Even if createPage returns false, the page might still have been created
147
+ // Wait a bit and continue to next retry
148
+ await new Promise(resolve => setTimeout(resolve, delayMs));
149
+ continue;
150
+ }
151
+ if (retry < maxRetries - 1) {
152
+ await new Promise(resolve => setTimeout(resolve, delayMs));
153
+ }
154
+ }
155
+ throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
156
+ };
157
+ // Get or create the target page
158
+ const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
159
+ // Helper function to find block with improved relationship checks
160
+ const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
161
+ // Try multiple query strategies
162
+ const queries = [
163
+ // Strategy 1: Direct page and string match
164
+ `[:find ?b-uid ?order
165
+ :where [?p :block/uid "${pageUid}"]
166
+ [?b :block/page ?p]
167
+ [?b :block/string "${blockString}"]
168
+ [?b :block/order ?order]
169
+ [?b :block/uid ?b-uid]]`,
170
+ // Strategy 2: Parent-child relationship
171
+ `[:find ?b-uid ?order
172
+ :where [?p :block/uid "${pageUid}"]
173
+ [?b :block/parents ?p]
174
+ [?b :block/string "${blockString}"]
175
+ [?b :block/order ?order]
176
+ [?b :block/uid ?b-uid]]`,
177
+ // Strategy 3: Broader page relationship
178
+ `[:find ?b-uid ?order
179
+ :where [?p :block/uid "${pageUid}"]
180
+ [?b :block/page ?page]
181
+ [?p :block/page ?page]
182
+ [?b :block/string "${blockString}"]
183
+ [?b :block/order ?order]
184
+ [?b :block/uid ?b-uid]]`
185
+ ];
186
+ for (let retry = 0; retry < maxRetries; retry++) {
187
+ // Try each query strategy
188
+ for (const queryStr of queries) {
189
+ const blockResults = await q(this.graph, queryStr, []);
190
+ if (blockResults && blockResults.length > 0) {
191
+ // Use the most recently created block
192
+ const sorted = blockResults.sort((a, b) => b[1] - a[1]);
193
+ return sorted[0][0];
194
+ }
195
+ }
196
+ // Exponential backoff
197
+ const delay = initialDelay * Math.pow(2, retry);
198
+ await new Promise(resolve => setTimeout(resolve, delay));
199
+ console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
200
+ }
201
+ throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
202
+ };
203
+ // Helper function to create and verify block with improved error handling
204
+ const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
205
+ try {
206
+ // Initial delay before any operations
207
+ if (!isRetry) {
208
+ await new Promise(resolve => setTimeout(resolve, initialDelay));
209
+ }
210
+ for (let retry = 0; retry < maxRetries; retry++) {
211
+ console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
212
+ // Create block
213
+ const success = await createBlock(this.graph, {
214
+ action: 'create-block',
215
+ location: {
216
+ 'parent-uid': parentUid,
217
+ order: 'last'
218
+ },
219
+ block: { string: content }
220
+ });
221
+ // Wait with exponential backoff
222
+ const delay = initialDelay * Math.pow(2, retry);
223
+ await new Promise(resolve => setTimeout(resolve, delay));
224
+ try {
225
+ // Try to find the block using our improved findBlockWithRetry
226
+ return await findBlockWithRetry(parentUid, content);
227
+ }
228
+ catch (error) {
229
+ const errorMessage = error instanceof Error ? error.message : String(error);
230
+ console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`);
231
+ if (retry === maxRetries - 1)
232
+ throw error;
233
+ }
234
+ }
235
+ throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
236
+ }
237
+ catch (error) {
238
+ // If this is already a retry, throw the error
239
+ if (isRetry)
240
+ throw error;
241
+ // Otherwise, try one more time with a clean slate
242
+ console.log(`Retrying block creation for "${content}" with fresh attempt`);
243
+ await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
244
+ return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
245
+ }
246
+ };
247
+ // Get or create the parent block
248
+ let targetParentUid;
249
+ if (!block_text_uid) {
250
+ targetParentUid = targetPageUid;
251
+ }
252
+ else {
253
+ try {
254
+ // Create header block and get its UID
255
+ targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
256
+ }
257
+ catch (error) {
258
+ const errorMessage = error instanceof Error ? error.message : String(error);
259
+ throw new McpError(ErrorCode.InternalError, `Failed to create header block "${block_text_uid}": ${errorMessage}`);
260
+ }
261
+ }
262
+ // Initialize result variable
263
+ let result;
264
+ try {
265
+ // Validate level sequence
266
+ let prevLevel = 0;
267
+ for (const item of validOutline) {
268
+ // Level should not increase by more than 1 at a time
269
+ if (item.level > prevLevel + 1) {
270
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}`);
271
+ }
272
+ prevLevel = item.level;
273
+ }
274
+ // Convert outline items to markdown-like structure
275
+ const markdownContent = validOutline
276
+ .map(item => {
277
+ const indent = ' '.repeat(item.level - 1);
278
+ return `${indent}- ${item.text?.trim()}`;
279
+ })
280
+ .join('\n');
281
+ // Convert to Roam markdown format
282
+ const convertedContent = convertToRoamMarkdown(markdownContent);
283
+ // Parse markdown into hierarchical structure
284
+ const nodes = parseMarkdown(convertedContent);
285
+ // Convert nodes to batch actions
286
+ const actions = convertToRoamActions(nodes, targetParentUid, 'last');
287
+ if (actions.length === 0) {
288
+ throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
289
+ }
290
+ // Execute batch actions to create the outline
291
+ result = await batchActions(this.graph, {
292
+ action: 'batch-actions',
293
+ actions
294
+ }).catch(error => {
295
+ throw new McpError(ErrorCode.InternalError, `Failed to create outline blocks: ${error.message}`);
296
+ });
297
+ if (!result) {
298
+ throw new McpError(ErrorCode.InternalError, 'Failed to create outline blocks - no result returned');
299
+ }
300
+ }
301
+ catch (error) {
302
+ if (error instanceof McpError)
303
+ throw error;
304
+ throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
305
+ }
306
+ // Get the created block UIDs
307
+ const createdUids = result?.created_uids || [];
308
+ return {
309
+ success: true,
310
+ page_uid: targetPageUid,
311
+ parent_uid: targetParentUid,
312
+ created_uids: createdUids
313
+ };
314
+ }
315
+ async fetchPageByTitle(title) {
316
+ if (!title) {
317
+ throw new McpError(ErrorCode.InvalidRequest, 'title is required');
318
+ }
319
+ // Try different case variations
320
+ const variations = [
321
+ title, // Original
322
+ capitalizeWords(title), // Each word capitalized
323
+ title.toLowerCase() // All lowercase
324
+ ];
325
+ let uid = null;
326
+ for (const variation of variations) {
327
+ const searchQuery = `[:find ?uid .
328
+ :where [?e :node/title "${variation}"]
329
+ [?e :block/uid ?uid]]`;
330
+ const result = await q(this.graph, searchQuery, []);
331
+ uid = (result === null || result === undefined) ? null : String(result);
332
+ if (uid)
333
+ break;
334
+ }
335
+ if (!uid) {
336
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
337
+ }
338
+ // Define ancestor rule for traversing block hierarchy
339
+ const ancestorRule = `[
340
+ [ (ancestor ?b ?a)
341
+ [?a :block/children ?b] ]
342
+ [ (ancestor ?b ?a)
343
+ [?parent :block/children ?b]
344
+ (ancestor ?parent ?a) ]
345
+ ]`;
346
+ // Get all blocks under this page using ancestor rule
347
+ const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
348
+ :in $ % ?page-title
349
+ :where [?page :node/title ?page-title]
350
+ [?block :block/string ?block-str]
351
+ [?block :block/uid ?block-uid]
352
+ [?block :block/order ?order]
353
+ (ancestor ?block ?page)
354
+ [?parent :block/children ?block]
355
+ [?parent :block/uid ?parent-uid]]`;
356
+ const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]);
357
+ if (!blocks || blocks.length === 0) {
358
+ return `${title} (no content found)`;
359
+ }
360
+ // Create a map of all blocks
361
+ const blockMap = new Map();
362
+ const rootBlocks = [];
363
+ // First pass: Create all block objects
364
+ for (const [blockUid, blockStr, order, parentUid] of blocks) {
365
+ const resolvedString = await resolveRefs(this.graph, blockStr);
366
+ const block = {
367
+ uid: blockUid,
368
+ string: resolvedString,
369
+ order: order,
370
+ children: []
371
+ };
372
+ blockMap.set(blockUid, block);
373
+ // If no parent or parent is the page itself, it's a root block
374
+ if (!parentUid || parentUid === uid) {
375
+ rootBlocks.push(block);
376
+ }
377
+ }
378
+ // Second pass: Build parent-child relationships
379
+ for (const [blockUid, _, __, parentUid] of blocks) {
380
+ if (parentUid && parentUid !== uid) {
381
+ const child = blockMap.get(blockUid);
382
+ const parent = blockMap.get(parentUid);
383
+ if (child && parent && !parent.children.includes(child)) {
384
+ parent.children.push(child);
385
+ }
386
+ }
387
+ }
388
+ // Sort blocks recursively
389
+ const sortBlocks = (blocks) => {
390
+ blocks.sort((a, b) => a.order - b.order);
391
+ blocks.forEach(block => {
392
+ if (block.children.length > 0) {
393
+ sortBlocks(block.children);
394
+ }
395
+ });
396
+ };
397
+ sortBlocks(rootBlocks);
398
+ // Convert to markdown with proper nesting
399
+ const toMarkdown = (blocks, level = 0) => {
400
+ return blocks.map(block => {
401
+ const indent = ' '.repeat(level);
402
+ let md = `${indent}- ${block.string}`;
403
+ if (block.children.length > 0) {
404
+ md += '\n' + toMarkdown(block.children, level + 1);
405
+ }
406
+ return md;
407
+ }).join('\n');
408
+ };
409
+ return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
410
+ }
411
+ async createPage(title, content) {
412
+ // Ensure title is properly formatted
413
+ const pageTitle = String(title).trim();
414
+ // First try to find if the page exists
415
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
416
+ const findResults = await q(this.graph, findQuery, [pageTitle]);
417
+ let pageUid;
418
+ if (findResults && findResults.length > 0) {
419
+ // Page exists, use its UID
420
+ pageUid = findResults[0][0];
421
+ }
422
+ else {
423
+ // Create new page
424
+ const success = await createPage(this.graph, {
425
+ action: 'create-page',
426
+ page: {
427
+ title: pageTitle
428
+ }
429
+ });
430
+ if (!success) {
431
+ throw new Error('Failed to create page');
432
+ }
433
+ // Get the new page's UID
434
+ const results = await q(this.graph, findQuery, [pageTitle]);
435
+ if (!results || results.length === 0) {
436
+ throw new Error('Could not find created page');
437
+ }
438
+ pageUid = results[0][0];
439
+ }
440
+ // If content is provided, check if it looks like nested markdown
441
+ if (content) {
442
+ const isMultilined = content.includes('\n') || hasMarkdownTable(content);
443
+ if (isMultilined) {
444
+ // Use import_nested_markdown functionality
445
+ const convertedContent = convertToRoamMarkdown(content);
446
+ const nodes = parseMarkdown(convertedContent);
447
+ const actions = convertToRoamActions(nodes, pageUid, 'last');
448
+ const result = await batchActions(this.graph, {
449
+ action: 'batch-actions',
450
+ actions
451
+ });
452
+ if (!result) {
453
+ throw new Error('Failed to import nested markdown content');
454
+ }
455
+ }
456
+ else {
457
+ // Create a simple block for non-nested content
458
+ const blockSuccess = await createBlock(this.graph, {
459
+ action: 'create-block',
460
+ location: {
461
+ "parent-uid": pageUid,
462
+ "order": "last"
463
+ },
464
+ block: { string: content }
465
+ });
466
+ if (!blockSuccess) {
467
+ throw new Error('Failed to create content block');
468
+ }
469
+ }
470
+ }
471
+ return { success: true, uid: pageUid };
472
+ }
473
+ async createBlock(content, page_uid, title) {
474
+ // If page_uid provided, use it directly
475
+ let targetPageUid = page_uid;
476
+ // If no page_uid but title provided, search for page by title
477
+ if (!targetPageUid && title) {
478
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
479
+ const findResults = await q(this.graph, findQuery, [title]);
480
+ if (findResults && findResults.length > 0) {
481
+ targetPageUid = findResults[0][0];
482
+ }
483
+ else {
484
+ // Create page with provided title if it doesn't exist
485
+ const success = await createPage(this.graph, {
486
+ action: 'create-page',
487
+ page: { title }
488
+ });
489
+ if (!success) {
490
+ throw new Error('Failed to create page with provided title');
491
+ }
492
+ // Get the new page's UID
493
+ const results = await q(this.graph, findQuery, [title]);
494
+ if (!results || results.length === 0) {
495
+ throw new Error('Could not find created page');
496
+ }
497
+ targetPageUid = results[0][0];
498
+ }
499
+ }
500
+ // If neither page_uid nor title provided, use today's date page
501
+ if (!targetPageUid) {
502
+ const today = new Date();
503
+ const dateStr = formatRoamDate(today);
504
+ // Try to find today's page
505
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
506
+ const findResults = await q(this.graph, findQuery, [dateStr]);
507
+ if (findResults && findResults.length > 0) {
508
+ targetPageUid = findResults[0][0];
509
+ }
510
+ else {
511
+ // Create today's page if it doesn't exist
512
+ const success = await createPage(this.graph, {
513
+ action: 'create-page',
514
+ page: { title: dateStr }
515
+ });
516
+ if (!success) {
517
+ throw new Error('Failed to create today\'s page');
518
+ }
519
+ // Get the new page's UID
520
+ const results = await q(this.graph, findQuery, [dateStr]);
521
+ if (!results || results.length === 0) {
522
+ throw new Error('Could not find created today\'s page');
523
+ }
524
+ targetPageUid = results[0][0];
525
+ }
526
+ }
527
+ // If the content has multiple lines or is a table, use nested import
528
+ if (content.includes('\n')) {
529
+ // Parse and import the nested content
530
+ const convertedContent = convertToRoamMarkdown(content);
531
+ const nodes = parseMarkdown(convertedContent);
532
+ const actions = convertToRoamActions(nodes, targetPageUid, 'last');
533
+ // Execute batch actions to create the nested structure
534
+ const result = await batchActions(this.graph, {
535
+ action: 'batch-actions',
536
+ actions
537
+ });
538
+ if (!result) {
539
+ throw new Error('Failed to create nested blocks');
540
+ }
541
+ const blockUid = result.created_uids?.[0];
542
+ return {
543
+ success: true,
544
+ block_uid: blockUid,
545
+ parent_uid: targetPageUid
546
+ };
547
+ }
548
+ else {
549
+ // For non-table content, create a simple block
550
+ const result = await createBlock(this.graph, {
551
+ action: 'create-block',
552
+ location: {
553
+ "parent-uid": targetPageUid,
554
+ "order": "last"
555
+ },
556
+ block: { string: content }
557
+ });
558
+ if (!result) {
559
+ throw new Error('Failed to create block');
560
+ }
561
+ // Get the block's UID
562
+ const findBlockQuery = `[:find ?uid
563
+ :in $ ?parent ?string
564
+ :where [?b :block/uid ?uid]
565
+ [?b :block/string ?string]
566
+ [?b :block/parents ?p]
567
+ [?p :block/uid ?parent]]`;
568
+ const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
569
+ if (!blockResults || blockResults.length === 0) {
570
+ throw new Error('Could not find created block');
571
+ }
572
+ const blockUid = blockResults[0][0];
573
+ return {
574
+ success: true,
575
+ block_uid: blockUid,
576
+ parent_uid: targetPageUid
577
+ };
578
+ }
579
+ }
580
+ async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
581
+ // First get the page UID
582
+ let targetPageUid = page_uid;
583
+ if (!targetPageUid && page_title) {
584
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
585
+ const findResults = await q(this.graph, findQuery, [page_title]);
586
+ if (findResults && findResults.length > 0) {
587
+ targetPageUid = findResults[0][0];
588
+ }
589
+ else {
590
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
591
+ }
592
+ }
593
+ // If no page specified, use today's date page
594
+ if (!targetPageUid) {
595
+ const today = new Date();
596
+ const dateStr = formatRoamDate(today);
597
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
598
+ const findResults = await q(this.graph, findQuery, [dateStr]);
599
+ if (findResults && findResults.length > 0) {
600
+ targetPageUid = findResults[0][0];
601
+ }
602
+ else {
603
+ // Create today's page
604
+ const success = await createPage(this.graph, {
605
+ action: 'create-page',
606
+ page: { title: dateStr }
607
+ });
608
+ if (!success) {
609
+ throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
610
+ }
611
+ const results = await q(this.graph, findQuery, [dateStr]);
612
+ if (!results || results.length === 0) {
613
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
614
+ }
615
+ targetPageUid = results[0][0];
616
+ }
617
+ }
618
+ // Now get the parent block UID
619
+ let targetParentUid = parent_uid;
620
+ if (!targetParentUid && parent_string) {
621
+ if (!targetPageUid) {
622
+ throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
623
+ }
624
+ // Find block by exact string match within the page
625
+ const findBlockQuery = `[:find ?uid
626
+ :where [?p :block/uid "${targetPageUid}"]
627
+ [?b :block/page ?p]
628
+ [?b :block/string "${parent_string}"]]`;
629
+ const blockResults = await q(this.graph, findBlockQuery, []);
630
+ if (!blockResults || blockResults.length === 0) {
631
+ throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
632
+ }
633
+ targetParentUid = blockResults[0][0];
634
+ }
635
+ // If no parent specified, use page as parent
636
+ if (!targetParentUid) {
637
+ targetParentUid = targetPageUid;
638
+ }
639
+ // Always use parseMarkdown for content with multiple lines or any markdown formatting
640
+ const isMultilined = content.includes('\n');
641
+ if (isMultilined) {
642
+ // Parse markdown into hierarchical structure
643
+ const convertedContent = convertToRoamMarkdown(content);
644
+ const nodes = parseMarkdown(convertedContent);
645
+ // Convert markdown nodes to batch actions
646
+ const actions = convertToRoamActions(nodes, targetParentUid, order);
647
+ // Execute batch actions to add content
648
+ const result = await batchActions(this.graph, {
649
+ action: 'batch-actions',
650
+ actions
651
+ });
652
+ if (!result) {
653
+ throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
654
+ }
655
+ // Get the created block UIDs
656
+ const createdUids = result.created_uids || [];
657
+ return {
658
+ success: true,
659
+ page_uid: targetPageUid,
660
+ parent_uid: targetParentUid,
661
+ created_uids: createdUids
662
+ };
663
+ }
664
+ else {
665
+ // Create a simple block for non-nested content
666
+ const blockSuccess = await createBlock(this.graph, {
667
+ action: 'create-block',
668
+ location: {
669
+ "parent-uid": targetParentUid,
670
+ order
671
+ },
672
+ block: { string: content }
673
+ });
674
+ if (!blockSuccess) {
675
+ throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
676
+ }
677
+ return {
678
+ success: true,
679
+ page_uid: targetPageUid,
680
+ parent_uid: targetParentUid
681
+ };
682
+ }
683
+ }
684
+ async updateBlock(block_uid, content, transform) {
685
+ if (!block_uid) {
686
+ throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
687
+ }
688
+ // Get current block content
689
+ const blockQuery = `[:find ?string .
690
+ :where [?b :block/uid "${block_uid}"]
691
+ [?b :block/string ?string]]`;
692
+ const result = await q(this.graph, blockQuery, []);
693
+ if (result === null || result === undefined) {
694
+ throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
695
+ }
696
+ const currentContent = String(result);
697
+ if (currentContent === null || currentContent === undefined) {
698
+ throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
699
+ }
700
+ // Determine new content
701
+ let newContent;
702
+ if (content) {
703
+ newContent = content;
704
+ }
705
+ else if (transform) {
706
+ newContent = transform(currentContent);
707
+ }
708
+ else {
709
+ throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
710
+ }
711
+ try {
712
+ const success = await updateBlock(this.graph, {
713
+ action: 'update-block',
714
+ block: {
715
+ uid: block_uid,
716
+ string: newContent
717
+ }
718
+ });
719
+ if (!success) {
720
+ throw new Error('Failed to update block');
721
+ }
722
+ return {
723
+ success: true,
724
+ content: newContent
725
+ };
726
+ }
727
+ catch (error) {
728
+ throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
729
+ }
730
+ }
731
+ async searchByStatus(status, page_title_uid, include, exclude) {
732
+ // Get target page UID if provided
733
+ let targetPageUid;
734
+ if (page_title_uid) {
735
+ // Try to find page by title or UID
736
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
737
+ const findResults = await q(this.graph, findQuery, [page_title_uid]);
738
+ if (findResults && findResults.length > 0) {
739
+ targetPageUid = findResults[0][0];
740
+ }
741
+ else {
742
+ // Try as UID
743
+ const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
744
+ const uidResults = await q(this.graph, uidQuery, []);
745
+ if (!uidResults || uidResults.length === 0) {
746
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
747
+ }
748
+ targetPageUid = uidResults[0][0];
749
+ }
750
+ }
751
+ // Build query based on whether we're searching in a specific page
752
+ let queryStr;
753
+ let queryParams;
754
+ const statusPattern = `{{[[${status}]]}}`;
755
+ if (targetPageUid) {
756
+ queryStr = `[:find ?block-uid ?block-str
757
+ :in $ ?status-pattern ?page-uid
758
+ :where [?p :block/uid ?page-uid]
759
+ [?b :block/page ?p]
760
+ [?b :block/string ?block-str]
761
+ [?b :block/uid ?block-uid]
762
+ [(clojure.string/includes? ?block-str ?status-pattern)]]`;
763
+ queryParams = [statusPattern, targetPageUid];
764
+ }
765
+ else {
766
+ queryStr = `[:find ?block-uid ?block-str ?page-title
767
+ :in $ ?status-pattern
768
+ :where [?b :block/string ?block-str]
769
+ [?b :block/uid ?block-uid]
770
+ [?b :block/page ?p]
771
+ [?p :node/title ?page-title]
772
+ [(clojure.string/includes? ?block-str ?status-pattern)]]`;
773
+ queryParams = [statusPattern];
774
+ }
775
+ const results = await q(this.graph, queryStr, queryParams);
776
+ if (!results || results.length === 0) {
777
+ return {
778
+ success: true,
779
+ matches: [],
780
+ message: `No blocks found with status ${status}`
781
+ };
782
+ }
783
+ // Format initial results
784
+ let matches = results.map(result => {
785
+ const [uid, content, pageTitle] = result;
786
+ return {
787
+ block_uid: uid,
788
+ content,
789
+ ...(pageTitle && { page_title: pageTitle })
790
+ };
791
+ });
792
+ // Post-query filtering
793
+ if (include) {
794
+ const includeTerms = include.toLowerCase().split(',').map(term => term.trim());
795
+ matches = matches.filter(match => includeTerms.some(term => match.content.toLowerCase().includes(term) ||
796
+ (match.page_title && match.page_title.toLowerCase().includes(term))));
797
+ }
798
+ if (exclude) {
799
+ const excludeTerms = exclude.toLowerCase().split(',').map(term => term.trim());
800
+ matches = matches.filter(match => !excludeTerms.some(term => match.content.toLowerCase().includes(term) ||
801
+ (match.page_title && match.page_title.toLowerCase().includes(term))));
802
+ }
803
+ return {
804
+ success: true,
805
+ matches,
806
+ message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
807
+ };
808
+ }
809
+ async searchForTag(primary_tag, page_title_uid, near_tag) {
810
+ // Ensure tags are properly formatted with #
811
+ const formatTag = (tag) => tag.startsWith('#') ? tag : `#${tag}`;
812
+ const primaryTagFormatted = formatTag(primary_tag);
813
+ const nearTagFormatted = near_tag ? formatTag(near_tag) : undefined;
814
+ // Get target page UID if provided
815
+ let targetPageUid;
816
+ if (page_title_uid) {
817
+ // Try to find page by title or UID
818
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
819
+ const findResults = await q(this.graph, findQuery, [page_title_uid]);
820
+ if (findResults && findResults.length > 0) {
821
+ targetPageUid = findResults[0][0];
822
+ }
823
+ else {
824
+ // Try as UID
825
+ const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
826
+ const uidResults = await q(this.graph, uidQuery, []);
827
+ if (!uidResults || uidResults.length === 0) {
828
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
829
+ }
830
+ targetPageUid = uidResults[0][0];
831
+ }
832
+ }
833
+ // Build query based on whether we're searching in a specific page and/or for a nearby tag
834
+ let queryStr;
835
+ let queryParams;
836
+ if (targetPageUid) {
837
+ if (nearTagFormatted) {
838
+ queryStr = `[:find ?block-uid ?block-str
839
+ :in $ ?primary-tag ?near-tag ?page-uid
840
+ :where [?p :block/uid ?page-uid]
841
+ [?b :block/page ?p]
842
+ [?b :block/string ?block-str]
843
+ [?b :block/uid ?block-uid]
844
+ [(clojure.string/includes? ?block-str ?primary-tag)]
845
+ [(clojure.string/includes? ?block-str ?near-tag)]]`;
846
+ queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
847
+ }
848
+ else {
849
+ queryStr = `[:find ?block-uid ?block-str
850
+ :in $ ?primary-tag ?page-uid
851
+ :where [?p :block/uid ?page-uid]
852
+ [?b :block/page ?p]
853
+ [?b :block/string ?block-str]
854
+ [?b :block/uid ?block-uid]
855
+ [(clojure.string/includes? ?block-str ?primary-tag)]]`;
856
+ queryParams = [primaryTagFormatted, targetPageUid];
857
+ }
858
+ }
859
+ else {
860
+ // Search across all pages
861
+ if (nearTagFormatted) {
862
+ queryStr = `[:find ?block-uid ?block-str ?page-title
863
+ :in $ ?primary-tag ?near-tag
864
+ :where [?b :block/string ?block-str]
865
+ [?b :block/uid ?block-uid]
866
+ [?b :block/page ?p]
867
+ [?p :node/title ?page-title]
868
+ [(clojure.string/includes? ?block-str ?primary-tag)]
869
+ [(clojure.string/includes? ?block-str ?near-tag)]]`;
870
+ queryParams = [primaryTagFormatted, nearTagFormatted];
871
+ }
872
+ else {
873
+ queryStr = `[:find ?block-uid ?block-str ?page-title
874
+ :in $ ?primary-tag
875
+ :where [?b :block/string ?block-str]
876
+ [?b :block/uid ?block-uid]
877
+ [?b :block/page ?p]
878
+ [?p :node/title ?page-title]
879
+ [(clojure.string/includes? ?block-str ?primary-tag)]]`;
880
+ queryParams = [primaryTagFormatted];
881
+ }
882
+ }
883
+ const results = await q(this.graph, queryStr, queryParams);
884
+ if (!results || results.length === 0) {
885
+ return {
886
+ success: true,
887
+ matches: [],
888
+ message: `No blocks found containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
889
+ };
890
+ }
891
+ // Format results
892
+ const matches = results.map(([uid, content, pageTitle]) => ({
893
+ block_uid: uid,
894
+ content,
895
+ ...(pageTitle && { page_title: pageTitle })
896
+ }));
897
+ return {
898
+ success: true,
899
+ matches,
900
+ message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
901
+ };
902
+ }
903
+ async addTodos(todos) {
904
+ if (!Array.isArray(todos) || todos.length === 0) {
905
+ throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
906
+ }
907
+ // Get today's date
908
+ const today = new Date();
909
+ const dateStr = formatRoamDate(today);
910
+ // Try to find today's page
911
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
912
+ const findResults = await q(this.graph, findQuery, [dateStr]);
913
+ let targetPageUid;
914
+ if (findResults && findResults.length > 0) {
915
+ targetPageUid = findResults[0][0];
916
+ }
917
+ else {
918
+ // Create today's page if it doesn't exist
919
+ const success = await createPage(this.graph, {
920
+ action: 'create-page',
921
+ page: { title: dateStr }
922
+ });
923
+ if (!success) {
924
+ throw new Error('Failed to create today\'s page');
925
+ }
926
+ // Get the new page's UID
927
+ const results = await q(this.graph, findQuery, [dateStr]);
928
+ if (!results || results.length === 0) {
929
+ throw new Error('Could not find created today\'s page');
930
+ }
931
+ targetPageUid = results[0][0];
932
+ }
933
+ // If more than 10 todos, use batch actions
934
+ const todo_tag = "{{TODO}}";
935
+ if (todos.length > 10) {
936
+ const actions = todos.map((todo, index) => ({
937
+ action: 'create-block',
938
+ location: {
939
+ 'parent-uid': targetPageUid,
940
+ order: index
941
+ },
942
+ block: {
943
+ string: `${todo_tag} ${todo}`
944
+ }
945
+ }));
946
+ const result = await batchActions(this.graph, {
947
+ action: 'batch-actions',
948
+ actions
949
+ });
950
+ if (!result) {
951
+ throw new Error('Failed to create todo blocks');
952
+ }
953
+ }
954
+ else {
955
+ // Create todos sequentially
956
+ for (const todo of todos) {
957
+ const success = await createBlock(this.graph, {
958
+ action: 'create-block',
959
+ location: {
960
+ "parent-uid": targetPageUid,
961
+ "order": "last"
962
+ },
963
+ block: { string: `${todo_tag} ${todo}` }
964
+ });
965
+ if (!success) {
966
+ throw new Error('Failed to create todo block');
967
+ }
968
+ }
969
+ }
970
+ return { success: true };
971
+ }
972
+ }