roam-research-mcp 0.19.0 → 0.22.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
@@ -28,7 +28,7 @@ npm run build
28
28
 
29
29
  ## Features
30
30
 
31
- The server provides twelve powerful tools for interacting with Roam Research:
31
+ The server provides fourteen powerful tools for interacting with Roam Research:
32
32
 
33
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
34
34
  2. `roam_create_page`: Create new pages with optional content
@@ -42,13 +42,16 @@ The server provides twelve powerful tools for interacting with Roam Research:
42
42
  10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
43
43
  11. `roam_update_block`: Update block content with direct text or pattern-based transformations
44
44
  12. `roam_search_by_date`: Search for blocks and pages based on creation or modification dates
45
+ 13. `roam_search_for_tag`: Search for blocks containing specific tags with optional filtering by nearby tags
46
+ 14. `roam_remember`: Store and categorize memories or information with automatic tagging
47
+ 15. `roam_recall`: Recall memories of blocks marked with tag MEMORIES_TAG (see below) or blocks on page title of the same name.
45
48
 
46
49
  ## Setup
47
50
 
48
- 1. Create a Roam Research API token:
51
+ 1. Create a [Roam Research API token](https://x.com/RoamResearch/status/1789358175474327881):
49
52
 
50
53
  - Go to your graph settings
51
- - Navigate to the "API tokens" section
54
+ - Navigate to the "API tokens" section (Settings > "Graph" tab > "API Tokens" section and click on the "+ New API Token" button)
52
55
  - Create a new token
53
56
 
54
57
  2. Configure the environment variables:
@@ -60,28 +63,14 @@ The server provides twelve powerful tools for interacting with Roam Research:
60
63
  ```
61
64
  ROAM_API_TOKEN=your-api-token
62
65
  ROAM_GRAPH_NAME=your-graph-name
66
+ MEMORIES_TAG='#[[LLM/Memories]]'
67
+ PROFILE_PAGE='LLM/Profile' (not yet implemented)
63
68
  ```
64
69
 
65
70
  Option 2: Using MCP settings (Alternative method)
66
71
  Add the configuration to your MCP settings file:
67
72
 
68
73
  - For Cline (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`):
69
-
70
- ```json
71
- {
72
- "mcpServers": {
73
- "roam-research": {
74
- "command": "node",
75
- "args": ["/path/to/roam-research/build/index.js"],
76
- "env": {
77
- "ROAM_API_TOKEN": "your-api-token",
78
- "ROAM_GRAPH_NAME": "your-graph-name"
79
- }
80
- }
81
- }
82
- }
83
- ```
84
-
85
74
  - For Claude desktop app (`~/Library/Application Support/Claude/claude_desktop_config.json`):
86
75
 
87
76
  ```json
@@ -92,7 +81,9 @@ The server provides twelve powerful tools for interacting with Roam Research:
92
81
  "args": ["/path/to/roam-research/build/index.js"],
93
82
  "env": {
94
83
  "ROAM_API_TOKEN": "your-api-token",
95
- "ROAM_GRAPH_NAME": "your-graph-name"
84
+ "ROAM_GRAPH_NAME": "your-graph-name",
85
+ "MEMORIES_TAG": "#[[LLM/Memories]]",
86
+ "PROFILE_PAGE": "LLM/Profile"
96
87
  }
97
88
  }
98
89
  }
@@ -421,6 +412,86 @@ Returns:
421
412
  }
422
413
  ```
423
414
 
415
+ ### Search For Tags
416
+
417
+ Search for blocks containing specific tags with optional filtering by nearby tags:
418
+
419
+ ```typescript
420
+ use_mcp_tool roam-research roam_search_for_tag {
421
+ "primary_tag": "Project/Tasks",
422
+ "page_title_uid": "optional-page-title-or-uid",
423
+ "near_tag": "optional-secondary-tag",
424
+ "case_sensitive": true
425
+ }
426
+ ```
427
+
428
+ Features:
429
+
430
+ - Search for blocks containing specific tags
431
+ - Optional filtering by presence of another tag
432
+ - Page-scoped or graph-wide search
433
+ - Case-sensitive or case-insensitive search
434
+ - Returns block content with page context
435
+ - Efficient tag matching using Datalog queries
436
+
437
+ Parameters:
438
+
439
+ - `primary_tag`: The main tag to search for (required)
440
+ - `page_title_uid`: Title or UID of the page to search in (optional)
441
+ - `near_tag`: Another tag to filter results by (optional)
442
+ - `case_sensitive`: Whether to perform case-sensitive search (optional, default: true to match Roam's native behavior)
443
+
444
+ Returns:
445
+
446
+ ```json
447
+ {
448
+ "success": true,
449
+ "matches": [
450
+ {
451
+ "block_uid": "matching-block-uid",
452
+ "content": "Block content containing #[[primary_tag]]",
453
+ "page_title": "Page containing block"
454
+ }
455
+ ],
456
+ "message": "Found N block(s) referencing \"primary_tag\""
457
+ }
458
+ ```
459
+
460
+ ### Remember Information
461
+
462
+ Store memories or important information with automatic tagging and categorization:
463
+
464
+ ```typescript
465
+ use_mcp_tool roam-research roam_remember {
466
+ "memory": "Important information to remember",
467
+ "categories": ["Work", "Project/Alpha"]
468
+ }
469
+ ```
470
+
471
+ Features:
472
+
473
+ - Store information with #[[LLM/Memories]] tag
474
+ - Add optional category tags for organization
475
+ - Automatically adds to today's daily page
476
+ - Supports multiple categories per memory
477
+ - Easy retrieval using roam_search_for_tag
478
+ - Maintains chronological order of memories
479
+
480
+ Parameters:
481
+
482
+ - `memory`: The information to remember (required)
483
+ - `categories`: Optional array of categories to tag the memory with
484
+
485
+ Returns:
486
+
487
+ ```json
488
+ {
489
+ "success": true,
490
+ "block_uid": "created-block-uid",
491
+ "content": "Memory content with tags"
492
+ }
493
+ ```
494
+
424
495
  ### Search By Date
425
496
 
426
497
  Search for blocks and pages based on creation or modification dates:
@@ -8,7 +8,7 @@ export class TextSearchHandler extends BaseSearchHandler {
8
8
  this.params = params;
9
9
  }
10
10
  async execute() {
11
- const { text, page_title_uid, case_sensitive = false } = this.params;
11
+ const { text, page_title_uid } = this.params;
12
12
  // Get target page UID if provided for scoped search
13
13
  let targetPageUid;
14
14
  if (page_title_uid) {
@@ -19,14 +19,13 @@ export class TextSearchHandler extends BaseSearchHandler {
19
19
  :in $ ?search-text
20
20
  :where
21
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)'})]
22
+ [(clojure.string/includes? ?block-str ?search-text)]
24
23
  [?b :block/uid ?block-uid]
25
24
  [?b :block/page ?p]
26
25
  [?p :node/title ?page-title]]`;
27
26
  const queryParams = [text];
28
27
  const results = await q(this.graph, queryStr, queryParams);
29
- const searchDescription = `containing "${text}"${case_sensitive ? ' (case sensitive)' : ''}`;
28
+ const searchDescription = `containing "${text}"`;
30
29
  return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
31
30
  }
32
31
  }
@@ -5,7 +5,6 @@ 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
7
  import { ToolHandlers } from '../tools/tool-handlers.js';
8
- import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../search/index.js';
9
8
  export class RoamServer {
10
9
  server;
11
10
  toolHandlers;
@@ -22,6 +21,8 @@ export class RoamServer {
22
21
  }, {
23
22
  capabilities: {
24
23
  tools: {
24
+ roam_remember: {},
25
+ roam_recall: {},
25
26
  roam_add_todo: {},
26
27
  roam_fetch_page_by_title: {},
27
28
  roam_create_page: {},
@@ -57,6 +58,13 @@ export class RoamServer {
57
58
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
58
59
  try {
59
60
  switch (request.params.name) {
61
+ case 'roam_remember': {
62
+ const { memory, categories } = request.params.arguments;
63
+ const result = await this.toolHandlers.remember(memory, categories);
64
+ return {
65
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
66
+ };
67
+ }
60
68
  case 'roam_fetch_page_by_title': {
61
69
  const { title } = request.params.arguments;
62
70
  const content = await this.toolHandlers.fetchPageByTitle(title);
@@ -100,9 +108,8 @@ export class RoamServer {
100
108
  };
101
109
  }
102
110
  case 'roam_search_for_tag': {
103
- const params = request.params.arguments;
104
- const handler = new TagSearchHandler(this.graph, params);
105
- const result = await handler.execute();
111
+ const { primary_tag, page_title_uid, near_tag } = request.params.arguments;
112
+ const result = await this.toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
106
113
  return {
107
114
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
108
115
  };
@@ -116,16 +123,14 @@ export class RoamServer {
116
123
  }
117
124
  case 'roam_search_block_refs': {
118
125
  const params = request.params.arguments;
119
- const handler = new BlockRefSearchHandler(this.graph, params);
120
- const result = await handler.execute();
126
+ const result = await this.toolHandlers.searchBlockRefs(params);
121
127
  return {
122
128
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
123
129
  };
124
130
  }
125
131
  case 'roam_search_hierarchy': {
126
132
  const params = request.params.arguments;
127
- const handler = new HierarchySearchHandler(this.graph, params);
128
- const result = await handler.execute();
133
+ const result = await this.toolHandlers.searchHierarchy(params);
129
134
  return {
130
135
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
131
136
  };
@@ -138,8 +143,7 @@ export class RoamServer {
138
143
  }
139
144
  case 'roam_search_by_text': {
140
145
  const params = request.params.arguments;
141
- const handler = new TextSearchHandler(this.graph, params);
142
- const result = await handler.execute();
146
+ const result = await this.toolHandlers.searchByText(params);
143
147
  return {
144
148
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
145
149
  };
@@ -167,6 +171,12 @@ export class RoamServer {
167
171
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
168
172
  };
169
173
  }
174
+ case 'roam_recall': {
175
+ const result = await this.toolHandlers.recall();
176
+ return {
177
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
178
+ };
179
+ }
170
180
  case 'roam_update_blocks': {
171
181
  const { updates } = request.params.arguments;
172
182
  const result = await this.toolHandlers.updateBlocks(updates);
@@ -0,0 +1,47 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ /**
3
+ * Collects all referenced block UIDs from text
4
+ */
5
+ export const collectRefs = (text, depth = 0, refs = new Set()) => {
6
+ if (depth >= 4)
7
+ return refs; // Max recursion depth
8
+ const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
9
+ let match;
10
+ while ((match = refRegex.exec(text)) !== null) {
11
+ const [_, uid] = match;
12
+ refs.add(uid);
13
+ }
14
+ return refs;
15
+ };
16
+ /**
17
+ * Resolves block references in text by replacing them with their content
18
+ */
19
+ export const resolveRefs = async (graph, text, depth = 0) => {
20
+ if (depth >= 4)
21
+ return text; // Max recursion depth
22
+ const refs = collectRefs(text, depth);
23
+ if (refs.size === 0)
24
+ return text;
25
+ // Get referenced block contents
26
+ const refQuery = `[:find ?uid ?string
27
+ :in $ [?uid ...]
28
+ :where [?b :block/uid ?uid]
29
+ [?b :block/string ?string]]`;
30
+ const refResults = await q(graph, refQuery, [Array.from(refs)]);
31
+ // Create lookup map of uid -> string
32
+ const refMap = new Map();
33
+ refResults.forEach(([uid, string]) => {
34
+ refMap.set(uid, string);
35
+ });
36
+ // Replace references with their content
37
+ let resolvedText = text;
38
+ for (const uid of refs) {
39
+ const refContent = refMap.get(uid);
40
+ if (refContent) {
41
+ // Recursively resolve nested references
42
+ const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
43
+ resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
44
+ }
45
+ }
46
+ return resolvedText;
47
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Capitalizes each word in a string
3
+ */
4
+ export const capitalizeWords = (str) => {
5
+ return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
6
+ };
@@ -0,0 +1,269 @@
1
+ import { q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { formatRoamDate } from '../../utils/helpers.js';
4
+ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
5
+ export class BlockOperations {
6
+ graph;
7
+ constructor(graph) {
8
+ this.graph = graph;
9
+ }
10
+ async createBlock(content, page_uid, title) {
11
+ // If page_uid provided, use it directly
12
+ let targetPageUid = page_uid;
13
+ // If no page_uid but title provided, search for page by title
14
+ if (!targetPageUid && title) {
15
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
16
+ const findResults = await q(this.graph, findQuery, [title]);
17
+ if (findResults && findResults.length > 0) {
18
+ targetPageUid = findResults[0][0];
19
+ }
20
+ else {
21
+ // Create page with provided title if it doesn't exist
22
+ try {
23
+ await createPage(this.graph, {
24
+ action: 'create-page',
25
+ page: { title }
26
+ });
27
+ // Get the new page's UID
28
+ const results = await q(this.graph, findQuery, [title]);
29
+ if (!results || results.length === 0) {
30
+ throw new Error('Could not find created page');
31
+ }
32
+ targetPageUid = results[0][0];
33
+ }
34
+ catch (error) {
35
+ throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
36
+ }
37
+ }
38
+ }
39
+ // If neither page_uid nor title provided, use today's date page
40
+ if (!targetPageUid) {
41
+ const today = new Date();
42
+ const dateStr = formatRoamDate(today);
43
+ // Try to find today's page
44
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
45
+ const findResults = await q(this.graph, findQuery, [dateStr]);
46
+ if (findResults && findResults.length > 0) {
47
+ targetPageUid = findResults[0][0];
48
+ }
49
+ else {
50
+ // Create today's page if it doesn't exist
51
+ try {
52
+ await createPage(this.graph, {
53
+ action: 'create-page',
54
+ page: { title: dateStr }
55
+ });
56
+ // Get the new page's UID
57
+ const results = await q(this.graph, findQuery, [dateStr]);
58
+ if (!results || results.length === 0) {
59
+ throw new Error('Could not find created today\'s page');
60
+ }
61
+ targetPageUid = results[0][0];
62
+ }
63
+ catch (error) {
64
+ throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
65
+ }
66
+ }
67
+ }
68
+ try {
69
+ // If the content has multiple lines or is a table, use nested import
70
+ if (content.includes('\n')) {
71
+ // Parse and import the nested content
72
+ const convertedContent = convertToRoamMarkdown(content);
73
+ const nodes = parseMarkdown(convertedContent);
74
+ const actions = convertToRoamActions(nodes, targetPageUid, 'last');
75
+ // Execute batch actions to create the nested structure
76
+ const result = await batchActions(this.graph, {
77
+ action: 'batch-actions',
78
+ actions
79
+ });
80
+ if (!result) {
81
+ throw new Error('Failed to create nested blocks');
82
+ }
83
+ const blockUid = result.created_uids?.[0];
84
+ return {
85
+ success: true,
86
+ block_uid: blockUid,
87
+ parent_uid: targetPageUid
88
+ };
89
+ }
90
+ else {
91
+ // For non-table content, create a simple block
92
+ await createRoamBlock(this.graph, {
93
+ action: 'create-block',
94
+ location: {
95
+ "parent-uid": targetPageUid,
96
+ "order": "last"
97
+ },
98
+ block: { string: content }
99
+ });
100
+ // Get the block's UID
101
+ const findBlockQuery = `[:find ?uid
102
+ :in $ ?parent ?string
103
+ :where [?b :block/uid ?uid]
104
+ [?b :block/string ?string]
105
+ [?b :block/parents ?p]
106
+ [?p :block/uid ?parent]]`;
107
+ const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
108
+ if (!blockResults || blockResults.length === 0) {
109
+ throw new Error('Could not find created block');
110
+ }
111
+ const blockUid = blockResults[0][0];
112
+ return {
113
+ success: true,
114
+ block_uid: blockUid,
115
+ parent_uid: targetPageUid
116
+ };
117
+ }
118
+ }
119
+ catch (error) {
120
+ throw new McpError(ErrorCode.InternalError, `Failed to create block: ${error instanceof Error ? error.message : String(error)}`);
121
+ }
122
+ }
123
+ async updateBlock(block_uid, content, transform) {
124
+ if (!block_uid) {
125
+ throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
126
+ }
127
+ // Get current block content
128
+ const blockQuery = `[:find ?string .
129
+ :where [?b :block/uid "${block_uid}"]
130
+ [?b :block/string ?string]]`;
131
+ const result = await q(this.graph, blockQuery, []);
132
+ if (result === null || result === undefined) {
133
+ throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
134
+ }
135
+ const currentContent = String(result);
136
+ if (currentContent === null || currentContent === undefined) {
137
+ throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
138
+ }
139
+ // Determine new content
140
+ let newContent;
141
+ if (content) {
142
+ newContent = content;
143
+ }
144
+ else if (transform) {
145
+ newContent = transform(currentContent);
146
+ }
147
+ else {
148
+ throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
149
+ }
150
+ try {
151
+ await updateRoamBlock(this.graph, {
152
+ action: 'update-block',
153
+ block: {
154
+ uid: block_uid,
155
+ string: newContent
156
+ }
157
+ });
158
+ return {
159
+ success: true,
160
+ content: newContent
161
+ };
162
+ }
163
+ catch (error) {
164
+ throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
165
+ }
166
+ }
167
+ async updateBlocks(updates) {
168
+ if (!Array.isArray(updates) || updates.length === 0) {
169
+ throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
170
+ }
171
+ // Validate each update has required fields
172
+ updates.forEach((update, index) => {
173
+ if (!update.block_uid) {
174
+ throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
175
+ }
176
+ if (!update.content && !update.transform) {
177
+ throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
178
+ }
179
+ });
180
+ // Get current content for all blocks
181
+ const blockUids = updates.map(u => u.block_uid);
182
+ const blockQuery = `[:find ?uid ?string
183
+ :in $ [?uid ...]
184
+ :where [?b :block/uid ?uid]
185
+ [?b :block/string ?string]]`;
186
+ const blockResults = await q(this.graph, blockQuery, [blockUids]);
187
+ // Create map of uid -> current content
188
+ const contentMap = new Map();
189
+ blockResults.forEach(([uid, string]) => {
190
+ contentMap.set(uid, string);
191
+ });
192
+ // Prepare batch actions
193
+ const actions = [];
194
+ const results = [];
195
+ for (const update of updates) {
196
+ try {
197
+ const currentContent = contentMap.get(update.block_uid);
198
+ if (!currentContent) {
199
+ results.push({
200
+ block_uid: update.block_uid,
201
+ content: '',
202
+ success: false,
203
+ error: `Block with UID "${update.block_uid}" not found`
204
+ });
205
+ continue;
206
+ }
207
+ // Determine new content
208
+ let newContent;
209
+ if (update.content) {
210
+ newContent = update.content;
211
+ }
212
+ else if (update.transform) {
213
+ const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
214
+ newContent = currentContent.replace(regex, update.transform.replace);
215
+ }
216
+ else {
217
+ // This shouldn't happen due to earlier validation
218
+ throw new Error('Invalid update configuration');
219
+ }
220
+ // Add to batch actions
221
+ actions.push({
222
+ action: 'update-block',
223
+ block: {
224
+ uid: update.block_uid,
225
+ string: newContent
226
+ }
227
+ });
228
+ results.push({
229
+ block_uid: update.block_uid,
230
+ content: newContent,
231
+ success: true
232
+ });
233
+ }
234
+ catch (error) {
235
+ results.push({
236
+ block_uid: update.block_uid,
237
+ content: contentMap.get(update.block_uid) || '',
238
+ success: false,
239
+ error: error.message
240
+ });
241
+ }
242
+ }
243
+ // Execute batch update if we have any valid actions
244
+ if (actions.length > 0) {
245
+ try {
246
+ const batchResult = await batchActions(this.graph, {
247
+ action: 'batch-actions',
248
+ actions
249
+ });
250
+ if (!batchResult) {
251
+ throw new Error('Batch update failed');
252
+ }
253
+ }
254
+ catch (error) {
255
+ // Mark all previously successful results as failed
256
+ results.forEach(result => {
257
+ if (result.success) {
258
+ result.success = false;
259
+ result.error = `Batch update failed: ${error.message}`;
260
+ }
261
+ });
262
+ }
263
+ }
264
+ return {
265
+ success: results.every(r => r.success),
266
+ results
267
+ };
268
+ }
269
+ }