roam-research-mcp 0.14.0 → 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.
package/README.md CHANGED
@@ -27,7 +27,7 @@ npm run build
27
27
 
28
28
  ## Features
29
29
 
30
- The server provides eight powerful tools for interacting with Roam Research:
30
+ The server provides eleven powerful tools for interacting with Roam Research:
31
31
 
32
32
  1. `roam_fetch_page_by_title`: Fetch and read a page's content by title, recursively resolving block references up to 4 levels deep
33
33
  2. `roam_create_page`: Create new pages with optional content
@@ -37,6 +37,9 @@ The server provides eight powerful tools for interacting with Roam Research:
37
37
  6. `roam_create_outline`: Create hierarchical outlines with proper nesting and structure
38
38
  7. `roam_search_block_refs`: Search for block references within pages or across the graph
39
39
  8. `roam_search_hierarchy`: Navigate and search through block parent-child relationships
40
+ 9. `find_pages_modified_today`: Find all pages that have been modified since midnight today
41
+ 10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
42
+ 11. `roam_update_block`: Update block content with direct text or pattern-based transformations
40
43
 
41
44
  ## Setup
42
45
 
@@ -322,6 +325,126 @@ Returns:
322
325
  }
323
326
  ```
324
327
 
328
+ ### Search By Text
329
+
330
+ Search for blocks containing specific text across all pages or within a specific page:
331
+
332
+ ```typescript
333
+ use_mcp_tool roam-research roam_search_by_text {
334
+ "text": "search text",
335
+ "page_title_uid": "optional-page-title-or-uid",
336
+ "case_sensitive": false
337
+ }
338
+ ```
339
+
340
+ Features:
341
+
342
+ - Search for any text across all blocks in the graph
343
+ - Optional page-scoped search
344
+ - Case-sensitive or case-insensitive search
345
+ - Returns block content with page context
346
+ - Efficient text matching using Datalog queries
347
+
348
+ Parameters:
349
+
350
+ - `text`: The text to search for (required)
351
+ - `page_title_uid`: Title or UID of the page to search in (optional)
352
+ - `case_sensitive`: Whether to perform a case-sensitive search (optional, default: false)
353
+
354
+ Returns:
355
+
356
+ ```json
357
+ {
358
+ "success": true,
359
+ "matches": [
360
+ {
361
+ "block_uid": "matching-block-uid",
362
+ "content": "Block content containing search text",
363
+ "page_title": "Page containing block"
364
+ }
365
+ ],
366
+ "message": "Found N block(s) containing \"search text\""
367
+ }
368
+ ```
369
+
370
+ ### Update Block Content
371
+
372
+ Update a block's content using either direct text replacement or pattern-based transformations:
373
+
374
+ ```typescript
375
+ use_mcp_tool roam-research roam_update_block {
376
+ "block_uid": "target-block-uid",
377
+ "content": "New block content"
378
+ }
379
+ ```
380
+
381
+ Or use pattern-based transformation:
382
+
383
+ ```typescript
384
+ use_mcp_tool roam-research roam_update_block {
385
+ "block_uid": "target-block-uid",
386
+ "transform_pattern": {
387
+ "find": "\\bPython\\b",
388
+ "replace": "[[Python]]",
389
+ "global": true
390
+ }
391
+ }
392
+ ```
393
+
394
+ Features:
395
+
396
+ - Two update modes:
397
+ - Direct content replacement
398
+ - Pattern-based transformation using regex
399
+ - Verify block existence before updating
400
+ - Return updated content in response
401
+ - Support for global or single-match replacements
402
+ - Preserve block relationships and metadata
403
+
404
+ Parameters:
405
+
406
+ - `block_uid`: UID of the block to update (required)
407
+ - `content`: New content for the block (if using direct replacement)
408
+ - `transform_pattern`: Pattern for transforming existing content:
409
+ - `find`: Text or regex pattern to find
410
+ - `replace`: Text to replace with
411
+ - `global`: Whether to replace all occurrences (default: true)
412
+
413
+ Returns:
414
+
415
+ ```json
416
+ {
417
+ "success": true,
418
+ "content": "Updated block content"
419
+ }
420
+ ```
421
+
422
+ ### Find Pages Modified Today
423
+
424
+ Find all pages that have been modified since midnight today:
425
+
426
+ ```typescript
427
+ use_mcp_tool roam-research find_pages_modified_today {}
428
+ ```
429
+
430
+ Features:
431
+
432
+ - Tracks all modifications made to pages since midnight
433
+ - Detects changes at any level in the block hierarchy
434
+ - Returns unique list of modified page titles
435
+ - Includes count of modified pages
436
+ - No parameters required
437
+
438
+ Returns:
439
+
440
+ ```json
441
+ {
442
+ "success": true,
443
+ "pages": ["Page 1", "Page 2"],
444
+ "message": "Found 2 page(s) modified today"
445
+ }
446
+ ```
447
+
325
448
  ### Search Block Hierarchy
326
449
 
327
450
  Navigate and search through block parent-child relationships:
@@ -41,25 +41,134 @@ function convertAllTables(text) {
41
41
  return '\n' + convertTableToRoamFormat(match) + '\n';
42
42
  });
43
43
  }
44
+ /**
45
+ * Parse markdown heading syntax (e.g. "### Heading") and return the heading level (1-3) and content.
46
+ * Heading level is determined by the number of # characters (e.g. # = h1, ## = h2, ### = h3).
47
+ * Returns heading_level: 0 for non-heading content.
48
+ */
49
+ function parseMarkdownHeadingLevel(text) {
50
+ const match = text.match(/^(#{1,3})\s+(.+)$/);
51
+ if (match) {
52
+ return {
53
+ heading_level: match[1].length, // Number of # characters determines heading level
54
+ content: match[2].trim()
55
+ };
56
+ }
57
+ return {
58
+ heading_level: 0, // Not a heading
59
+ content: text.trim()
60
+ };
61
+ }
44
62
  function convertToRoamMarkdown(text) {
45
- // First handle double asterisks/underscores (bold)
63
+ // Handle double asterisks/underscores (bold)
46
64
  text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
47
- // Then handle single asterisks/underscores (italic)
65
+ // Handle single asterisks/underscores (italic)
48
66
  text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore
49
67
  text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore
50
68
  // Handle highlights
51
69
  text = text.replace(/==(.+?)==/g, '^^$1^^');
70
+ // Convert tasks
71
+ text = text.replace(/- \[ \]/g, '- {{[[TODO]]}}');
72
+ text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}');
52
73
  // Convert tables
53
74
  text = convertAllTables(text);
54
75
  return text;
55
76
  }
56
77
  function parseMarkdown(markdown) {
78
+ // Convert markdown syntax first
79
+ markdown = convertToRoamMarkdown(markdown);
57
80
  const lines = markdown.split('\n');
58
81
  const rootNodes = [];
59
82
  const stack = [];
83
+ let inCodeBlock = false;
84
+ let codeBlockContent = '';
85
+ let codeBlockIndentation = 0;
86
+ let codeBlockParentLevel = 0;
60
87
  for (let i = 0; i < lines.length; i++) {
61
88
  const line = lines[i];
62
89
  const trimmedLine = line.trimEnd();
90
+ // Handle code blocks
91
+ if (trimmedLine.match(/^(\s*)```/)) {
92
+ if (!inCodeBlock) {
93
+ // Start of code block
94
+ inCodeBlock = true;
95
+ // Store the opening backticks without indentation
96
+ codeBlockContent = trimmedLine.trimStart() + '\n';
97
+ codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0;
98
+ // Save current parent level
99
+ codeBlockParentLevel = stack.length;
100
+ }
101
+ else {
102
+ // End of code block
103
+ inCodeBlock = false;
104
+ // Add closing backticks without indentation
105
+ codeBlockContent += trimmedLine.trimStart();
106
+ // Process the code block content to fix indentation
107
+ const lines = codeBlockContent.split('\n');
108
+ // Find the first non-empty code line to determine base indentation
109
+ let baseIndentation = '';
110
+ let codeStartIndex = -1;
111
+ for (let i = 1; i < lines.length - 1; i++) {
112
+ const line = lines[i];
113
+ if (line.trim().length > 0) {
114
+ const indentMatch = line.match(/^[\t ]*/);
115
+ if (indentMatch) {
116
+ baseIndentation = indentMatch[0];
117
+ codeStartIndex = i;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ // Process lines maintaining relative indentation from the first code line
123
+ const processedLines = lines.map((line, index) => {
124
+ // Keep backticks as is
125
+ if (index === 0 || index === lines.length - 1)
126
+ return line.trimStart();
127
+ // Empty lines should be completely trimmed
128
+ if (line.trim().length === 0)
129
+ return '';
130
+ // For code lines, remove only the base indentation
131
+ if (line.startsWith(baseIndentation)) {
132
+ return line.slice(baseIndentation.length);
133
+ }
134
+ // If line has less indentation than base, trim all leading whitespace
135
+ return line.trimStart();
136
+ });
137
+ // Create node for the entire code block
138
+ const level = Math.floor(codeBlockIndentation / 2);
139
+ const node = {
140
+ content: processedLines.join('\n'),
141
+ level,
142
+ children: []
143
+ };
144
+ // Restore to code block's parent level
145
+ while (stack.length > codeBlockParentLevel) {
146
+ stack.pop();
147
+ }
148
+ if (level === 0) {
149
+ rootNodes.push(node);
150
+ stack[0] = node;
151
+ }
152
+ else {
153
+ while (stack.length > level) {
154
+ stack.pop();
155
+ }
156
+ if (stack[level - 1]) {
157
+ stack[level - 1].children.push(node);
158
+ }
159
+ else {
160
+ rootNodes.push(node);
161
+ }
162
+ stack[level] = node;
163
+ }
164
+ codeBlockContent = '';
165
+ }
166
+ continue;
167
+ }
168
+ if (inCodeBlock) {
169
+ codeBlockContent += line + '\n';
170
+ continue;
171
+ }
63
172
  // Skip truly empty lines (no spaces)
64
173
  if (trimmedLine === '') {
65
174
  continue;
@@ -67,52 +176,54 @@ function parseMarkdown(markdown) {
67
176
  // Calculate indentation level (2 spaces = 1 level)
68
177
  const indentation = line.match(/^\s*/)?.[0].length ?? 0;
69
178
  let level = Math.floor(indentation / 2);
70
- // Extract content after bullet point or heading
71
- let content = trimmedLine;
72
- content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
73
- if (trimmedLine.startsWith('#') || trimmedLine.includes('{{table}}') || (trimmedLine.startsWith('**') && trimmedLine.endsWith('**'))) {
74
- // Remove bullet point if it precedes a table marker
75
- // content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
76
- level = 0;
77
- // Reset stack but keep heading/table as parent
78
- stack.length = 1; // Keep only the heading/table
179
+ // First check for headings
180
+ const { heading_level, content: headingContent } = parseMarkdownHeadingLevel(trimmedLine);
181
+ // Then handle bullet points if not a heading
182
+ let content;
183
+ if (heading_level > 0) {
184
+ content = headingContent; // Use clean heading content without # marks
185
+ level = 0; // Headings start at root level
186
+ stack.length = 1; // Reset stack but keep heading as parent
187
+ // Create heading node
188
+ const node = {
189
+ content,
190
+ level,
191
+ heading_level, // Store heading level in node
192
+ children: []
193
+ };
194
+ rootNodes.push(node);
195
+ stack[0] = node;
196
+ continue; // Skip to next line
79
197
  }
80
- else if (stack[0]?.content.startsWith('#') || stack[0]?.content.includes('{{table}}') || (stack[0]?.content.startsWith('**') && stack[0]?.content.endsWith('**'))) {
81
- // If previous node was a heading or table marker or wrapped in double-asterisks, increase level by 1
82
- level = Math.max(level, 1);
83
- // Remove bullet point
84
- // content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
198
+ // Handle non-heading content
199
+ const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
200
+ if (bulletMatch) {
201
+ // For bullet points, use the bullet's indentation for level
202
+ content = trimmedLine.substring(bulletMatch[0].length);
203
+ level = Math.floor(bulletMatch[1].length / 2);
85
204
  }
86
205
  else {
87
- // Remove bullet point
88
- content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
206
+ content = trimmedLine;
89
207
  }
90
- // Create new node
208
+ // Create regular node
91
209
  const node = {
92
210
  content,
93
211
  level,
94
212
  children: []
95
213
  };
96
- // Find the appropriate parent for this node based on level
97
- if (level === 0) {
214
+ // Pop stack until we find the parent level
215
+ while (stack.length > level) {
216
+ stack.pop();
217
+ }
218
+ // Add to appropriate parent
219
+ if (level === 0 || !stack[level - 1]) {
98
220
  rootNodes.push(node);
99
221
  stack[0] = node;
100
222
  }
101
223
  else {
102
- // Pop stack until we find the parent level
103
- while (stack.length > level) {
104
- stack.pop();
105
- }
106
- // Add as child to parent
107
- if (stack[level - 1]) {
108
- stack[level - 1].children.push(node);
109
- }
110
- else {
111
- // If no parent found, treat as root node
112
- rootNodes.push(node);
113
- }
114
- stack[level] = node;
224
+ stack[level - 1].children.push(node);
115
225
  }
226
+ stack[level] = node;
116
227
  }
117
228
  return rootNodes;
118
229
  }
@@ -168,12 +279,13 @@ function convertNodesToBlocks(nodes) {
168
279
  return nodes.map(node => ({
169
280
  uid: generateBlockUid(),
170
281
  content: node.content,
282
+ ...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present
171
283
  children: convertNodesToBlocks(node.children)
172
284
  }));
173
285
  }
174
286
  function convertToRoamActions(nodes, parentUid, order = 'last') {
175
- // First convert nodes to blocks with UIDs
176
- const blocks = convertNodesToBlocks(nodes);
287
+ // First convert nodes to blocks with UIDs, reversing to maintain original order
288
+ const blocks = convertNodesToBlocks([...nodes].reverse());
177
289
  const actions = [];
178
290
  // Helper function to recursively create actions
179
291
  function createBlockActions(blocks, parentUid, order) {
@@ -187,7 +299,8 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
187
299
  },
188
300
  block: {
189
301
  uid: block.uid,
190
- string: block.content
302
+ string: block.content,
303
+ ...(block.heading_level && { heading: block.heading_level })
191
304
  }
192
305
  };
193
306
  actions.push(action);
@@ -202,4 +315,4 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
202
315
  return actions;
203
316
  }
204
317
  // Export public functions and types
205
- export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown };
318
+ export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
@@ -21,64 +21,68 @@ export class HierarchySearchHandler extends BaseSearchHandler {
21
21
  if (page_title_uid) {
22
22
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
23
23
  }
24
+ // Define ancestor rule for recursive traversal
25
+ const ancestorRule = `[
26
+ [ (ancestor ?child ?parent)
27
+ [?parent :block/children ?child] ]
28
+ [ (ancestor ?child ?a)
29
+ [?parent :block/children ?child]
30
+ (ancestor ?parent ?a) ]
31
+ ]`;
24
32
  let queryStr;
25
33
  let queryParams;
26
34
  if (parent_uid) {
27
- // Search for children of a specific block
35
+ // Search for all descendants using ancestor rule
28
36
  if (targetPageUid) {
29
37
  queryStr = `[:find ?block-uid ?block-str ?depth
30
- :in $ ?parent-uid ?page-uid ?max-depth
38
+ :in $ % ?parent-uid ?page-uid
31
39
  :where [?p :block/uid ?page-uid]
32
40
  [?parent :block/uid ?parent-uid]
33
- [?b :block/parents ?parent]
41
+ (ancestor ?b ?parent)
34
42
  [?b :block/string ?block-str]
35
43
  [?b :block/uid ?block-uid]
36
44
  [?b :block/page ?p]
37
- [(get-else $ ?b :block/path-length 1) ?depth]
38
- [(<= ?depth ?max-depth)]]`;
39
- queryParams = [parent_uid, targetPageUid, max_depth];
45
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
46
+ queryParams = [ancestorRule, parent_uid, targetPageUid];
40
47
  }
41
48
  else {
42
49
  queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
43
- :in $ ?parent-uid ?max-depth
50
+ :in $ % ?parent-uid
44
51
  :where [?parent :block/uid ?parent-uid]
45
- [?b :block/parents ?parent]
52
+ (ancestor ?b ?parent)
46
53
  [?b :block/string ?block-str]
47
54
  [?b :block/uid ?block-uid]
48
55
  [?b :block/page ?p]
49
56
  [?p :node/title ?page-title]
50
- [(get-else $ ?b :block/path-length 1) ?depth]
51
- [(<= ?depth ?max-depth)]]`;
52
- queryParams = [parent_uid, max_depth];
57
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
58
+ queryParams = [ancestorRule, parent_uid];
53
59
  }
54
60
  }
55
61
  else {
56
- // Search for parents of a specific block
62
+ // Search for ancestors using the same rule
57
63
  if (targetPageUid) {
58
64
  queryStr = `[:find ?block-uid ?block-str ?depth
59
- :in $ ?child-uid ?page-uid ?max-depth
65
+ :in $ % ?child-uid ?page-uid
60
66
  :where [?p :block/uid ?page-uid]
61
67
  [?child :block/uid ?child-uid]
62
- [?child :block/parents ?b]
68
+ (ancestor ?child ?b)
63
69
  [?b :block/string ?block-str]
64
70
  [?b :block/uid ?block-uid]
65
71
  [?b :block/page ?p]
66
- [(get-else $ ?b :block/path-length 1) ?depth]
67
- [(<= ?depth ?max-depth)]]`;
68
- queryParams = [child_uid, targetPageUid, max_depth];
72
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
73
+ queryParams = [ancestorRule, child_uid, targetPageUid];
69
74
  }
70
75
  else {
71
76
  queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
72
- :in $ ?child-uid ?max-depth
77
+ :in $ % ?child-uid
73
78
  :where [?child :block/uid ?child-uid]
74
- [?child :block/parents ?b]
79
+ (ancestor ?child ?b)
75
80
  [?b :block/string ?block-str]
76
81
  [?b :block/uid ?block-uid]
77
82
  [?b :block/page ?p]
78
83
  [?p :node/title ?page-title]
79
- [(get-else $ ?b :block/path-length 1) ?depth]
80
- [(<= ?depth ?max-depth)]]`;
81
- queryParams = [child_uid, max_depth];
84
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
85
+ queryParams = [ancestorRule, child_uid];
82
86
  }
83
87
  }
84
88
  const results = await q(this.graph, queryStr, queryParams);
@@ -90,8 +94,8 @@ export class HierarchySearchHandler extends BaseSearchHandler {
90
94
  ...(pageTitle && { page_title: pageTitle })
91
95
  }));
92
96
  const searchDescription = parent_uid
93
- ? `children of block ${parent_uid} (max depth: ${max_depth})`
94
- : `parents of block ${child_uid} (max depth: ${max_depth})`;
97
+ ? `descendants of block ${parent_uid}`
98
+ : `ancestors of block ${child_uid}`;
95
99
  return {
96
100
  success: true,
97
101
  matches,
@@ -4,3 +4,4 @@ export * from './tag-search.js';
4
4
  export * from './status-search.js';
5
5
  export * from './block-ref-search.js';
6
6
  export * from './hierarchy-search.js';
7
+ export * from './text-search.js';
@@ -9,81 +9,27 @@ export class TagSearchHandler extends BaseSearchHandler {
9
9
  }
10
10
  async execute() {
11
11
  const { primary_tag, page_title_uid, near_tag, exclude_tag } = this.params;
12
- // Format tags to handle both # and [[]] formats
13
- const primaryTagFormats = SearchUtils.formatTag(primary_tag);
14
- const nearTagFormats = near_tag ? SearchUtils.formatTag(near_tag) : undefined;
15
- const excludeTagFormats = exclude_tag ? SearchUtils.formatTag(exclude_tag) : undefined;
16
- // Get target page UID if provided
12
+ // Get target page UID if provided for scoped search
17
13
  let targetPageUid;
18
14
  if (page_title_uid) {
19
15
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
20
16
  }
21
- // Build query based on whether we're searching in a specific page and/or for a nearby tag
22
- let queryStr;
23
- let queryParams;
24
- if (targetPageUid) {
25
- if (nearTagFormats) {
26
- queryStr = `[:find ?block-uid ?block-str
27
- :in $ [?primary-tag1 ?primary-tag2] [?near-tag1 ?near-tag2] [?exclude-tag1 ?exclude-tag2] ?page-uid
28
- :where [?p :block/uid ?page-uid]
29
- [?b :block/page ?p]
30
- [?b :block/string ?block-str]
31
- [?b :block/uid ?block-uid]
32
- (or [(clojure.string/includes? ?block-str ?primary-tag1)]
33
- [(clojure.string/includes? ?block-str ?primary-tag2)])
34
- (or [(clojure.string/includes? ?block-str ?near-tag1)]
35
- [(clojure.string/includes? ?block-str ?near-tag2)])
36
- (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
37
- [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
38
- queryParams = [primaryTagFormats, nearTagFormats, excludeTagFormats || ['', ''], targetPageUid];
39
- }
40
- else {
41
- queryStr = `[:find ?block-uid ?block-str
42
- :in $ [?primary-tag1 ?primary-tag2] [?exclude-tag1 ?exclude-tag2] ?page-uid
43
- :where [?p :block/uid ?page-uid]
44
- [?b :block/page ?p]
45
- [?b :block/string ?block-str]
46
- [?b :block/uid ?block-uid]
47
- (or [(clojure.string/includes? ?block-str ?primary-tag1)]
48
- [(clojure.string/includes? ?block-str ?primary-tag2)])
49
- (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
50
- [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
51
- queryParams = [primaryTagFormats, excludeTagFormats || ['', ''], targetPageUid];
52
- }
53
- }
54
- else {
55
- // Search across all pages
56
- if (nearTagFormats) {
57
- queryStr = `[:find ?block-uid ?block-str ?page-title
58
- :in $ [?primary-tag1 ?primary-tag2] [?near-tag1 ?near-tag2] [?exclude-tag1 ?exclude-tag2]
59
- :where [?b :block/string ?block-str]
60
- [?b :block/uid ?block-uid]
61
- [?b :block/page ?p]
62
- [?p :node/title ?page-title]
63
- (or [(clojure.string/includes? ?block-str ?primary-tag1)]
64
- [(clojure.string/includes? ?block-str ?primary-tag2)])
65
- (or [(clojure.string/includes? ?block-str ?near-tag1)]
66
- [(clojure.string/includes? ?block-str ?near-tag2)])
67
- (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
68
- [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
69
- queryParams = [primaryTagFormats, nearTagFormats, excludeTagFormats || ['', '']];
70
- }
71
- else {
72
- queryStr = `[:find ?block-uid ?block-str ?page-title
73
- :in $ [?primary-tag1 ?primary-tag2] [?exclude-tag1 ?exclude-tag2]
74
- :where [?b :block/string ?block-str]
75
- [?b :block/uid ?block-uid]
76
- [?b :block/page ?p]
77
- [?p :node/title ?page-title]
78
- (or [(clojure.string/includes? ?block-str ?primary-tag1)]
79
- [(clojure.string/includes? ?block-str ?primary-tag2)])
80
- (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
81
- [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
82
- queryParams = [primaryTagFormats, excludeTagFormats || ['', '']];
83
- }
84
- }
17
+ // Build query to find blocks referencing the page
18
+ const queryStr = `[:find ?block-uid ?block-str ?page-title
19
+ :in $ ?title
20
+ :where
21
+ [?ref-page :node/title ?title-match]
22
+ [(clojure.string/lower-case ?title-match) ?lower-title]
23
+ [(clojure.string/lower-case ?title) ?search-title]
24
+ [(= ?lower-title ?search-title)]
25
+ [?b :block/refs ?ref-page]
26
+ [?b :block/string ?block-str]
27
+ [?b :block/uid ?block-uid]
28
+ [?b :block/page ?p]
29
+ [?p :node/title ?page-title]]`;
30
+ const queryParams = [primary_tag];
85
31
  const results = await q(this.graph, queryStr, queryParams);
86
- const searchDescription = `containing ${primaryTagFormats.join(' or ')}${nearTagFormats ? ` near ${nearTagFormats.join(' or ')}` : ''}${excludeTagFormats ? ` excluding ${excludeTagFormats.join(' or ')}` : ''}`;
32
+ const searchDescription = `referencing "${primary_tag}"`;
87
33
  return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
88
34
  }
89
35
  }
@@ -0,0 +1,32 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class TextSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { text, page_title_uid, case_sensitive = false } = this.params;
12
+ // Get target page UID if provided for scoped search
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query to find blocks containing the text
18
+ const queryStr = `[:find ?block-uid ?block-str ?page-title
19
+ :in $ ?search-text
20
+ :where
21
+ [?b :block/string ?block-str]
22
+ [(clojure.string/includes? ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
23
+ ${case_sensitive ? '?search-text' : '(clojure.string/lower-case ?search-text)'})]
24
+ [?b :block/uid ?block-uid]
25
+ [?b :block/page ?p]
26
+ [?p :node/title ?page-title]]`;
27
+ const queryParams = [text];
28
+ const results = await q(this.graph, queryStr, queryParams);
29
+ const searchDescription = `containing "${text}"${case_sensitive ? ' (case sensitive)' : ''}`;
30
+ return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
31
+ }
32
+ }
@@ -4,8 +4,8 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } f
4
4
  import { initializeGraph } from '@roam-research/roam-api-sdk';
5
5
  import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
6
6
  import { toolSchemas } from '../tools/schemas.js';
7
- import { ToolHandlers } from '../tools/handlers.js';
8
- import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler } from '../search/index.js';
7
+ import { ToolHandlers } from '../tools/tool-handlers.js';
8
+ import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../search/index.js';
9
9
  export class RoamServer {
10
10
  server;
11
11
  toolHandlers;
@@ -18,7 +18,7 @@ export class RoamServer {
18
18
  this.toolHandlers = new ToolHandlers(this.graph);
19
19
  this.server = new Server({
20
20
  name: 'roam-research',
21
- version: '0.12.1',
21
+ version: '0.16.0',
22
22
  }, {
23
23
  capabilities: {
24
24
  tools: {
@@ -31,7 +31,10 @@ export class RoamServer {
31
31
  roam_search_for_tag: {},
32
32
  roam_search_by_status: {},
33
33
  roam_search_block_refs: {},
34
- roam_search_hierarchy: {}
34
+ roam_search_hierarchy: {},
35
+ find_pages_modified_today: {},
36
+ roam_search_by_text: {},
37
+ roam_update_block: {}
35
38
  },
36
39
  },
37
40
  });
@@ -125,6 +128,36 @@ export class RoamServer {
125
128
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
126
129
  };
127
130
  }
131
+ case 'find_pages_modified_today': {
132
+ const result = await this.toolHandlers.findPagesModifiedToday();
133
+ return {
134
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
135
+ };
136
+ }
137
+ case 'roam_search_by_text': {
138
+ const params = request.params.arguments;
139
+ const handler = new TextSearchHandler(this.graph, params);
140
+ const result = await handler.execute();
141
+ return {
142
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
143
+ };
144
+ }
145
+ case 'roam_update_block': {
146
+ const { block_uid, content, transform_pattern } = request.params.arguments;
147
+ let result;
148
+ if (content) {
149
+ result = await this.toolHandlers.updateBlock(block_uid, content);
150
+ }
151
+ else if (transform_pattern) {
152
+ result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
153
+ const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
154
+ return currentContent.replace(regex, transform_pattern.replace);
155
+ });
156
+ }
157
+ return {
158
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
159
+ };
160
+ }
128
161
  default:
129
162
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
130
163
  }
@@ -240,5 +240,78 @@ export const toolSchemas = {
240
240
  { required: ['child_uid'] }
241
241
  ]
242
242
  }
243
+ },
244
+ find_pages_modified_today: {
245
+ name: 'find_pages_modified_today',
246
+ description: 'Find all pages that have been modified today (since midnight).',
247
+ inputSchema: {
248
+ type: 'object',
249
+ properties: {},
250
+ required: []
251
+ }
252
+ },
253
+ roam_search_by_text: {
254
+ name: 'roam_search_by_text',
255
+ description: 'Search for blocks containing specific text across all pages or within a specific page.',
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ text: {
260
+ type: 'string',
261
+ description: 'The text to search for'
262
+ },
263
+ page_title_uid: {
264
+ type: 'string',
265
+ description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
266
+ },
267
+ case_sensitive: {
268
+ type: 'boolean',
269
+ description: 'Optional: Whether to perform a case-sensitive search (default: false)'
270
+ }
271
+ },
272
+ required: ['text']
273
+ }
274
+ },
275
+ roam_update_block: {
276
+ name: 'roam_update_block',
277
+ description: 'Update the content of an existing block identified by its UID. Can either provide new content directly or use a transform pattern to modify existing content.',
278
+ inputSchema: {
279
+ type: 'object',
280
+ properties: {
281
+ block_uid: {
282
+ type: 'string',
283
+ description: 'UID of the block to update'
284
+ },
285
+ content: {
286
+ type: 'string',
287
+ description: 'New content for the block. If not provided, transform_pattern will be used.'
288
+ },
289
+ transform_pattern: {
290
+ type: 'object',
291
+ description: 'Pattern to transform the current content. Used if content is not provided.',
292
+ properties: {
293
+ find: {
294
+ type: 'string',
295
+ description: 'Text or regex pattern to find'
296
+ },
297
+ replace: {
298
+ type: 'string',
299
+ description: 'Text to replace with'
300
+ },
301
+ global: {
302
+ type: 'boolean',
303
+ description: 'Whether to replace all occurrences',
304
+ default: true
305
+ }
306
+ },
307
+ required: ['find', 'replace']
308
+ }
309
+ },
310
+ required: ['block_uid'],
311
+ oneOf: [
312
+ { required: ['content'] },
313
+ { required: ['transform_pattern'] }
314
+ ]
315
+ }
243
316
  }
244
317
  };
@@ -1,5 +1,5 @@
1
- import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { q, createPage, createBlock, batchActions, } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, createBlock, batchActions, updateBlock } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../utils/helpers.js';
4
4
  import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable } from '../markdown-utils.js';
5
5
  // Helper function to capitalize each word
@@ -53,6 +53,46 @@ export class ToolHandlers {
53
53
  constructor(graph) {
54
54
  this.graph = graph;
55
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
+ }
56
96
  async createOutline(outline, page_title_uid, block_text_uid) {
57
97
  // Validate input
58
98
  if (!Array.isArray(outline) || outline.length === 0) {
@@ -295,71 +335,78 @@ export class ToolHandlers {
295
335
  if (!uid) {
296
336
  throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
297
337
  }
298
- // Get all blocks under this page with their order and parent relationships
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
299
347
  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]
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]
306
355
  [?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
- }
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);
320
376
  }
321
- // Build parent-child relationships
322
- blocks.forEach(([childUid, _, __, parentUid]) => {
323
- const child = blockMap.get(childUid);
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);
324
382
  const parent = blockMap.get(parentUid);
325
383
  if (child && parent && !parent.children.includes(child)) {
326
384
  parent.children.push(child);
327
385
  }
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)}`;
386
+ }
361
387
  }
362
- return `${title} (no content found)`;
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)}`;
363
410
  }
364
411
  async createPage(title, content) {
365
412
  // Ensure title is properly formatted
@@ -634,6 +681,53 @@ export class ToolHandlers {
634
681
  };
635
682
  }
636
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
+ }
637
731
  async searchByStatus(status, page_title_uid, include, exclude) {
638
732
  // Get target page UID if provided
639
733
  let targetPageUid;
@@ -658,15 +752,6 @@ export class ToolHandlers {
658
752
  let queryStr;
659
753
  let queryParams;
660
754
  const statusPattern = `{{[[${status}]]}}`;
661
- // Helper function to get parent block content
662
- const getParentContent = async (blockUid) => {
663
- const parentQuery = `[:find ?parent-str .
664
- :where [?b :block/uid "${blockUid}"]
665
- [?b :block/parents ?parent]
666
- [?parent :block/string ?parent-str]]`;
667
- const result = await q(this.graph, parentQuery, []);
668
- return result ? String(result) : null;
669
- };
670
755
  if (targetPageUid) {
671
756
  queryStr = `[:find ?block-uid ?block-str
672
757
  :in $ ?status-pattern ?page-uid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {
@@ -1,38 +0,0 @@
1
- import { q } from '@roam-research/roam-api-sdk';
2
- import { BaseSearchHandler } from './types.js';
3
- import { SearchUtils } from './utils.js';
4
- export class DateSearchHandler extends BaseSearchHandler {
5
- params;
6
- constructor(graph, params) {
7
- super(graph);
8
- this.params = params;
9
- }
10
- async execute() {
11
- const { start_date, end_date, filter_tag } = this.params;
12
- const [startDateFormatted, endDateFormatted] = SearchUtils.parseDateRange(start_date, end_date);
13
- const filterTagFormatted = filter_tag ? `[[${filter_tag}]]` : undefined;
14
- // Build Roam query string
15
- const dateQuery = `{between: [[${startDateFormatted}]] [[${endDateFormatted}]]}`;
16
- const query = filterTagFormatted
17
- ? `{{query: {and: ${filterTagFormatted} ${dateQuery}}}}`
18
- : `{{query: ${dateQuery}}}`;
19
- // Log the query for debugging
20
- console.log('Roam query:', query);
21
- // Find blocks matching the query
22
- const queryStr = `[:find ?block-uid ?block-str ?page-title
23
- :in $ ?query-str
24
- :where [?b :block/string ?block-str]
25
- [?b :block/uid ?block-uid]
26
- [?b :block/page ?p]
27
- [?p :node/title ?page-title]
28
- [(clojure.string/includes? ?block-str ?query-str)]]`;
29
- const results = await q(this.graph, queryStr, [query]);
30
- const dateRange = start_date === end_date
31
- ? `on ${SearchUtils.parseDate(start_date)}`
32
- : `between ${SearchUtils.parseDate(start_date)} and ${SearchUtils.parseDate(end_date)}`;
33
- const searchDescription = filterTagFormatted
34
- ? `${dateRange} containing ${filterTagFormatted}`
35
- : dateRange;
36
- return SearchUtils.formatSearchResults(results, searchDescription, true);
37
- }
38
- }