roam-research-mcp 0.17.0 → 0.19.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 eleven 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
@@ -40,6 +41,7 @@ The server provides eleven powerful tools for interacting with Roam Research:
40
41
  9. `find_pages_modified_today`: Find all pages that have been modified since midnight today
41
42
  10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
42
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
43
45
 
44
46
  ## Setup
45
47
 
@@ -333,7 +335,7 @@ Search for blocks containing specific text across all pages or within a specific
333
335
  use_mcp_tool roam-research roam_search_by_text {
334
336
  "text": "search text",
335
337
  "page_title_uid": "optional-page-title-or-uid",
336
- "case_sensitive": false
338
+ "case_sensitive": true
337
339
  }
338
340
  ```
339
341
 
@@ -349,7 +351,7 @@ Parameters:
349
351
 
350
352
  - `text`: The text to search for (required)
351
353
  - `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)
354
+ - `case_sensitive`: Whether to perform a case-sensitive search (optional, default: true to match Roam's native behavior)
353
355
 
354
356
  Returns:
355
357
 
@@ -419,6 +421,55 @@ Returns:
419
421
  }
420
422
  ```
421
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
+
422
473
  ### Find Pages Modified Today
423
474
 
424
475
  Find all pages that have been modified since midnight today:
@@ -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.16.0',
21
+ version: '0.17.0',
22
22
  }, {
23
23
  capabilities: {
24
24
  tools: {
@@ -34,7 +34,9 @@ export class RoamServer {
34
34
  roam_search_hierarchy: {},
35
35
  find_pages_modified_today: {},
36
36
  roam_search_by_text: {},
37
- roam_update_block: {}
37
+ roam_update_block: {},
38
+ roam_update_blocks: {},
39
+ roam_search_by_date: {}
38
40
  },
39
41
  },
40
42
  });
@@ -142,6 +144,13 @@ export class RoamServer {
142
144
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
143
145
  };
144
146
  }
147
+ case 'roam_search_by_date': {
148
+ const params = request.params.arguments;
149
+ const result = await this.toolHandlers.searchByDate(params);
150
+ return {
151
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
152
+ };
153
+ }
145
154
  case 'roam_update_block': {
146
155
  const { block_uid, content, transform_pattern } = request.params.arguments;
147
156
  let result;
@@ -158,6 +167,13 @@ export class RoamServer {
158
167
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
159
168
  };
160
169
  }
170
+ case 'roam_update_blocks': {
171
+ const { updates } = request.params.arguments;
172
+ const result = await this.toolHandlers.updateBlocks(updates);
173
+ return {
174
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
175
+ };
176
+ }
161
177
  default:
162
178
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
163
179
  }
@@ -2,7 +2,7 @@
2
2
  export const toolSchemas = {
3
3
  roam_add_todo: {
4
4
  name: 'roam_add_todo',
5
- description: 'Add a list of todo items as individual blocks to today\'s daily page in Roam. Each item becomes its own actionable block with todo status.',
5
+ description: 'Add a list of todo items as individual blocks to today\'s daily page in Roam. Each item becomes its own actionable block with todo status.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
6
6
  inputSchema: {
7
7
  type: 'object',
8
8
  properties: {
@@ -52,7 +52,7 @@ export const toolSchemas = {
52
52
  },
53
53
  roam_create_block: {
54
54
  name: 'roam_create_block',
55
- description: 'Add a new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.',
55
+ description: 'Add a new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
56
56
  inputSchema: {
57
57
  type: 'object',
58
58
  properties: {
@@ -161,6 +161,11 @@ export const toolSchemas = {
161
161
  near_tag: {
162
162
  type: 'string',
163
163
  description: 'Optional: Another tag to filter results by - will only return blocks where both tags appear',
164
+ },
165
+ case_sensitive: {
166
+ type: 'boolean',
167
+ description: 'Optional: Whether to perform case-sensitive matching (default: true, matching Roam\'s native behavior)',
168
+ default: true
164
169
  }
165
170
  },
166
171
  required: ['primary_tag']
@@ -188,6 +193,11 @@ export const toolSchemas = {
188
193
  exclude: {
189
194
  type: 'string',
190
195
  description: 'Optional: Comma-separated list of terms to filter results by exclusion (matches content or page title)'
196
+ },
197
+ case_sensitive: {
198
+ type: 'boolean',
199
+ description: 'Optional: Whether to perform case-sensitive matching (default: true, matching Roam\'s native behavior)',
200
+ default: true
191
201
  }
192
202
  },
193
203
  required: ['status']
@@ -266,7 +276,8 @@ export const toolSchemas = {
266
276
  },
267
277
  case_sensitive: {
268
278
  type: 'boolean',
269
- description: 'Optional: Whether to perform a case-sensitive search (default: false)'
279
+ description: 'Optional: Whether to perform case-sensitive search (default: true, matching Roam\'s native behavior)',
280
+ default: true
270
281
  }
271
282
  },
272
283
  required: ['text']
@@ -274,7 +285,7 @@ export const toolSchemas = {
274
285
  },
275
286
  roam_update_block: {
276
287
  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.',
288
+ 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.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
278
289
  inputSchema: {
279
290
  type: 'object',
280
291
  properties: {
@@ -313,5 +324,95 @@ export const toolSchemas = {
313
324
  { required: ['transform_pattern'] }
314
325
  ]
315
326
  }
327
+ },
328
+ roam_update_blocks: {
329
+ name: 'roam_update_blocks',
330
+ description: 'Update multiple blocks in a single batch operation. Each update can provide either new content directly or a transform pattern.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ updates: {
335
+ type: 'array',
336
+ description: 'Array of block updates to perform',
337
+ items: {
338
+ type: 'object',
339
+ properties: {
340
+ block_uid: {
341
+ type: 'string',
342
+ description: 'UID of the block to update'
343
+ },
344
+ content: {
345
+ type: 'string',
346
+ description: 'New content for the block. If not provided, transform will be used.'
347
+ },
348
+ transform: {
349
+ type: 'object',
350
+ description: 'Pattern to transform the current content. Used if content is not provided.',
351
+ properties: {
352
+ find: {
353
+ type: 'string',
354
+ description: 'Text or regex pattern to find'
355
+ },
356
+ replace: {
357
+ type: 'string',
358
+ description: 'Text to replace with'
359
+ },
360
+ global: {
361
+ type: 'boolean',
362
+ description: 'Whether to replace all occurrences',
363
+ default: true
364
+ }
365
+ },
366
+ required: ['find', 'replace']
367
+ }
368
+ },
369
+ required: ['block_uid'],
370
+ oneOf: [
371
+ { required: ['content'] },
372
+ { required: ['transform'] }
373
+ ]
374
+ }
375
+ }
376
+ },
377
+ required: ['updates']
378
+ }
379
+ },
380
+ roam_search_by_date: {
381
+ name: 'roam_search_by_date',
382
+ description: 'Search for blocks or pages based on creation or modification dates',
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ start_date: {
387
+ type: 'string',
388
+ description: 'Start date in ISO format (YYYY-MM-DD)',
389
+ },
390
+ end_date: {
391
+ type: 'string',
392
+ description: 'Optional: End date in ISO format (YYYY-MM-DD)',
393
+ },
394
+ type: {
395
+ type: 'string',
396
+ enum: ['created', 'modified', 'both'],
397
+ description: 'Whether to search by creation date, modification date, or both',
398
+ },
399
+ scope: {
400
+ type: 'string',
401
+ enum: ['blocks', 'pages', 'both'],
402
+ description: 'Whether to search blocks, pages, or both',
403
+ },
404
+ include_content: {
405
+ type: 'boolean',
406
+ description: 'Whether to include the content of matching blocks/pages',
407
+ default: true,
408
+ },
409
+ case_sensitive: {
410
+ type: 'boolean',
411
+ description: 'Optional: Whether to perform case-sensitive matching (default: true, matching Roam\'s native behavior)',
412
+ default: true
413
+ }
414
+ },
415
+ required: ['start_date', 'type', 'scope']
416
+ }
316
417
  }
317
418
  };
@@ -157,21 +157,23 @@ export class ToolHandlers {
157
157
  // Get or create the target page
158
158
  const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
159
159
  // Helper function to find block with improved relationship checks
160
- const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
160
+ const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000, case_sensitive = false) => {
161
161
  // Try multiple query strategies
162
162
  const queries = [
163
163
  // Strategy 1: Direct page and string match
164
164
  `[:find ?b-uid ?order
165
165
  :where [?p :block/uid "${pageUid}"]
166
166
  [?b :block/page ?p]
167
- [?b :block/string "${blockString}"]
167
+ [?b :block/string ?block-str]
168
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
168
169
  [?b :block/order ?order]
169
170
  [?b :block/uid ?b-uid]]`,
170
171
  // Strategy 2: Parent-child relationship
171
172
  `[:find ?b-uid ?order
172
173
  :where [?p :block/uid "${pageUid}"]
173
174
  [?b :block/parents ?p]
174
- [?b :block/string "${blockString}"]
175
+ [?b :block/string ?block-str]
176
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
175
177
  [?b :block/order ?order]
176
178
  [?b :block/uid ?b-uid]]`,
177
179
  // Strategy 3: Broader page relationship
@@ -179,7 +181,8 @@ export class ToolHandlers {
179
181
  :where [?p :block/uid "${pageUid}"]
180
182
  [?b :block/page ?page]
181
183
  [?p :block/page ?page]
182
- [?b :block/string "${blockString}"]
184
+ [?b :block/string ?block-str]
185
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
183
186
  [?b :block/order ?order]
184
187
  [?b :block/uid ?b-uid]]`
185
188
  ];
@@ -201,7 +204,7 @@ export class ToolHandlers {
201
204
  throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
202
205
  };
203
206
  // Helper function to create and verify block with improved error handling
204
- const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
207
+ const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false, case_sensitive = false) => {
205
208
  try {
206
209
  // Initial delay before any operations
207
210
  if (!isRetry) {
@@ -223,7 +226,7 @@ export class ToolHandlers {
223
226
  await new Promise(resolve => setTimeout(resolve, delay));
224
227
  try {
225
228
  // Try to find the block using our improved findBlockWithRetry
226
- return await findBlockWithRetry(parentUid, content);
229
+ return await findBlockWithRetry(parentUid, content, maxRetries, initialDelay, case_sensitive);
227
230
  }
228
231
  catch (error) {
229
232
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -241,7 +244,7 @@ export class ToolHandlers {
241
244
  // Otherwise, try one more time with a clean slate
242
245
  console.log(`Retrying block creation for "${content}" with fresh attempt`);
243
246
  await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
244
- return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
247
+ return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true, case_sensitive);
245
248
  }
246
249
  };
247
250
  // Get or create the parent block
@@ -728,7 +731,110 @@ export class ToolHandlers {
728
731
  throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
729
732
  }
730
733
  }
731
- async searchByStatus(status, page_title_uid, include, exclude) {
734
+ async updateBlocks(updates) {
735
+ if (!Array.isArray(updates) || updates.length === 0) {
736
+ throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
737
+ }
738
+ // Validate each update has required fields
739
+ updates.forEach((update, index) => {
740
+ if (!update.block_uid) {
741
+ throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
742
+ }
743
+ if (!update.content && !update.transform) {
744
+ throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
745
+ }
746
+ });
747
+ // Get current content for all blocks
748
+ const blockUids = updates.map(u => u.block_uid);
749
+ const blockQuery = `[:find ?uid ?string
750
+ :in $ [?uid ...]
751
+ :where [?b :block/uid ?uid]
752
+ [?b :block/string ?string]]`;
753
+ const blockResults = await q(this.graph, blockQuery, [blockUids]);
754
+ // Create map of uid -> current content
755
+ const contentMap = new Map();
756
+ blockResults.forEach(([uid, string]) => {
757
+ contentMap.set(uid, string);
758
+ });
759
+ // Prepare batch actions
760
+ const actions = [];
761
+ const results = [];
762
+ for (const update of updates) {
763
+ try {
764
+ const currentContent = contentMap.get(update.block_uid);
765
+ if (!currentContent) {
766
+ results.push({
767
+ block_uid: update.block_uid,
768
+ content: '',
769
+ success: false,
770
+ error: `Block with UID "${update.block_uid}" not found`
771
+ });
772
+ continue;
773
+ }
774
+ // Determine new content
775
+ let newContent;
776
+ if (update.content) {
777
+ newContent = update.content;
778
+ }
779
+ else if (update.transform) {
780
+ const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
781
+ newContent = currentContent.replace(regex, update.transform.replace);
782
+ }
783
+ else {
784
+ // This shouldn't happen due to earlier validation
785
+ throw new Error('Invalid update configuration');
786
+ }
787
+ // Add to batch actions
788
+ actions.push({
789
+ action: 'update-block',
790
+ block: {
791
+ uid: update.block_uid,
792
+ string: newContent
793
+ }
794
+ });
795
+ results.push({
796
+ block_uid: update.block_uid,
797
+ content: newContent,
798
+ success: true
799
+ });
800
+ }
801
+ catch (error) {
802
+ results.push({
803
+ block_uid: update.block_uid,
804
+ content: contentMap.get(update.block_uid) || '',
805
+ success: false,
806
+ error: error.message
807
+ });
808
+ }
809
+ }
810
+ // Execute batch update if we have any valid actions
811
+ if (actions.length > 0) {
812
+ try {
813
+ const batchResult = await batchActions(this.graph, {
814
+ action: 'batch-actions',
815
+ actions
816
+ });
817
+ if (!batchResult) {
818
+ throw new Error('Batch update failed');
819
+ }
820
+ }
821
+ catch (error) {
822
+ // Mark all previously successful results as failed
823
+ results.forEach(result => {
824
+ if (result.success) {
825
+ result.success = false;
826
+ result.error = `Batch update failed: ${error.message}`;
827
+ }
828
+ });
829
+ }
830
+ }
831
+ return {
832
+ success: results.every(r => r.success),
833
+ results
834
+ };
835
+ }
836
+ async searchByStatus(status, page_title_uid, include, exclude, case_sensitive = true // Changed to true to match Roam's behavior
837
+ ) {
732
838
  // Get target page UID if provided
733
839
  let targetPageUid;
734
840
  if (page_title_uid) {
@@ -789,16 +895,26 @@ export class ToolHandlers {
789
895
  ...(pageTitle && { page_title: pageTitle })
790
896
  };
791
897
  });
792
- // Post-query filtering
898
+ // Post-query filtering with case sensitivity option
793
899
  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))));
900
+ const includeTerms = include.split(',').map(term => term.trim());
901
+ matches = matches.filter(match => {
902
+ const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
903
+ const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
904
+ const terms = case_sensitive ? includeTerms : includeTerms.map(t => t.toLowerCase());
905
+ return terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
906
+ (matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
907
+ });
797
908
  }
798
909
  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))));
910
+ const excludeTerms = exclude.split(',').map(term => term.trim());
911
+ matches = matches.filter(match => {
912
+ const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
913
+ const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
914
+ const terms = case_sensitive ? excludeTerms : excludeTerms.map(t => t.toLowerCase());
915
+ return !terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
916
+ (matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
917
+ });
802
918
  }
803
919
  return {
804
920
  success: true,
@@ -806,7 +922,8 @@ export class ToolHandlers {
806
922
  message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
807
923
  };
808
924
  }
809
- async searchForTag(primary_tag, page_title_uid, near_tag) {
925
+ async searchForTag(primary_tag, page_title_uid, near_tag, case_sensitive = true // Changed to true to match Roam's behavior
926
+ ) {
810
927
  // Ensure tags are properly formatted with #
811
928
  const formatTag = (tag) => tag.startsWith('#') ? tag : `#${tag}`;
812
929
  const primaryTagFormatted = formatTag(primary_tag);
@@ -841,8 +958,12 @@ export class ToolHandlers {
841
958
  [?b :block/page ?p]
842
959
  [?b :block/string ?block-str]
843
960
  [?b :block/uid ?block-uid]
844
- [(clojure.string/includes? ?block-str ?primary-tag)]
845
- [(clojure.string/includes? ?block-str ?near-tag)]]`;
961
+ [(clojure.string/includes?
962
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
963
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
964
+ [(clojure.string/includes?
965
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
966
+ ${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
846
967
  queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
847
968
  }
848
969
  else {
@@ -852,7 +973,9 @@ export class ToolHandlers {
852
973
  [?b :block/page ?p]
853
974
  [?b :block/string ?block-str]
854
975
  [?b :block/uid ?block-uid]
855
- [(clojure.string/includes? ?block-str ?primary-tag)]]`;
976
+ [(clojure.string/includes?
977
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
978
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
856
979
  queryParams = [primaryTagFormatted, targetPageUid];
857
980
  }
858
981
  }
@@ -865,8 +988,12 @@ export class ToolHandlers {
865
988
  [?b :block/uid ?block-uid]
866
989
  [?b :block/page ?p]
867
990
  [?p :node/title ?page-title]
868
- [(clojure.string/includes? ?block-str ?primary-tag)]
869
- [(clojure.string/includes? ?block-str ?near-tag)]]`;
991
+ [(clojure.string/includes?
992
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
993
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
994
+ [(clojure.string/includes?
995
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
996
+ ${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
870
997
  queryParams = [primaryTagFormatted, nearTagFormatted];
871
998
  }
872
999
  else {
@@ -876,7 +1003,9 @@ export class ToolHandlers {
876
1003
  [?b :block/uid ?block-uid]
877
1004
  [?b :block/page ?p]
878
1005
  [?p :node/title ?page-title]
879
- [(clojure.string/includes? ?block-str ?primary-tag)]]`;
1006
+ [(clojure.string/includes?
1007
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
1008
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
880
1009
  queryParams = [primaryTagFormatted];
881
1010
  }
882
1011
  }
@@ -900,6 +1029,73 @@ export class ToolHandlers {
900
1029
  message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
901
1030
  };
902
1031
  }
1032
+ async searchByDate(params) {
1033
+ // Convert dates to timestamps
1034
+ const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime();
1035
+ const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined;
1036
+ // Define rule for entity type
1037
+ const entityRule = `[
1038
+ [(block? ?e)
1039
+ [?e :block/string]
1040
+ [?e :block/page ?p]
1041
+ [?p :node/title]]
1042
+ [(page? ?e)
1043
+ [?e :node/title]]
1044
+ ]`;
1045
+ // Build query based on cheatsheet pattern
1046
+ const timeAttr = params.type === 'created' ? ':create/time' : ':edit/time';
1047
+ let queryStr = `[:find ?block-uid ?string ?time ?page-title
1048
+ :in $ ?start-ts ${endTimestamp ? '?end-ts' : ''}
1049
+ :where
1050
+ [?b ${timeAttr} ?time]
1051
+ [(>= ?time ?start-ts)]
1052
+ ${endTimestamp ? '[(<= ?time ?end-ts)]' : ''}
1053
+ [?b :block/uid ?block-uid]
1054
+ [?b :block/string ?string]
1055
+ [?b :block/page ?p]
1056
+ [?p :node/title ?page-title]]`;
1057
+ // Execute query
1058
+ const queryParams = endTimestamp ?
1059
+ [startTimestamp, endTimestamp] :
1060
+ [startTimestamp];
1061
+ const results = await q(this.graph, queryStr, queryParams);
1062
+ if (!results || results.length === 0) {
1063
+ return {
1064
+ success: true,
1065
+ matches: [],
1066
+ message: 'No matches found for the given date range and criteria'
1067
+ };
1068
+ }
1069
+ // Process results - now we get [block-uid, string, time, page-title]
1070
+ const matches = results.map(([uid, content, time, pageTitle]) => ({
1071
+ uid,
1072
+ type: 'block',
1073
+ time,
1074
+ ...(params.include_content && { content }),
1075
+ page_title: pageTitle
1076
+ }));
1077
+ // Apply case sensitivity if content is included
1078
+ if (params.include_content) {
1079
+ const case_sensitive = params.case_sensitive ?? true; // Default to true to match Roam's behavior
1080
+ if (!case_sensitive) {
1081
+ matches.forEach(match => {
1082
+ if (match.content) {
1083
+ match.content = match.content.toLowerCase();
1084
+ }
1085
+ if (match.page_title) {
1086
+ match.page_title = match.page_title.toLowerCase();
1087
+ }
1088
+ });
1089
+ }
1090
+ }
1091
+ // Sort by time
1092
+ const sortedMatches = matches.sort((a, b) => b.time - a.time);
1093
+ return {
1094
+ success: true,
1095
+ matches: sortedMatches,
1096
+ message: `Found ${sortedMatches.length} matches for the given date range and criteria`
1097
+ };
1098
+ }
903
1099
  async addTodos(todos) {
904
1100
  if (!Array.isArray(todos) || todos.length === 0) {
905
1101
  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.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {