roam-research-mcp 0.32.0 → 0.35.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
@@ -73,6 +73,7 @@ docker run -p 3000:3000 -p 8088:8088 -p 8087:8087 \
73
73
  -e ROAM_API_TOKEN="your-api-token" \
74
74
  -e ROAM_GRAPH_NAME="your-graph-name" \
75
75
  -e MEMORIES_TAG="#[[LLM/Memories]]" \
76
+ -e CUSTOM_INSTRUCTIONS_PATH="/path/to/your/custom_instructions_file.md" \
76
77
  -e HTTP_STREAM_PORT="8088" \
77
78
  -e SSE_PORT="8087" \
78
79
  roam-research-mcp
@@ -106,10 +107,11 @@ The server provides powerful tools for interacting with Roam Research:
106
107
  - Efficient batch operations
107
108
  - Hierarchical outline creation
108
109
  - Enhanced documentation for Roam Tables in `Roam_Markdown_Cheatsheet.md` for clearer guidance on nesting.
110
+ - Custom instruction appended to the cheat sheet about your specific Roam notes.
109
111
 
110
112
  1. `roam_fetch_page_by_title`: Fetch page content by title. Returns content in the specified format.
111
113
  2. `roam_fetch_block_with_children`: Fetch a block by its UID along with its hierarchical children down to a specified depth. Automatically handles `((UID))` formatting.
112
- 3. `roam_create_page`: Create new pages with optional content and headings.
114
+ 3. `roam_create_page`: Create new pages with optional content and headings. Now creates a block on the daily page linking to the newly created page.
113
115
  4. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
114
116
  5. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
115
117
  6. `roam_create_outline`: Add a structured outline to an existing page or block, with support for `children_view_type`. Best for simpler, sequential outlines. For complex nesting (e.g., tables), consider `roam_process_batch_actions`. If `page_title_uid` and `block_text_uid` are both blank, content defaults to the daily page. (Internally uses `roam_process_batch_actions`.)
@@ -242,6 +244,7 @@ This demonstrates moving a block from one location to another and simultaneously
242
244
  ROAM_API_TOKEN=your-api-token
243
245
  ROAM_GRAPH_NAME=your-graph-name
244
246
  MEMORIES_TAG='#[[LLM/Memories]]'
247
+ CUSTOM_INSTRUCTIONS_PATH='/path/to/your/custom_instructions_file.md'
245
248
  HTTP_STREAM_PORT=8088 # Or your desired port for HTTP Stream communication
246
249
  SSE_PORT=8087 # Or your desired port for SSE communication
247
250
  ```
@@ -262,6 +265,7 @@ This demonstrates moving a block from one location to another and simultaneously
262
265
  "ROAM_API_TOKEN": "your-api-token",
263
266
  "ROAM_GRAPH_NAME": "your-graph-name",
264
267
  "MEMORIES_TAG": "#[[LLM/Memories]]",
268
+ "CUSTOM_INSTRUCTIONS_PATH": "/path/to/your/custom_instructions_file.md",
265
269
  "HTTP_STREAM_PORT": "8088",
266
270
  "SSE_PORT": "8087"
267
271
  }
@@ -347,3 +351,9 @@ This will:
347
351
  ## License
348
352
 
349
353
  MIT License
354
+
355
+ ---
356
+
357
+ ## About the Author
358
+
359
+ This project is maintained by [Ian Shen](https://github.com/2b3pro).
@@ -15,6 +15,7 @@
15
15
  - {{[[TODO]]}} todo text
16
16
  - {{[[DONE]]}} todo text
17
17
  - LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$`
18
+ - Bullet points use dashes not asterisks.
18
19
 
19
20
  ## Roam-specific Markdown:
20
21
 
@@ -112,7 +113,12 @@ The provided markdown structure represents a Roam Research Kanban board. It star
112
113
  This markdown structure allows embedding custom HTML or other content using Hiccup syntax. The `:hiccup` keyword is followed by a Clojure-like vector defining the HTML elements and their attributes in one block. This provides a powerful way to inject dynamic or custom components into your Roam graph. Example: `:hiccup [:iframe {:width "600" :height "400" :src "https://www.example.com"}]`
113
114
 
114
115
  ## Specific notes and preferences concerning my Roam Research graph
116
+ ### What To Tag
115
117
 
116
- ---
118
+ NONE
119
+
120
+ ### Don't Include…
121
+
122
+ NONE
117
123
 
118
124
  ⭐️📋 END (Cheat Sheet LOADED) < < < 📋⭐️
@@ -75,73 +75,69 @@ function convertToRoamMarkdown(text) {
75
75
  return text;
76
76
  }
77
77
  function parseMarkdown(markdown) {
78
- // Convert markdown syntax first
79
78
  markdown = convertToRoamMarkdown(markdown);
80
- const lines = markdown.split('\n');
79
+ const originalLines = markdown.split('\n');
80
+ const processedLines = [];
81
+ // Pre-process lines to handle mid-line code blocks without splice
82
+ for (const line of originalLines) {
83
+ const trimmedLine = line.trimEnd();
84
+ const codeStartIndex = trimmedLine.indexOf('```');
85
+ if (codeStartIndex > 0) {
86
+ const indentationWhitespace = line.match(/^\s*/)?.[0] ?? '';
87
+ processedLines.push(indentationWhitespace + trimmedLine.substring(0, codeStartIndex));
88
+ processedLines.push(indentationWhitespace + trimmedLine.substring(codeStartIndex));
89
+ }
90
+ else {
91
+ processedLines.push(line);
92
+ }
93
+ }
81
94
  const rootNodes = [];
82
95
  const stack = [];
83
96
  let inCodeBlock = false;
84
97
  let codeBlockContent = '';
85
98
  let codeBlockIndentation = 0;
86
99
  let codeBlockParentLevel = 0;
87
- for (let i = 0; i < lines.length; i++) {
88
- const line = lines[i];
100
+ for (let i = 0; i < processedLines.length; i++) {
101
+ const line = processedLines[i];
89
102
  const trimmedLine = line.trimEnd();
90
- // Handle code blocks
91
103
  if (trimmedLine.match(/^(\s*)```/)) {
92
104
  if (!inCodeBlock) {
93
- // Start of code block
94
105
  inCodeBlock = true;
95
- // Store the opening backticks without indentation
96
106
  codeBlockContent = trimmedLine.trimStart() + '\n';
97
107
  codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0;
98
- // Save current parent level
99
108
  codeBlockParentLevel = stack.length;
100
109
  }
101
110
  else {
102
- // End of code block
103
111
  inCodeBlock = false;
104
- // Add closing backticks without indentation
105
112
  codeBlockContent += trimmedLine.trimStart();
106
- // Process the code block content to fix indentation
107
- const lines = codeBlockContent.split('\n');
108
- // Find the first non-empty code line to determine base indentation
113
+ const linesInCodeBlock = codeBlockContent.split('\n');
109
114
  let baseIndentation = '';
110
- let codeStartIndex = -1;
111
- for (let i = 1; i < lines.length - 1; i++) {
112
- const line = lines[i];
113
- if (line.trim().length > 0) {
114
- const indentMatch = line.match(/^[\t ]*/);
115
+ for (let j = 1; j < linesInCodeBlock.length - 1; j++) {
116
+ const codeLine = linesInCodeBlock[j];
117
+ if (codeLine.trim().length > 0) {
118
+ const indentMatch = codeLine.match(/^[\t ]*/);
115
119
  if (indentMatch) {
116
120
  baseIndentation = indentMatch[0];
117
- codeStartIndex = i;
118
121
  break;
119
122
  }
120
123
  }
121
124
  }
122
- // Process lines maintaining relative indentation from the first code line
123
- const processedLines = lines.map((line, index) => {
124
- // Keep backticks as is
125
- if (index === 0 || index === lines.length - 1)
126
- return line.trimStart();
127
- // Empty lines should be completely trimmed
128
- if (line.trim().length === 0)
125
+ const processedCodeLines = linesInCodeBlock.map((codeLine, index) => {
126
+ if (index === 0 || index === linesInCodeBlock.length - 1)
127
+ return codeLine.trimStart();
128
+ if (codeLine.trim().length === 0)
129
129
  return '';
130
- // For code lines, remove only the base indentation
131
- if (line.startsWith(baseIndentation)) {
132
- return line.slice(baseIndentation.length);
130
+ if (codeLine.startsWith(baseIndentation)) {
131
+ return codeLine.slice(baseIndentation.length);
133
132
  }
134
- // If line has less indentation than base, trim all leading whitespace
135
- return line.trimStart();
133
+ return codeLine.trimStart();
136
134
  });
137
- // Create node for the entire code block
138
135
  const level = Math.floor(codeBlockIndentation / 2);
139
136
  const node = {
140
- content: processedLines.join('\n'),
137
+ content: processedCodeLines.join('\n'),
141
138
  level,
142
139
  children: []
143
140
  };
144
- // Restore to code block's parent level
145
141
  while (stack.length > codeBlockParentLevel) {
146
142
  stack.pop();
147
143
  }
@@ -169,37 +165,30 @@ function parseMarkdown(markdown) {
169
165
  codeBlockContent += line + '\n';
170
166
  continue;
171
167
  }
172
- // Skip truly empty lines (no spaces)
173
168
  if (trimmedLine === '') {
174
169
  continue;
175
170
  }
176
- // Calculate indentation level (2 spaces = 1 level)
177
171
  const indentation = line.match(/^\s*/)?.[0].length ?? 0;
178
172
  let level = Math.floor(indentation / 2);
179
173
  let contentToParse;
180
174
  const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
181
175
  if (bulletMatch) {
182
- // If it's a bullet point, adjust level based on bullet indentation
183
176
  level = Math.floor(bulletMatch[1].length / 2);
184
- contentToParse = trimmedLine.substring(bulletMatch[0].length); // Content after bullet
177
+ contentToParse = trimmedLine.substring(bulletMatch[0].length);
185
178
  }
186
179
  else {
187
- contentToParse = trimmedLine; // No bullet, use trimmed line
180
+ contentToParse = trimmedLine;
188
181
  }
189
- // Now, from the content after bullet/initial indentation, check for heading
190
182
  const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse);
191
- // Create node
192
183
  const node = {
193
- content: finalContent, // Use content after heading parsing
194
- level, // Use level derived from bullet/indentation
184
+ content: finalContent,
185
+ level,
195
186
  ...(heading_level > 0 && { heading_level }),
196
187
  children: []
197
188
  };
198
- // Pop stack until we find the parent level
199
189
  while (stack.length > level) {
200
190
  stack.pop();
201
191
  }
202
- // Add to appropriate parent
203
192
  if (level === 0 || !stack[level - 1]) {
204
193
  rootNodes.push(node);
205
194
  stack[0] = node;
@@ -273,13 +262,14 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
273
262
  const actions = [];
274
263
  // Helper function to recursively create actions
275
264
  function createBlockActions(blocks, parentUid, order) {
276
- for (const block of blocks) {
265
+ for (let i = 0; i < blocks.length; i++) {
266
+ const block = blocks[i];
277
267
  // Create the current block
278
268
  const action = {
279
269
  action: 'create-block',
280
270
  location: {
281
271
  'parent-uid': parentUid,
282
- order
272
+ order: typeof order === 'number' ? order + i : i
283
273
  },
284
274
  block: {
285
275
  uid: block.uid,
@@ -1,6 +1,65 @@
1
+ /**
2
+ * Capitalizes each word in a string
3
+ */
4
+ import { q } from '@roam-research/roam-api-sdk';
1
5
  /**
2
6
  * Capitalizes each word in a string
3
7
  */
4
8
  export const capitalizeWords = (str) => {
5
9
  return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
6
10
  };
11
+ /**
12
+ * Retrieves a block's UID based on its exact text content.
13
+ * This function is intended for internal use by other MCP tools.
14
+ * @param graph The Roam graph instance.
15
+ * @param blockText The exact text content of the block to find.
16
+ * @returns The UID of the block if found, otherwise null.
17
+ */
18
+ export const getBlockUidByText = async (graph, blockText) => {
19
+ const query = `[:find ?uid .
20
+ :in $ ?blockString
21
+ :where [?b :block/string ?blockString]
22
+ [?b :block/uid ?uid]]`;
23
+ const result = await q(graph, query, [blockText]);
24
+ return result && result.length > 0 ? result[0][0] : null;
25
+ };
26
+ /**
27
+ * Retrieves all UIDs nested under a given block_uid or block_text (exact match).
28
+ * This function is intended for internal use by other MCP tools.
29
+ * @param graph The Roam graph instance.
30
+ * @param rootIdentifier The UID or exact text content of the root block.
31
+ * @returns An array of UIDs of all descendant blocks, including the root block's UID.
32
+ */
33
+ export const getNestedUids = async (graph, rootIdentifier) => {
34
+ let rootUid = rootIdentifier;
35
+ // If the rootIdentifier is not a UID (simple check for 9 alphanumeric characters), try to resolve it as block text
36
+ if (!rootIdentifier.match(/^[a-zA-Z0-9]{9}$/)) {
37
+ rootUid = await getBlockUidByText(graph, rootIdentifier);
38
+ }
39
+ if (!rootUid) {
40
+ return []; // No root block found
41
+ }
42
+ const query = `[:find ?child-uid
43
+ :in $ ?root-uid
44
+ :where
45
+ [?root-block :block/uid ?root-uid]
46
+ [?root-block :block/children ?child-block]
47
+ [?child-block :block/uid ?child-uid]]`;
48
+ const results = await q(graph, query, [rootUid]);
49
+ return results.map(r => r[0]);
50
+ };
51
+ /**
52
+ * Retrieves all UIDs nested under a given block_text (exact match).
53
+ * This function is intended for internal use by other MCP tools.
54
+ * It strictly requires an exact text match for the root block.
55
+ * @param graph The Roam graph instance.
56
+ * @param blockText The exact text content of the root block.
57
+ * @returns An array of UIDs of all descendant blocks, including the root block's UID.
58
+ */
59
+ export const getNestedUidsByText = async (graph, blockText) => {
60
+ const rootUid = await getBlockUidByText(graph, blockText);
61
+ if (!rootUid) {
62
+ return []; // No root block found with exact text match
63
+ }
64
+ return getNestedUids(graph, rootUid);
65
+ };
@@ -16,7 +16,7 @@ export class BlockRetrievalOperations {
16
16
  const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading
17
17
  :in $ [?parentUid ...]
18
18
  :where [?parent :block/uid ?parentUid]
19
- [?child :block/parents ?parent]
19
+ [?parent :block/children ?child]
20
20
  [?child :block/uid ?childUid]
21
21
  [?child :block/string ?childString]
22
22
  [?child :block/order ?childOrder]
@@ -6,6 +6,188 @@ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../.
6
6
  export class OutlineOperations {
7
7
  constructor(graph) {
8
8
  this.graph = graph;
9
+ /**
10
+ * Helper function to check if string is a valid Roam UID (9 characters)
11
+ */
12
+ this.isValidUid = (str) => {
13
+ return typeof str === 'string' && str.length === 9;
14
+ };
15
+ }
16
+ /**
17
+ * Helper function to find block with improved relationship checks
18
+ */
19
+ async findBlockWithRetry(pageUid, blockString, maxRetries = 5, initialDelay = 1000) {
20
+ // Try multiple query strategies
21
+ const queries = [
22
+ // Strategy 1: Direct page and string match
23
+ `[:find ?b-uid ?order
24
+ :where [?p :block/uid "${pageUid}"]
25
+ [?b :block/page ?p]
26
+ [?b :block/string "${blockString}"]
27
+ [?b :block/order ?order]
28
+ [?b :block/uid ?b-uid]]`,
29
+ // Strategy 2: Parent-child relationship
30
+ `[:find ?b-uid ?order
31
+ :where [?p :block/uid "${pageUid}"]
32
+ [?b :block/parents ?p]
33
+ [?b :block/string "${blockString}"]
34
+ [?b :block/order ?order]
35
+ [?b :block/uid ?b-uid]]`,
36
+ // Strategy 3: Broader page relationship
37
+ `[:find ?b-uid ?order
38
+ :where [?p :block/uid "${pageUid}"]
39
+ [?b :block/page ?page]
40
+ [?p :block/page ?page]
41
+ [?b :block/string "${blockString}"]
42
+ [?b :block/order ?order]
43
+ [?b :block/uid ?b-uid]]`
44
+ ];
45
+ for (let retry = 0; retry < maxRetries; retry++) {
46
+ // Try each query strategy
47
+ for (const queryStr of queries) {
48
+ const blockResults = await q(this.graph, queryStr, []);
49
+ if (blockResults && blockResults.length > 0) {
50
+ // Use the most recently created block
51
+ const sorted = blockResults.sort((a, b) => b[1] - a[1]);
52
+ return sorted[0][0];
53
+ }
54
+ }
55
+ // Exponential backoff
56
+ const delay = initialDelay * Math.pow(2, retry);
57
+ await new Promise(resolve => setTimeout(resolve, delay));
58
+ }
59
+ throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
60
+ }
61
+ ;
62
+ /**
63
+ * Helper function to create and verify block with improved error handling
64
+ */
65
+ async createAndVerifyBlock(content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) {
66
+ try {
67
+ // Initial delay before any operations
68
+ if (!isRetry) {
69
+ await new Promise(resolve => setTimeout(resolve, initialDelay));
70
+ }
71
+ for (let retry = 0; retry < maxRetries; retry++) {
72
+ console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
73
+ // Create block using batchActions
74
+ const batchResult = await batchActions(this.graph, {
75
+ action: 'batch-actions',
76
+ actions: [{
77
+ action: 'create-block',
78
+ location: {
79
+ 'parent-uid': parentUid,
80
+ order: 'last'
81
+ },
82
+ block: { string: content }
83
+ }]
84
+ });
85
+ if (!batchResult) {
86
+ throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
87
+ }
88
+ // Wait with exponential backoff
89
+ const delay = initialDelay * Math.pow(2, retry);
90
+ await new Promise(resolve => setTimeout(resolve, delay));
91
+ try {
92
+ // Try to find the block using our improved findBlockWithRetry
93
+ return await this.findBlockWithRetry(parentUid, content);
94
+ }
95
+ catch (error) {
96
+ const errorMessage = error instanceof Error ? error.message : String(error);
97
+ // console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
98
+ if (retry === maxRetries - 1)
99
+ throw error;
100
+ }
101
+ }
102
+ throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
103
+ }
104
+ catch (error) {
105
+ // If this is already a retry, throw the error
106
+ if (isRetry)
107
+ throw error;
108
+ // Otherwise, try one more time with a clean slate
109
+ // console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
110
+ await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
111
+ return this.createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
112
+ }
113
+ }
114
+ ;
115
+ /**
116
+ * Helper function to fetch a block and its children recursively
117
+ */
118
+ async fetchBlockWithChildren(blockUid, level = 1) {
119
+ const query = `
120
+ [:find ?childUid ?childString ?childOrder
121
+ :in $ ?parentUid
122
+ :where
123
+ [?parentEntity :block/uid ?parentUid]
124
+ [?parentEntity :block/children ?childEntity] ; This ensures direct children
125
+ [?childEntity :block/uid ?childUid]
126
+ [?childEntity :block/string ?childString]
127
+ [?childEntity :block/order ?childOrder]]
128
+ `;
129
+ const blockQuery = `
130
+ [:find ?string
131
+ :in $ ?uid
132
+ :where
133
+ [?e :block/uid ?uid]
134
+ [?e :block/string ?string]]
135
+ `;
136
+ try {
137
+ const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
138
+ if (!blockStringResult || blockStringResult.length === 0) {
139
+ return null;
140
+ }
141
+ const text = blockStringResult[0][0];
142
+ const childrenResults = await q(this.graph, query, [blockUid]);
143
+ const children = [];
144
+ if (childrenResults && childrenResults.length > 0) {
145
+ // Sort children by order
146
+ const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
147
+ for (const childResult of sortedChildren) {
148
+ const childUid = childResult[0];
149
+ const nestedChild = await this.fetchBlockWithChildren(childUid, level + 1);
150
+ if (nestedChild) {
151
+ children.push(nestedChild);
152
+ }
153
+ }
154
+ }
155
+ // The order of the root block is not available from this query, so we set it to 0
156
+ return { uid: blockUid, text, level, order: 0, children: children.length > 0 ? children : undefined };
157
+ }
158
+ catch (error) {
159
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
160
+ }
161
+ }
162
+ ;
163
+ /**
164
+ * Recursively fetches a nested structure of blocks under a given root block UID.
165
+ */
166
+ async fetchNestedStructure(rootUid) {
167
+ const query = `[:find ?child-uid ?child-string ?child-order
168
+ :in $ ?parent-uid
169
+ :where
170
+ [?parent :block/uid ?parent-uid]
171
+ [?parent :block/children ?child]
172
+ [?child :block/uid ?child-uid]
173
+ [?child :block/string ?child-string]
174
+ [?child :block/order ?child-order]]`;
175
+ const directChildrenResult = await q(this.graph, query, [rootUid]);
176
+ if (directChildrenResult.length === 0) {
177
+ return [];
178
+ }
179
+ const nestedBlocks = [];
180
+ for (const [childUid, childString, childOrder] of directChildrenResult) {
181
+ const children = await this.fetchNestedStructure(childUid);
182
+ nestedBlocks.push({
183
+ uid: childUid,
184
+ text: childString,
185
+ level: 0, // Level is not easily determined here, so we set it to 0
186
+ children: children,
187
+ order: childOrder
188
+ });
189
+ }
190
+ return nestedBlocks.sort((a, b) => a.order - b.order);
9
191
  }
10
192
  /**
11
193
  * Creates an outline structure on a Roam Research page, optionally under a specific block.
@@ -21,6 +203,7 @@ export class OutlineOperations {
21
203
  * no matching block is found, a new block with that text will be created
22
204
  * on the page to serve as the parent. If a UID is provided and the block
23
205
  * is not found, an error will be thrown.
206
+ * @returns An object containing success status, page UID, parent UID, and a nested array of created block UIDs.
24
207
  */
25
208
  async createOutline(outline, page_title_uid, block_text_uid) {
26
209
  // Validate input
@@ -85,104 +268,6 @@ export class OutlineOperations {
85
268
  };
86
269
  // Get or create the target page
87
270
  const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
88
- // Helper function to find block with improved relationship checks
89
- const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
90
- // Try multiple query strategies
91
- const queries = [
92
- // Strategy 1: Direct page and string match
93
- `[:find ?b-uid ?order
94
- :where [?p :block/uid "${pageUid}"]
95
- [?b :block/page ?p]
96
- [?b :block/string "${blockString}"]
97
- [?b :block/order ?order]
98
- [?b :block/uid ?b-uid]]`,
99
- // Strategy 2: Parent-child relationship
100
- `[:find ?b-uid ?order
101
- :where [?p :block/uid "${pageUid}"]
102
- [?b :block/parents ?p]
103
- [?b :block/string "${blockString}"]
104
- [?b :block/order ?order]
105
- [?b :block/uid ?b-uid]]`,
106
- // Strategy 3: Broader page relationship
107
- `[:find ?b-uid ?order
108
- :where [?p :block/uid "${pageUid}"]
109
- [?b :block/page ?page]
110
- [?p :block/page ?page]
111
- [?b :block/string "${blockString}"]
112
- [?b :block/order ?order]
113
- [?b :block/uid ?b-uid]]`
114
- ];
115
- for (let retry = 0; retry < maxRetries; retry++) {
116
- // Try each query strategy
117
- for (const queryStr of queries) {
118
- const blockResults = await q(this.graph, queryStr, []);
119
- if (blockResults && blockResults.length > 0) {
120
- // Use the most recently created block
121
- const sorted = blockResults.sort((a, b) => b[1] - a[1]);
122
- return sorted[0][0];
123
- }
124
- }
125
- // Exponential backoff
126
- const delay = initialDelay * Math.pow(2, retry);
127
- await new Promise(resolve => setTimeout(resolve, delay));
128
- console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
129
- }
130
- throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
131
- };
132
- // Helper function to create and verify block with improved error handling
133
- const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
134
- try {
135
- // Initial delay before any operations
136
- if (!isRetry) {
137
- await new Promise(resolve => setTimeout(resolve, initialDelay));
138
- }
139
- for (let retry = 0; retry < maxRetries; retry++) {
140
- console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
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
- }]
152
- });
153
- if (!batchResult) {
154
- throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
155
- }
156
- // Wait with exponential backoff
157
- const delay = initialDelay * Math.pow(2, retry);
158
- await new Promise(resolve => setTimeout(resolve, delay));
159
- try {
160
- // Try to find the block using our improved findBlockWithRetry
161
- return await findBlockWithRetry(parentUid, content);
162
- }
163
- catch (error) {
164
- const errorMessage = error instanceof Error ? error.message : String(error);
165
- console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`);
166
- if (retry === maxRetries - 1)
167
- throw error;
168
- }
169
- }
170
- throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
171
- }
172
- catch (error) {
173
- // If this is already a retry, throw the error
174
- if (isRetry)
175
- throw error;
176
- // Otherwise, try one more time with a clean slate
177
- console.log(`Retrying block creation for "${content}" with fresh attempt`);
178
- await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
179
- return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
180
- }
181
- };
182
- // Helper function to check if string is a valid Roam UID (9 characters)
183
- const isValidUid = (str) => {
184
- return typeof str === 'string' && str.length === 9;
185
- };
186
271
  // Get or create the parent block
187
272
  let targetParentUid;
188
273
  if (!block_text_uid) {
@@ -190,7 +275,7 @@ export class OutlineOperations {
190
275
  }
191
276
  else {
192
277
  try {
193
- if (isValidUid(block_text_uid)) {
278
+ if (this.isValidUid(block_text_uid)) {
194
279
  // First try to find block by UID
195
280
  const uidQuery = `[:find ?uid
196
281
  :where [?e :block/uid "${block_text_uid}"]
@@ -206,12 +291,12 @@ export class OutlineOperations {
206
291
  }
207
292
  else {
208
293
  // Create header block and get its UID if not a valid UID
209
- targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
294
+ targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid);
210
295
  }
211
296
  }
212
297
  catch (error) {
213
298
  const errorMessage = error instanceof Error ? error.message : String(error);
214
- throw new McpError(ErrorCode.InternalError, `Failed to ${isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
299
+ throw new McpError(ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
215
300
  }
216
301
  }
217
302
  // Initialize result variable
@@ -235,7 +320,9 @@ export class OutlineOperations {
235
320
  const indent = ' '.repeat(item.level - 1);
236
321
  // If the item text starts with a markdown heading (e.g., #, ##, ###),
237
322
  // treat it as a direct heading without adding a bullet or outline indentation.
238
- return `${indent}- ${item.text?.trim()}`;
323
+ // NEW CHANGE: Handle standalone code blocks - do not prepend bullet
324
+ const isCodeBlock = item.text?.startsWith('```') && item.text.endsWith('```') && item.text.includes('\n');
325
+ return isCodeBlock ? `${indent}${item.text?.trim()}` : `${indent}- ${item.text?.trim()}`;
239
326
  })
240
327
  .join('\n');
241
328
  // Convert to Roam markdown format
@@ -243,13 +330,16 @@ export class OutlineOperations {
243
330
  // Parse markdown into hierarchical structure
244
331
  // We pass the original OutlineItem properties (heading, children_view_type)
245
332
  // along with the parsed content to the nodes.
246
- const nodes = parseMarkdown(convertedContent).map((node, index) => ({
247
- ...node,
248
- ...(validOutline[index].heading && { heading_level: validOutline[index].heading }),
249
- ...(validOutline[index].children_view_type && { children_view_type: validOutline[index].children_view_type })
250
- }));
333
+ const nodes = parseMarkdown(convertedContent).map((node, index) => {
334
+ const outlineItem = validOutline[index];
335
+ return {
336
+ ...node,
337
+ ...(outlineItem?.heading && { heading_level: outlineItem.heading }),
338
+ ...(outlineItem?.children_view_type && { children_view_type: outlineItem.children_view_type })
339
+ };
340
+ });
251
341
  // Convert nodes to batch actions
252
- const actions = convertToRoamActions(nodes, targetParentUid, 'first');
342
+ const actions = convertToRoamActions(nodes, targetParentUid, 'last');
253
343
  if (actions.length === 0) {
254
344
  throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
255
345
  }
@@ -269,16 +359,35 @@ export class OutlineOperations {
269
359
  throw error;
270
360
  throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
271
361
  }
272
- // Get the created block UIDs
273
- const createdUids = result?.created_uids || [];
362
+ // Post-creation verification to get actual UIDs for top-level blocks and their children
363
+ const createdBlocks = [];
364
+ // Only query for top-level blocks (level 1) based on the original outline input
365
+ const topLevelOutlineItems = validOutline.filter(item => item.level === 1);
366
+ for (const item of topLevelOutlineItems) {
367
+ try {
368
+ // Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
369
+ const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
370
+ if (foundUid) {
371
+ const nestedBlock = await this.fetchBlockWithChildren(foundUid);
372
+ if (nestedBlock) {
373
+ createdBlocks.push(nestedBlock);
374
+ }
375
+ }
376
+ }
377
+ catch (error) {
378
+ // This is a warning because even if one block fails to fetch, others might succeed.
379
+ // The error will be logged but not re-thrown to allow partial success reporting.
380
+ // console.warn(`Could not fetch nested block for "${item.text}": ${error.message}`);
381
+ }
382
+ }
274
383
  return {
275
384
  success: true,
276
385
  page_uid: targetPageUid,
277
386
  parent_uid: targetParentUid,
278
- created_uids: createdUids
387
+ created_uids: createdBlocks
279
388
  };
280
389
  }
281
- async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
390
+ async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
282
391
  // First get the page UID
283
392
  let targetPageUid = page_uid;
284
393
  if (!targetPageUid && page_title) {
@@ -325,15 +434,20 @@ export class OutlineOperations {
325
434
  throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
326
435
  }
327
436
  // Find block by exact string match within the page
328
- const findBlockQuery = `[:find ?uid
329
- :where [?p :block/uid "${targetPageUid}"]
437
+ const findBlockQuery = `[:find ?b-uid
438
+ :in $ ?page-uid ?block-string
439
+ :where [?p :block/uid ?page-uid]
330
440
  [?b :block/page ?p]
331
- [?b :block/string "${parent_string}"]]`;
332
- const blockResults = await q(this.graph, findBlockQuery, []);
333
- if (!blockResults || blockResults.length === 0) {
334
- throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
441
+ [?b :block/string ?block-string]
442
+ [?b :block/uid ?b-uid]]`;
443
+ const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, parent_string]);
444
+ if (blockResults && blockResults.length > 0) {
445
+ targetParentUid = blockResults[0][0];
446
+ }
447
+ else {
448
+ // If parent_string block doesn't exist, create it
449
+ targetParentUid = await this.createAndVerifyBlock(parent_string, targetPageUid);
335
450
  }
336
- targetParentUid = blockResults[0][0];
337
451
  }
338
452
  // If no parent specified, use page as parent
339
453
  if (!targetParentUid) {
@@ -355,8 +469,8 @@ export class OutlineOperations {
355
469
  if (!result) {
356
470
  throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
357
471
  }
358
- // Get the created block UIDs
359
- const createdUids = result.created_uids || [];
472
+ // After successful batch action, get all nested UIDs under the parent
473
+ const createdUids = await this.fetchNestedStructure(targetParentUid);
360
474
  return {
361
475
  success: true,
362
476
  page_uid: targetPageUid,
@@ -370,26 +484,42 @@ export class OutlineOperations {
370
484
  action: 'create-block',
371
485
  location: {
372
486
  "parent-uid": targetParentUid,
373
- order
487
+ "order": order
374
488
  },
375
489
  block: { string: content }
376
490
  }];
377
491
  try {
378
- const result = await batchActions(this.graph, {
492
+ await batchActions(this.graph, {
379
493
  action: 'batch-actions',
380
494
  actions
381
495
  });
382
- if (!result) {
383
- throw new McpError(ErrorCode.InternalError, 'Failed to create content block via batch action');
384
- }
385
496
  }
386
497
  catch (error) {
387
498
  throw new McpError(ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}`);
388
499
  }
500
+ // For single-line content, we still need to fetch the UID and construct a NestedBlock
501
+ const createdUids = [];
502
+ try {
503
+ const foundUid = await this.findBlockWithRetry(targetParentUid, content);
504
+ if (foundUid) {
505
+ createdUids.push({
506
+ uid: foundUid,
507
+ text: content,
508
+ level: 0,
509
+ order: 0,
510
+ children: []
511
+ });
512
+ }
513
+ }
514
+ catch (error) {
515
+ // Log warning but don't re-throw, as the block might be created, just not immediately verifiable
516
+ // console.warn(`Could not verify single block creation for "${content}": ${error.message}`);
517
+ }
389
518
  return {
390
519
  success: true,
391
520
  page_uid: targetPageUid,
392
- parent_uid: targetParentUid
521
+ parent_uid: targetParentUid,
522
+ created_uids: createdUids
393
523
  };
394
524
  }
395
525
  }
@@ -1,8 +1,19 @@
1
- import { q, createPage as createRoamPage, batchActions } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { capitalizeWords } from '../helpers/text.js';
4
4
  import { resolveRefs } from '../helpers/refs.js';
5
5
  import { convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
6
+ // Helper to get ordinal suffix for dates
7
+ function getOrdinalSuffix(day) {
8
+ if (day > 3 && day < 21)
9
+ return 'th'; // Handles 11th, 12th, 13th
10
+ switch (day % 10) {
11
+ case 1: return 'st';
12
+ case 2: return 'nd';
13
+ case 3: return 'rd';
14
+ default: return 'th';
15
+ }
16
+ }
6
17
  export class PageOperations {
7
18
  constructor(graph) {
8
19
  this.graph = graph;
@@ -124,6 +135,37 @@ export class PageOperations {
124
135
  throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
125
136
  }
126
137
  }
138
+ // Add a link to the created page on today's daily page
139
+ try {
140
+ const today = new Date();
141
+ const day = today.getDate();
142
+ const month = today.toLocaleString('en-US', { month: 'long' });
143
+ const year = today.getFullYear();
144
+ const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
145
+ const dailyPageQuery = `[:find ?uid .
146
+ :where [?e :node/title "${formattedTodayTitle}"]
147
+ [?e :block/uid ?uid]]`;
148
+ const dailyPageResult = await q(this.graph, dailyPageQuery, []);
149
+ const dailyPageUid = dailyPageResult ? String(dailyPageResult) : null;
150
+ if (dailyPageUid) {
151
+ await createBlock(this.graph, {
152
+ action: 'create-block',
153
+ block: {
154
+ string: `Created page: [[${pageTitle}]]`
155
+ },
156
+ location: {
157
+ 'parent-uid': dailyPageUid,
158
+ order: 'last'
159
+ }
160
+ });
161
+ }
162
+ else {
163
+ console.warn(`Could not find daily page with title: ${formattedTodayTitle}. Link to created page not added.`);
164
+ }
165
+ }
166
+ catch (error) {
167
+ console.error(`Failed to add link to daily page: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
127
169
  return { success: true, uid: pageUid };
128
170
  }
129
171
  async fetchPageByTitle(title, format = 'raw') {
@@ -40,7 +40,7 @@ export const toolSchemas = {
40
40
  },
41
41
  roam_create_page: {
42
42
  name: 'roam_create_page',
43
- description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
43
+ description: 'Create a new standalone page in Roam with optional content, including structured outlines, using explicit nesting levels and headings (H1-H3). This is the preferred method for creating a new page with an outline in a single step. Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
44
44
  inputSchema: {
45
45
  type: 'object',
46
46
  properties: {
@@ -80,7 +80,7 @@ export const toolSchemas = {
80
80
  },
81
81
  roam_create_outline: {
82
82
  name: 'roam_create_outline',
83
- description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. 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\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
83
+ description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. To create a new page with an outline, use the `roam_create_page` tool instead. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. 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\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
84
84
  inputSchema: {
85
85
  type: 'object',
86
86
  properties: {
@@ -129,7 +129,7 @@ export const toolSchemas = {
129
129
  },
130
130
  roam_import_markdown: {
131
131
  name: 'roam_import_markdown',
132
- description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
132
+ description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page. If a `parent_string` is provided and the block does not exist, it will be created. Returns a nested structure of the created blocks.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
133
133
  inputSchema: {
134
134
  type: 'object',
135
135
  properties: {
@@ -151,7 +151,7 @@ export const toolSchemas = {
151
151
  },
152
152
  parent_string: {
153
153
  type: 'string',
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).'
154
+ description: 'Optional: Exact string content of an existing parent block to add content under (used if parent_uid is not provided; requires page_uid or page_title). If the block does not exist, it will be created.'
155
155
  },
156
156
  order: {
157
157
  type: 'string',
@@ -463,7 +463,7 @@ export const toolSchemas = {
463
463
  },
464
464
  roam_fetch_block_with_children: {
465
465
  name: 'roam_fetch_block_with_children',
466
- description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth.',
466
+ description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth. Returns a nested object structure containing the block\'s UID, text, order, and an array of its children.',
467
467
  inputSchema: {
468
468
  type: 'object',
469
469
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.32.0",
3
+ "version": "0.35.1",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "build"
29
29
  ],
30
30
  "scripts": {
31
- "build": "tsc && cp Roam_Markdown_Cheatsheet.md build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
31
+ "build": "tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
32
32
  "clean": "rm -rf build",
33
33
  "watch": "tsc --watch",
34
34
  "inspector": "npx @modelcontextprotocol/inspector build/index.js",