roam-research-mcp 0.27.0 → 0.30.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
@@ -105,22 +105,154 @@ The server provides powerful tools for interacting with Roam Research:
105
105
 
106
106
  1. `roam_fetch_page_by_title`: Fetch page content by title.
107
107
  2. `roam_create_page`: Create new pages with optional content and headings.
108
- 3. `roam_create_block`: Add new blocks to an existing page or today's daily note.
109
- 4. `roam_import_markdown`: Import nested markdown content under a specific block.
110
- 5. `roam_add_todo`: Add a list of todo items to today's daily page.
111
- 6. `roam_create_outline`: Add a structured outline to an existing page or block.
112
- 7. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
113
- 8. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
114
- 9. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight).
115
- 10. `roam_search_by_text`: Search for blocks containing specific text.
116
- 11. `roam_update_block`: Update a single block identified by its UID.
117
- 12. `roam_update_multiple_blocks`: Efficiently update multiple blocks in a single batch operation.
118
- 13. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.
119
- 14. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates.
120
- 15. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
121
- 16. `roam_remember`: Add a memory or piece of information to remember.
122
- 17. `roam_recall`: Retrieve all stored memories.
123
- 18. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph beyond the available search tools.
108
+ 3. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
109
+ 4. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
110
+ 5. `roam_create_outline`: Add a structured outline to an existing page or block, with support for `children_view_type`. (Internally uses `roam_process_batch_actions`.)
111
+ 6. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
112
+ 7. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
113
+ 8. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight).
114
+ 9. `roam_search_by_text`: Search for blocks containing specific text.
115
+ 10. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.
116
+ 11. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates.
117
+ 12. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
118
+ 13. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.)
119
+ 14. `roam_recall`: Retrieve all stored memories.
120
+ 15. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph beyond the available search tools.
121
+ 16. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch.
122
+
123
+ **Deprecated Tools**:
124
+ The following tools have been deprecated as of `v.0.30.0` in favor of the more powerful and flexible `roam_process_batch_actions`:
125
+
126
+ - `roam_create_block`: Use `roam_process_batch_actions` with the `create-block` action.
127
+ - `roam_update_block`: Use `roam_process_batch_actions` with the `update-block` action.
128
+ - `roam_update_multiple_blocks`: Use `roam_process_batch_actions` with multiple `update-block` actions.
129
+
130
+ ### Important Considerations for Tool Usage
131
+
132
+ When making changes to your Roam graph, precision in your requests is crucial for achieving desired outcomes.
133
+
134
+ **Specificity in Requests:**
135
+ Some tools allow for identifying blocks or pages by their text content (e.g., `parent_string`, `title`). While convenient, using **Unique Identifiers (UIDs)** is always preferred for accuracy and reliability. Text-based matching can be prone to errors if there are multiple blocks with similar content or if the content changes. Tools are designed to work best when provided with explicit UIDs where available.
136
+
137
+ **Example of Specificity:**
138
+ Instead of:
139
+ `"parent_string": "My project notes"`
140
+
141
+ Prefer:
142
+ `"parent_uid": "((some-unique-uid))"`
143
+
144
+ **Migrating from Deprecated Tools:**
145
+ The following examples demonstrate how to achieve the functionality of the deprecated tools using `roam_process_batch_actions`.
146
+
147
+ **1. Replacing `roam_create_block`:**
148
+
149
+ - **Old (Deprecated):**
150
+ ```json
151
+ {
152
+ "tool_name": "roam_create_block",
153
+ "arguments": {
154
+ "content": "New block content",
155
+ "page_uid": "((page-uid))"
156
+ }
157
+ }
158
+ ```
159
+ - **New (Recommended):**
160
+ ```json
161
+ {
162
+ "tool_name": "roam_process_batch_actions",
163
+ "arguments": {
164
+ "actions": [
165
+ {
166
+ "action": "create-block",
167
+ "location": {
168
+ "parent-uid": "((page-uid))",
169
+ "order": "last"
170
+ },
171
+ "block": {
172
+ "string": "New block content"
173
+ }
174
+ }
175
+ ]
176
+ }
177
+ }
178
+ ```
179
+
180
+ **2. Replacing `roam_update_block`:**
181
+
182
+ - **Old (Deprecated):**
183
+ ```json
184
+ {
185
+ "tool_name": "roam_update_block",
186
+ "arguments": {
187
+ "block_uid": "((block-uid))",
188
+ "content": "Updated block content"
189
+ }
190
+ }
191
+ ```
192
+ - **New (Recommended):**
193
+ ```json
194
+ {
195
+ "tool_name": "roam_process_batch_actions",
196
+ "arguments": {
197
+ "actions": [
198
+ {
199
+ "action": "update-block",
200
+ "uid": "((block-uid))",
201
+ "string": "Updated block content"
202
+ }
203
+ ]
204
+ }
205
+ }
206
+ ```
207
+
208
+ **3. Replacing `roam_update_multiple_blocks`:**
209
+
210
+ - **Old (Deprecated):**
211
+ ```json
212
+ {
213
+ "tool_name": "roam_update_multiple_blocks",
214
+ "arguments": {
215
+ "updates": [
216
+ {
217
+ "block_uid": "((block-uid-1))",
218
+ "content": "Content for block 1"
219
+ },
220
+ {
221
+ "block_uid": "((block-uid-2))",
222
+ "transform": {
223
+ "find": "old text",
224
+ "replace": "new text"
225
+ }
226
+ }
227
+ ]
228
+ }
229
+ }
230
+ ```
231
+ - **New (Recommended):**
232
+ ```json
233
+ {
234
+ "tool_name": "roam_process_batch_actions",
235
+ "arguments": {
236
+ "actions": [
237
+ {
238
+ "action": "update-block",
239
+ "uid": "((block-uid-1))",
240
+ "string": "Content for block 1"
241
+ },
242
+ {
243
+ "action": "update-block",
244
+ "uid": "((block-uid-2))",
245
+ "string": "((block-content-with-new-text))"
246
+ // Note: Transformations (find/replace) must be handled by the client
247
+ // before sending the 'string' to roam_process_batch_actions.
248
+ }
249
+ ]
250
+ }
251
+ }
252
+ ```
253
+
254
+ **Caveat Regarding Heading Formatting:**
255
+ Please note that while the `roam_process_batch_actions` tool can set block headings (H1, H2, H3), directly **removing** an existing heading (i.e., reverting a heading block to a plain text block) through this tool is not currently supported by the Roam API. The `heading` attribute persists its value once set, and attempting to remove it by setting `heading` to `0`, `null`, or omitting the property will not unset the heading.
124
256
 
125
257
  ## Setup
126
258
 
@@ -284,7 +284,8 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
284
284
  block: {
285
285
  uid: block.uid,
286
286
  string: block.content,
287
- ...(block.heading_level && { heading: block.heading_level })
287
+ ...(block.heading_level && { heading: block.heading_level }),
288
+ ...(block.children_view_type && { 'children-view-type': block.children_view_type })
288
289
  }
289
290
  };
290
291
  actions.push(action);
@@ -11,6 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { createServer } from 'node:http';
13
13
  import { fileURLToPath } from 'node:url';
14
+ import { findAvailablePort } from '../utils/net.js';
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
16
17
  // Read package.json to get the version
@@ -72,13 +73,6 @@ export class RoamServer {
72
73
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
73
74
  };
74
75
  }
75
- case 'roam_create_block': {
76
- const { content, page_uid, title, heading } = request.params.arguments;
77
- const result = await this.toolHandlers.createBlock(content, page_uid, title, heading);
78
- return {
79
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
80
- };
81
- }
82
76
  case 'roam_import_markdown': {
83
77
  const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = request.params.arguments;
84
78
  const result = await this.toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
@@ -153,27 +147,6 @@ export class RoamServer {
153
147
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
154
148
  };
155
149
  }
156
- case 'roam_update_block': {
157
- const { block_uid, content, transform_pattern } = request.params.arguments;
158
- // Validate that either content or transform_pattern is provided, but not both or neither
159
- if ((!content && !transform_pattern) || (content && transform_pattern)) {
160
- throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform_pattern must be provided, but not both or neither');
161
- }
162
- let result;
163
- if (content) {
164
- result = await this.toolHandlers.updateBlock(block_uid, content);
165
- }
166
- else {
167
- // We know transform_pattern exists due to validation above
168
- result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
169
- const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
170
- return currentContent.replace(regex, transform_pattern.replace);
171
- });
172
- }
173
- return {
174
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
175
- };
176
- }
177
150
  case 'roam_recall': {
178
151
  const { sort_by = 'newest', filter_tag } = request.params.arguments;
179
152
  const result = await this.toolHandlers.recall(sort_by, filter_tag);
@@ -181,22 +154,16 @@ export class RoamServer {
181
154
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
182
155
  };
183
156
  }
184
- case 'roam_update_multiple_blocks': {
185
- const { updates } = request.params.arguments;
186
- // Validate that for each update, either content or transform is provided, but not both or neither
187
- for (const update of updates) {
188
- if ((!update.content && !update.transform) || (update.content && update.transform)) {
189
- throw new McpError(ErrorCode.InvalidRequest, 'For each update, either content or transform must be provided, but not both or neither');
190
- }
191
- }
192
- const result = await this.toolHandlers.updateBlocks(updates);
157
+ case 'roam_datomic_query': {
158
+ const { query, inputs } = request.params.arguments;
159
+ const result = await this.toolHandlers.executeDatomicQuery({ query, inputs });
193
160
  return {
194
161
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
195
162
  };
196
163
  }
197
- case 'roam_datomic_query': {
198
- const { query, inputs } = request.params.arguments;
199
- const result = await this.toolHandlers.executeDatomicQuery({ query, inputs });
164
+ case 'roam_process_batch_actions': {
165
+ const { actions } = request.params.arguments;
166
+ const result = await this.toolHandlers.processBatch(actions);
200
167
  return {
201
168
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
202
169
  };
@@ -256,8 +223,9 @@ export class RoamServer {
256
223
  }
257
224
  }
258
225
  });
259
- httpServer.listen(parseInt(HTTP_STREAM_PORT), () => {
260
- // console.log(`MCP Roam Research server running HTTP Stream on port ${HTTP_STREAM_PORT}`);
226
+ const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT));
227
+ httpServer.listen(availableHttpPort, () => {
228
+ // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`);
261
229
  });
262
230
  // SSE Server setup
263
231
  const sseMcpServer = new Server({
@@ -318,8 +286,9 @@ export class RoamServer {
318
286
  }
319
287
  }
320
288
  });
321
- sseHttpServer.listen(parseInt(SSE_PORT), () => {
322
- // console.log(`MCP Roam Research server running SSE on port ${SSE_PORT}`);
289
+ const availableSsePort = await findAvailablePort(parseInt(SSE_PORT));
290
+ sseHttpServer.listen(availableSsePort, () => {
291
+ // console.log(`MCP Roam Research server running SSE on port ${availableSsePort}`);
323
292
  });
324
293
  }
325
294
  catch (error) {
@@ -0,0 +1,37 @@
1
+ import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk';
2
+ export class BatchOperations {
3
+ constructor(graph) {
4
+ this.graph = graph;
5
+ }
6
+ async processBatch(actions) {
7
+ const batchActions = actions.map(action => {
8
+ const { action: actionType, ...rest } = action;
9
+ const roamAction = { action: actionType };
10
+ if (rest.location) {
11
+ roamAction.location = {
12
+ 'parent-uid': rest.location['parent-uid'],
13
+ order: rest.location.order,
14
+ };
15
+ }
16
+ const block = {};
17
+ if (rest.string)
18
+ block.string = rest.string;
19
+ if (rest.uid)
20
+ block.uid = rest.uid;
21
+ if (rest.open !== undefined)
22
+ block.open = rest.open;
23
+ if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) {
24
+ block.heading = rest.heading;
25
+ }
26
+ if (rest['text-align'])
27
+ block['text-align'] = rest['text-align'];
28
+ if (rest['children-view-type'])
29
+ block['children-view-type'] = rest['children-view-type'];
30
+ if (Object.keys(block).length > 0) {
31
+ roamAction.block = block;
32
+ }
33
+ return roamAction;
34
+ });
35
+ return await roamBatchActions(this.graph, { actions: batchActions });
36
+ }
37
+ }
@@ -1,4 +1,4 @@
1
- import { q, createBlock, createPage } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { resolveRefs } from '../helpers/refs.js';
@@ -49,18 +49,27 @@ export class MemoryOperations {
49
49
  }).join(' ') || '';
50
50
  // Create block with memory, memories tag, and optional categories
51
51
  const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
52
- try {
53
- await createBlock(this.graph, {
52
+ const actions = [{
54
53
  action: 'create-block',
55
54
  location: {
56
- "parent-uid": pageUid,
57
- "order": "last"
55
+ 'parent-uid': pageUid,
56
+ order: 'last'
58
57
  },
59
- block: { string: blockContent }
58
+ block: {
59
+ string: blockContent
60
+ }
61
+ }];
62
+ try {
63
+ const result = await batchActions(this.graph, {
64
+ action: 'batch-actions',
65
+ actions
60
66
  });
67
+ if (!result) {
68
+ throw new McpError(ErrorCode.InternalError, 'Failed to create memory block via batch action');
69
+ }
61
70
  }
62
71
  catch (error) {
63
- throw new McpError(ErrorCode.InternalError, 'Failed to create memory block');
72
+ throw new McpError(ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}`);
64
73
  }
65
74
  return { success: true };
66
75
  }
@@ -1,4 +1,4 @@
1
- import { q, createPage, createBlock, batchActions } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { capitalizeWords } from '../helpers/text.js';
@@ -138,15 +138,21 @@ export class OutlineOperations {
138
138
  }
139
139
  for (let retry = 0; retry < maxRetries; retry++) {
140
140
  console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
141
- // Create block
142
- const success = await createBlock(this.graph, {
143
- action: 'create-block',
144
- location: {
145
- 'parent-uid': parentUid,
146
- order: 'last'
147
- },
148
- block: { string: content }
141
+ // Create block using batchActions
142
+ const batchResult = await batchActions(this.graph, {
143
+ action: 'batch-actions',
144
+ actions: [{
145
+ action: 'create-block',
146
+ location: {
147
+ 'parent-uid': parentUid,
148
+ order: 'last'
149
+ },
150
+ block: { string: content }
151
+ }]
149
152
  });
153
+ if (!batchResult) {
154
+ throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
155
+ }
150
156
  // Wait with exponential backoff
151
157
  const delay = initialDelay * Math.pow(2, retry);
152
158
  await new Promise(resolve => setTimeout(resolve, delay));
@@ -232,7 +238,13 @@ export class OutlineOperations {
232
238
  // Convert to Roam markdown format
233
239
  const convertedContent = convertToRoamMarkdown(markdownContent);
234
240
  // Parse markdown into hierarchical structure
235
- const nodes = parseMarkdown(convertedContent);
241
+ // We pass the original OutlineItem properties (heading, children_view_type)
242
+ // along with the parsed content to the nodes.
243
+ const nodes = parseMarkdown(convertedContent).map((node, index) => ({
244
+ ...node,
245
+ ...(validOutline[index].heading && { heading_level: validOutline[index].heading }),
246
+ ...(validOutline[index].children_view_type && { children_view_type: validOutline[index].children_view_type })
247
+ }));
236
248
  // Convert nodes to batch actions
237
249
  const actions = convertToRoamActions(nodes, targetParentUid, 'first');
238
250
  if (actions.length === 0) {
@@ -350,19 +362,26 @@ export class OutlineOperations {
350
362
  };
351
363
  }
352
364
  else {
353
- // Create a simple block for non-nested content
354
- try {
355
- await createBlock(this.graph, {
365
+ // Create a simple block for non-nested content using batchActions
366
+ const actions = [{
356
367
  action: 'create-block',
357
368
  location: {
358
369
  "parent-uid": targetParentUid,
359
370
  order
360
371
  },
361
372
  block: { string: content }
373
+ }];
374
+ try {
375
+ const result = await batchActions(this.graph, {
376
+ action: 'batch-actions',
377
+ actions
362
378
  });
379
+ if (!result) {
380
+ throw new McpError(ErrorCode.InternalError, 'Failed to create content block via batch action');
381
+ }
363
382
  }
364
383
  catch (error) {
365
- throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
384
+ throw new McpError(ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}`);
366
385
  }
367
386
  return {
368
387
  success: true,
@@ -1,4 +1,4 @@
1
- import { q, createBlock, createPage, batchActions } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  export class TodoOperations {
@@ -37,44 +37,23 @@ export class TodoOperations {
37
37
  throw new Error('Failed to create today\'s page');
38
38
  }
39
39
  }
40
- // If more than 10 todos, use batch actions
41
40
  const todo_tag = "{{TODO}}";
42
- if (todos.length > 10) {
43
- const actions = todos.map((todo, index) => ({
44
- action: 'create-block',
45
- location: {
46
- 'parent-uid': targetPageUid,
47
- order: index
48
- },
49
- block: {
50
- string: `${todo_tag} ${todo}`
51
- }
52
- }));
53
- const result = await batchActions(this.graph, {
54
- action: 'batch-actions',
55
- actions
56
- });
57
- if (!result) {
58
- throw new Error('Failed to create todo blocks');
59
- }
60
- }
61
- else {
62
- // Create todos sequentially
63
- for (const todo of todos) {
64
- try {
65
- await createBlock(this.graph, {
66
- action: 'create-block',
67
- location: {
68
- "parent-uid": targetPageUid,
69
- "order": "last"
70
- },
71
- block: { string: `${todo_tag} ${todo}` }
72
- });
73
- }
74
- catch (error) {
75
- throw new Error('Failed to create todo block');
76
- }
41
+ const actions = todos.map((todo, index) => ({
42
+ action: 'create-block',
43
+ location: {
44
+ 'parent-uid': targetPageUid,
45
+ order: index
46
+ },
47
+ block: {
48
+ string: `${todo_tag} ${todo}`
77
49
  }
50
+ }));
51
+ const result = await batchActions(this.graph, {
52
+ action: 'batch-actions',
53
+ actions
54
+ });
55
+ if (!result) {
56
+ throw new Error('Failed to create todo blocks');
78
57
  }
79
58
  return { success: true };
80
59
  }
@@ -78,34 +78,6 @@ export const toolSchemas = {
78
78
  required: ['title'],
79
79
  },
80
80
  },
81
- roam_create_block: {
82
- name: 'roam_create_block',
83
- description: 'Add 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).',
84
- inputSchema: {
85
- type: 'object',
86
- properties: {
87
- content: {
88
- type: 'string',
89
- description: 'Content of the block',
90
- },
91
- page_uid: {
92
- type: 'string',
93
- description: 'Optional: UID of the page to add block to',
94
- },
95
- title: {
96
- type: 'string',
97
- description: 'Optional: Title of the page to add block to (defaults to today\'s date if neither page_uid nor title provided)',
98
- },
99
- heading: {
100
- type: 'integer',
101
- description: 'Optional: Heading formatting for this block (1-3)',
102
- minimum: 1,
103
- maximum: 3
104
- }
105
- },
106
- required: ['content'],
107
- },
108
- },
109
81
  roam_create_outline: {
110
82
  name: 'roam_create_outline',
111
83
  description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept',
@@ -114,11 +86,11 @@ export const toolSchemas = {
114
86
  properties: {
115
87
  page_title_uid: {
116
88
  type: 'string',
117
- description: 'Title (or UID if known) of the page. Leave blank to use the default daily page'
89
+ description: 'Title or UID of the page (UID is preferred for accuracy). Leave blank to use the default daily page.'
118
90
  },
119
91
  block_text_uid: {
120
92
  type: 'string',
121
- description: 'A relevant title heading for the outline (or UID, if known) of the block under which outline content will be nested. If blank, content will be nested directly under the page. This can be either the text content of the block or its UID.'
93
+ description: 'The text content or UID of the block to nest the outline under (UID is preferred for accuracy). If blank, content is nested directly under the page.'
122
94
  },
123
95
  outline: {
124
96
  type: 'array',
@@ -141,6 +113,11 @@ export const toolSchemas = {
141
113
  description: 'Optional: Heading formatting for this block (1-3)',
142
114
  minimum: 1,
143
115
  maximum: 3
116
+ },
117
+ children_view_type: {
118
+ type: 'string',
119
+ description: 'Optional: The view type for children of this block ("bullet", "document", or "numbered")',
120
+ enum: ["bullet", "document", "numbered"]
144
121
  }
145
122
  },
146
123
  required: ['text', 'level']
@@ -162,19 +139,19 @@ export const toolSchemas = {
162
139
  },
163
140
  page_uid: {
164
141
  type: 'string',
165
- description: 'Optional: UID of the page containing the parent block'
142
+ description: 'Optional: UID of the page containing the parent block (preferred for accuracy).'
166
143
  },
167
144
  page_title: {
168
145
  type: 'string',
169
- description: 'Optional: Title of the page containing the parent block (ignored if page_uid provided)'
146
+ description: 'Optional: Title of the page containing the parent block (used if page_uid is not provided).'
170
147
  },
171
148
  parent_uid: {
172
149
  type: 'string',
173
- description: 'Optional: UID of the parent block to add content under'
150
+ description: 'Optional: UID of the parent block to add content under (preferred for accuracy).'
174
151
  },
175
152
  parent_string: {
176
153
  type: 'string',
177
- description: 'Optional: Exact string content of the parent block to add content under (must provide either page_uid (preferred) or page_title)'
154
+ description: 'Optional: Exact string content of the parent block to add content under (used if parent_uid is not provided; requires page_uid or page_title).'
178
155
  },
179
156
  order: {
180
157
  type: 'string',
@@ -198,7 +175,7 @@ export const toolSchemas = {
198
175
  },
199
176
  page_title_uid: {
200
177
  type: 'string',
201
- description: 'Optional: Title or UID of the page to search in. Defaults to today\'s daily page if not provided',
178
+ description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). Defaults to today\'s daily page if not provided.',
202
179
  },
203
180
  near_tag: {
204
181
  type: 'string',
@@ -221,7 +198,7 @@ export const toolSchemas = {
221
198
  },
222
199
  page_title_uid: {
223
200
  type: 'string',
224
- description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
201
+ description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
225
202
  },
226
203
  include: {
227
204
  type: 'string',
@@ -247,7 +224,7 @@ export const toolSchemas = {
247
224
  },
248
225
  page_title_uid: {
249
226
  type: 'string',
250
- description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
227
+ description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
251
228
  }
252
229
  }
253
230
  }
@@ -268,7 +245,7 @@ export const toolSchemas = {
268
245
  },
269
246
  page_title_uid: {
270
247
  type: 'string',
271
- description: 'Optional: Title or UID of the page to search in'
248
+ description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy).'
272
249
  },
273
250
  max_depth: {
274
251
  type: 'integer',
@@ -306,100 +283,12 @@ export const toolSchemas = {
306
283
  },
307
284
  page_title_uid: {
308
285
  type: 'string',
309
- description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
286
+ description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
310
287
  }
311
288
  },
312
289
  required: ['text']
313
290
  }
314
291
  },
315
- roam_update_block: {
316
- name: 'roam_update_block',
317
- description: 'Update a single block identified by its UID. Use this for individual block updates when you need to either replace the entire content or apply a transform pattern to modify specific parts of the content.\nNOTE 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).',
318
- inputSchema: {
319
- type: 'object',
320
- properties: {
321
- block_uid: {
322
- type: 'string',
323
- description: 'UID of the block to update'
324
- },
325
- content: {
326
- type: 'string',
327
- description: 'New content for the block. If not provided, transform_pattern will be used.'
328
- },
329
- transform_pattern: {
330
- type: 'object',
331
- description: 'Pattern to transform the current content. Used if content is not provided.',
332
- properties: {
333
- find: {
334
- type: 'string',
335
- description: 'Text or regex pattern to find'
336
- },
337
- replace: {
338
- type: 'string',
339
- description: 'Text to replace with'
340
- },
341
- global: {
342
- type: 'boolean',
343
- description: 'Whether to replace all occurrences',
344
- default: true
345
- }
346
- },
347
- required: ['find', 'replace']
348
- }
349
- },
350
- required: ['block_uid']
351
- // Note: Validation for either content or transform_pattern is handled in the server code
352
- }
353
- },
354
- roam_update_multiple_blocks: {
355
- name: 'roam_update_multiple_blocks',
356
- description: 'Efficiently update multiple blocks in a single batch operation. Use this when you need to update several blocks at once to avoid making multiple separate API calls. Each block in the batch can independently either have its content replaced or transformed using a 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).',
357
- inputSchema: {
358
- type: 'object',
359
- properties: {
360
- updates: {
361
- type: 'array',
362
- description: 'Array of block updates to perform',
363
- items: {
364
- type: 'object',
365
- properties: {
366
- block_uid: {
367
- type: 'string',
368
- description: 'UID of the block to update'
369
- },
370
- content: {
371
- type: 'string',
372
- description: 'New content for the block. If not provided, transform will be used.'
373
- },
374
- transform: {
375
- type: 'object',
376
- description: 'Pattern to transform the current content. Used if content is not provided.',
377
- properties: {
378
- find: {
379
- type: 'string',
380
- description: 'Text or regex pattern to find'
381
- },
382
- replace: {
383
- type: 'string',
384
- description: 'Text to replace with'
385
- },
386
- global: {
387
- type: 'boolean',
388
- description: 'Whether to replace all occurrences',
389
- default: true
390
- }
391
- },
392
- required: ['find', 'replace']
393
- }
394
- },
395
- required: ['block_uid']
396
- // Note: Validation for either content or transform is handled in the server code
397
- }
398
- }
399
- },
400
- required: ['updates']
401
- }
402
- },
403
292
  roam_search_by_date: {
404
293
  name: 'roam_search_by_date',
405
294
  description: 'Search for blocks or pages based on creation or modification dates. Not for daily pages with ordinal date titles.',
@@ -493,5 +382,71 @@ export const toolSchemas = {
493
382
  },
494
383
  required: ['query']
495
384
  }
385
+ },
386
+ roam_process_batch_actions: {
387
+ name: 'roam_process_batch_actions',
388
+ description: 'Executes a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Actions are executed in the provided order. For creating nested blocks, you can use a temporary client-side UID in a parent block and refer to it in a child block within the same batch. For actions on existing blocks, a valid block UID is required.',
389
+ inputSchema: {
390
+ type: 'object',
391
+ properties: {
392
+ actions: {
393
+ type: 'array',
394
+ description: 'An array of action objects to execute in order.',
395
+ items: {
396
+ type: 'object',
397
+ properties: {
398
+ "action": {
399
+ type: 'string',
400
+ description: 'The specific action to perform.',
401
+ enum: ['create-block', 'update-block', 'move-block', 'delete-block']
402
+ },
403
+ "uid": {
404
+ type: 'string',
405
+ description: 'The UID of the block to target for "update-block", "move-block", or "delete-block" actions.'
406
+ },
407
+ "string": {
408
+ type: 'string',
409
+ description: 'The content for the block, used in "create-block" and "update-block" actions.'
410
+ },
411
+ "open": {
412
+ type: "boolean",
413
+ description: "Optional: Sets the open/closed state of a block, used in 'update-block' or 'create-block'. Defaults to true."
414
+ },
415
+ "heading": {
416
+ type: "integer",
417
+ description: "Optional: The heading level (1, 2, or 3) for 'create-block' or 'update-block'.",
418
+ enum: [1, 2, 3]
419
+ },
420
+ "text-align": {
421
+ type: "string",
422
+ description: "Optional: The text alignment for 'create-block' or 'update-block'.",
423
+ enum: ["left", "center", "right", "justify"]
424
+ },
425
+ "children-view-type": {
426
+ type: "string",
427
+ description: "Optional: The view type for children of the block, for 'create-block' or 'update-block'.",
428
+ enum: ["bullet", "document", "numbered"]
429
+ },
430
+ "location": {
431
+ type: 'object',
432
+ description: 'Specifies where to place a block, used in "create-block" and "move-block" actions.',
433
+ properties: {
434
+ "parent-uid": {
435
+ type: 'string',
436
+ description: 'The UID of the parent block or page.'
437
+ },
438
+ "order": {
439
+ type: ['number', 'string'],
440
+ description: 'The position of the block under its parent (0 for top, "last" for bottom).'
441
+ }
442
+ }
443
+ }
444
+ },
445
+ required: ['action']
446
+ }
447
+ }
448
+ },
449
+ required: ['actions']
450
+ }
496
451
  }
497
452
  };
@@ -4,6 +4,7 @@ import { SearchOperations } from './operations/search/index.js';
4
4
  import { MemoryOperations } from './operations/memory.js';
5
5
  import { TodoOperations } from './operations/todos.js';
6
6
  import { OutlineOperations } from './operations/outline.js';
7
+ import { BatchOperations } from './operations/batch.js';
7
8
  import { DatomicSearchHandlerImpl } from './operations/search/handlers.js';
8
9
  export class ToolHandlers {
9
10
  constructor(graph) {
@@ -14,6 +15,7 @@ export class ToolHandlers {
14
15
  this.memoryOps = new MemoryOperations(graph);
15
16
  this.todoOps = new TodoOperations(graph);
16
17
  this.outlineOps = new OutlineOperations(graph);
18
+ this.batchOps = new BatchOperations(graph);
17
19
  }
18
20
  // Page Operations
19
21
  async findPagesModifiedToday(max_num_pages = 50) {
@@ -26,15 +28,6 @@ export class ToolHandlers {
26
28
  return this.pageOps.fetchPageByTitle(title, format);
27
29
  }
28
30
  // Block Operations
29
- async createBlock(content, page_uid, title, heading) {
30
- return this.blockOps.createBlock(content, page_uid, title, heading);
31
- }
32
- async updateBlock(block_uid, content, transform) {
33
- return this.blockOps.updateBlock(block_uid, content, transform);
34
- }
35
- async updateBlocks(updates) {
36
- return this.blockOps.updateBlocks(updates);
37
- }
38
31
  // Search Operations
39
32
  async searchByStatus(status, page_title_uid, include, exclude) {
40
33
  return this.searchOps.searchByStatus(status, page_title_uid, include, exclude);
@@ -77,4 +70,8 @@ export class ToolHandlers {
77
70
  async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
78
71
  return this.outlineOps.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
79
72
  }
73
+ // Batch Operations
74
+ async processBatch(actions) {
75
+ return this.batchOps.processBatch(actions);
76
+ }
80
77
  }
@@ -0,0 +1,38 @@
1
+ import { createServer } from 'node:net';
2
+ /**
3
+ * Checks if a given port is currently in use.
4
+ * @param port The port to check.
5
+ * @returns A promise that resolves to true if the port is in use, and false otherwise.
6
+ */
7
+ export function isPortInUse(port) {
8
+ return new Promise((resolve) => {
9
+ const server = createServer();
10
+ server.once('error', (err) => {
11
+ if (err.code === 'EADDRINUSE') {
12
+ resolve(true);
13
+ }
14
+ else {
15
+ // Handle other errors if necessary, but for this check, we assume other errors mean the port is available.
16
+ resolve(false);
17
+ }
18
+ });
19
+ server.once('listening', () => {
20
+ server.close();
21
+ resolve(false);
22
+ });
23
+ server.listen(port);
24
+ });
25
+ }
26
+ /**
27
+ * Finds an available port, starting from a given port and incrementing by a specified amount.
28
+ * @param startPort The port to start checking from.
29
+ * @param incrementBy The amount to increment the port by if it's in use. Defaults to 2.
30
+ * @returns A promise that resolves to an available port number.
31
+ */
32
+ export async function findAvailablePort(startPort, incrementBy = 2) {
33
+ let port = startPort;
34
+ while (await isPortInUse(port)) {
35
+ port += incrementBy;
36
+ }
37
+ return port;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.27.0",
3
+ "version": "0.30.1",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {