roam-research-mcp 0.32.0 → 0.32.4

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
  }
@@ -112,7 +112,12 @@ The provided markdown structure represents a Roam Research Kanban board. It star
112
112
  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
113
 
114
114
  ## Specific notes and preferences concerning my Roam Research graph
115
+ ### What To Tag
115
116
 
116
- ---
117
+ NONE
118
+
119
+ ### Don't Include…
120
+
121
+ NONE
117
122
 
118
123
  ⭐️📋 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;
@@ -21,6 +21,7 @@ export class OutlineOperations {
21
21
  * no matching block is found, a new block with that text will be created
22
22
  * on the page to serve as the parent. If a UID is provided and the block
23
23
  * is not found, an error will be thrown.
24
+ * @returns An object containing success status, page UID, parent UID, and a nested array of created block UIDs.
24
25
  */
25
26
  async createOutline(outline, page_title_uid, block_text_uid) {
26
27
  // Validate input
@@ -125,7 +126,6 @@ export class OutlineOperations {
125
126
  // Exponential backoff
126
127
  const delay = initialDelay * Math.pow(2, retry);
127
128
  await new Promise(resolve => setTimeout(resolve, delay));
128
- console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
129
129
  }
130
130
  throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
131
131
  };
@@ -162,7 +162,7 @@ export class OutlineOperations {
162
162
  }
163
163
  catch (error) {
164
164
  const errorMessage = error instanceof Error ? error.message : String(error);
165
- console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`);
165
+ // console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
166
166
  if (retry === maxRetries - 1)
167
167
  throw error;
168
168
  }
@@ -174,7 +174,7 @@ export class OutlineOperations {
174
174
  if (isRetry)
175
175
  throw error;
176
176
  // Otherwise, try one more time with a clean slate
177
- console.log(`Retrying block creation for "${content}" with fresh attempt`);
177
+ // console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
178
178
  await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
179
179
  return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
180
180
  }
@@ -183,6 +183,50 @@ export class OutlineOperations {
183
183
  const isValidUid = (str) => {
184
184
  return typeof str === 'string' && str.length === 9;
185
185
  };
186
+ // Helper function to fetch a block and its children recursively
187
+ const fetchBlockWithChildren = async (blockUid, level = 1) => {
188
+ const query = `
189
+ [:find ?childUid ?childString ?childOrder
190
+ :in $ ?parentUid
191
+ :where
192
+ [?parentEntity :block/uid ?parentUid]
193
+ [?parentEntity :block/children ?childEntity] ; This ensures direct children
194
+ [?childEntity :block/uid ?childUid]
195
+ [?childEntity :block/string ?childString]
196
+ [?childEntity :block/order ?childOrder]]
197
+ `;
198
+ const blockQuery = `
199
+ [:find ?string
200
+ :in $ ?uid
201
+ :where
202
+ [?e :block/uid ?uid]
203
+ [?e :block/string ?string]]
204
+ `;
205
+ try {
206
+ const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
207
+ if (!blockStringResult || blockStringResult.length === 0) {
208
+ return null;
209
+ }
210
+ const text = blockStringResult[0][0];
211
+ const childrenResults = await q(this.graph, query, [blockUid]);
212
+ const children = [];
213
+ if (childrenResults && childrenResults.length > 0) {
214
+ // Sort children by order
215
+ const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
216
+ for (const childResult of sortedChildren) {
217
+ const childUid = childResult[0];
218
+ const nestedChild = await fetchBlockWithChildren(childUid, level + 1);
219
+ if (nestedChild) {
220
+ children.push(nestedChild);
221
+ }
222
+ }
223
+ }
224
+ return { uid: blockUid, text, level, children: children.length > 0 ? children : undefined };
225
+ }
226
+ catch (error) {
227
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
228
+ }
229
+ };
186
230
  // Get or create the parent block
187
231
  let targetParentUid;
188
232
  if (!block_text_uid) {
@@ -235,7 +279,9 @@ export class OutlineOperations {
235
279
  const indent = ' '.repeat(item.level - 1);
236
280
  // If the item text starts with a markdown heading (e.g., #, ##, ###),
237
281
  // treat it as a direct heading without adding a bullet or outline indentation.
238
- return `${indent}- ${item.text?.trim()}`;
282
+ // NEW CHANGE: Handle standalone code blocks - do not prepend bullet
283
+ const isCodeBlock = item.text?.startsWith('```') && item.text.endsWith('```') && item.text.includes('\n');
284
+ return isCodeBlock ? `${indent}${item.text?.trim()}` : `${indent}- ${item.text?.trim()}`;
239
285
  })
240
286
  .join('\n');
241
287
  // Convert to Roam markdown format
@@ -243,13 +289,16 @@ export class OutlineOperations {
243
289
  // Parse markdown into hierarchical structure
244
290
  // We pass the original OutlineItem properties (heading, children_view_type)
245
291
  // 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
- }));
292
+ const nodes = parseMarkdown(convertedContent).map((node, index) => {
293
+ const outlineItem = validOutline[index];
294
+ return {
295
+ ...node,
296
+ ...(outlineItem?.heading && { heading_level: outlineItem.heading }),
297
+ ...(outlineItem?.children_view_type && { children_view_type: outlineItem.children_view_type })
298
+ };
299
+ });
251
300
  // Convert nodes to batch actions
252
- const actions = convertToRoamActions(nodes, targetParentUid, 'first');
301
+ const actions = convertToRoamActions(nodes, targetParentUid, 'last');
253
302
  if (actions.length === 0) {
254
303
  throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
255
304
  }
@@ -269,13 +318,32 @@ export class OutlineOperations {
269
318
  throw error;
270
319
  throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
271
320
  }
272
- // Get the created block UIDs
273
- const createdUids = result?.created_uids || [];
321
+ // Post-creation verification to get actual UIDs for top-level blocks and their children
322
+ const createdBlocks = [];
323
+ // Only query for top-level blocks (level 1) based on the original outline input
324
+ const topLevelOutlineItems = validOutline.filter(item => item.level === 1);
325
+ for (const item of topLevelOutlineItems) {
326
+ try {
327
+ // Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
328
+ const foundUid = await findBlockWithRetry(targetParentUid, item.text);
329
+ if (foundUid) {
330
+ const nestedBlock = await fetchBlockWithChildren(foundUid);
331
+ if (nestedBlock) {
332
+ createdBlocks.push(nestedBlock);
333
+ }
334
+ }
335
+ }
336
+ catch (error) {
337
+ // This is a warning because even if one block fails to fetch, others might succeed.
338
+ // The error will be logged but not re-thrown to allow partial success reporting.
339
+ // console.warn(`Could not fetch nested block for "${item.text}": ${error.message}`);
340
+ }
341
+ }
274
342
  return {
275
343
  success: true,
276
344
  page_uid: targetPageUid,
277
345
  parent_uid: targetParentUid,
278
- created_uids: createdUids
346
+ created_uids: createdBlocks
279
347
  };
280
348
  }
281
349
  async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
@@ -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') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.32.0",
3
+ "version": "0.32.4",
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",