roam-research-mcp 0.12.3

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.
@@ -0,0 +1,204 @@
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
+ if (trimmedLine.startsWith('#') || trimmedLine.includes('{{table}}')) {
73
+ // Remove bullet point if it precedes a table marker
74
+ content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
75
+ level = 0;
76
+ // Reset stack but keep heading/table as parent
77
+ stack.length = 1; // Keep only the heading/table
78
+ }
79
+ else if (stack[0]?.content.startsWith('#') || stack[0]?.content.includes('{{table}}')) {
80
+ // If previous node was a heading or table marker, increase level by 1
81
+ level = Math.max(level, 1);
82
+ // Remove bullet point
83
+ content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
84
+ }
85
+ else {
86
+ // Remove bullet point
87
+ content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
88
+ }
89
+ // Create new node
90
+ const node = {
91
+ content,
92
+ level,
93
+ children: []
94
+ };
95
+ // Find the appropriate parent for this node based on level
96
+ if (level === 0) {
97
+ rootNodes.push(node);
98
+ stack[0] = node;
99
+ }
100
+ else {
101
+ // Pop stack until we find the parent level
102
+ while (stack.length > level) {
103
+ stack.pop();
104
+ }
105
+ // Add as child to parent
106
+ if (stack[level - 1]) {
107
+ stack[level - 1].children.push(node);
108
+ }
109
+ else {
110
+ // If no parent found, treat as root node
111
+ rootNodes.push(node);
112
+ }
113
+ stack[level] = node;
114
+ }
115
+ }
116
+ return rootNodes;
117
+ }
118
+ function parseTableRows(lines) {
119
+ const tableNodes = [];
120
+ let currentLevel = -1;
121
+ for (const line of lines) {
122
+ const trimmedLine = line.trimEnd();
123
+ if (!trimmedLine)
124
+ continue;
125
+ // Calculate indentation level
126
+ const indentation = line.match(/^\s*/)?.[0].length ?? 0;
127
+ const level = Math.floor(indentation / 2);
128
+ // Extract content after bullet point
129
+ const content = trimmedLine.replace(/^\s*[-*+]\s*/, '');
130
+ // Create node for this cell
131
+ const node = {
132
+ content,
133
+ level,
134
+ children: []
135
+ };
136
+ // Track the first level we see to maintain relative nesting
137
+ if (currentLevel === -1) {
138
+ currentLevel = level;
139
+ }
140
+ // Add node to appropriate parent based on level
141
+ if (level === currentLevel) {
142
+ tableNodes.push(node);
143
+ }
144
+ else {
145
+ // Find parent by walking back through nodes
146
+ let parent = tableNodes[tableNodes.length - 1];
147
+ while (parent && parent.level < level - 1) {
148
+ parent = parent.children[parent.children.length - 1];
149
+ }
150
+ if (parent) {
151
+ parent.children.push(node);
152
+ }
153
+ }
154
+ }
155
+ return tableNodes;
156
+ }
157
+ function generateBlockUid() {
158
+ // Generate a random string of 9 characters (Roam's format)
159
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';
160
+ let uid = '';
161
+ for (let i = 0; i < 9; i++) {
162
+ uid += chars.charAt(Math.floor(Math.random() * chars.length));
163
+ }
164
+ return uid;
165
+ }
166
+ function convertNodesToBlocks(nodes) {
167
+ return nodes.map(node => ({
168
+ uid: generateBlockUid(),
169
+ content: node.content,
170
+ children: convertNodesToBlocks(node.children)
171
+ }));
172
+ }
173
+ function convertToRoamActions(nodes, parentUid, order = 'last') {
174
+ // First convert nodes to blocks with UIDs
175
+ const blocks = convertNodesToBlocks(nodes);
176
+ const actions = [];
177
+ // Helper function to recursively create actions
178
+ function createBlockActions(blocks, parentUid, order) {
179
+ for (const block of blocks) {
180
+ // Create the current block
181
+ const action = {
182
+ action: 'create-block',
183
+ location: {
184
+ 'parent-uid': parentUid,
185
+ order
186
+ },
187
+ block: {
188
+ uid: block.uid,
189
+ string: block.content
190
+ }
191
+ };
192
+ actions.push(action);
193
+ // Create child blocks if any
194
+ if (block.children.length > 0) {
195
+ createBlockActions(block.children, block.uid, 'last');
196
+ }
197
+ }
198
+ }
199
+ // Create all block actions
200
+ createBlockActions(blocks, parentUid, order);
201
+ return actions;
202
+ }
203
+ // Export public functions and types
204
+ export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown };
@@ -0,0 +1,87 @@
1
+ import { initializeGraph, createPage, batchActions, q } from '@roam-research/roam-api-sdk';
2
+ import { parseMarkdown, convertToRoamActions } from './markdown-utils.js';
3
+ import * as dotenv from 'dotenv';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+ // Load environment variables
7
+ const scriptPath = fileURLToPath(import.meta.url);
8
+ const projectRoot = dirname(dirname(scriptPath));
9
+ const envPath = join(projectRoot, '.env');
10
+ dotenv.config({ path: envPath });
11
+ const API_TOKEN = process.env.ROAM_API_TOKEN;
12
+ const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
13
+ if (!API_TOKEN || !GRAPH_NAME) {
14
+ throw new Error('Missing required environment variables: ROAM_API_TOKEN and/or ROAM_GRAPH_NAME');
15
+ }
16
+ async function testAddMarkdownText() {
17
+ try {
18
+ // Initialize graph
19
+ console.log('Initializing graph...');
20
+ const graph = initializeGraph({
21
+ token: API_TOKEN,
22
+ graph: GRAPH_NAME,
23
+ });
24
+ // Test markdown content
25
+ const testPageTitle = `Test Markdown Import ${new Date().toISOString()}`;
26
+ console.log(`Using test page title: ${testPageTitle}`);
27
+ const markdownContent = `
28
+ | Month | Savings |
29
+ | -------- | ------- |
30
+ | January | $250 |
31
+ | February | $80 |
32
+ | March | $420 |
33
+
34
+ # Main Topic
35
+ - First point
36
+ - Nested point A
37
+ - Deep nested point
38
+ - Nested point B
39
+ - Second point
40
+ 1. Numbered subpoint
41
+ 2. Another numbered point
42
+ - Mixed list type
43
+ - Third point
44
+ - With some **bold** text
45
+ - And *italic* text
46
+ - And a [[Page Reference]]
47
+ - And a #[[Page Tag]]
48
+ `;
49
+ // First create the page
50
+ console.log('\nCreating page...');
51
+ const success = await createPage(graph, {
52
+ action: 'create-page',
53
+ page: {
54
+ title: testPageTitle
55
+ }
56
+ });
57
+ if (!success) {
58
+ throw new Error('Failed to create test page');
59
+ }
60
+ // Get the page UID
61
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
62
+ const findResults = await q(graph, findQuery, [testPageTitle]);
63
+ if (!findResults || findResults.length === 0) {
64
+ throw new Error('Could not find created page');
65
+ }
66
+ const pageUid = findResults[0][0];
67
+ console.log('Page UID:', pageUid);
68
+ // Import markdown
69
+ console.log('\nImporting markdown...');
70
+ const nodes = parseMarkdown(markdownContent);
71
+ console.log('Parsed nodes:', JSON.stringify(nodes, null, 2));
72
+ // Convert and add markdown content
73
+ const actions = convertToRoamActions(nodes, pageUid, 'last');
74
+ const result = await batchActions(graph, {
75
+ action: 'batch-actions',
76
+ actions
77
+ });
78
+ console.log('\nImport result:', JSON.stringify(result, null, 2));
79
+ console.log('\nTest completed successfully!');
80
+ console.log(`Check your Roam graph for the page titled: ${testPageTitle}`);
81
+ }
82
+ catch (error) {
83
+ console.error('Error:', error);
84
+ }
85
+ }
86
+ // Run the test
87
+ testAddMarkdownText();
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { initializeGraph, q } from '@roam-research/roam-api-sdk';
3
+ import * as dotenv from 'dotenv';
4
+ // Load environment variables
5
+ dotenv.config();
6
+ const API_TOKEN = process.env.ROAM_API_TOKEN;
7
+ const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
8
+ if (!API_TOKEN || !GRAPH_NAME) {
9
+ throw new Error('Missing required environment variables: ROAM_API_TOKEN and ROAM_GRAPH_NAME');
10
+ }
11
+ async function main() {
12
+ const graph = initializeGraph({
13
+ token: API_TOKEN,
14
+ graph: GRAPH_NAME,
15
+ });
16
+ try {
17
+ // First verify we can find the page
18
+ console.log('Finding page...');
19
+ const searchQuery = `[:find ?uid .
20
+ :where [?e :node/title "December 18th, 2024"]
21
+ [?e :block/uid ?uid]]`;
22
+ const uid = await q(graph, searchQuery, []);
23
+ console.log('Page UID:', uid);
24
+ if (!uid) {
25
+ throw new Error('Page not found');
26
+ }
27
+ // Get all blocks under this page with their order
28
+ console.log('\nGetting blocks...');
29
+ const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
30
+ :where [?p :block/uid "${uid}"]
31
+ [?b :block/page ?p]
32
+ [?b :block/uid ?block-uid]
33
+ [?b :block/string ?block-str]
34
+ [?b :block/order ?order]
35
+ [?b :block/parents ?parent]
36
+ [?parent :block/uid ?parent-uid]]`;
37
+ const blocks = await q(graph, blocksQuery, []);
38
+ console.log('Found', blocks.length, 'blocks');
39
+ // Create a map of all blocks
40
+ const blockMap = new Map();
41
+ blocks.forEach(([uid, string, order]) => {
42
+ if (!blockMap.has(uid)) {
43
+ blockMap.set(uid, {
44
+ uid,
45
+ string,
46
+ order: order,
47
+ children: []
48
+ });
49
+ }
50
+ });
51
+ console.log('Created block map with', blockMap.size, 'entries');
52
+ // Build parent-child relationships
53
+ let relationshipsBuilt = 0;
54
+ blocks.forEach(([childUid, _, __, parentUid]) => {
55
+ const child = blockMap.get(childUid);
56
+ const parent = blockMap.get(parentUid);
57
+ if (child && parent && !parent.children.includes(child)) {
58
+ parent.children.push(child);
59
+ relationshipsBuilt++;
60
+ }
61
+ });
62
+ console.log('Built', relationshipsBuilt, 'parent-child relationships');
63
+ // Get top-level blocks (those directly under the page)
64
+ console.log('\nGetting top-level blocks...');
65
+ const topQuery = `[:find ?block-uid ?block-str ?order
66
+ :where [?p :block/uid "${uid}"]
67
+ [?b :block/page ?p]
68
+ [?b :block/uid ?block-uid]
69
+ [?b :block/string ?block-str]
70
+ [?b :block/order ?order]
71
+ (not-join [?b]
72
+ [?b :block/parents ?parent]
73
+ [?parent :block/page ?p])]`;
74
+ const topBlocks = await q(graph, topQuery, []);
75
+ console.log('Found', topBlocks.length, 'top-level blocks');
76
+ // Create root blocks
77
+ const rootBlocks = topBlocks
78
+ .map(([uid, string, order]) => ({
79
+ uid,
80
+ string,
81
+ order: order,
82
+ children: blockMap.get(uid)?.children || []
83
+ }))
84
+ .sort((a, b) => a.order - b.order);
85
+ // Log block hierarchy
86
+ console.log('\nBlock hierarchy:');
87
+ const logHierarchy = (blocks, level = 0) => {
88
+ blocks.forEach(block => {
89
+ console.log(' '.repeat(level) + '- ' + block.string.substring(0, 50) + '...');
90
+ if (block.children.length > 0) {
91
+ logHierarchy(block.children.sort((a, b) => a.order - b.order), level + 1);
92
+ }
93
+ });
94
+ };
95
+ logHierarchy(rootBlocks);
96
+ // Convert to markdown
97
+ console.log('\nConverting to markdown...');
98
+ const toMarkdown = (blocks, level = 0) => {
99
+ return blocks.map(block => {
100
+ const indent = ' '.repeat(level);
101
+ let md = `${indent}- ${block.string}\n`;
102
+ if (block.children.length > 0) {
103
+ md += toMarkdown(block.children.sort((a, b) => a.order - b.order), level + 1);
104
+ }
105
+ return md;
106
+ }).join('');
107
+ };
108
+ const markdown = toMarkdown(rootBlocks);
109
+ console.log('\nMarkdown output:');
110
+ console.log(markdown);
111
+ }
112
+ catch (error) {
113
+ console.error('Error:', error);
114
+ }
115
+ }
116
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "roam-research-mcp",
3
+ "version": "0.12.3",
4
+ "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
+ "private": false,
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/2b3pro/roam-research-mcp.git"
9
+ },
10
+ "keywords": [
11
+ "mcp",
12
+ "roam-research",
13
+ "api",
14
+ "claude",
15
+ "model-context-protocol"
16
+ ],
17
+ "author": "Ian Shen / 2B3 PRODUCTIONS LLC",
18
+ "license": "MIT",
19
+ "bugs": {
20
+ "url": "https://github.com/2b3pro/roam-research-mcp/issues"
21
+ },
22
+ "homepage": "https://github.com/2b3pro/roam-research-mcp#readme",
23
+ "type": "module",
24
+ "bin": {
25
+ "roam-research": "./build/index.js"
26
+ },
27
+ "files": [
28
+ "build"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc && chmod 755 build/index.js",
32
+ "prepare": "npm run build",
33
+ "watch": "tsc --watch",
34
+ "inspector": "npx @modelcontextprotocol/inspector build/index.js"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "0.6.0",
38
+ "@roam-research/roam-api-sdk": "^0.10.0",
39
+ "dotenv": "^16.4.7"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^20.11.24",
43
+ "ts-node": "^10.9.2",
44
+ "typescript": "^5.3.3"
45
+ }
46
+ }