roam-research-mcp 0.14.0 → 0.18.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
@@ -1,10 +1,11 @@
1
1
  # Roam Research MCP Server
2
2
 
3
3
  [![npm version](https://badge.fury.io/js/roam-research-mcp.svg)](https://badge.fury.io/js/roam-research-mcp)
4
+ [![Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public.](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
6
  [![GitHub](https://img.shields.io/github/license/2b3pro/roam-research-mcp)](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE)
6
7
 
7
- A Model Context Protocol (MCP) server that provides comprehensive access to Roam Research's API functionality. This server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface.
8
+ A Model Context Protocol (MCP) server that provides comprehensive access to Roam Research's API functionality. This server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface. (A WORK-IN-PROGRESS)
8
9
 
9
10
  <a href="https://glama.ai/mcp/servers/fzfznyaflu"><img width="380" height="200" src="https://glama.ai/mcp/servers/fzfznyaflu/badge" alt="Roam Research MCP server" /></a>
10
11
 
@@ -27,7 +28,7 @@ npm run build
27
28
 
28
29
  ## Features
29
30
 
30
- The server provides eight powerful tools for interacting with Roam Research:
31
+ The server provides twelve powerful tools for interacting with Roam Research:
31
32
 
32
33
  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
34
  2. `roam_create_page`: Create new pages with optional content
@@ -37,6 +38,10 @@ The server provides eight powerful tools for interacting with Roam Research:
37
38
  6. `roam_create_outline`: Create hierarchical outlines with proper nesting and structure
38
39
  7. `roam_search_block_refs`: Search for block references within pages or across the graph
39
40
  8. `roam_search_hierarchy`: Navigate and search through block parent-child relationships
41
+ 9. `find_pages_modified_today`: Find all pages that have been modified since midnight today
42
+ 10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
43
+ 11. `roam_update_block`: Update block content with direct text or pattern-based transformations
44
+ 12. `roam_search_by_date`: Search for blocks and pages based on creation or modification dates
40
45
 
41
46
  ## Setup
42
47
 
@@ -322,6 +327,175 @@ Returns:
322
327
  }
323
328
  ```
324
329
 
330
+ ### Search By Text
331
+
332
+ Search for blocks containing specific text across all pages or within a specific page:
333
+
334
+ ```typescript
335
+ use_mcp_tool roam-research roam_search_by_text {
336
+ "text": "search text",
337
+ "page_title_uid": "optional-page-title-or-uid",
338
+ "case_sensitive": false
339
+ }
340
+ ```
341
+
342
+ Features:
343
+
344
+ - Search for any text across all blocks in the graph
345
+ - Optional page-scoped search
346
+ - Case-sensitive or case-insensitive search
347
+ - Returns block content with page context
348
+ - Efficient text matching using Datalog queries
349
+
350
+ Parameters:
351
+
352
+ - `text`: The text to search for (required)
353
+ - `page_title_uid`: Title or UID of the page to search in (optional)
354
+ - `case_sensitive`: Whether to perform a case-sensitive search (optional, default: false)
355
+
356
+ Returns:
357
+
358
+ ```json
359
+ {
360
+ "success": true,
361
+ "matches": [
362
+ {
363
+ "block_uid": "matching-block-uid",
364
+ "content": "Block content containing search text",
365
+ "page_title": "Page containing block"
366
+ }
367
+ ],
368
+ "message": "Found N block(s) containing \"search text\""
369
+ }
370
+ ```
371
+
372
+ ### Update Block Content
373
+
374
+ Update a block's content using either direct text replacement or pattern-based transformations:
375
+
376
+ ```typescript
377
+ use_mcp_tool roam-research roam_update_block {
378
+ "block_uid": "target-block-uid",
379
+ "content": "New block content"
380
+ }
381
+ ```
382
+
383
+ Or use pattern-based transformation:
384
+
385
+ ```typescript
386
+ use_mcp_tool roam-research roam_update_block {
387
+ "block_uid": "target-block-uid",
388
+ "transform_pattern": {
389
+ "find": "\\bPython\\b",
390
+ "replace": "[[Python]]",
391
+ "global": true
392
+ }
393
+ }
394
+ ```
395
+
396
+ Features:
397
+
398
+ - Two update modes:
399
+ - Direct content replacement
400
+ - Pattern-based transformation using regex
401
+ - Verify block existence before updating
402
+ - Return updated content in response
403
+ - Support for global or single-match replacements
404
+ - Preserve block relationships and metadata
405
+
406
+ Parameters:
407
+
408
+ - `block_uid`: UID of the block to update (required)
409
+ - `content`: New content for the block (if using direct replacement)
410
+ - `transform_pattern`: Pattern for transforming existing content:
411
+ - `find`: Text or regex pattern to find
412
+ - `replace`: Text to replace with
413
+ - `global`: Whether to replace all occurrences (default: true)
414
+
415
+ Returns:
416
+
417
+ ```json
418
+ {
419
+ "success": true,
420
+ "content": "Updated block content"
421
+ }
422
+ ```
423
+
424
+ ### Search By Date
425
+
426
+ Search for blocks and pages based on creation or modification dates:
427
+
428
+ ```typescript
429
+ use_mcp_tool roam-research roam_search_by_date {
430
+ "start_date": "2025-01-01",
431
+ "end_date": "2025-01-31",
432
+ "type": "modified",
433
+ "scope": "blocks",
434
+ "include_content": true
435
+ }
436
+ ```
437
+
438
+ Features:
439
+
440
+ - Search by creation date, modification date, or both
441
+ - Filter blocks, pages, or both
442
+ - Optional date range with start and end dates
443
+ - Include or exclude block/page content in results
444
+ - Sort results by timestamp
445
+ - Efficient date-based filtering using Datalog queries
446
+
447
+ Parameters:
448
+
449
+ - `start_date`: Start date in ISO format (YYYY-MM-DD) (required)
450
+ - `end_date`: End date in ISO format (YYYY-MM-DD) (optional)
451
+ - `type`: Whether to search by 'created', 'modified', or 'both' (required)
452
+ - `scope`: Whether to search 'blocks', 'pages', or 'both' (required)
453
+ - `include_content`: Whether to include the content of matching blocks/pages (optional, default: true)
454
+
455
+ Returns:
456
+
457
+ ```json
458
+ {
459
+ "success": true,
460
+ "matches": [
461
+ {
462
+ "uid": "block-or-page-uid",
463
+ "type": "block",
464
+ "time": 1704067200000,
465
+ "content": "Block or page content",
466
+ "page_title": "Page title (for blocks)"
467
+ }
468
+ ],
469
+ "message": "Found N matches for the given date range and criteria"
470
+ }
471
+ ```
472
+
473
+ ### Find Pages Modified Today
474
+
475
+ Find all pages that have been modified since midnight today:
476
+
477
+ ```typescript
478
+ use_mcp_tool roam-research find_pages_modified_today {}
479
+ ```
480
+
481
+ Features:
482
+
483
+ - Tracks all modifications made to pages since midnight
484
+ - Detects changes at any level in the block hierarchy
485
+ - Returns unique list of modified page titles
486
+ - Includes count of modified pages
487
+ - No parameters required
488
+
489
+ Returns:
490
+
491
+ ```json
492
+ {
493
+ "success": true,
494
+ "pages": ["Page 1", "Page 2"],
495
+ "message": "Found 2 page(s) modified today"
496
+ }
497
+ ```
498
+
325
499
  ### Search Block Hierarchy
326
500
 
327
501
  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.17.0',
22
22
  }, {
23
23
  capabilities: {
24
24
  tools: {
@@ -31,7 +31,11 @@ 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: {},
38
+ roam_search_by_date: {}
35
39
  },
36
40
  },
37
41
  });
@@ -125,6 +129,43 @@ export class RoamServer {
125
129
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
126
130
  };
127
131
  }
132
+ case 'find_pages_modified_today': {
133
+ const result = await this.toolHandlers.findPagesModifiedToday();
134
+ return {
135
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
136
+ };
137
+ }
138
+ case 'roam_search_by_text': {
139
+ const params = request.params.arguments;
140
+ const handler = new TextSearchHandler(this.graph, params);
141
+ const result = await handler.execute();
142
+ return {
143
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
144
+ };
145
+ }
146
+ case 'roam_search_by_date': {
147
+ const params = request.params.arguments;
148
+ const result = await this.toolHandlers.searchByDate(params);
149
+ return {
150
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
151
+ };
152
+ }
153
+ case 'roam_update_block': {
154
+ const { block_uid, content, transform_pattern } = request.params.arguments;
155
+ let result;
156
+ if (content) {
157
+ result = await this.toolHandlers.updateBlock(block_uid, content);
158
+ }
159
+ else if (transform_pattern) {
160
+ result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
161
+ const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
162
+ return currentContent.replace(regex, transform_pattern.replace);
163
+ });
164
+ }
165
+ return {
166
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
167
+ };
168
+ }
128
169
  default:
129
170
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
130
171
  }
@@ -240,5 +240,111 @@ 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
+ }
316
+ },
317
+ roam_search_by_date: {
318
+ name: 'roam_search_by_date',
319
+ description: 'Search for blocks or pages based on creation or modification dates',
320
+ inputSchema: {
321
+ type: 'object',
322
+ properties: {
323
+ start_date: {
324
+ type: 'string',
325
+ description: 'Start date in ISO format (YYYY-MM-DD)',
326
+ },
327
+ end_date: {
328
+ type: 'string',
329
+ description: 'Optional: End date in ISO format (YYYY-MM-DD)',
330
+ },
331
+ type: {
332
+ type: 'string',
333
+ enum: ['created', 'modified', 'both'],
334
+ description: 'Whether to search by creation date, modification date, or both',
335
+ },
336
+ scope: {
337
+ type: 'string',
338
+ enum: ['blocks', 'pages', 'both'],
339
+ description: 'Whether to search blocks, pages, or both',
340
+ },
341
+ include_content: {
342
+ type: 'boolean',
343
+ description: 'Whether to include the content of matching blocks/pages',
344
+ default: true,
345
+ }
346
+ },
347
+ required: ['start_date', 'type', 'scope']
348
+ }
243
349
  }
244
350
  };
@@ -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
@@ -815,6 +900,59 @@ export class ToolHandlers {
815
900
  message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
816
901
  };
817
902
  }
903
+ async searchByDate(params) {
904
+ // Convert dates to timestamps
905
+ const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime();
906
+ const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined;
907
+ // Define rule for entity type
908
+ const entityRule = `[
909
+ [(block? ?e)
910
+ [?e :block/string]
911
+ [?e :block/page ?p]
912
+ [?p :node/title]]
913
+ [(page? ?e)
914
+ [?e :node/title]]
915
+ ]`;
916
+ // Build query based on cheatsheet pattern
917
+ const timeAttr = params.type === 'created' ? ':create/time' : ':edit/time';
918
+ let queryStr = `[:find ?block-uid ?string ?time ?page-title
919
+ :in $ ?start-ts ${endTimestamp ? '?end-ts' : ''}
920
+ :where
921
+ [?b ${timeAttr} ?time]
922
+ [(>= ?time ?start-ts)]
923
+ ${endTimestamp ? '[(<= ?time ?end-ts)]' : ''}
924
+ [?b :block/uid ?block-uid]
925
+ [?b :block/string ?string]
926
+ [?b :block/page ?p]
927
+ [?p :node/title ?page-title]]`;
928
+ // Execute query
929
+ const queryParams = endTimestamp ?
930
+ [startTimestamp, endTimestamp] :
931
+ [startTimestamp];
932
+ const results = await q(this.graph, queryStr, queryParams);
933
+ if (!results || results.length === 0) {
934
+ return {
935
+ success: true,
936
+ matches: [],
937
+ message: 'No matches found for the given date range and criteria'
938
+ };
939
+ }
940
+ // Process results - now we get [block-uid, string, time, page-title]
941
+ const matches = results.map(([uid, content, time, pageTitle]) => ({
942
+ uid,
943
+ type: 'block',
944
+ time,
945
+ ...(params.include_content && { content }),
946
+ page_title: pageTitle
947
+ }));
948
+ // Sort by time
949
+ const sortedMatches = matches.sort((a, b) => b.time - a.time);
950
+ return {
951
+ success: true,
952
+ matches: sortedMatches,
953
+ message: `Found ${sortedMatches.length} matches for the given date range and criteria`
954
+ };
955
+ }
818
956
  async addTodos(todos) {
819
957
  if (!Array.isArray(todos) || todos.length === 0) {
820
958
  throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.18.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
- }