roam-research-mcp 0.24.2 → 0.25.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/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.2',
20
+ version: '0.25.0',
21
21
  }, {
22
22
  capabilities: {
23
23
  tools: {
@@ -36,7 +36,7 @@ export class RoamServer {
36
36
  roam_find_pages_modified_today: {},
37
37
  roam_search_by_text: {},
38
38
  roam_update_block: {},
39
- roam_update_blocks: {},
39
+ roam_update_multiple_blocks: {},
40
40
  roam_search_by_date: {},
41
41
  roam_datomic_query: {}
42
42
  },
@@ -131,13 +131,18 @@ 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) }],
137
141
  };
138
142
  }
139
143
  case 'roam_find_pages_modified_today': {
140
- const result = await this.toolHandlers.findPagesModifiedToday();
144
+ const { max_num_pages } = request.params.arguments;
145
+ const result = await this.toolHandlers.findPagesModifiedToday(max_num_pages || 50);
141
146
  return {
142
147
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
143
148
  };
@@ -158,11 +163,16 @@ export class RoamServer {
158
163
  }
159
164
  case 'roam_update_block': {
160
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
+ }
161
170
  let result;
162
171
  if (content) {
163
172
  result = await this.toolHandlers.updateBlock(block_uid, content);
164
173
  }
165
- else if (transform_pattern) {
174
+ else {
175
+ // We know transform_pattern exists due to validation above
166
176
  result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
167
177
  const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
168
178
  return currentContent.replace(regex, transform_pattern.replace);
@@ -179,8 +189,14 @@ export class RoamServer {
179
189
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
180
190
  };
181
191
  }
182
- case 'roam_update_blocks': {
192
+ case 'roam_update_multiple_blocks': {
183
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
+ }
184
200
  const result = await this.toolHandlers.updateBlocks(updates);
185
201
  return {
186
202
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -159,6 +159,10 @@ export class OutlineOperations {
159
159
  return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
160
160
  }
161
161
  };
162
+ // Helper function to check if string is a valid Roam UID (9 characters)
163
+ const isValidUid = (str) => {
164
+ return typeof str === 'string' && str.length === 9;
165
+ };
162
166
  // Get or create the parent block
163
167
  let targetParentUid;
164
168
  if (!block_text_uid) {
@@ -166,12 +170,28 @@ export class OutlineOperations {
166
170
  }
167
171
  else {
168
172
  try {
169
- // Create header block and get its UID
170
- targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
173
+ if (isValidUid(block_text_uid)) {
174
+ // First try to find block by UID
175
+ const uidQuery = `[:find ?uid
176
+ :where [?e :block/uid "${block_text_uid}"]
177
+ [?e :block/uid ?uid]]`;
178
+ const uidResult = await q(this.graph, uidQuery, []);
179
+ if (uidResult && uidResult.length > 0) {
180
+ // Use existing block if found
181
+ targetParentUid = uidResult[0][0];
182
+ }
183
+ else {
184
+ throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_text_uid}" not found`);
185
+ }
186
+ }
187
+ else {
188
+ // Create header block and get its UID if not a valid UID
189
+ targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
190
+ }
171
191
  }
172
192
  catch (error) {
173
193
  const errorMessage = error instanceof Error ? error.message : String(error);
174
- throw new McpError(ErrorCode.InternalError, `Failed to create header block "${block_text_uid}": ${errorMessage}`);
194
+ throw new McpError(ErrorCode.InternalError, `Failed to ${isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
175
195
  }
176
196
  }
177
197
  // Initialize result variable
@@ -1,14 +1,14 @@
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) {
9
9
  this.graph = graph;
10
10
  }
11
- async findPagesModifiedToday(num_pages = 10) {
11
+ async findPagesModifiedToday(max_num_pages = 50) {
12
12
  // Define ancestor rule for traversing block hierarchy
13
13
  const ancestorRule = `[
14
14
  [ (ancestor ?b ?a)
@@ -29,7 +29,7 @@ export class PageOperations {
29
29
  (ancestor ?block ?page)
30
30
  [?block :edit/time ?time]
31
31
  [(> ?time ?start_of_day)]]
32
- :limit ${num_pages}`, [startOfDay.getTime(), ancestorRule]);
32
+ :limit ${max_num_pages}`, [startOfDay.getTime(), ancestorRule]);
33
33
  if (!results || results.length === 0) {
34
34
  return {
35
35
  success: true,
@@ -80,34 +80,45 @@ 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
+ children: []
91
+ }));
92
+ // Create hierarchical structure based on levels
93
+ const rootNodes = [];
94
+ const levelMap = {};
95
+ for (const node of nodes) {
96
+ if (node.level === 1) {
97
+ rootNodes.push(node);
98
+ levelMap[1] = node;
99
+ }
100
+ else {
101
+ const parentLevel = node.level - 1;
102
+ const parent = levelMap[parentLevel];
103
+ if (!parent) {
104
+ throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
105
+ }
106
+ parent.children.push(node);
107
+ levelMap[node.level] = node;
108
+ }
109
+ }
110
+ // Generate batch actions for all blocks
111
+ const actions = convertToRoamActions(rootNodes, pageUid, 'last');
112
+ // Execute batch operation
113
+ if (actions.length > 0) {
114
+ const batchResult = await batchActions(this.graph, {
93
115
  action: 'batch-actions',
94
116
  actions
95
117
  });
96
- if (!result) {
97
- throw new Error('Failed to import nested markdown content');
118
+ if (!batchResult) {
119
+ throw new Error('Failed to create blocks');
98
120
  }
99
121
  }
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
122
  }
112
123
  catch (error) {
113
124
  throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
@@ -20,13 +20,13 @@ export const toolSchemas = {
20
20
  },
21
21
  roam_fetch_page_by_title: {
22
22
  name: 'roam_fetch_page_by_title',
23
- description: 'Retrieve complete page contents by exact title, including all nested blocks and resolved block references. Use for reading and analyzing existing Roam pages.',
23
+ description: 'Retrieve complete page contents by exact title, including all nested blocks and resolved block references. Use for accessing daily pages, reading and analyzing existing Roam pages.',
24
24
  inputSchema: {
25
25
  type: 'object',
26
26
  properties: {
27
27
  title: {
28
28
  type: 'string',
29
- description: 'Title of the page to fetch and read',
29
+ description: 'Title of the page. For date pages, use ordinal date formats such as January 2nd, 2025',
30
30
  },
31
31
  },
32
32
  required: ['title'],
@@ -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 a new standalone page in Roam with optional content using explicit nesting levels. 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,24 @@ 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
+ },
62
+ required: ['text', 'level']
63
+ }
48
64
  },
49
65
  },
50
66
  required: ['title'],
@@ -236,11 +252,8 @@ export const toolSchemas = {
236
252
  minimum: 1,
237
253
  maximum: 10
238
254
  }
239
- },
240
- oneOf: [
241
- { required: ['parent_uid'] },
242
- { required: ['child_uid'] }
243
- ]
255
+ }
256
+ // Note: Validation for either parent_uid or child_uid is handled in the server code
244
257
  }
245
258
  },
246
259
  roam_find_pages_modified_today: {
@@ -249,13 +262,12 @@ export const toolSchemas = {
249
262
  inputSchema: {
250
263
  type: 'object',
251
264
  properties: {
252
- num_pages: {
265
+ max_num_pages: {
253
266
  type: 'integer',
254
- description: 'Number of result pages to retrieve (default: 10)',
255
- default: 10
267
+ description: 'Max number of pages to retrieve (default: 50)',
268
+ default: 50
256
269
  },
257
- },
258
- required: ['num_pages']
270
+ }
259
271
  }
260
272
  },
261
273
  roam_search_by_text: {
@@ -278,7 +290,7 @@ export const toolSchemas = {
278
290
  },
279
291
  roam_update_block: {
280
292
  name: 'roam_update_block',
281
- description: 'Update the content of an existing block identified by its UID. Can either provide new content directly or use a transform pattern to modify existing content.\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).',
293
+ description: 'Update a single block identified by its UID. Use this for individual block updates when you need to either replace the entire content or apply a transform pattern to modify specific parts of the content.\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).',
282
294
  inputSchema: {
283
295
  type: 'object',
284
296
  properties: {
@@ -311,16 +323,13 @@ export const toolSchemas = {
311
323
  required: ['find', 'replace']
312
324
  }
313
325
  },
314
- required: ['block_uid'],
315
- oneOf: [
316
- { required: ['content'] },
317
- { required: ['transform_pattern'] }
318
- ]
326
+ required: ['block_uid']
327
+ // Note: Validation for either content or transform_pattern is handled in the server code
319
328
  }
320
329
  },
321
- roam_update_blocks: {
322
- name: 'roam_update_blocks',
323
- description: 'Update multiple blocks in a single batch operation. Each update can provide either new content directly or a transform pattern.\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).',
330
+ roam_update_multiple_blocks: {
331
+ name: 'roam_update_multiple_blocks',
332
+ description: 'Efficiently update multiple blocks in a single batch operation. Use this when you need to update several blocks at once to avoid making multiple separate API calls. Each block in the batch can independently either have its content replaced or transformed using a pattern.\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).',
324
333
  inputSchema: {
325
334
  type: 'object',
326
335
  properties: {
@@ -359,11 +368,8 @@ export const toolSchemas = {
359
368
  required: ['find', 'replace']
360
369
  }
361
370
  },
362
- required: ['block_uid'],
363
- oneOf: [
364
- { required: ['content'] },
365
- { required: ['transform'] }
366
- ]
371
+ required: ['block_uid']
372
+ // Note: Validation for either content or transform is handled in the server code
367
373
  }
368
374
  }
369
375
  },
@@ -372,7 +378,7 @@ export const toolSchemas = {
372
378
  },
373
379
  roam_search_by_date: {
374
380
  name: 'roam_search_by_date',
375
- description: 'Search for blocks or pages based on creation or modification dates',
381
+ description: 'Search for blocks or pages based on creation or modification dates. Not for daily pages with ordinal date titles.',
376
382
  inputSchema: {
377
383
  type: 'object',
378
384
  properties: {
@@ -23,8 +23,8 @@ export class ToolHandlers {
23
23
  this.outlineOps = new OutlineOperations(graph);
24
24
  }
25
25
  // Page Operations
26
- async findPagesModifiedToday(num_pages = 10) {
27
- return this.pageOps.findPagesModifiedToday(num_pages);
26
+ async findPagesModifiedToday(max_num_pages = 50) {
27
+ return this.pageOps.findPagesModifiedToday(max_num_pages);
28
28
  }
29
29
  async createPage(title, content) {
30
30
  return this.pageOps.createPage(title, content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
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
- }