roam-research-mcp 0.30.2 → 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
@@ -1,3 +1,5 @@
1
+ ![](./roam-research-mcp-image.jpeg)
2
+
1
3
  # Roam Research MCP Server
2
4
 
3
5
  [![npm version](https://badge.fury.io/js/roam-research-mcp.svg)](https://badge.fury.io/js/roam-research-mcp)
@@ -8,6 +10,7 @@
8
10
  A Model Context Protocol (MCP) server that provides comprehensive access to Roam Research's API functionality. This server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface. It supports standard input/output (stdio), HTTP Stream, and Server-Sent Events (SSE) communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research)
9
11
 
10
12
  <a href="https://glama.ai/mcp/servers/fzfznyaflu"><img width="380" height="200" src="https://glama.ai/mcp/servers/fzfznyaflu/badge" alt="Roam Research MCP server" /></a>
13
+ <a href="https://mseep.ai/app/2b3pro-roam-research-mcp"><img width="380" height="200" src="https://mseep.net/pr/2b3pro-roam-research-mcp-badge.png" alt="MseeP.ai Security Assessment Badge" /></a>
11
14
 
12
15
  ## Installation and Usage
13
16
 
@@ -70,6 +73,7 @@ docker run -p 3000:3000 -p 8088:8088 -p 8087:8087 \
70
73
  -e ROAM_API_TOKEN="your-api-token" \
71
74
  -e ROAM_GRAPH_NAME="your-graph-name" \
72
75
  -e MEMORIES_TAG="#[[LLM/Memories]]" \
76
+ -e CUSTOM_INSTRUCTIONS_PATH="/path/to/your/custom_instructions_file.md" \
73
77
  -e HTTP_STREAM_PORT="8088" \
74
78
  -e SSE_PORT="8087" \
75
79
  roam-research-mcp
@@ -103,23 +107,26 @@ The server provides powerful tools for interacting with Roam Research:
103
107
  - Efficient batch operations
104
108
  - Hierarchical outline creation
105
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.
106
111
 
107
112
  1. `roam_fetch_page_by_title`: Fetch page content by title. Returns content in the specified format.
108
- 2. `roam_create_page`: Create new pages with optional content and headings.
109
- 3. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
110
- 4. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
111
- 5. `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`.)
112
- 6. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
113
- 7. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
114
- 8. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight).
115
- 9. `roam_search_by_text`: Search for blocks containing specific text.
116
- 10. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.
117
- 11. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates.
118
- 12. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
119
- 13. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.)
120
- 14. `roam_recall`: Retrieve all stored memories.
121
- 15. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools.
122
- 16. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.)
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.
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.
115
+ 4. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
116
+ 5. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
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`.)
118
+ 7. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
119
+ 8. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
120
+ 9. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight).
121
+ 10. `roam_search_by_text`: Search for blocks containing specific text.
122
+ 11. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.
123
+ 12. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates.
124
+ 13. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
125
+ 14. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.)
126
+ 15. `roam_recall`: Retrieve all stored memories.
127
+ 16. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools.
128
+ 17. `roam_markdown_cheatsheet`: Provides the content of the Roam Markdown Cheatsheet resource, optionally concatenated with custom instructions if `CUSTOM_INSTRUCTIONS_PATH` environment variable is set.
129
+ 18. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.)
123
130
 
124
131
  **Deprecated Tools**:
125
132
  The following tools have been deprecated as of `v.0.30.0` in favor of the more powerful and flexible `roam_process_batch_actions`:
@@ -135,6 +142,8 @@ The following tools have been deprecated as of `v.0.30.0` in favor of the more p
135
142
  **Pre-computation and Context Loading:**
136
143
  ✅ Before attempting any Roam operations, **it is highly recommended** to load the `Roam Markdown Cheatsheet` resource into your context. This ensures you have immediate access to the correct Roam-flavored Markdown syntax, including details for tables, block references, and other special formatting. Example prompt: "Read the Roam cheatsheet first. Then, … <rest of your instructions>"
137
144
 
145
+ - **Specific notes and preferences** concerning my Roam Research graph. Users can add their own specific notes and preferences for working with their own graph in the Cheatsheet.
146
+
138
147
  **Identifying Pages and Blocks for Manipulation:**
139
148
  To ensure accurate operations, always strive to identify target pages and blocks using their Unique Identifiers (UIDs) whenever possible. While some tools accept case-sensitive text titles or content, UIDs provide unambiguous references, reducing the risk of errors due to ambiguity or changes in text.
140
149
 
@@ -207,6 +216,14 @@ This demonstrates moving a block from one location to another and simultaneously
207
216
  "Move the block 'Important note about client feedback' (from page 'Meeting Notes 2025-06-30') under the 'Action Items' section on the 'Project Alpha Planning' page, and change its content to 'Client feedback reviewed and incorporated'."
208
217
  ```
209
218
 
219
+ ### Example 4: Making a Table
220
+
221
+ This demonstrates moving a block from one location to another and simultaneously updating its content.
222
+
223
+ ```
224
+ "In Roam, add a new table on the page "Fruity Tables" that compares four types of fruits: apples, oranges, grapes, and dates. Choose randomly four areas to compare."
225
+ ```
226
+
210
227
  ---
211
228
 
212
229
  ## Setup
@@ -227,6 +244,7 @@ This demonstrates moving a block from one location to another and simultaneously
227
244
  ROAM_API_TOKEN=your-api-token
228
245
  ROAM_GRAPH_NAME=your-graph-name
229
246
  MEMORIES_TAG='#[[LLM/Memories]]'
247
+ CUSTOM_INSTRUCTIONS_PATH='/path/to/your/custom_instructions_file.md'
230
248
  HTTP_STREAM_PORT=8088 # Or your desired port for HTTP Stream communication
231
249
  SSE_PORT=8087 # Or your desired port for SSE communication
232
250
  ```
@@ -247,6 +265,7 @@ This demonstrates moving a block from one location to another and simultaneously
247
265
  "ROAM_API_TOKEN": "your-api-token",
248
266
  "ROAM_GRAPH_NAME": "your-graph-name",
249
267
  "MEMORIES_TAG": "#[[LLM/Memories]]",
268
+ "CUSTOM_INSTRUCTIONS_PATH": "/path/to/your/custom_instructions_file.md",
250
269
  "HTTP_STREAM_PORT": "8088",
251
270
  "SSE_PORT": "8087"
252
271
  }
@@ -258,6 +277,9 @@ This demonstrates moving a block from one location to another and simultaneously
258
277
  Note: The server will first try to load from .env file, then fall back to environment variables from MCP settings.
259
278
 
260
279
  3. Build the server (make sure you're in the root directory of the MCP):
280
+
281
+ Note: Customize 'Roam_Markdown_Cheatsheet.md' with any notes and preferences specific to your graph BEFORE building.
282
+
261
283
  ```bash
262
284
  cd roam-research-mcp
263
285
  npm install
@@ -0,0 +1,123 @@
1
+ !!!! IMPORTANT: Always consult this cheatsheet for correct Roam-flavored markdown syntax BEFORE making any Roam tool calls.
2
+
3
+ # Roam Markdown Cheatsheet
4
+
5
+ ⭐️📋 > > > START 📋⭐️
6
+
7
+ ## Markdown Styles in Roam:
8
+
9
+ - **Bold Text here**
10
+ - **Italics Text here**
11
+ - External Link: `[Link text](URL)`
12
+ - Image Embed: `![Alt text](URL)`
13
+ - ^^Highlighted Text here^^
14
+ - Bullet points: - or \* followed by a space and the text
15
+ - {{[[TODO]]}} todo text
16
+ - {{[[DONE]]}} todo text
17
+ - LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$`
18
+
19
+ ## Roam-specific Markdown:
20
+
21
+ - Dates are in ordinal format: `[[January 1st, 2025]]`
22
+ - Block references: `((block-id))` This inserts a reference to the content of a specific block.
23
+ - Page references: `[[Page name]]` This creates a link to another page within your Roam graph.
24
+ - Link to blocks: `[Link Text](<((block-id))>)` This will link to the block.
25
+ - Embed block in a block: `{{[[embed]]: ((block-id))}}`
26
+ - To-do items: `{{[[TODO]]}} todo text` or `{{[[DONE]]}} todo text`
27
+ - Syntax highlighting for fenced code blocks (add language next to backticks before fenced code block - all in one block) - Example:
28
+ ```javascript
29
+ const foo(bar) => {
30
+ return bar;
31
+ }
32
+ ```
33
+ - Tags:
34
+ - one-word: `#word`
35
+ - multiple words: `#[[two or more words]]`
36
+ - hyphenated words: `#self-esteem`
37
+
38
+ ## Roam Tables
39
+
40
+ Roam tables are created by nesting blocks under a `{{[[table]]}}` parent block. The key to correct table rendering is to ensure proper indentation levels for headers and data cells. Each subsequent header or data cell within a row must be nested one level deeper than the previous one.
41
+
42
+ - The `{{[[table]]}}` block acts as the container for the entire table.
43
+ - The first header block should be at level 2 (one level deeper than `{{[[table]]}}`).
44
+ - Subsequent header blocks must increase their level by one.
45
+ - Each row starts at level 2.
46
+ - The first data cell in a row is at level 3 (one level deeper than the row block).
47
+ - Subsequent data cells within the same row must increase their level by one.
48
+
49
+ Example of a 4x4 table structure:
50
+
51
+ ```
52
+ {{[[table]]}}
53
+ - Header 1
54
+ - Header 2
55
+ - Header 3
56
+ - Header 4
57
+ - Row 1
58
+ - Data 1.1
59
+ - Data 1.2
60
+ - Data 1.3
61
+ - Data 1.4
62
+ - Row 2
63
+ - Data 2.1
64
+ - Data 2.2
65
+ - Data 2.3
66
+ - Data 2.4
67
+ - Row 3
68
+ - Data 3.1
69
+ - Data 3.2
70
+ - Data 3.3
71
+ - Data 3.4
72
+ - Row 4
73
+ - Data 4.1
74
+ - Data 4.2
75
+ - Data 4.3
76
+ - Data 4.4
77
+ ```
78
+
79
+ ## Roam Mermaid
80
+
81
+ This markdown structure represents a Roam Research Mermaid diagram. It begins with a `{{[[mermaid]]}}` block, which serves as the primary container for the diagram definition. Nested underneath this block, using bullet points, is the actual Mermaid syntax. Each bullet point corresponds to a line of the Mermaid graph definition, allowing Roam to render a visual diagram based on the provided code. For example, `graph TD` specifies a top-down directed graph, and subsequent bullet points define nodes and their connections.
82
+
83
+ ```
84
+ - {{[[mermaid]]}}
85
+ - graph TD
86
+ - A[Start] --> B{Decision Point}
87
+ - B -->|Yes| C[Process A]
88
+ - B -->|No| D[Process B]
89
+ - C --> E[Merge Results]
90
+ - D --> E
91
+ - E --> F[End]
92
+ ```
93
+
94
+ ## Roam Kanban Boards
95
+
96
+ The provided markdown structure represents a Roam Research Kanban board. It starts with a `{{[[kanban]]}}` block, under which nested bullet points define the Kanban cards. Each top-level bullet point directly under `{{[[kanban]]}}` serves as a card title, and any further nested bullet points under a card title act as details or sub-items for that specific card.
97
+
98
+ ```
99
+ - {{[[kanban]]}}
100
+ - card title 1
101
+ - bullet point 1.1
102
+ - bullet point 1.2
103
+ - card title 2
104
+ - bullet point 2.1
105
+ - bullet point 2.2
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Roam Hiccup
111
+
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
+
114
+ ## Specific notes and preferences concerning my Roam Research graph
115
+ ### What To Tag
116
+
117
+ NONE
118
+
119
+ ### Don't Include…
120
+
121
+ NONE
122
+
123
+ ⭐️📋 END (Cheat Sheet LOADED) < < < 📋⭐️
@@ -41,6 +41,7 @@ if (!API_TOKEN || !GRAPH_NAME) {
41
41
  ' ROAM_API_TOKEN=your-api-token\n' +
42
42
  ' ROAM_GRAPH_NAME=your-graph-name');
43
43
  }
44
- const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8080
44
+ const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8088
45
45
  const SSE_PORT = process.env.SSE_PORT || '8087'; // Default to 8087
46
- export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT };
46
+ const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5678';
47
+ export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT, CORS_ORIGIN };
@@ -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;
@@ -268,8 +257,8 @@ function convertNodesToBlocks(nodes) {
268
257
  }));
269
258
  }
270
259
  function convertToRoamActions(nodes, parentUid, order = 'last') {
271
- // First convert nodes to blocks with UIDs, reversing to maintain original order
272
- const blocks = convertNodesToBlocks([...nodes].reverse());
260
+ // First convert nodes to blocks with UIDs
261
+ const blocks = convertNodesToBlocks(nodes);
273
262
  const actions = [];
274
263
  // Helper function to recursively create actions
275
264
  function createBlockActions(blocks, parentUid, order) {
@@ -15,9 +15,8 @@ export class TagSearchHandler extends BaseSearchHandler {
15
15
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
16
  }
17
17
  // Build query to find blocks referencing the page
18
- const queryStr = `[:find ?block-uid ?block-str ?page-title
19
- :in $ ?title
20
- :where
18
+ let queryArgs = [primary_tag];
19
+ let queryWhereClauses = `
21
20
  [?ref-page :node/title ?title-match]
22
21
  [(clojure.string/lower-case ?title-match) ?lower-title]
23
22
  [(clojure.string/lower-case ?title) ?search-title]
@@ -26,9 +25,19 @@ export class TagSearchHandler extends BaseSearchHandler {
26
25
  [?b :block/string ?block-str]
27
26
  [?b :block/uid ?block-uid]
28
27
  [?b :block/page ?p]
29
- [?p :node/title ?page-title]]`;
30
- const queryParams = [primary_tag];
31
- const rawResults = await q(this.graph, queryStr, queryParams);
28
+ [?p :node/title ?page-title]`;
29
+ let inClause = `:in $ ?title`;
30
+ if (targetPageUid) {
31
+ inClause += ` ?target-page-uid`;
32
+ queryArgs.push(targetPageUid);
33
+ queryWhereClauses += `
34
+ [?p :block/uid ?target-page-uid]`;
35
+ }
36
+ const queryStr = `[:find ?block-uid ?block-str ?page-title
37
+ ${inClause}
38
+ :where
39
+ ${queryWhereClauses}]`;
40
+ const rawResults = await q(this.graph, queryStr, queryArgs);
32
41
  // Resolve block references in content
33
42
  const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
34
43
  const resolvedContent = await resolveRefs(this.graph, content);
@@ -1,10 +1,9 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
5
4
  import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
5
  import { initializeGraph } from '@roam-research/roam-api-sdk';
7
- import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT } from '../config/environment.js';
6
+ import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT } from '../config/environment.js';
8
7
  import { toolSchemas } from '../tools/schemas.js';
9
8
  import { ToolHandlers } from '../tools/tool-handlers.js';
10
9
  import { readFileSync } from 'node:fs';
@@ -12,6 +11,7 @@ import { join, dirname } from 'node:path';
12
11
  import { createServer } from 'node:http';
13
12
  import { fileURLToPath } from 'node:url';
14
13
  import { findAvailablePort } from '../utils/net.js';
14
+ import { CORS_ORIGIN } from '../config/environment.js';
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  // Read package.json to get the version
@@ -20,7 +20,7 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
20
20
  const serverVersion = packageJson.version;
21
21
  export class RoamServer {
22
22
  constructor() {
23
- console.log('RoamServer: Constructor started.');
23
+ // console.log('RoamServer: Constructor started.');
24
24
  try {
25
25
  this.graph = initializeGraph({
26
26
  token: API_TOKEN,
@@ -42,7 +42,7 @@ export class RoamServer {
42
42
  if (Object.keys(toolSchemas).length === 0) {
43
43
  throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts');
44
44
  }
45
- console.log('RoamServer: Constructor finished.');
45
+ // console.log('RoamServer: Constructor finished.');
46
46
  }
47
47
  // Refactored to accept a Server instance
48
48
  setupRequestHandlers(mcpServer) {
@@ -52,34 +52,23 @@ export class RoamServer {
52
52
  }));
53
53
  // List available resources
54
54
  mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => {
55
- const resources = [
56
- {
57
- name: 'Roam Markdown Cheatsheet',
58
- uri: 'roam-markdown-cheatsheet.md',
59
- type: 'text', // Changed from ResourceType.Text to string literal 'text'
60
- description: 'A cheatsheet for Roam-flavored Markdown syntax.',
61
- },
62
- ];
55
+ const resources = []; // No resources, as cheatsheet is now a tool
63
56
  return { resources };
64
57
  });
65
- // Access resource
58
+ // Access resource - no resources handled directly here anymore
66
59
  mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
67
- if (request.params.uri === 'roam-markdown-cheatsheet.md') {
68
- const cheatsheetPath = join(__dirname, '../../Roam_Markdown_Cheatsheet.md');
69
- try {
70
- const content = readFileSync(cheatsheetPath, 'utf8');
71
- return { contents: [{ type: 'text', text: content, uri: request.params.uri }] };
72
- }
73
- catch (error) {
74
- throw new McpError(ErrorCode.InternalError, `Resource not found: ${request.params.uri}`); // Changed to InternalError
75
- }
76
- }
77
- throw new McpError(ErrorCode.InternalError, `Resource not found: ${request.params.uri}`); // Changed to InternalError
60
+ throw new McpError(ErrorCode.InternalError, `Resource not found: ${request.params.uri}`);
78
61
  });
79
62
  // Handle tool calls
80
63
  mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
81
64
  try {
82
65
  switch (request.params.name) {
66
+ case 'roam_markdown_cheatsheet': {
67
+ const content = await this.toolHandlers.getRoamMarkdownCheatsheet();
68
+ return {
69
+ content: [{ type: 'text', text: content }],
70
+ };
71
+ }
83
72
  case 'roam_remember': {
84
73
  const { memory, categories } = request.params.arguments;
85
74
  const result = await this.toolHandlers.remember(memory, categories);
@@ -196,6 +185,13 @@ export class RoamServer {
196
185
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
197
186
  };
198
187
  }
188
+ case 'roam_fetch_block_with_children': {
189
+ const { block_uid, depth } = request.params.arguments;
190
+ const result = await this.toolHandlers.fetchBlockWithChildren(block_uid, depth);
191
+ return {
192
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
193
+ };
194
+ }
199
195
  default:
200
196
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
201
197
  }
@@ -210,55 +206,65 @@ export class RoamServer {
210
206
  });
211
207
  }
212
208
  async run() {
213
- console.log('RoamServer: run() method started.');
209
+ // console.log('RoamServer: run() method started.');
214
210
  try {
215
- console.log('RoamServer: Attempting to create stdioMcpServer...');
211
+ // console.log('RoamServer: Attempting to create stdioMcpServer...');
216
212
  const stdioMcpServer = new Server({
217
213
  name: 'roam-research',
218
214
  version: serverVersion,
219
215
  }, {
220
216
  capabilities: {
221
217
  tools: {
222
- ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
218
+ ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, toolSchemas[toolName].inputSchema])),
223
219
  },
224
220
  resources: {
225
221
  'roam-markdown-cheatsheet.md': {}
226
222
  }
227
223
  },
228
224
  });
229
- console.log('RoamServer: stdioMcpServer created. Setting up request handlers...');
225
+ // console.log('RoamServer: stdioMcpServer created. Setting up request handlers...');
230
226
  this.setupRequestHandlers(stdioMcpServer);
231
- console.log('RoamServer: stdioMcpServer handlers setup complete. Connecting transport...');
227
+ // console.log('RoamServer: stdioMcpServer handlers setup complete. Connecting transport...');
232
228
  const stdioTransport = new StdioServerTransport();
233
229
  await stdioMcpServer.connect(stdioTransport);
234
- console.log('RoamServer: stdioTransport connected. Attempting to create httpMcpServer...');
230
+ // console.log('RoamServer: stdioTransport connected. Attempting to create httpMcpServer...');
235
231
  const httpMcpServer = new Server({
236
232
  name: 'roam-research-http', // A distinct name for the HTTP server
237
233
  version: serverVersion,
238
234
  }, {
239
235
  capabilities: {
240
236
  tools: {
241
- ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
237
+ ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, toolSchemas[toolName].inputSchema])),
242
238
  },
243
239
  resources: {
244
240
  'roam-markdown-cheatsheet.md': {}
245
241
  }
246
242
  },
247
243
  });
248
- console.log('RoamServer: httpMcpServer created. Setting up request handlers...');
244
+ // console.log('RoamServer: httpMcpServer created. Setting up request handlers...');
249
245
  this.setupRequestHandlers(httpMcpServer);
250
- console.log('RoamServer: httpMcpServer handlers setup complete. Connecting transport...');
246
+ // console.log('RoamServer: httpMcpServer handlers setup complete. Connecting transport...');
251
247
  const httpStreamTransport = new StreamableHTTPServerTransport({
252
248
  sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
253
249
  });
254
250
  await httpMcpServer.connect(httpStreamTransport);
255
- console.log('RoamServer: httpStreamTransport connected.');
251
+ // console.log('RoamServer: httpStreamTransport connected.');
256
252
  const httpServer = createServer(async (req, res) => {
253
+ // Set CORS headers
254
+ res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
255
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
256
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
257
+ // Handle preflight OPTIONS requests
258
+ if (req.method === 'OPTIONS') {
259
+ res.writeHead(204); // No Content
260
+ res.end();
261
+ return;
262
+ }
257
263
  try {
258
264
  await httpStreamTransport.handleRequest(req, res);
259
265
  }
260
266
  catch (error) {
261
- // console.error('HTTP Stream Server error:', error);
267
+ // // console.error('HTTP Stream Server error:', error);
262
268
  if (!res.headersSent) {
263
269
  res.writeHead(500, { 'Content-Type': 'application/json' });
264
270
  res.end(JSON.stringify({ error: 'Internal Server Error' }));
@@ -267,70 +273,7 @@ export class RoamServer {
267
273
  });
268
274
  const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT));
269
275
  httpServer.listen(availableHttpPort, () => {
270
- // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`);
271
- });
272
- // SSE Server setup
273
- const sseMcpServer = new Server({
274
- name: 'roam-research-sse', // Distinct name for SSE server
275
- version: serverVersion,
276
- }, {
277
- capabilities: {
278
- tools: {
279
- ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
280
- },
281
- },
282
- });
283
- this.setupRequestHandlers(sseMcpServer);
284
- const sseHttpServer = createServer(async (req, res) => {
285
- const parseBody = (request) => {
286
- return new Promise((resolve, reject) => {
287
- let body = '';
288
- request.on('data', (chunk) => {
289
- body += chunk.toString();
290
- });
291
- request.on('end', () => {
292
- try {
293
- resolve(body ? JSON.parse(body) : {});
294
- }
295
- catch (error) {
296
- reject(error);
297
- }
298
- });
299
- request.on('error', reject);
300
- });
301
- };
302
- try {
303
- if (req.url === '/sse') {
304
- const sseTransport = new SSEServerTransport('/sse', res);
305
- await sseMcpServer.connect(sseTransport);
306
- if (req.method === 'GET') {
307
- await sseTransport.start();
308
- }
309
- else if (req.method === 'POST') {
310
- const parsedBody = await parseBody(req);
311
- await sseTransport.handlePostMessage(req, res, parsedBody);
312
- }
313
- else {
314
- res.writeHead(405, { 'Content-Type': 'text/plain' });
315
- res.end('Method Not Allowed');
316
- }
317
- }
318
- else {
319
- res.writeHead(404, { 'Content-Type': 'text/plain' });
320
- res.end('Not Found');
321
- }
322
- }
323
- catch (error) {
324
- // console.error('SSE HTTP Server error:', error);
325
- if (!res.headersSent) {
326
- res.writeHead(500, { 'Content-Type': 'application/json' });
327
- res.end(JSON.stringify({ error: 'Internal Server Error' }));
328
- }
329
- }
330
- });
331
- const availableSsePort = await findAvailablePort(parseInt(SSE_PORT));
332
- sseHttpServer.listen(availableSsePort, () => {
333
- // console.log(`MCP Roam Research server running SSE on port ${availableSsePort}`);
276
+ // // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`);
334
277
  });
335
278
  }
336
279
  catch (error) {
@@ -0,0 +1,74 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ export class BlockRetrievalOperations {
4
+ constructor(graph) {
5
+ this.graph = graph;
6
+ }
7
+ async fetchBlockWithChildren(block_uid_raw, depth = 4) {
8
+ if (!block_uid_raw) {
9
+ throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required.');
10
+ }
11
+ const block_uid = block_uid_raw.replace(/^\(\((.*)\)\)$/, '$1');
12
+ const fetchChildren = async (parentUids, currentDepth) => {
13
+ if (currentDepth >= depth || parentUids.length === 0) {
14
+ return {};
15
+ }
16
+ const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading
17
+ :in $ [?parentUid ...]
18
+ :where [?parent :block/uid ?parentUid]
19
+ [?child :block/parents ?parent]
20
+ [?child :block/uid ?childUid]
21
+ [?child :block/string ?childString]
22
+ [?child :block/order ?childOrder]
23
+ [(get-else $ ?child :block/heading 0) ?childHeading]]`;
24
+ const childrenResults = await q(this.graph, childrenQuery, [parentUids]);
25
+ const childrenByParent = {};
26
+ const allChildUids = [];
27
+ for (const [parentUid, childUid, childString, childOrder, childHeading] of childrenResults) {
28
+ if (!childrenByParent[parentUid]) {
29
+ childrenByParent[parentUid] = [];
30
+ }
31
+ childrenByParent[parentUid].push({
32
+ uid: childUid,
33
+ string: childString,
34
+ order: childOrder,
35
+ heading: childHeading || undefined,
36
+ children: [],
37
+ });
38
+ allChildUids.push(childUid);
39
+ }
40
+ const grandChildren = await fetchChildren(allChildUids, currentDepth + 1);
41
+ for (const parentUid in childrenByParent) {
42
+ for (const child of childrenByParent[parentUid]) {
43
+ child.children = grandChildren[child.uid] || [];
44
+ }
45
+ childrenByParent[parentUid].sort((a, b) => a.order - b.order);
46
+ }
47
+ return childrenByParent;
48
+ };
49
+ try {
50
+ const rootBlockQuery = `[:find ?string ?order ?heading
51
+ :in $ ?blockUid
52
+ :where [?b :block/uid ?blockUid]
53
+ [?b :block/string ?string]
54
+ [?b :block/order ?order]
55
+ [(get-else $ ?b :block/heading 0) ?heading]]`;
56
+ const rootBlockResult = await q(this.graph, rootBlockQuery, [block_uid]);
57
+ if (!rootBlockResult) {
58
+ return null;
59
+ }
60
+ const [rootString, rootOrder, rootHeading] = rootBlockResult;
61
+ const childrenMap = await fetchChildren([block_uid], 0);
62
+ return {
63
+ uid: block_uid,
64
+ string: rootString,
65
+ order: rootOrder,
66
+ heading: rootHeading || undefined,
67
+ children: childrenMap[block_uid] || [],
68
+ };
69
+ }
70
+ catch (error) {
71
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children: ${error instanceof Error ? error.message : String(error)}`);
72
+ }
73
+ }
74
+ }
@@ -96,14 +96,6 @@ export class BlockOperations {
96
96
  // No heading parameter, use original parsing logic
97
97
  const convertedContent = convertToRoamMarkdown(content);
98
98
  nodes = parseMarkdown(convertedContent);
99
- // If we have simple newline-separated content (no markdown formatting),
100
- // and parseMarkdown created nodes at level 0, reverse them to maintain original order
101
- if (nodes.every(node => node.level === 0 && !node.heading_level)) {
102
- const lines = content.split('\n').filter(line => line.trim());
103
- if (lines.length === nodes.length) {
104
- nodes.reverse();
105
- }
106
- }
107
99
  }
108
100
  const actions = convertToRoamActions(nodes, targetPageUid, 'last');
109
101
  // Execute batch actions to create the nested structure
@@ -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) {
@@ -218,6 +262,9 @@ export class OutlineOperations {
218
262
  let result;
219
263
  try {
220
264
  // Validate level sequence
265
+ if (validOutline.length > 0 && validOutline[0].level !== 1) {
266
+ throw new McpError(ErrorCode.InvalidRequest, 'Invalid outline structure - the first item must be at level 1');
267
+ }
221
268
  let prevLevel = 0;
222
269
  for (const item of validOutline) {
223
270
  // Level should not increase by more than 1 at a time
@@ -232,7 +279,9 @@ export class OutlineOperations {
232
279
  const indent = ' '.repeat(item.level - 1);
233
280
  // If the item text starts with a markdown heading (e.g., #, ##, ###),
234
281
  // treat it as a direct heading without adding a bullet or outline indentation.
235
- 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()}`;
236
285
  })
237
286
  .join('\n');
238
287
  // Convert to Roam markdown format
@@ -240,13 +289,16 @@ export class OutlineOperations {
240
289
  // Parse markdown into hierarchical structure
241
290
  // We pass the original OutlineItem properties (heading, children_view_type)
242
291
  // 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
- }));
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
+ });
248
300
  // Convert nodes to batch actions
249
- const actions = convertToRoamActions(nodes, targetParentUid, 'first');
301
+ const actions = convertToRoamActions(nodes, targetParentUid, 'last');
250
302
  if (actions.length === 0) {
251
303
  throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
252
304
  }
@@ -266,13 +318,32 @@ export class OutlineOperations {
266
318
  throw error;
267
319
  throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
268
320
  }
269
- // Get the created block UIDs
270
- 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
+ }
271
342
  return {
272
343
  success: true,
273
344
  page_uid: targetPageUid,
274
345
  parent_uid: targetParentUid,
275
- created_uids: createdUids
346
+ created_uids: createdBlocks
276
347
  };
277
348
  }
278
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
- import { convertToRoamActions } from '../../markdown-utils.js';
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;
@@ -84,7 +95,7 @@ export class PageOperations {
84
95
  try {
85
96
  // Convert content array to MarkdownNode format expected by convertToRoamActions
86
97
  const nodes = content.map(block => ({
87
- content: block.text,
98
+ content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
88
99
  level: block.level,
89
100
  ...(block.heading && { heading_level: block.heading }),
90
101
  children: []
@@ -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') {
@@ -50,7 +50,7 @@ export const toolSchemas = {
50
50
  },
51
51
  content: {
52
52
  type: 'array',
53
- description: 'Initial content for the page as an array of blocks with explicit nesting levels',
53
+ description: 'Initial content for the page as an array of blocks with explicit nesting levels. Note: While empty blocks (e.g., {"text": "", "level": 1}) can be used for visual spacing, they create empty entities in the database. Please use them sparingly and only for structural purposes, not for simple visual separation.',
54
54
  items: {
55
55
  type: 'object',
56
56
  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. 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. 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: {
@@ -94,7 +94,7 @@ export const toolSchemas = {
94
94
  },
95
95
  outline: {
96
96
  type: 'array',
97
- description: 'Array of outline items with block text and explicit nesting level',
97
+ description: 'Array of outline items with block text and explicit nesting level. Must be a valid hierarchy: the first item must be level 1, and subsequent levels cannot increase by more than 1 at a time (e.g., a level 3 cannot follow a level 1).',
98
98
  items: {
99
99
  type: 'object',
100
100
  properties: {
@@ -104,7 +104,7 @@ export const toolSchemas = {
104
104
  },
105
105
  level: {
106
106
  type: 'integer',
107
- description: 'Indentation level (1-10, where 1 is top level)',
107
+ description: 'Indentation level (1-10, where 1 is top level). Levels must be sequential and cannot be skipped (e.g., a level 3 item cannot directly follow a level 1 item).',
108
108
  minimum: 1,
109
109
  maximum: 10
110
110
  },
@@ -155,7 +155,7 @@ export const toolSchemas = {
155
155
  },
156
156
  order: {
157
157
  type: 'string',
158
- description: 'Optional: Where to add the content under the parent ("first" or "last")',
158
+ description: 'Optional: Where to add the content undeIs this tr the parent ("first" or "last")',
159
159
  enum: ['first', 'last'],
160
160
  default: 'first'
161
161
  }
@@ -322,6 +322,15 @@ export const toolSchemas = {
322
322
  required: ['start_date', 'type', 'scope']
323
323
  }
324
324
  },
325
+ roam_markdown_cheatsheet: {
326
+ name: 'roam_markdown_cheatsheet',
327
+ description: 'Provides the content of the Roam Markdown Cheatsheet resource, optionally concatenated with custom instructions if CUSTOM_INSTRUCTIONS_PATH is set.',
328
+ inputSchema: {
329
+ type: 'object',
330
+ properties: {},
331
+ required: [],
332
+ },
333
+ },
325
334
  roam_remember: {
326
335
  name: 'roam_remember',
327
336
  description: 'Add a memory or piece of information to remember, stored on the daily page with MEMORIES_TAG tag and optional categories. \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).\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
@@ -436,8 +445,11 @@ export const toolSchemas = {
436
445
  description: 'The UID of the parent block or page.'
437
446
  },
438
447
  "order": {
439
- type: ['number', 'string'],
440
- description: 'The position of the block under its parent (0 for top, "last" for bottom).'
448
+ oneOf: [
449
+ { type: 'integer', description: 'Zero-indexed position.' },
450
+ { type: 'string', enum: ['first', 'last'], description: 'Position keyword.' }
451
+ ],
452
+ description: 'The position of the block under its parent (e.g., 0, 1, 2) or a keyword ("first", "last").'
441
453
  }
442
454
  }
443
455
  }
@@ -448,5 +460,25 @@ export const toolSchemas = {
448
460
  },
449
461
  required: ['actions']
450
462
  }
451
- }
463
+ },
464
+ roam_fetch_block_with_children: {
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.',
467
+ inputSchema: {
468
+ type: 'object',
469
+ properties: {
470
+ block_uid: {
471
+ type: 'string',
472
+ description: 'The UID of the block to fetch.'
473
+ },
474
+ depth: {
475
+ type: 'integer',
476
+ description: 'Optional: The number of levels deep to fetch children. Defaults to 4.',
477
+ minimum: 0,
478
+ maximum: 10
479
+ }
480
+ },
481
+ required: ['block_uid']
482
+ },
483
+ },
452
484
  };
@@ -1,5 +1,9 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { fileURLToPath } from 'url';
1
4
  import { PageOperations } from './operations/pages.js';
2
5
  import { BlockOperations } from './operations/blocks.js';
6
+ import { BlockRetrievalOperations } from './operations/block-retrieval.js'; // New import
3
7
  import { SearchOperations } from './operations/search/index.js';
4
8
  import { MemoryOperations } from './operations/memory.js';
5
9
  import { TodoOperations } from './operations/todos.js';
@@ -11,6 +15,7 @@ export class ToolHandlers {
11
15
  this.graph = graph;
12
16
  this.pageOps = new PageOperations(graph);
13
17
  this.blockOps = new BlockOperations(graph);
18
+ this.blockRetrievalOps = new BlockRetrievalOperations(graph); // Initialize new instance
14
19
  this.searchOps = new SearchOperations(graph);
15
20
  this.memoryOps = new MemoryOperations(graph);
16
21
  this.todoOps = new TodoOperations(graph);
@@ -28,6 +33,9 @@ export class ToolHandlers {
28
33
  return this.pageOps.fetchPageByTitle(title, format);
29
34
  }
30
35
  // Block Operations
36
+ async fetchBlockWithChildren(block_uid, depth) {
37
+ return this.blockRetrievalOps.fetchBlockWithChildren(block_uid, depth);
38
+ }
31
39
  // Search Operations
32
40
  async searchByStatus(status, page_title_uid, include, exclude) {
33
41
  return this.searchOps.searchByStatus(status, page_title_uid, include, exclude);
@@ -74,4 +82,21 @@ export class ToolHandlers {
74
82
  async processBatch(actions) {
75
83
  return this.batchOps.processBatch(actions);
76
84
  }
85
+ async getRoamMarkdownCheatsheet() {
86
+ const __filename = fileURLToPath(import.meta.url);
87
+ const __dirname = path.dirname(__filename);
88
+ const cheatsheetPath = path.join(__dirname, '../../Roam_Markdown_Cheatsheet.md');
89
+ let cheatsheetContent = fs.readFileSync(cheatsheetPath, 'utf-8');
90
+ const customInstructionsPath = process.env.CUSTOM_INSTRUCTIONS_PATH;
91
+ if (customInstructionsPath && fs.existsSync(customInstructionsPath)) {
92
+ try {
93
+ const customInstructionsContent = fs.readFileSync(customInstructionsPath, 'utf-8');
94
+ cheatsheetContent += `\n\n${customInstructionsContent}`;
95
+ }
96
+ catch (error) {
97
+ console.warn(`Could not read custom instructions file at ${customInstructionsPath}: ${error}`);
98
+ }
99
+ }
100
+ return cheatsheetContent;
101
+ }
77
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.30.2",
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,10 +28,15 @@
28
28
  "build"
29
29
  ],
30
30
  "scripts": {
31
- "build": "tsc && 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
+ "clean": "rm -rf build",
32
33
  "watch": "tsc --watch",
33
34
  "inspector": "npx @modelcontextprotocol/inspector build/index.js",
34
- "start": "node build/index.js"
35
+ "start": "node build/index.js",
36
+ "prepublishOnly": "npm run clean && npm run build",
37
+ "release:patch": "npm version patch && git push origin v$(node -p \"require('./package.json').version\")",
38
+ "release:minor": "npm version minor && git push origin v$(node -p \"require('./package.json').version\")",
39
+ "release:major": "npm version major && git push origin v$(node -p \"require('./package.json').version\")"
35
40
  },
36
41
  "dependencies": {
37
42
  "@modelcontextprotocol/sdk": "^1.13.2",