roam-research-mcp 0.18.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
@@ -335,7 +335,7 @@ Search for blocks containing specific text across all pages or within a specific
335
335
  use_mcp_tool roam-research roam_search_by_text {
336
336
  "text": "search text",
337
337
  "page_title_uid": "optional-page-title-or-uid",
338
- "case_sensitive": false
338
+ "case_sensitive": true
339
339
  }
340
340
  ```
341
341
 
@@ -351,7 +351,7 @@ Parameters:
351
351
 
352
352
  - `text`: The text to search for (required)
353
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)
354
+ - `case_sensitive`: Whether to perform a case-sensitive search (optional, default: true to match Roam's native behavior)
355
355
 
356
356
  Returns:
357
357
 
@@ -35,6 +35,7 @@ export class RoamServer {
35
35
  find_pages_modified_today: {},
36
36
  roam_search_by_text: {},
37
37
  roam_update_block: {},
38
+ roam_update_blocks: {},
38
39
  roam_search_by_date: {}
39
40
  },
40
41
  },
@@ -166,6 +167,13 @@ export class RoamServer {
166
167
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
167
168
  };
168
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
+ }
169
177
  default:
170
178
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
171
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: {
@@ -314,6 +325,58 @@ export const toolSchemas = {
314
325
  ]
315
326
  }
316
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
+ },
317
380
  roam_search_by_date: {
318
381
  name: 'roam_search_by_date',
319
382
  description: 'Search for blocks or pages based on creation or modification dates',
@@ -342,6 +405,11 @@ export const toolSchemas = {
342
405
  type: 'boolean',
343
406
  description: 'Whether to include the content of matching blocks/pages',
344
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
345
413
  }
346
414
  },
347
415
  required: ['start_date', 'type', 'scope']
@@ -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
  }
@@ -945,6 +1074,20 @@ export class ToolHandlers {
945
1074
  ...(params.include_content && { content }),
946
1075
  page_title: pageTitle
947
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
+ }
948
1091
  // Sort by time
949
1092
  const sortedMatches = matches.sort((a, b) => b.time - a.time);
950
1093
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.18.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": {