roam-research-mcp 0.20.0 → 0.22.1

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
@@ -44,13 +44,14 @@ The server provides fourteen powerful tools for interacting with Roam Research:
44
44
  12. `roam_search_by_date`: Search for blocks and pages based on creation or modification dates
45
45
  13. `roam_search_for_tag`: Search for blocks containing specific tags with optional filtering by nearby tags
46
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.
47
48
 
48
49
  ## Setup
49
50
 
50
- 1. Create a Roam Research API token:
51
+ 1. Create a [Roam Research API token](https://x.com/RoamResearch/status/1789358175474327881):
51
52
 
52
53
  - Go to your graph settings
53
- - 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)
54
55
  - Create a new token
55
56
 
56
57
  2. Configure the environment variables:
@@ -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;
@@ -23,6 +22,7 @@ export class RoamServer {
23
22
  capabilities: {
24
23
  tools: {
25
24
  roam_remember: {},
25
+ roam_recall: {},
26
26
  roam_add_todo: {},
27
27
  roam_fetch_page_by_title: {},
28
28
  roam_create_page: {},
@@ -108,9 +108,8 @@ export class RoamServer {
108
108
  };
109
109
  }
110
110
  case 'roam_search_for_tag': {
111
- const params = request.params.arguments;
112
- const handler = new TagSearchHandler(this.graph, params);
113
- 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);
114
113
  return {
115
114
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
116
115
  };
@@ -124,16 +123,14 @@ export class RoamServer {
124
123
  }
125
124
  case 'roam_search_block_refs': {
126
125
  const params = request.params.arguments;
127
- const handler = new BlockRefSearchHandler(this.graph, params);
128
- const result = await handler.execute();
126
+ const result = await this.toolHandlers.searchBlockRefs(params);
129
127
  return {
130
128
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
131
129
  };
132
130
  }
133
131
  case 'roam_search_hierarchy': {
134
132
  const params = request.params.arguments;
135
- const handler = new HierarchySearchHandler(this.graph, params);
136
- const result = await handler.execute();
133
+ const result = await this.toolHandlers.searchHierarchy(params);
137
134
  return {
138
135
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
139
136
  };
@@ -146,8 +143,7 @@ export class RoamServer {
146
143
  }
147
144
  case 'roam_search_by_text': {
148
145
  const params = request.params.arguments;
149
- const handler = new TextSearchHandler(this.graph, params);
150
- const result = await handler.execute();
146
+ const result = await this.toolHandlers.searchByText(params);
151
147
  return {
152
148
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
153
149
  };
@@ -175,6 +171,12 @@ export class RoamServer {
175
171
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
176
172
  };
177
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
+ }
178
180
  case 'roam_update_blocks': {
179
181
  const { updates } = request.params.arguments;
180
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
+ }
@@ -0,0 +1,102 @@
1
+ import { q, createBlock, 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 { resolveRefs } from '../helpers/refs.js';
5
+ import { SearchOperations } from './search/index.js';
6
+ export class MemoryOperations {
7
+ graph;
8
+ searchOps;
9
+ constructor(graph) {
10
+ this.graph = graph;
11
+ this.searchOps = new SearchOperations(graph);
12
+ }
13
+ async remember(memory, categories) {
14
+ // Get today's date
15
+ const today = new Date();
16
+ const dateStr = formatRoamDate(today);
17
+ // Try to find today's page
18
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
19
+ const findResults = await q(this.graph, findQuery, [dateStr]);
20
+ let pageUid;
21
+ if (findResults && findResults.length > 0) {
22
+ pageUid = findResults[0][0];
23
+ }
24
+ else {
25
+ // Create today's page if it doesn't exist
26
+ try {
27
+ await createPage(this.graph, {
28
+ action: 'create-page',
29
+ page: { title: dateStr }
30
+ });
31
+ // Get the new page's UID
32
+ const results = await q(this.graph, findQuery, [dateStr]);
33
+ if (!results || results.length === 0) {
34
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
35
+ }
36
+ pageUid = results[0][0];
37
+ }
38
+ catch (error) {
39
+ throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
40
+ }
41
+ }
42
+ // Get memories tag from environment
43
+ const memoriesTag = process.env.MEMORIES_TAG;
44
+ if (!memoriesTag) {
45
+ throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
46
+ }
47
+ // Format categories as Roam tags if provided
48
+ const categoryTags = categories?.map(cat => {
49
+ // Handle multi-word categories
50
+ return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
51
+ }).join(' ') || '';
52
+ // Create block with memory, memories tag, and optional categories
53
+ const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
54
+ try {
55
+ await createBlock(this.graph, {
56
+ action: 'create-block',
57
+ location: {
58
+ "parent-uid": pageUid,
59
+ "order": "last"
60
+ },
61
+ block: { string: blockContent }
62
+ });
63
+ }
64
+ catch (error) {
65
+ throw new McpError(ErrorCode.InternalError, 'Failed to create memory block');
66
+ }
67
+ return { success: true };
68
+ }
69
+ async recall() {
70
+ // Get memories tag from environment
71
+ const memoriesTag = process.env.MEMORIES_TAG;
72
+ if (!memoriesTag) {
73
+ throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
74
+ }
75
+ // Extract the tag text, removing any formatting
76
+ const tagText = memoriesTag
77
+ .replace(/^#/, '') // Remove leading #
78
+ .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]]
79
+ // Get results from tag search
80
+ const tagResults = await this.searchOps.searchForTag(tagText);
81
+ // Get blocks from the memories page
82
+ const pageQuery = `[:find ?string
83
+ :in $ ?title
84
+ :where [?p :node/title ?title]
85
+ [?b :block/page ?p]
86
+ [?b :block/string ?string]]`;
87
+ const pageResults = await q(this.graph, pageQuery, [tagText]);
88
+ // Combine both sets of results and remove the memories tag
89
+ const allMemories = [
90
+ ...tagResults.matches.map((match) => match.content),
91
+ ...pageResults.map(([content]) => content)
92
+ ].map(content => content.replace(`${memoriesTag} `, ''));
93
+ // Resolve any block references in the combined memories
94
+ const resolvedMemories = await Promise.all(allMemories.map(async (content) => resolveRefs(this.graph, content)));
95
+ // Remove duplicates
96
+ const uniqueMemories = [...new Set(resolvedMemories)];
97
+ return {
98
+ success: true,
99
+ memories: uniqueMemories
100
+ };
101
+ }
102
+ }