roam-research-mcp 0.24.5 → 0.25.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.
package/README.md CHANGED
@@ -26,6 +26,14 @@ npm install
26
26
  npm run build
27
27
  ```
28
28
 
29
+ ## To Test
30
+
31
+ Run [MCP Inspector](https://github.com/modelcontextprotocol/inspector) after build…
32
+
33
+ ```
34
+ npx @modelcontextprotocol/inspector node build/index.js
35
+ ```
36
+
29
37
  ## Features
30
38
 
31
39
  The server provides powerful tools for interacting with Roam Research:
@@ -88,7 +96,7 @@ The server provides powerful tools for interacting with Roam Research:
88
96
  "mcpServers": {
89
97
  "roam-research": {
90
98
  "command": "node",
91
- "args": ["/path/to/roam-research/build/index.js"],
99
+ "args": ["/path/to/roam-research-mcp/build/index.js"],
92
100
  "env": {
93
101
  "ROAM_API_TOKEN": "your-api-token",
94
102
  "ROAM_GRAPH_NAME": "your-graph-name",
@@ -101,9 +109,9 @@ The server provides powerful tools for interacting with Roam Research:
101
109
 
102
110
  Note: The server will first try to load from .env file, then fall back to environment variables from MCP settings.
103
111
 
104
- 3. Build the server:
112
+ 3. Build the server (make sure you're in the root directory of the MCP):
105
113
  ```bash
106
- cd roam-research
114
+ cd roam-research-mcp
107
115
  npm install
108
116
  npm run build
109
117
  ```
@@ -17,7 +17,7 @@ export class RoamServer {
17
17
  this.toolHandlers = new ToolHandlers(this.graph);
18
18
  this.server = new Server({
19
19
  name: 'roam-research',
20
- version: '0.24.5',
20
+ version: '0.25.3',
21
21
  }, {
22
22
  capabilities: {
23
23
  tools: {
@@ -81,8 +81,8 @@ export class RoamServer {
81
81
  };
82
82
  }
83
83
  case 'roam_create_block': {
84
- const { content, page_uid, title } = request.params.arguments;
85
- const result = await this.toolHandlers.createBlock(content, page_uid, title);
84
+ const { content, page_uid, title, heading } = request.params.arguments;
85
+ const result = await this.toolHandlers.createBlock(content, page_uid, title, heading);
86
86
  return {
87
87
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
88
88
  };
@@ -131,6 +131,10 @@ export class RoamServer {
131
131
  }
132
132
  case 'roam_search_hierarchy': {
133
133
  const params = request.params.arguments;
134
+ // Validate that either parent_uid or child_uid is provided, but not both
135
+ if ((!params.parent_uid && !params.child_uid) || (params.parent_uid && params.child_uid)) {
136
+ throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
137
+ }
134
138
  const result = await this.toolHandlers.searchHierarchy(params);
135
139
  return {
136
140
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -159,11 +163,16 @@ export class RoamServer {
159
163
  }
160
164
  case 'roam_update_block': {
161
165
  const { block_uid, content, transform_pattern } = request.params.arguments;
166
+ // Validate that either content or transform_pattern is provided, but not both or neither
167
+ if ((!content && !transform_pattern) || (content && transform_pattern)) {
168
+ throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform_pattern must be provided, but not both or neither');
169
+ }
162
170
  let result;
163
171
  if (content) {
164
172
  result = await this.toolHandlers.updateBlock(block_uid, content);
165
173
  }
166
- else if (transform_pattern) {
174
+ else {
175
+ // We know transform_pattern exists due to validation above
167
176
  result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
168
177
  const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
169
178
  return currentContent.replace(regex, transform_pattern.replace);
@@ -182,6 +191,12 @@ export class RoamServer {
182
191
  }
183
192
  case 'roam_update_multiple_blocks': {
184
193
  const { updates } = request.params.arguments;
194
+ // Validate that for each update, either content or transform is provided, but not both or neither
195
+ for (const update of updates) {
196
+ if ((!update.content && !update.transform) || (update.content && update.transform)) {
197
+ throw new McpError(ErrorCode.InvalidRequest, 'For each update, either content or transform must be provided, but not both or neither');
198
+ }
199
+ }
185
200
  const result = await this.toolHandlers.updateBlocks(updates);
186
201
  return {
187
202
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -1,4 +1,4 @@
1
- import { q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
1
+ import { q, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
@@ -7,7 +7,7 @@ export class BlockOperations {
7
7
  constructor(graph) {
8
8
  this.graph = graph;
9
9
  }
10
- async createBlock(content, page_uid, title) {
10
+ async createBlock(content, page_uid, title, heading) {
11
11
  // If page_uid provided, use it directly
12
12
  let targetPageUid = page_uid;
13
13
  // If no page_uid but title provided, search for page by title
@@ -68,9 +68,44 @@ export class BlockOperations {
68
68
  try {
69
69
  // If the content has multiple lines or is a table, use nested import
70
70
  if (content.includes('\n')) {
71
- // Parse and import the nested content
72
- const convertedContent = convertToRoamMarkdown(content);
73
- const nodes = parseMarkdown(convertedContent);
71
+ let nodes;
72
+ // If heading parameter is provided, manually construct nodes to preserve heading
73
+ if (heading) {
74
+ const lines = content.split('\n');
75
+ const firstLine = lines[0].trim();
76
+ const remainingLines = lines.slice(1);
77
+ // Create the first node with heading formatting
78
+ const firstNode = {
79
+ content: firstLine,
80
+ level: 0,
81
+ heading_level: heading,
82
+ children: []
83
+ };
84
+ // If there are remaining lines, parse them as children or siblings
85
+ if (remainingLines.length > 0 && remainingLines.some(line => line.trim())) {
86
+ const remainingContent = remainingLines.join('\n');
87
+ const convertedRemainingContent = convertToRoamMarkdown(remainingContent);
88
+ const remainingNodes = parseMarkdown(convertedRemainingContent);
89
+ // Add remaining nodes as siblings to the first node
90
+ nodes = [firstNode, ...remainingNodes];
91
+ }
92
+ else {
93
+ nodes = [firstNode];
94
+ }
95
+ }
96
+ else {
97
+ // No heading parameter, use original parsing logic
98
+ const convertedContent = convertToRoamMarkdown(content);
99
+ nodes = parseMarkdown(convertedContent);
100
+ // If we have simple newline-separated content (no markdown formatting),
101
+ // and parseMarkdown created nodes at level 0, reverse them to maintain original order
102
+ if (nodes.every(node => node.level === 0 && !node.heading_level)) {
103
+ const lines = content.split('\n').filter(line => line.trim());
104
+ if (lines.length === nodes.length) {
105
+ nodes.reverse();
106
+ }
107
+ }
108
+ }
74
109
  const actions = convertToRoamActions(nodes, targetPageUid, 'last');
75
110
  // Execute batch actions to create the nested structure
76
111
  const result = await batchActions(this.graph, {
@@ -88,27 +123,26 @@ export class BlockOperations {
88
123
  };
89
124
  }
90
125
  else {
91
- // For non-table content, create a simple block
92
- await createRoamBlock(this.graph, {
93
- action: 'create-block',
94
- location: {
95
- "parent-uid": targetPageUid,
96
- "order": "last"
97
- },
98
- block: { string: content }
126
+ // For single block content, use the same convertToRoamActions approach that works in roam_create_page
127
+ const nodes = [{
128
+ content: content,
129
+ level: 0,
130
+ ...(heading && typeof heading === 'number' && heading > 0 && { heading_level: heading }),
131
+ children: []
132
+ }];
133
+ if (!targetPageUid) {
134
+ throw new McpError(ErrorCode.InternalError, 'targetPageUid is undefined');
135
+ }
136
+ const actions = convertToRoamActions(nodes, targetPageUid, 'last');
137
+ // Execute batch actions to create the block
138
+ const result = await batchActions(this.graph, {
139
+ action: 'batch-actions',
140
+ actions
99
141
  });
100
- // Get the block's UID
101
- const findBlockQuery = `[:find ?uid
102
- :in $ ?parent ?string
103
- :where [?b :block/uid ?uid]
104
- [?b :block/string ?string]
105
- [?b :block/parents ?p]
106
- [?p :block/uid ?parent]]`;
107
- const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
108
- if (!blockResults || blockResults.length === 0) {
109
- throw new Error('Could not find created block');
142
+ if (!result) {
143
+ throw new Error('Failed to create block');
110
144
  }
111
- const blockUid = blockResults[0][0];
145
+ const blockUid = result.created_uids?.[0];
112
146
  return {
113
147
  success: true,
114
148
  block_uid: blockUid,
@@ -1,8 +1,8 @@
1
- import { q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage as createRoamPage, batchActions } 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 { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable } from '../../markdown-utils.js';
5
+ import { convertToRoamActions } from '../../markdown-utils.js';
6
6
  export class PageOperations {
7
7
  graph;
8
8
  constructor(graph) {
@@ -80,34 +80,46 @@ export class PageOperations {
80
80
  throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
81
81
  }
82
82
  }
83
- // If content is provided, check if it looks like nested markdown
84
- if (content) {
83
+ // If content is provided, create blocks using batch operations
84
+ if (content && content.length > 0) {
85
85
  try {
86
- const isMultilined = content.includes('\n') || hasMarkdownTable(content);
87
- if (isMultilined) {
88
- // Use import_nested_markdown functionality
89
- const convertedContent = convertToRoamMarkdown(content);
90
- const nodes = parseMarkdown(convertedContent);
91
- const actions = convertToRoamActions(nodes, pageUid, 'first');
92
- const result = await batchActions(this.graph, {
86
+ // Convert content array to MarkdownNode format expected by convertToRoamActions
87
+ const nodes = content.map(block => ({
88
+ content: block.text,
89
+ level: block.level,
90
+ ...(block.heading && { heading_level: block.heading }),
91
+ children: []
92
+ }));
93
+ // Create hierarchical structure based on levels
94
+ const rootNodes = [];
95
+ const levelMap = {};
96
+ for (const node of nodes) {
97
+ if (node.level === 1) {
98
+ rootNodes.push(node);
99
+ levelMap[1] = node;
100
+ }
101
+ else {
102
+ const parentLevel = node.level - 1;
103
+ const parent = levelMap[parentLevel];
104
+ if (!parent) {
105
+ throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
106
+ }
107
+ parent.children.push(node);
108
+ levelMap[node.level] = node;
109
+ }
110
+ }
111
+ // Generate batch actions for all blocks
112
+ const actions = convertToRoamActions(rootNodes, pageUid, 'last');
113
+ // Execute batch operation
114
+ if (actions.length > 0) {
115
+ const batchResult = await batchActions(this.graph, {
93
116
  action: 'batch-actions',
94
117
  actions
95
118
  });
96
- if (!result) {
97
- throw new Error('Failed to import nested markdown content');
119
+ if (!batchResult) {
120
+ throw new Error('Failed to create blocks');
98
121
  }
99
122
  }
100
- else {
101
- // Create a simple block for non-nested content
102
- await createBlock(this.graph, {
103
- action: 'create-block',
104
- location: {
105
- "parent-uid": pageUid,
106
- "order": "last"
107
- },
108
- block: { string: content }
109
- });
110
- }
111
123
  }
112
124
  catch (error) {
113
125
  throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
@@ -160,6 +172,21 @@ export class PageOperations {
160
172
  if (!blocks || blocks.length === 0) {
161
173
  return `${title} (no content found)`;
162
174
  }
175
+ // Get heading information for blocks that have it
176
+ const headingsQuery = `[:find ?block-uid ?heading
177
+ :in $ % ?page-title
178
+ :where [?page :node/title ?page-title]
179
+ [?block :block/uid ?block-uid]
180
+ [?block :block/heading ?heading]
181
+ (ancestor ?block ?page)]`;
182
+ const headings = await q(this.graph, headingsQuery, [ancestorRule, title]);
183
+ // Create a map of block UIDs to heading levels
184
+ const headingMap = new Map();
185
+ if (headings) {
186
+ for (const [blockUid, heading] of headings) {
187
+ headingMap.set(blockUid, heading);
188
+ }
189
+ }
163
190
  // Create a map of all blocks
164
191
  const blockMap = new Map();
165
192
  const rootBlocks = [];
@@ -170,6 +197,7 @@ export class PageOperations {
170
197
  uid: blockUid,
171
198
  string: resolvedString,
172
199
  order: order,
200
+ heading: headingMap.get(blockUid) || null,
173
201
  children: []
174
202
  };
175
203
  blockMap.set(blockUid, block);
@@ -202,7 +230,17 @@ export class PageOperations {
202
230
  const toMarkdown = (blocks, level = 0) => {
203
231
  return blocks.map(block => {
204
232
  const indent = ' '.repeat(level);
205
- let md = `${indent}- ${block.string}`;
233
+ let md;
234
+ // Check block heading level and format accordingly
235
+ if (block.heading && block.heading > 0) {
236
+ // Format as heading with appropriate number of hashtags
237
+ const hashtags = '#'.repeat(block.heading);
238
+ md = `${indent}${hashtags} ${block.string}`;
239
+ }
240
+ else {
241
+ // No heading, use bullet point (current behavior)
242
+ md = `${indent}- ${block.string}`;
243
+ }
206
244
  if (block.children.length > 0) {
207
245
  md += '\n' + toMarkdown(block.children, level + 1);
208
246
  }
@@ -34,7 +34,7 @@ export const toolSchemas = {
34
34
  },
35
35
  roam_create_page: {
36
36
  name: 'roam_create_page',
37
- description: 'Create a new standalone page in Roam with optional content using markdown-style formatting. The nesting structure is inferred from markdown indentation (spaces). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.',
37
+ description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.',
38
38
  inputSchema: {
39
39
  type: 'object',
40
40
  properties: {
@@ -43,8 +43,30 @@ export const toolSchemas = {
43
43
  description: 'Title of the new page',
44
44
  },
45
45
  content: {
46
- type: 'string',
47
- description: 'Initial content for the page (optional). Each line becomes a separate block. Indentation (using spaces or tabs) determines the nesting level of each block.',
46
+ type: 'array',
47
+ description: 'Initial content for the page as an array of blocks with explicit nesting levels',
48
+ items: {
49
+ type: 'object',
50
+ properties: {
51
+ text: {
52
+ type: 'string',
53
+ description: 'Content of the block'
54
+ },
55
+ level: {
56
+ type: 'integer',
57
+ description: 'Indentation level (1-10, where 1 is top level)',
58
+ minimum: 1,
59
+ maximum: 10
60
+ },
61
+ heading: {
62
+ type: 'integer',
63
+ description: 'Optional: Heading formatting for this block (1-3)',
64
+ minimum: 1,
65
+ maximum: 3
66
+ }
67
+ },
68
+ required: ['text', 'level']
69
+ }
48
70
  },
49
71
  },
50
72
  required: ['title'],
@@ -52,7 +74,7 @@ export const toolSchemas = {
52
74
  },
53
75
  roam_create_block: {
54
76
  name: 'roam_create_block',
55
- description: 'Add a new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\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).',
77
+ description: 'Add new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\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).',
56
78
  inputSchema: {
57
79
  type: 'object',
58
80
  properties: {
@@ -68,6 +90,12 @@ export const toolSchemas = {
68
90
  type: 'string',
69
91
  description: 'Optional: Title of the page to add block to (defaults to today\'s date if neither page_uid nor title provided)',
70
92
  },
93
+ heading: {
94
+ type: 'integer',
95
+ description: 'Optional: Heading formatting for this block (1-3)',
96
+ minimum: 1,
97
+ maximum: 3
98
+ }
71
99
  },
72
100
  required: ['content'],
73
101
  },
@@ -236,11 +264,8 @@ export const toolSchemas = {
236
264
  minimum: 1,
237
265
  maximum: 10
238
266
  }
239
- },
240
- oneOf: [
241
- { required: ['parent_uid'] },
242
- { required: ['child_uid'] }
243
- ]
267
+ }
268
+ // Note: Validation for either parent_uid or child_uid is handled in the server code
244
269
  }
245
270
  },
246
271
  roam_find_pages_modified_today: {
@@ -310,11 +335,8 @@ export const toolSchemas = {
310
335
  required: ['find', 'replace']
311
336
  }
312
337
  },
313
- required: ['block_uid'],
314
- oneOf: [
315
- { required: ['content'] },
316
- { required: ['transform_pattern'] }
317
- ]
338
+ required: ['block_uid']
339
+ // Note: Validation for either content or transform_pattern is handled in the server code
318
340
  }
319
341
  },
320
342
  roam_update_multiple_blocks: {
@@ -358,11 +380,8 @@ export const toolSchemas = {
358
380
  required: ['find', 'replace']
359
381
  }
360
382
  },
361
- required: ['block_uid'],
362
- oneOf: [
363
- { required: ['content'] },
364
- { required: ['transform'] }
365
- ]
383
+ required: ['block_uid']
384
+ // Note: Validation for either content or transform is handled in the server code
366
385
  }
367
386
  }
368
387
  },
@@ -33,8 +33,8 @@ export class ToolHandlers {
33
33
  return this.pageOps.fetchPageByTitle(title);
34
34
  }
35
35
  // Block Operations
36
- async createBlock(content, page_uid, title) {
37
- return this.blockOps.createBlock(content, page_uid, title);
36
+ async createBlock(content, page_uid, title, heading) {
37
+ return this.blockOps.createBlock(content, page_uid, title, heading);
38
38
  }
39
39
  async updateBlock(block_uid, content, transform) {
40
40
  return this.blockOps.updateBlock(block_uid, content, transform);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.24.5",
3
+ "version": "0.25.3",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {
@@ -1,87 +0,0 @@
1
- import { initializeGraph, createPage, batchActions, q } from '@roam-research/roam-api-sdk';
2
- import { parseMarkdown, convertToRoamActions } from '../src/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();
@@ -1,116 +0,0 @@
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);
@@ -1,285 +0,0 @@
1
- import { q } from '@roam-research/roam-api-sdk';
2
- import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
- import { BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../../search/index.js';
4
- export class SearchOperations {
5
- graph;
6
- constructor(graph) {
7
- this.graph = graph;
8
- }
9
- async searchBlockRefs(params) {
10
- const handler = new BlockRefSearchHandler(this.graph, params);
11
- return handler.execute();
12
- }
13
- async searchHierarchy(params) {
14
- const handler = new HierarchySearchHandler(this.graph, params);
15
- return handler.execute();
16
- }
17
- async searchByText(params) {
18
- const handler = new TextSearchHandler(this.graph, params);
19
- return handler.execute();
20
- }
21
- async searchByStatus(status, page_title_uid, include, exclude, case_sensitive = true) {
22
- // Get target page UID if provided
23
- let targetPageUid;
24
- if (page_title_uid) {
25
- // Try to find page by title or UID
26
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
27
- const findResults = await q(this.graph, findQuery, [page_title_uid]);
28
- if (findResults && findResults.length > 0) {
29
- targetPageUid = findResults[0][0];
30
- }
31
- else {
32
- // Try as UID
33
- const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
34
- const uidResults = await q(this.graph, uidQuery, []);
35
- if (!uidResults || uidResults.length === 0) {
36
- throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
37
- }
38
- targetPageUid = uidResults[0][0];
39
- }
40
- }
41
- // Build query based on whether we're searching in a specific page
42
- let queryStr;
43
- let queryParams;
44
- const statusPattern = `{{[[${status}]]}}`;
45
- if (targetPageUid) {
46
- queryStr = `[:find ?block-uid ?block-str
47
- :in $ ?status-pattern ?page-uid
48
- :where [?p :block/uid ?page-uid]
49
- [?b :block/page ?p]
50
- [?b :block/string ?block-str]
51
- [?b :block/uid ?block-uid]
52
- [(clojure.string/includes? ?block-str ?status-pattern)]]`;
53
- queryParams = [statusPattern, targetPageUid];
54
- }
55
- else {
56
- queryStr = `[:find ?block-uid ?block-str ?page-title
57
- :in $ ?status-pattern
58
- :where [?b :block/string ?block-str]
59
- [?b :block/uid ?block-uid]
60
- [?b :block/page ?p]
61
- [?p :node/title ?page-title]
62
- [(clojure.string/includes? ?block-str ?status-pattern)]]`;
63
- queryParams = [statusPattern];
64
- }
65
- const results = await q(this.graph, queryStr, queryParams);
66
- if (!results || results.length === 0) {
67
- return {
68
- success: true,
69
- matches: [],
70
- message: `No blocks found with status ${status}`
71
- };
72
- }
73
- // Format initial results
74
- let matches = results.map(result => {
75
- const [uid, content, pageTitle] = result;
76
- return {
77
- block_uid: uid,
78
- content,
79
- ...(pageTitle && { page_title: pageTitle })
80
- };
81
- });
82
- // Post-query filtering with case sensitivity option
83
- if (include) {
84
- const includeTerms = include.split(',').map(term => term.trim());
85
- matches = matches.filter(match => {
86
- const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
87
- const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
88
- const terms = case_sensitive ? includeTerms : includeTerms.map(t => t.toLowerCase());
89
- return terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
90
- (matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
91
- });
92
- }
93
- if (exclude) {
94
- const excludeTerms = exclude.split(',').map(term => term.trim());
95
- matches = matches.filter(match => {
96
- const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
97
- const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
98
- const terms = case_sensitive ? excludeTerms : excludeTerms.map(t => t.toLowerCase());
99
- return !terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
100
- (matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
101
- });
102
- }
103
- return {
104
- success: true,
105
- matches,
106
- message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
107
- };
108
- }
109
- async searchForTag(primary_tag, page_title_uid, near_tag, case_sensitive = true) {
110
- // Ensure tags are properly formatted with #
111
- const formatTag = (tag) => {
112
- return tag.replace(/^#/, '').replace(/^\[\[/, '').replace(/\]\]$/, '');
113
- };
114
- // Extract the tag text, removing any formatting
115
- const primaryTagFormatted = formatTag(primary_tag);
116
- const nearTagFormatted = near_tag ? formatTag(near_tag) : undefined;
117
- // Get target page UID if provided
118
- let targetPageUid;
119
- if (page_title_uid) {
120
- // Try to find page by title or UID
121
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
122
- const findResults = await q(this.graph, findQuery, [page_title_uid]);
123
- if (findResults && findResults.length > 0) {
124
- targetPageUid = findResults[0][0];
125
- }
126
- else {
127
- // Try as UID
128
- const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
129
- const uidResults = await q(this.graph, uidQuery, []);
130
- if (!uidResults || uidResults.length === 0) {
131
- throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
132
- }
133
- targetPageUid = uidResults[0][0];
134
- }
135
- }
136
- // Build query based on whether we're searching in a specific page and/or for a nearby tag
137
- let queryStr;
138
- let queryParams;
139
- if (targetPageUid) {
140
- if (nearTagFormatted) {
141
- queryStr = `[:find ?block-uid ?block-str
142
- :in $ ?primary-tag ?near-tag ?page-uid
143
- :where [?p :block/uid ?page-uid]
144
- [?b :block/page ?p]
145
- [?b :block/string ?block-str]
146
- [?b :block/uid ?block-uid]
147
- [(clojure.string/includes?
148
- ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
149
- ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
150
- [(clojure.string/includes?
151
- ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
152
- ${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
153
- queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
154
- }
155
- else {
156
- queryStr = `[:find ?block-uid ?block-str
157
- :in $ ?primary-tag ?page-uid
158
- :where [?p :block/uid ?page-uid]
159
- [?b :block/page ?p]
160
- [?b :block/string ?block-str]
161
- [?b :block/uid ?block-uid]
162
- [(clojure.string/includes?
163
- ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
164
- ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
165
- queryParams = [primaryTagFormatted, targetPageUid];
166
- }
167
- }
168
- else {
169
- // Search across all pages
170
- if (nearTagFormatted) {
171
- queryStr = `[:find ?block-uid ?block-str ?page-title
172
- :in $ ?primary-tag ?near-tag
173
- :where [?b :block/string ?block-str]
174
- [?b :block/uid ?block-uid]
175
- [?b :block/page ?p]
176
- [?p :node/title ?page-title]
177
- [(clojure.string/includes?
178
- ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
179
- ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
180
- [(clojure.string/includes?
181
- ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
182
- ${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
183
- queryParams = [primaryTagFormatted, nearTagFormatted];
184
- }
185
- else {
186
- queryStr = `[:find ?block-uid ?block-str ?page-title
187
- :in $ ?primary-tag
188
- :where [?b :block/string ?block-str]
189
- [?b :block/uid ?block-uid]
190
- [?b :block/page ?p]
191
- [?p :node/title ?page-title]
192
- [(clojure.string/includes?
193
- ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
194
- ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
195
- queryParams = [primaryTagFormatted];
196
- }
197
- }
198
- const results = await q(this.graph, queryStr, queryParams);
199
- if (!results || results.length === 0) {
200
- return {
201
- success: true,
202
- matches: [],
203
- message: `No blocks found containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
204
- };
205
- }
206
- // Format results
207
- const matches = results.map(([uid, content, pageTitle]) => ({
208
- block_uid: uid,
209
- content,
210
- ...(pageTitle && { page_title: pageTitle })
211
- }));
212
- return {
213
- success: true,
214
- matches,
215
- message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
216
- };
217
- }
218
- async searchByDate(params) {
219
- // Convert dates to timestamps
220
- const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime();
221
- const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined;
222
- // Define rule for entity type
223
- const entityRule = `[
224
- [(block? ?e)
225
- [?e :block/string]
226
- [?e :block/page ?p]
227
- [?p :node/title]]
228
- [(page? ?e)
229
- [?e :node/title]]
230
- ]`;
231
- // Build query based on cheatsheet pattern
232
- const timeAttr = params.type === 'created' ? ':create/time' : ':edit/time';
233
- let queryStr = `[:find ?block-uid ?string ?time ?page-title
234
- :in $ ?start-ts ${endTimestamp ? '?end-ts' : ''}
235
- :where
236
- [?b ${timeAttr} ?time]
237
- [(>= ?time ?start-ts)]
238
- ${endTimestamp ? '[(<= ?time ?end-ts)]' : ''}
239
- [?b :block/uid ?block-uid]
240
- [?b :block/string ?string]
241
- [?b :block/page ?p]
242
- [?p :node/title ?page-title]]`;
243
- // Execute query
244
- const queryParams = endTimestamp ?
245
- [startTimestamp, endTimestamp] :
246
- [startTimestamp];
247
- const results = await q(this.graph, queryStr, queryParams);
248
- if (!results || results.length === 0) {
249
- return {
250
- success: true,
251
- matches: [],
252
- message: 'No matches found for the given date range and criteria'
253
- };
254
- }
255
- // Process results - now we get [block-uid, string, time, page-title]
256
- const matches = results.map(([uid, content, time, pageTitle]) => ({
257
- uid,
258
- type: 'block',
259
- time,
260
- ...(params.include_content && { content }),
261
- page_title: pageTitle
262
- }));
263
- // Apply case sensitivity if content is included
264
- if (params.include_content) {
265
- const case_sensitive = params.case_sensitive ?? true; // Default to true to match Roam's behavior
266
- if (!case_sensitive) {
267
- matches.forEach(match => {
268
- if (match.content) {
269
- match.content = match.content.toLowerCase();
270
- }
271
- if (match.page_title) {
272
- match.page_title = match.page_title.toLowerCase();
273
- }
274
- });
275
- }
276
- }
277
- // Sort by time
278
- const sortedMatches = matches.sort((a, b) => b.time - a.time);
279
- return {
280
- success: true,
281
- matches: sortedMatches,
282
- message: `Found ${sortedMatches.length} matches for the given date range and criteria`
283
- };
284
- }
285
- }