roam-research-mcp 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Ian Shen / 2B3 PRODUCTIONS LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,329 @@
1
+ # Roam Research MCP Server
2
+
3
+ [![npm version](https://badge.fury.io/js/roam-research-mcp.svg)](https://badge.fury.io/js/roam-research-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![GitHub](https://img.shields.io/github/license/2b3pro/roam-research-mcp)](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE)
6
+
7
+ 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.
8
+
9
+ ## Installation
10
+
11
+ You can install the package globally:
12
+
13
+ ```bash
14
+ npm install -g roam-research-mcp
15
+ ```
16
+
17
+ Or clone the repository and build from source:
18
+
19
+ ```bash
20
+ git clone https://github.com/2b3pro/roam-research-mcp.git
21
+ cd roam-research-mcp
22
+ npm install
23
+ npm run build
24
+ ```
25
+
26
+ ## Features
27
+
28
+ The server provides six powerful tools for interacting with Roam Research:
29
+
30
+ 1. `roam_fetch_page_by_title`: Fetch and read a page's content by title, recursively resolving block references up to 4 levels deep
31
+ 2. `roam_create_page`: Create new pages with optional content
32
+ 3. `roam_create_block`: Create new blocks in a page (defaults to today's daily page)
33
+ 4. `roam_import_markdown`: Import nested markdown content under specific blocks
34
+ 5. `roam_add_todo`: Add multiple todo items to today's daily page with checkbox syntax
35
+ 6. `roam_create_outline`: Create hierarchical outlines with proper nesting and structure
36
+
37
+ ## Setup
38
+
39
+ 1. Create a Roam Research API token:
40
+
41
+ - Go to your graph settings
42
+ - Navigate to the "API tokens" section
43
+ - Create a new token
44
+
45
+ 2. Configure the environment variables:
46
+ You have two options for configuring the required environment variables:
47
+
48
+ Option 1: Using a .env file (Recommended for development)
49
+ Create a `.env` file in the roam-research directory:
50
+
51
+ ```
52
+ ROAM_API_TOKEN=your-api-token
53
+ ROAM_GRAPH_NAME=your-graph-name
54
+ ```
55
+
56
+ Option 2: Using MCP settings (Alternative method)
57
+ Add the configuration to your MCP settings file:
58
+
59
+ - For Cline (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`):
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "roam-research": {
65
+ "command": "node",
66
+ "args": ["/path/to/roam-research/build/index.js"],
67
+ "env": {
68
+ "ROAM_API_TOKEN": "your-api-token",
69
+ "ROAM_GRAPH_NAME": "your-graph-name"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ - For Claude desktop app (`~/Library/Application Support/Claude/claude_desktop_config.json`):
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "roam-research": {
82
+ "command": "node",
83
+ "args": ["/path/to/roam-research/build/index.js"],
84
+ "env": {
85
+ "ROAM_API_TOKEN": "your-api-token",
86
+ "ROAM_GRAPH_NAME": "your-graph-name"
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Note: The server will first try to load from .env file, then fall back to environment variables from MCP settings.
94
+
95
+ 3. Build the server:
96
+ ```bash
97
+ cd roam-research
98
+ npm install
99
+ npm run build
100
+ ```
101
+
102
+ ## Usage
103
+
104
+ ### Fetch Page By Title
105
+
106
+ Fetch and read a page's content with resolved block references:
107
+
108
+ ```typescript
109
+ use_mcp_tool roam-research roam_fetch_page_by_title {
110
+ "title": "Example Page"
111
+ }
112
+ ```
113
+
114
+ Returns the page content as markdown with:
115
+
116
+ - Complete hierarchical structure
117
+ - Block references recursively resolved (up to 4 levels deep)
118
+ - Proper indentation for nesting levels
119
+ - Full markdown formatting
120
+
121
+ ### Create Page
122
+
123
+ Create a new page with optional content:
124
+
125
+ ```typescript
126
+ use_mcp_tool roam-research roam_create_page {
127
+ "title": "New Page",
128
+ "content": "Initial content for the page"
129
+ }
130
+ ```
131
+
132
+ Returns the created page's UID on success.
133
+
134
+ ### Create Block
135
+
136
+ Add a new block to a page (defaults to today's daily page if neither page_uid nor title provided):
137
+
138
+ ```typescript
139
+ use_mcp_tool roam-research roam_create_block {
140
+ "content": "Block content",
141
+ "page_uid": "optional-target-page-uid",
142
+ "title": "optional-target-page-title"
143
+ }
144
+ ```
145
+
146
+ You can specify either:
147
+
148
+ - `page_uid`: Direct reference to target page
149
+ - `title`: Name of target page (will be created if it doesn't exist)
150
+ - Neither: Block will be added to today's daily page
151
+
152
+ Returns:
153
+
154
+ ```json
155
+ {
156
+ "success": true,
157
+ "block_uid": "created-block-uid",
158
+ "parent_uid": "parent-page-uid"
159
+ }
160
+ ```
161
+
162
+ ### Create Outline
163
+
164
+ Create a hierarchical outline with proper nesting and structure:
165
+
166
+ ```typescript
167
+ use_mcp_tool roam-research roam_create_outline {
168
+ "outline": [
169
+ {
170
+ "text": "I. Top Level",
171
+ "level": 1
172
+ },
173
+ {
174
+ "text": "A. Second Level",
175
+ "level": 2
176
+ },
177
+ {
178
+ "text": "1. Third Level",
179
+ "level": 3
180
+ }
181
+ ],
182
+ "page_title_uid": "optional-target-page",
183
+ "block_text_uid": "optional-header-text"
184
+ }
185
+ ```
186
+
187
+ Features:
188
+
189
+ - Create complex outlines with up to 10 levels of nesting
190
+ - Validate outline structure and content
191
+ - Maintain proper parent-child relationships
192
+ - Optional header block for the outline
193
+ - Defaults to today's daily page if no page specified
194
+ - Efficient batch operations for creating blocks
195
+
196
+ Parameters:
197
+
198
+ - `outline`: Array of outline items, each with:
199
+ - `text`: Content of the outline item (required)
200
+ - `level`: Nesting level (1-10, required)
201
+ - `page_title_uid`: Target page title or UID (optional, defaults to today's page)
202
+ - `block_text_uid`: Header text for the outline (optional)
203
+
204
+ Returns:
205
+
206
+ ```json
207
+ {
208
+ "success": true,
209
+ "page_uid": "target-page-uid",
210
+ "parent_uid": "header-block-uid",
211
+ "created_uids": ["uid1", "uid2", ...]
212
+ }
213
+ ```
214
+
215
+ ### Add Todo Items
216
+
217
+ Add one or more todo items to today's daily page:
218
+
219
+ ```typescript
220
+ use_mcp_tool roam-research roam_add_todo {
221
+ "todos": [
222
+ "First todo item",
223
+ "Second todo item",
224
+ "Third todo item"
225
+ ]
226
+ }
227
+ ```
228
+
229
+ Features:
230
+
231
+ - Adds todos with Roam checkbox syntax (`{{TODO}} todo text`)
232
+ - Supports adding multiple todos in a single operation
233
+ - Uses batch actions for efficiency when adding >10 todos
234
+ - Automatically creates today's page if it doesn't exist
235
+ - Adds todos as top-level blocks in sequential order
236
+
237
+ ### Import Nested Markdown
238
+
239
+ Import nested markdown content under a specific block:
240
+
241
+ ```typescript
242
+ use_mcp_tool roam-research roam_import_markdown {
243
+ "content": "- Item 1\n - Subitem A\n - Subitem B\n- Item 2",
244
+ "page_uid": "optional-page-uid",
245
+ "page_title": "optional-page-title",
246
+ "parent_uid": "optional-parent-block-uid",
247
+ "parent_string": "optional-exact-block-content",
248
+ "order": "first"
249
+ }
250
+ ```
251
+
252
+ Features:
253
+
254
+ - Import content under specific blocks:
255
+ - Find parent block by UID or exact string match
256
+ - Locate blocks within specific pages by title or UID
257
+ - Defaults to today's page if no page specified
258
+ - Control content placement:
259
+ - Add as first or last child of parent block
260
+ - Preserve hierarchical structure
261
+ - Efficient batch operations for nested content
262
+ - Comprehensive return value:
263
+ ```json
264
+ {
265
+ "success": true,
266
+ "page_uid": "target-page-uid",
267
+ "parent_uid": "parent-block-uid",
268
+ "created_uids": ["uid1", "uid2", ...]
269
+ }
270
+ ```
271
+
272
+ Parameters:
273
+
274
+ - `content`: Nested markdown content to import
275
+ - `page_uid`: UID of the page containing the parent block
276
+ - `page_title`: Title of the page containing the parent block (ignored if page_uid provided)
277
+ - `parent_uid`: UID of the parent block to add content under
278
+ - `parent_string`: Exact string content of the parent block (must provide either page_uid or page_title)
279
+ - `order`: Where to add the content ("first" or "last", defaults to "first")
280
+
281
+ ## Error Handling
282
+
283
+ The server provides comprehensive error handling for common scenarios:
284
+
285
+ - Configuration errors:
286
+ - Missing API token or graph name
287
+ - Invalid environment variables
288
+ - API errors:
289
+ - Authentication failures
290
+ - Invalid requests
291
+ - Failed operations
292
+ - Tool-specific errors:
293
+ - Page not found (with case-insensitive search)
294
+ - Block not found by string match
295
+ - Invalid markdown format
296
+ - Missing required parameters
297
+ - Invalid outline structure or content
298
+
299
+ Each error response includes:
300
+
301
+ - Standard MCP error code
302
+ - Detailed error message
303
+ - Suggestions for resolution when applicable
304
+
305
+ ## Development
306
+
307
+ The server is built with TypeScript and includes:
308
+
309
+ - Environment variable handling with .env support
310
+ - Comprehensive input validation
311
+ - Case-insensitive page title matching
312
+ - Recursive block reference resolution
313
+ - Markdown parsing and conversion
314
+ - Daily page integration
315
+ - Detailed debug logging
316
+ - Efficient batch operations
317
+ - Hierarchical outline creation
318
+
319
+ To modify or extend the server:
320
+
321
+ 1. Clone the repository
322
+ 2. Install dependencies with `npm install`
323
+ 3. Make changes to the source files
324
+ 4. Build with `npm run build`
325
+ 5. Test locally by configuring environment variables
326
+
327
+ ## License
328
+
329
+ MIT License
@@ -0,0 +1,44 @@
1
+ import * as dotenv from 'dotenv';
2
+ import { dirname, join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ // Get the project root from the script path
5
+ const scriptPath = process.argv[1]; // Full path to the running script
6
+ const projectRoot = dirname(dirname(scriptPath)); // Go up two levels from build/index.js
7
+ // Try to load .env from project root
8
+ const envPath = join(projectRoot, '.env');
9
+ if (existsSync(envPath)) {
10
+ dotenv.config({ path: envPath });
11
+ }
12
+ // Required environment variables
13
+ const API_TOKEN = process.env.ROAM_API_TOKEN;
14
+ const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
15
+ // Validate environment variables
16
+ if (!API_TOKEN || !GRAPH_NAME) {
17
+ const missingVars = [];
18
+ if (!API_TOKEN)
19
+ missingVars.push('ROAM_API_TOKEN');
20
+ if (!GRAPH_NAME)
21
+ missingVars.push('ROAM_GRAPH_NAME');
22
+ throw new Error(`Missing required environment variables: ${missingVars.join(', ')}\n\n` +
23
+ 'Please configure these variables either:\n' +
24
+ '1. In your MCP settings file:\n' +
25
+ ' - For Cline: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json\n' +
26
+ ' - For Claude: ~/Library/Application Support/Claude/claude_desktop_config.json\n\n' +
27
+ ' Example configuration:\n' +
28
+ ' {\n' +
29
+ ' "mcpServers": {\n' +
30
+ ' "roam-research": {\n' +
31
+ ' "command": "node",\n' +
32
+ ' "args": ["/path/to/roam-research/build/index.js"],\n' +
33
+ ' "env": {\n' +
34
+ ' "ROAM_API_TOKEN": "your-api-token",\n' +
35
+ ' "ROAM_GRAPH_NAME": "your-graph-name"\n' +
36
+ ' }\n' +
37
+ ' }\n' +
38
+ ' }\n' +
39
+ ' }\n\n' +
40
+ '2. Or in a .env file in the roam-research directory:\n' +
41
+ ' ROAM_API_TOKEN=your-api-token\n' +
42
+ ' ROAM_GRAPH_NAME=your-graph-name');
43
+ }
44
+ export { API_TOKEN, GRAPH_NAME };
package/build/index.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { RoamServer } from './server/roam-server.js';
3
+ const server = new RoamServer();
4
+ server.run().catch(() => { });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Check if text has a traditional markdown table
3
+ */
4
+ function hasMarkdownTable(text) {
5
+ return /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+$/.test(text);
6
+ }
7
+ /**
8
+ * Converts a markdown table to Roam format
9
+ */
10
+ function convertTableToRoamFormat(text) {
11
+ const lines = text.split('\n')
12
+ .map(line => line.trim())
13
+ .filter(line => line.length > 0);
14
+ const tableRegex = /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+/m;
15
+ if (!tableRegex.test(text)) {
16
+ return text;
17
+ }
18
+ const rows = lines
19
+ .filter((_, index) => index !== 1)
20
+ .map(line => line.trim()
21
+ .replace(/^\||\|$/g, '')
22
+ .split('|')
23
+ .map(cell => cell.trim()));
24
+ let roamTable = '{{table}}\n';
25
+ // First row becomes column headers
26
+ const headers = rows[0];
27
+ for (let i = 0; i < headers.length; i++) {
28
+ roamTable += `${' '.repeat(i + 1)}- ${headers[i]}\n`;
29
+ }
30
+ // Remaining rows become nested under each column
31
+ for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) {
32
+ const row = rows[rowIndex];
33
+ for (let colIndex = 0; colIndex < row.length; colIndex++) {
34
+ roamTable += `${' '.repeat(colIndex + 1)}- ${row[colIndex]}\n`;
35
+ }
36
+ }
37
+ return roamTable.trim();
38
+ }
39
+ function convertAllTables(text) {
40
+ return text.replaceAll(/(^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+)/gm, (match) => {
41
+ return '\n' + convertTableToRoamFormat(match) + '\n';
42
+ });
43
+ }
44
+ function convertToRoamMarkdown(text) {
45
+ // First handle double asterisks/underscores (bold)
46
+ text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
47
+ // Then handle single asterisks/underscores (italic)
48
+ text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore
49
+ text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore
50
+ // Handle highlights
51
+ text = text.replace(/==(.+?)==/g, '^^$1^^');
52
+ // Convert tables
53
+ text = convertAllTables(text);
54
+ return text;
55
+ }
56
+ function parseMarkdown(markdown) {
57
+ const lines = markdown.split('\n');
58
+ const rootNodes = [];
59
+ const stack = [];
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+ const trimmedLine = line.trimEnd();
63
+ // Skip truly empty lines (no spaces)
64
+ if (trimmedLine === '') {
65
+ continue;
66
+ }
67
+ // Calculate indentation level (2 spaces = 1 level)
68
+ const indentation = line.match(/^\s*/)?.[0].length ?? 0;
69
+ let level = Math.floor(indentation / 2);
70
+ // Extract content after bullet point or heading
71
+ let content = trimmedLine;
72
+ content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
73
+ if (trimmedLine.startsWith('#') || trimmedLine.includes('{{table}}') || (trimmedLine.startsWith('**') && trimmedLine.endsWith('**'))) {
74
+ // Remove bullet point if it precedes a table marker
75
+ // content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
76
+ level = 0;
77
+ // Reset stack but keep heading/table as parent
78
+ stack.length = 1; // Keep only the heading/table
79
+ }
80
+ else if (stack[0]?.content.startsWith('#') || stack[0]?.content.includes('{{table}}') || (stack[0]?.content.startsWith('**') && stack[0]?.content.endsWith('**'))) {
81
+ // If previous node was a heading or table marker or wrapped in double-asterisks, increase level by 1
82
+ level = Math.max(level, 1);
83
+ // Remove bullet point
84
+ // content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
85
+ }
86
+ else {
87
+ // Remove bullet point
88
+ content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
89
+ }
90
+ // Create new node
91
+ const node = {
92
+ content,
93
+ level,
94
+ children: []
95
+ };
96
+ // Find the appropriate parent for this node based on level
97
+ if (level === 0) {
98
+ rootNodes.push(node);
99
+ stack[0] = node;
100
+ }
101
+ else {
102
+ // Pop stack until we find the parent level
103
+ while (stack.length > level) {
104
+ stack.pop();
105
+ }
106
+ // Add as child to parent
107
+ if (stack[level - 1]) {
108
+ stack[level - 1].children.push(node);
109
+ }
110
+ else {
111
+ // If no parent found, treat as root node
112
+ rootNodes.push(node);
113
+ }
114
+ stack[level] = node;
115
+ }
116
+ }
117
+ return rootNodes;
118
+ }
119
+ function parseTableRows(lines) {
120
+ const tableNodes = [];
121
+ let currentLevel = -1;
122
+ for (const line of lines) {
123
+ const trimmedLine = line.trimEnd();
124
+ if (!trimmedLine)
125
+ continue;
126
+ // Calculate indentation level
127
+ const indentation = line.match(/^\s*/)?.[0].length ?? 0;
128
+ const level = Math.floor(indentation / 2);
129
+ // Extract content after bullet point
130
+ const content = trimmedLine.replace(/^\s*[-*+]\s*/, '');
131
+ // Create node for this cell
132
+ const node = {
133
+ content,
134
+ level,
135
+ children: []
136
+ };
137
+ // Track the first level we see to maintain relative nesting
138
+ if (currentLevel === -1) {
139
+ currentLevel = level;
140
+ }
141
+ // Add node to appropriate parent based on level
142
+ if (level === currentLevel) {
143
+ tableNodes.push(node);
144
+ }
145
+ else {
146
+ // Find parent by walking back through nodes
147
+ let parent = tableNodes[tableNodes.length - 1];
148
+ while (parent && parent.level < level - 1) {
149
+ parent = parent.children[parent.children.length - 1];
150
+ }
151
+ if (parent) {
152
+ parent.children.push(node);
153
+ }
154
+ }
155
+ }
156
+ return tableNodes;
157
+ }
158
+ function generateBlockUid() {
159
+ // Generate a random string of 9 characters (Roam's format)
160
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';
161
+ let uid = '';
162
+ for (let i = 0; i < 9; i++) {
163
+ uid += chars.charAt(Math.floor(Math.random() * chars.length));
164
+ }
165
+ return uid;
166
+ }
167
+ function convertNodesToBlocks(nodes) {
168
+ return nodes.map(node => ({
169
+ uid: generateBlockUid(),
170
+ content: node.content,
171
+ children: convertNodesToBlocks(node.children)
172
+ }));
173
+ }
174
+ function convertToRoamActions(nodes, parentUid, order = 'last') {
175
+ // First convert nodes to blocks with UIDs
176
+ const blocks = convertNodesToBlocks(nodes);
177
+ const actions = [];
178
+ // Helper function to recursively create actions
179
+ function createBlockActions(blocks, parentUid, order) {
180
+ for (const block of blocks) {
181
+ // Create the current block
182
+ const action = {
183
+ action: 'create-block',
184
+ location: {
185
+ 'parent-uid': parentUid,
186
+ order
187
+ },
188
+ block: {
189
+ uid: block.uid,
190
+ string: block.content
191
+ }
192
+ };
193
+ actions.push(action);
194
+ // Create child blocks if any
195
+ if (block.children.length > 0) {
196
+ createBlockActions(block.children, block.uid, 'last');
197
+ }
198
+ }
199
+ }
200
+ // Create all block actions
201
+ createBlockActions(blocks, parentUid, order);
202
+ return actions;
203
+ }
204
+ // Export public functions and types
205
+ export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown };