roam-research-mcp 0.2.0 → 0.14.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
@@ -6,6 +6,8 @@
6
6
 
7
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
8
 
9
+ <a href="https://glama.ai/mcp/servers/fzfznyaflu"><img width="380" height="200" src="https://glama.ai/mcp/servers/fzfznyaflu/badge" alt="Roam Research MCP server" /></a>
10
+
9
11
  ## Installation
10
12
 
11
13
  You can install the package globally:
@@ -25,7 +27,7 @@ npm run build
25
27
 
26
28
  ## Features
27
29
 
28
- The server provides six powerful tools for interacting with Roam Research:
30
+ The server provides eight powerful tools for interacting with Roam Research:
29
31
 
30
32
  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
33
  2. `roam_create_page`: Create new pages with optional content
@@ -33,6 +35,8 @@ The server provides six powerful tools for interacting with Roam Research:
33
35
  4. `roam_import_markdown`: Import nested markdown content under specific blocks
34
36
  5. `roam_add_todo`: Add multiple todo items to today's daily page with checkbox syntax
35
37
  6. `roam_create_outline`: Create hierarchical outlines with proper nesting and structure
38
+ 7. `roam_search_block_refs`: Search for block references within pages or across the graph
39
+ 8. `roam_search_hierarchy`: Navigate and search through block parent-child relationships
36
40
 
37
41
  ## Setup
38
42
 
@@ -278,6 +282,92 @@ Parameters:
278
282
  - `parent_string`: Exact string content of the parent block (must provide either page_uid or page_title)
279
283
  - `order`: Where to add the content ("first" or "last", defaults to "first")
280
284
 
285
+ ### Search Block References
286
+
287
+ Search for block references within pages or across the entire graph:
288
+
289
+ ```typescript
290
+ use_mcp_tool roam-research roam_search_block_refs {
291
+ "block_uid": "optional-block-uid",
292
+ "page_title_uid": "optional-page-title-or-uid"
293
+ }
294
+ ```
295
+
296
+ Features:
297
+
298
+ - Find all references to a specific block
299
+ - Search for any block references within a page
300
+ - Search across the entire graph
301
+ - Supports both direct and indirect references
302
+ - Includes block content and location context
303
+
304
+ Parameters:
305
+
306
+ - `block_uid`: UID of the block to find references to (optional)
307
+ - `page_title_uid`: Title or UID of the page to search in (optional)
308
+
309
+ Returns:
310
+
311
+ ```json
312
+ {
313
+ "success": true,
314
+ "matches": [
315
+ {
316
+ "block_uid": "referenced-block-uid",
317
+ "content": "Block content with ((reference))",
318
+ "page_title": "Page containing reference"
319
+ }
320
+ ],
321
+ "message": "Found N block(s) referencing..."
322
+ }
323
+ ```
324
+
325
+ ### Search Block Hierarchy
326
+
327
+ Navigate and search through block parent-child relationships:
328
+
329
+ ```typescript
330
+ use_mcp_tool roam-research roam_search_hierarchy {
331
+ "parent_uid": "optional-parent-block-uid",
332
+ "child_uid": "optional-child-block-uid",
333
+ "page_title_uid": "optional-page-title-or-uid",
334
+ "max_depth": 3
335
+ }
336
+ ```
337
+
338
+ Features:
339
+
340
+ - Search up or down the block hierarchy
341
+ - Find children of a specific block
342
+ - Find parents of a specific block
343
+ - Configure search depth (1-10 levels)
344
+ - Optional page scope filtering
345
+ - Includes depth information for each result
346
+
347
+ Parameters:
348
+
349
+ - `parent_uid`: UID of the block to find children of (required if searching down)
350
+ - `child_uid`: UID of the block to find parents of (required if searching up)
351
+ - `page_title_uid`: Title or UID of the page to search in (optional)
352
+ - `max_depth`: How many levels deep to search (optional, default: 1, max: 10)
353
+
354
+ Returns:
355
+
356
+ ```json
357
+ {
358
+ "success": true,
359
+ "matches": [
360
+ {
361
+ "block_uid": "related-block-uid",
362
+ "content": "Block content",
363
+ "depth": 2,
364
+ "page_title": "Page containing block"
365
+ }
366
+ ],
367
+ "message": "Found N block(s) as children/parents..."
368
+ }
369
+ ```
370
+
281
371
  ## Error Handling
282
372
 
283
373
  The server provides comprehensive error handling for common scenarios:
@@ -0,0 +1,72 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class BlockRefSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { block_uid, page_title_uid } = this.params;
12
+ // Get target page UID if provided
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query based on whether we're searching for references to a specific block
18
+ // or all block references within a page/graph
19
+ let queryStr;
20
+ let queryParams;
21
+ if (block_uid) {
22
+ // Search for references to a specific block
23
+ if (targetPageUid) {
24
+ queryStr = `[:find ?block-uid ?block-str
25
+ :in $ ?ref-uid ?page-uid
26
+ :where [?p :block/uid ?page-uid]
27
+ [?b :block/page ?p]
28
+ [?b :block/string ?block-str]
29
+ [?b :block/uid ?block-uid]
30
+ [(clojure.string/includes? ?block-str ?ref-uid)]]`;
31
+ queryParams = [`((${block_uid}))`, targetPageUid];
32
+ }
33
+ else {
34
+ queryStr = `[:find ?block-uid ?block-str ?page-title
35
+ :in $ ?ref-uid
36
+ :where [?b :block/string ?block-str]
37
+ [?b :block/uid ?block-uid]
38
+ [?b :block/page ?p]
39
+ [?p :node/title ?page-title]
40
+ [(clojure.string/includes? ?block-str ?ref-uid)]]`;
41
+ queryParams = [`((${block_uid}))`];
42
+ }
43
+ }
44
+ else {
45
+ // Search for any block references
46
+ if (targetPageUid) {
47
+ queryStr = `[:find ?block-uid ?block-str
48
+ :in $ ?page-uid
49
+ :where [?p :block/uid ?page-uid]
50
+ [?b :block/page ?p]
51
+ [?b :block/string ?block-str]
52
+ [?b :block/uid ?block-uid]
53
+ [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`;
54
+ queryParams = [targetPageUid];
55
+ }
56
+ else {
57
+ queryStr = `[:find ?block-uid ?block-str ?page-title
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
+ [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`;
63
+ queryParams = [];
64
+ }
65
+ }
66
+ const results = await q(this.graph, queryStr, queryParams);
67
+ const searchDescription = block_uid
68
+ ? `referencing block ((${block_uid}))`
69
+ : 'containing block references';
70
+ return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
71
+ }
72
+ }
@@ -0,0 +1,38 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class DateSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { start_date, end_date, filter_tag } = this.params;
12
+ const [startDateFormatted, endDateFormatted] = SearchUtils.parseDateRange(start_date, end_date);
13
+ const filterTagFormatted = filter_tag ? `[[${filter_tag}]]` : undefined;
14
+ // Build Roam query string
15
+ const dateQuery = `{between: [[${startDateFormatted}]] [[${endDateFormatted}]]}`;
16
+ const query = filterTagFormatted
17
+ ? `{{query: {and: ${filterTagFormatted} ${dateQuery}}}}`
18
+ : `{{query: ${dateQuery}}}`;
19
+ // Log the query for debugging
20
+ console.log('Roam query:', query);
21
+ // Find blocks matching the query
22
+ const queryStr = `[:find ?block-uid ?block-str ?page-title
23
+ :in $ ?query-str
24
+ :where [?b :block/string ?block-str]
25
+ [?b :block/uid ?block-uid]
26
+ [?b :block/page ?p]
27
+ [?p :node/title ?page-title]
28
+ [(clojure.string/includes? ?block-str ?query-str)]]`;
29
+ const results = await q(this.graph, queryStr, [query]);
30
+ const dateRange = start_date === end_date
31
+ ? `on ${SearchUtils.parseDate(start_date)}`
32
+ : `between ${SearchUtils.parseDate(start_date)} and ${SearchUtils.parseDate(end_date)}`;
33
+ const searchDescription = filterTagFormatted
34
+ ? `${dateRange} containing ${filterTagFormatted}`
35
+ : dateRange;
36
+ return SearchUtils.formatSearchResults(results, searchDescription, true);
37
+ }
38
+ }
@@ -0,0 +1,101 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class HierarchySearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { parent_uid, child_uid, page_title_uid, max_depth = 1 } = this.params;
12
+ if (!parent_uid && !child_uid) {
13
+ return {
14
+ success: false,
15
+ matches: [],
16
+ message: 'Either parent_uid or child_uid must be provided'
17
+ };
18
+ }
19
+ // Get target page UID if provided
20
+ let targetPageUid;
21
+ if (page_title_uid) {
22
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
23
+ }
24
+ let queryStr;
25
+ let queryParams;
26
+ if (parent_uid) {
27
+ // Search for children of a specific block
28
+ if (targetPageUid) {
29
+ queryStr = `[:find ?block-uid ?block-str ?depth
30
+ :in $ ?parent-uid ?page-uid ?max-depth
31
+ :where [?p :block/uid ?page-uid]
32
+ [?parent :block/uid ?parent-uid]
33
+ [?b :block/parents ?parent]
34
+ [?b :block/string ?block-str]
35
+ [?b :block/uid ?block-uid]
36
+ [?b :block/page ?p]
37
+ [(get-else $ ?b :block/path-length 1) ?depth]
38
+ [(<= ?depth ?max-depth)]]`;
39
+ queryParams = [parent_uid, targetPageUid, max_depth];
40
+ }
41
+ else {
42
+ queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
43
+ :in $ ?parent-uid ?max-depth
44
+ :where [?parent :block/uid ?parent-uid]
45
+ [?b :block/parents ?parent]
46
+ [?b :block/string ?block-str]
47
+ [?b :block/uid ?block-uid]
48
+ [?b :block/page ?p]
49
+ [?p :node/title ?page-title]
50
+ [(get-else $ ?b :block/path-length 1) ?depth]
51
+ [(<= ?depth ?max-depth)]]`;
52
+ queryParams = [parent_uid, max_depth];
53
+ }
54
+ }
55
+ else {
56
+ // Search for parents of a specific block
57
+ if (targetPageUid) {
58
+ queryStr = `[:find ?block-uid ?block-str ?depth
59
+ :in $ ?child-uid ?page-uid ?max-depth
60
+ :where [?p :block/uid ?page-uid]
61
+ [?child :block/uid ?child-uid]
62
+ [?child :block/parents ?b]
63
+ [?b :block/string ?block-str]
64
+ [?b :block/uid ?block-uid]
65
+ [?b :block/page ?p]
66
+ [(get-else $ ?b :block/path-length 1) ?depth]
67
+ [(<= ?depth ?max-depth)]]`;
68
+ queryParams = [child_uid, targetPageUid, max_depth];
69
+ }
70
+ else {
71
+ queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
72
+ :in $ ?child-uid ?max-depth
73
+ :where [?child :block/uid ?child-uid]
74
+ [?child :block/parents ?b]
75
+ [?b :block/string ?block-str]
76
+ [?b :block/uid ?block-uid]
77
+ [?b :block/page ?p]
78
+ [?p :node/title ?page-title]
79
+ [(get-else $ ?b :block/path-length 1) ?depth]
80
+ [(<= ?depth ?max-depth)]]`;
81
+ queryParams = [child_uid, max_depth];
82
+ }
83
+ }
84
+ const results = await q(this.graph, queryStr, queryParams);
85
+ // Format results to include depth information
86
+ const matches = results.map(([uid, content, pageTitle, depth]) => ({
87
+ block_uid: uid,
88
+ content,
89
+ depth: depth || 1,
90
+ ...(pageTitle && { page_title: pageTitle })
91
+ }));
92
+ const searchDescription = parent_uid
93
+ ? `children of block ${parent_uid} (max depth: ${max_depth})`
94
+ : `parents of block ${child_uid} (max depth: ${max_depth})`;
95
+ return {
96
+ success: true,
97
+ matches,
98
+ message: `Found ${matches.length} block(s) as ${searchDescription}`
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,6 @@
1
+ export * from './types.js';
2
+ export * from './utils.js';
3
+ export * from './tag-search.js';
4
+ export * from './status-search.js';
5
+ export * from './block-ref-search.js';
6
+ export * from './hierarchy-search.js';
@@ -0,0 +1,43 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class StatusSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { status, page_title_uid } = this.params;
12
+ // Get target page UID if provided
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query based on whether we're searching in a specific page
18
+ let queryStr;
19
+ let queryParams;
20
+ if (targetPageUid) {
21
+ queryStr = `[:find ?block-uid ?block-str
22
+ :in $ ?status ?page-uid
23
+ :where [?p :block/uid ?page-uid]
24
+ [?b :block/page ?p]
25
+ [?b :block/string ?block-str]
26
+ [?b :block/uid ?block-uid]
27
+ [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
28
+ queryParams = [status, targetPageUid];
29
+ }
30
+ else {
31
+ queryStr = `[:find ?block-uid ?block-str ?page-title
32
+ :in $ ?status
33
+ :where [?b :block/string ?block-str]
34
+ [?b :block/uid ?block-uid]
35
+ [?b :block/page ?p]
36
+ [?p :node/title ?page-title]
37
+ [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
38
+ queryParams = [status];
39
+ }
40
+ const results = await q(this.graph, queryStr, queryParams);
41
+ return SearchUtils.formatSearchResults(results, `with status ${status}`, !targetPageUid);
42
+ }
43
+ }
@@ -0,0 +1,89 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class TagSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { primary_tag, page_title_uid, near_tag, exclude_tag } = this.params;
12
+ // Format tags to handle both # and [[]] formats
13
+ const primaryTagFormats = SearchUtils.formatTag(primary_tag);
14
+ const nearTagFormats = near_tag ? SearchUtils.formatTag(near_tag) : undefined;
15
+ const excludeTagFormats = exclude_tag ? SearchUtils.formatTag(exclude_tag) : undefined;
16
+ // Get target page UID if provided
17
+ let targetPageUid;
18
+ if (page_title_uid) {
19
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
20
+ }
21
+ // Build query based on whether we're searching in a specific page and/or for a nearby tag
22
+ let queryStr;
23
+ let queryParams;
24
+ if (targetPageUid) {
25
+ if (nearTagFormats) {
26
+ queryStr = `[:find ?block-uid ?block-str
27
+ :in $ [?primary-tag1 ?primary-tag2] [?near-tag1 ?near-tag2] [?exclude-tag1 ?exclude-tag2] ?page-uid
28
+ :where [?p :block/uid ?page-uid]
29
+ [?b :block/page ?p]
30
+ [?b :block/string ?block-str]
31
+ [?b :block/uid ?block-uid]
32
+ (or [(clojure.string/includes? ?block-str ?primary-tag1)]
33
+ [(clojure.string/includes? ?block-str ?primary-tag2)])
34
+ (or [(clojure.string/includes? ?block-str ?near-tag1)]
35
+ [(clojure.string/includes? ?block-str ?near-tag2)])
36
+ (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
37
+ [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
38
+ queryParams = [primaryTagFormats, nearTagFormats, excludeTagFormats || ['', ''], targetPageUid];
39
+ }
40
+ else {
41
+ queryStr = `[:find ?block-uid ?block-str
42
+ :in $ [?primary-tag1 ?primary-tag2] [?exclude-tag1 ?exclude-tag2] ?page-uid
43
+ :where [?p :block/uid ?page-uid]
44
+ [?b :block/page ?p]
45
+ [?b :block/string ?block-str]
46
+ [?b :block/uid ?block-uid]
47
+ (or [(clojure.string/includes? ?block-str ?primary-tag1)]
48
+ [(clojure.string/includes? ?block-str ?primary-tag2)])
49
+ (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
50
+ [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
51
+ queryParams = [primaryTagFormats, excludeTagFormats || ['', ''], targetPageUid];
52
+ }
53
+ }
54
+ else {
55
+ // Search across all pages
56
+ if (nearTagFormats) {
57
+ queryStr = `[:find ?block-uid ?block-str ?page-title
58
+ :in $ [?primary-tag1 ?primary-tag2] [?near-tag1 ?near-tag2] [?exclude-tag1 ?exclude-tag2]
59
+ :where [?b :block/string ?block-str]
60
+ [?b :block/uid ?block-uid]
61
+ [?b :block/page ?p]
62
+ [?p :node/title ?page-title]
63
+ (or [(clojure.string/includes? ?block-str ?primary-tag1)]
64
+ [(clojure.string/includes? ?block-str ?primary-tag2)])
65
+ (or [(clojure.string/includes? ?block-str ?near-tag1)]
66
+ [(clojure.string/includes? ?block-str ?near-tag2)])
67
+ (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
68
+ [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
69
+ queryParams = [primaryTagFormats, nearTagFormats, excludeTagFormats || ['', '']];
70
+ }
71
+ else {
72
+ queryStr = `[:find ?block-uid ?block-str ?page-title
73
+ :in $ [?primary-tag1 ?primary-tag2] [?exclude-tag1 ?exclude-tag2]
74
+ :where [?b :block/string ?block-str]
75
+ [?b :block/uid ?block-uid]
76
+ [?b :block/page ?p]
77
+ [?p :node/title ?page-title]
78
+ (or [(clojure.string/includes? ?block-str ?primary-tag1)]
79
+ [(clojure.string/includes? ?block-str ?primary-tag2)])
80
+ (not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
81
+ [(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
82
+ queryParams = [primaryTagFormats, excludeTagFormats || ['', '']];
83
+ }
84
+ }
85
+ const results = await q(this.graph, queryStr, queryParams);
86
+ const searchDescription = `containing ${primaryTagFormats.join(' or ')}${nearTagFormats ? ` near ${nearTagFormats.join(' or ')}` : ''}${excludeTagFormats ? ` excluding ${excludeTagFormats.join(' or ')}` : ''}`;
87
+ return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
88
+ }
89
+ }
@@ -0,0 +1,7 @@
1
+ // Base class for all search handlers
2
+ export class BaseSearchHandler {
3
+ graph;
4
+ constructor(graph) {
5
+ this.graph = graph;
6
+ }
7
+ }
@@ -0,0 +1,98 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { q } from '@roam-research/roam-api-sdk';
3
+ export class SearchUtils {
4
+ /**
5
+ * Find a page by title or UID
6
+ */
7
+ static async findPageByTitleOrUid(graph, titleOrUid) {
8
+ // Try to find page by title
9
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
10
+ const findResults = await q(graph, findQuery, [titleOrUid]);
11
+ if (findResults && findResults.length > 0) {
12
+ return findResults[0][0];
13
+ }
14
+ // Try as UID
15
+ const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`;
16
+ const uidResults = await q(graph, uidQuery, []);
17
+ if (!uidResults || uidResults.length === 0) {
18
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${titleOrUid}" not found`);
19
+ }
20
+ return uidResults[0][0];
21
+ }
22
+ /**
23
+ * Format search results into a standard structure
24
+ */
25
+ static formatSearchResults(results, searchDescription, includePageTitle = true) {
26
+ if (!results || results.length === 0) {
27
+ return {
28
+ success: true,
29
+ matches: [],
30
+ message: `No blocks found ${searchDescription}`
31
+ };
32
+ }
33
+ const matches = results.map(([uid, content, pageTitle]) => ({
34
+ block_uid: uid,
35
+ content,
36
+ ...(includePageTitle && pageTitle && { page_title: pageTitle })
37
+ }));
38
+ return {
39
+ success: true,
40
+ matches,
41
+ message: `Found ${matches.length} block(s) ${searchDescription}`
42
+ };
43
+ }
44
+ /**
45
+ * Format a tag for searching, handling both # and [[]] formats
46
+ * @param tag Tag without prefix
47
+ * @returns Array of possible formats to search for
48
+ */
49
+ static formatTag(tag) {
50
+ // Remove any existing prefixes
51
+ const cleanTag = tag.replace(/^#|\[\[|\]\]$/g, '');
52
+ // Return both formats for comprehensive search
53
+ return [`#${cleanTag}`, `[[${cleanTag}]]`];
54
+ }
55
+ /**
56
+ * Parse a date string into a Roam-formatted date
57
+ */
58
+ static parseDate(dateStr) {
59
+ const date = new Date(dateStr);
60
+ const months = [
61
+ 'January', 'February', 'March', 'April', 'May', 'June',
62
+ 'July', 'August', 'September', 'October', 'November', 'December'
63
+ ];
64
+ // Adjust for timezone to ensure consistent date comparison
65
+ const utcDate = new Date(date.getTime() + date.getTimezoneOffset() * 60000);
66
+ return `${months[utcDate.getMonth()]} ${utcDate.getDate()}${this.getOrdinalSuffix(utcDate.getDate())}, ${utcDate.getFullYear()}`;
67
+ }
68
+ /**
69
+ * Parse a date string into a Roam-formatted date range
70
+ * Returns [startDate, endDate] with endDate being inclusive (end of day)
71
+ */
72
+ static parseDateRange(startStr, endStr) {
73
+ const startDate = new Date(startStr);
74
+ const endDate = new Date(endStr);
75
+ endDate.setHours(23, 59, 59, 999); // Make end date inclusive
76
+ const months = [
77
+ 'January', 'February', 'March', 'April', 'May', 'June',
78
+ 'July', 'August', 'September', 'October', 'November', 'December'
79
+ ];
80
+ // Adjust for timezone
81
+ const utcStart = new Date(startDate.getTime() + startDate.getTimezoneOffset() * 60000);
82
+ const utcEnd = new Date(endDate.getTime() + endDate.getTimezoneOffset() * 60000);
83
+ return [
84
+ `${months[utcStart.getMonth()]} ${utcStart.getDate()}${this.getOrdinalSuffix(utcStart.getDate())}, ${utcStart.getFullYear()}`,
85
+ `${months[utcEnd.getMonth()]} ${utcEnd.getDate()}${this.getOrdinalSuffix(utcEnd.getDate())}, ${utcEnd.getFullYear()}`
86
+ ];
87
+ }
88
+ static getOrdinalSuffix(day) {
89
+ if (day > 3 && day < 21)
90
+ return 'th';
91
+ switch (day % 10) {
92
+ case 1: return 'st';
93
+ case 2: return 'nd';
94
+ case 3: return 'rd';
95
+ default: return 'th';
96
+ }
97
+ }
98
+ }
@@ -5,15 +5,17 @@ import { initializeGraph } from '@roam-research/roam-api-sdk';
5
5
  import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
6
6
  import { toolSchemas } from '../tools/schemas.js';
7
7
  import { ToolHandlers } from '../tools/handlers.js';
8
+ import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler } from '../search/index.js';
8
9
  export class RoamServer {
9
10
  server;
10
11
  toolHandlers;
12
+ graph;
11
13
  constructor() {
12
- const graph = initializeGraph({
14
+ this.graph = initializeGraph({
13
15
  token: API_TOKEN,
14
16
  graph: GRAPH_NAME,
15
17
  });
16
- this.toolHandlers = new ToolHandlers(graph);
18
+ this.toolHandlers = new ToolHandlers(this.graph);
17
19
  this.server = new Server({
18
20
  name: 'roam-research',
19
21
  version: '0.12.1',
@@ -25,7 +27,11 @@ export class RoamServer {
25
27
  roam_create_page: {},
26
28
  roam_create_block: {},
27
29
  roam_import_markdown: {},
28
- roam_create_outline: {}
30
+ roam_create_outline: {},
31
+ roam_search_for_tag: {},
32
+ roam_search_by_status: {},
33
+ roam_search_block_refs: {},
34
+ roam_search_hierarchy: {}
29
35
  },
30
36
  },
31
37
  });
@@ -88,6 +94,37 @@ export class RoamServer {
88
94
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
89
95
  };
90
96
  }
97
+ case 'roam_search_for_tag': {
98
+ const params = request.params.arguments;
99
+ const handler = new TagSearchHandler(this.graph, params);
100
+ const result = await handler.execute();
101
+ return {
102
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
103
+ };
104
+ }
105
+ case 'roam_search_by_status': {
106
+ const { status, page_title_uid, include, exclude } = request.params.arguments;
107
+ const result = await this.toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
108
+ return {
109
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
110
+ };
111
+ }
112
+ case 'roam_search_block_refs': {
113
+ const params = request.params.arguments;
114
+ const handler = new BlockRefSearchHandler(this.graph, params);
115
+ const result = await handler.execute();
116
+ return {
117
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
118
+ };
119
+ }
120
+ case 'roam_search_hierarchy': {
121
+ const params = request.params.arguments;
122
+ const handler = new HierarchySearchHandler(this.graph, params);
123
+ const result = await handler.execute();
124
+ return {
125
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
126
+ };
127
+ }
91
128
  default:
92
129
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
93
130
  }
@@ -634,6 +634,187 @@ export class ToolHandlers {
634
634
  };
635
635
  }
636
636
  }
637
+ async searchByStatus(status, page_title_uid, include, exclude) {
638
+ // Get target page UID if provided
639
+ let targetPageUid;
640
+ if (page_title_uid) {
641
+ // Try to find page by title or UID
642
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
643
+ const findResults = await q(this.graph, findQuery, [page_title_uid]);
644
+ if (findResults && findResults.length > 0) {
645
+ targetPageUid = findResults[0][0];
646
+ }
647
+ else {
648
+ // Try as UID
649
+ const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
650
+ const uidResults = await q(this.graph, uidQuery, []);
651
+ if (!uidResults || uidResults.length === 0) {
652
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
653
+ }
654
+ targetPageUid = uidResults[0][0];
655
+ }
656
+ }
657
+ // Build query based on whether we're searching in a specific page
658
+ let queryStr;
659
+ let queryParams;
660
+ const statusPattern = `{{[[${status}]]}}`;
661
+ // Helper function to get parent block content
662
+ const getParentContent = async (blockUid) => {
663
+ const parentQuery = `[:find ?parent-str .
664
+ :where [?b :block/uid "${blockUid}"]
665
+ [?b :block/parents ?parent]
666
+ [?parent :block/string ?parent-str]]`;
667
+ const result = await q(this.graph, parentQuery, []);
668
+ return result ? String(result) : null;
669
+ };
670
+ if (targetPageUid) {
671
+ queryStr = `[:find ?block-uid ?block-str
672
+ :in $ ?status-pattern ?page-uid
673
+ :where [?p :block/uid ?page-uid]
674
+ [?b :block/page ?p]
675
+ [?b :block/string ?block-str]
676
+ [?b :block/uid ?block-uid]
677
+ [(clojure.string/includes? ?block-str ?status-pattern)]]`;
678
+ queryParams = [statusPattern, targetPageUid];
679
+ }
680
+ else {
681
+ queryStr = `[:find ?block-uid ?block-str ?page-title
682
+ :in $ ?status-pattern
683
+ :where [?b :block/string ?block-str]
684
+ [?b :block/uid ?block-uid]
685
+ [?b :block/page ?p]
686
+ [?p :node/title ?page-title]
687
+ [(clojure.string/includes? ?block-str ?status-pattern)]]`;
688
+ queryParams = [statusPattern];
689
+ }
690
+ const results = await q(this.graph, queryStr, queryParams);
691
+ if (!results || results.length === 0) {
692
+ return {
693
+ success: true,
694
+ matches: [],
695
+ message: `No blocks found with status ${status}`
696
+ };
697
+ }
698
+ // Format initial results
699
+ let matches = results.map(result => {
700
+ const [uid, content, pageTitle] = result;
701
+ return {
702
+ block_uid: uid,
703
+ content,
704
+ ...(pageTitle && { page_title: pageTitle })
705
+ };
706
+ });
707
+ // Post-query filtering
708
+ if (include) {
709
+ const includeTerms = include.toLowerCase().split(',').map(term => term.trim());
710
+ matches = matches.filter(match => includeTerms.some(term => match.content.toLowerCase().includes(term) ||
711
+ (match.page_title && match.page_title.toLowerCase().includes(term))));
712
+ }
713
+ if (exclude) {
714
+ const excludeTerms = exclude.toLowerCase().split(',').map(term => term.trim());
715
+ matches = matches.filter(match => !excludeTerms.some(term => match.content.toLowerCase().includes(term) ||
716
+ (match.page_title && match.page_title.toLowerCase().includes(term))));
717
+ }
718
+ return {
719
+ success: true,
720
+ matches,
721
+ message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
722
+ };
723
+ }
724
+ async searchForTag(primary_tag, page_title_uid, near_tag) {
725
+ // Ensure tags are properly formatted with #
726
+ const formatTag = (tag) => tag.startsWith('#') ? tag : `#${tag}`;
727
+ const primaryTagFormatted = formatTag(primary_tag);
728
+ const nearTagFormatted = near_tag ? formatTag(near_tag) : undefined;
729
+ // Get target page UID if provided
730
+ let targetPageUid;
731
+ if (page_title_uid) {
732
+ // Try to find page by title or UID
733
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
734
+ const findResults = await q(this.graph, findQuery, [page_title_uid]);
735
+ if (findResults && findResults.length > 0) {
736
+ targetPageUid = findResults[0][0];
737
+ }
738
+ else {
739
+ // Try as UID
740
+ const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
741
+ const uidResults = await q(this.graph, uidQuery, []);
742
+ if (!uidResults || uidResults.length === 0) {
743
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
744
+ }
745
+ targetPageUid = uidResults[0][0];
746
+ }
747
+ }
748
+ // Build query based on whether we're searching in a specific page and/or for a nearby tag
749
+ let queryStr;
750
+ let queryParams;
751
+ if (targetPageUid) {
752
+ if (nearTagFormatted) {
753
+ queryStr = `[:find ?block-uid ?block-str
754
+ :in $ ?primary-tag ?near-tag ?page-uid
755
+ :where [?p :block/uid ?page-uid]
756
+ [?b :block/page ?p]
757
+ [?b :block/string ?block-str]
758
+ [?b :block/uid ?block-uid]
759
+ [(clojure.string/includes? ?block-str ?primary-tag)]
760
+ [(clojure.string/includes? ?block-str ?near-tag)]]`;
761
+ queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
762
+ }
763
+ else {
764
+ queryStr = `[:find ?block-uid ?block-str
765
+ :in $ ?primary-tag ?page-uid
766
+ :where [?p :block/uid ?page-uid]
767
+ [?b :block/page ?p]
768
+ [?b :block/string ?block-str]
769
+ [?b :block/uid ?block-uid]
770
+ [(clojure.string/includes? ?block-str ?primary-tag)]]`;
771
+ queryParams = [primaryTagFormatted, targetPageUid];
772
+ }
773
+ }
774
+ else {
775
+ // Search across all pages
776
+ if (nearTagFormatted) {
777
+ queryStr = `[:find ?block-uid ?block-str ?page-title
778
+ :in $ ?primary-tag ?near-tag
779
+ :where [?b :block/string ?block-str]
780
+ [?b :block/uid ?block-uid]
781
+ [?b :block/page ?p]
782
+ [?p :node/title ?page-title]
783
+ [(clojure.string/includes? ?block-str ?primary-tag)]
784
+ [(clojure.string/includes? ?block-str ?near-tag)]]`;
785
+ queryParams = [primaryTagFormatted, nearTagFormatted];
786
+ }
787
+ else {
788
+ queryStr = `[:find ?block-uid ?block-str ?page-title
789
+ :in $ ?primary-tag
790
+ :where [?b :block/string ?block-str]
791
+ [?b :block/uid ?block-uid]
792
+ [?b :block/page ?p]
793
+ [?p :node/title ?page-title]
794
+ [(clojure.string/includes? ?block-str ?primary-tag)]]`;
795
+ queryParams = [primaryTagFormatted];
796
+ }
797
+ }
798
+ const results = await q(this.graph, queryStr, queryParams);
799
+ if (!results || results.length === 0) {
800
+ return {
801
+ success: true,
802
+ matches: [],
803
+ message: `No blocks found containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
804
+ };
805
+ }
806
+ // Format results
807
+ const matches = results.map(([uid, content, pageTitle]) => ({
808
+ block_uid: uid,
809
+ content,
810
+ ...(pageTitle && { page_title: pageTitle })
811
+ }));
812
+ return {
813
+ success: true,
814
+ matches,
815
+ message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
816
+ };
817
+ }
637
818
  async addTodos(todos) {
638
819
  if (!Array.isArray(todos) || todos.length === 0) {
639
820
  throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
@@ -73,8 +73,8 @@ export const toolSchemas = {
73
73
  },
74
74
  },
75
75
  roam_create_outline: {
76
- name: 'roam_create_outline',
77
- description: 'Create a structured outline in Roam from an array of outline items with explicit levels. Can be added under a specific page or block.',
76
+ name: 'roam_create_output_with_nested_structure',
77
+ description: 'Create a structured outline or output with nested structure in Roam from an array of items with explicit levels. Can be added on a specific page or under a specific block.',
78
78
  inputSchema: {
79
79
  type: 'object',
80
80
  properties: {
@@ -144,4 +144,101 @@ export const toolSchemas = {
144
144
  required: ['content']
145
145
  }
146
146
  },
147
+ roam_search_for_tag: {
148
+ name: 'roam_search_for_tag',
149
+ description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: {
153
+ primary_tag: {
154
+ type: 'string',
155
+ description: 'The main tag to search for (without the [[ ]] brackets)',
156
+ },
157
+ page_title_uid: {
158
+ type: 'string',
159
+ description: 'Optional: Title or UID of the page to search in. Defaults to today\'s daily page if not provided',
160
+ },
161
+ near_tag: {
162
+ type: 'string',
163
+ description: 'Optional: Another tag to filter results by - will only return blocks where both tags appear',
164
+ }
165
+ },
166
+ required: ['primary_tag']
167
+ }
168
+ },
169
+ roam_search_by_status: {
170
+ name: 'roam_search_by_status',
171
+ description: 'Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ status: {
176
+ type: 'string',
177
+ description: 'Status to search for (TODO or DONE)',
178
+ enum: ['TODO', 'DONE']
179
+ },
180
+ page_title_uid: {
181
+ type: 'string',
182
+ description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
183
+ },
184
+ include: {
185
+ type: 'string',
186
+ description: 'Optional: Comma-separated list of terms to filter results by inclusion (matches content or page title)'
187
+ },
188
+ exclude: {
189
+ type: 'string',
190
+ description: 'Optional: Comma-separated list of terms to filter results by exclusion (matches content or page title)'
191
+ }
192
+ },
193
+ required: ['status']
194
+ }
195
+ },
196
+ roam_search_block_refs: {
197
+ name: 'roam_search_block_refs',
198
+ description: 'Search for block references within a page or across the entire graph. Can search for references to a specific block or find all block references.',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ block_uid: {
203
+ type: 'string',
204
+ description: 'Optional: UID of the block to find references to'
205
+ },
206
+ page_title_uid: {
207
+ type: 'string',
208
+ description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
209
+ }
210
+ }
211
+ }
212
+ },
213
+ roam_search_hierarchy: {
214
+ name: 'roam_search_hierarchy',
215
+ description: 'Search for parent or child blocks in the block hierarchy. Can search up or down the hierarchy from a given block.',
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ parent_uid: {
220
+ type: 'string',
221
+ description: 'Optional: UID of the block to find children of'
222
+ },
223
+ child_uid: {
224
+ type: 'string',
225
+ description: 'Optional: UID of the block to find parents of'
226
+ },
227
+ page_title_uid: {
228
+ type: 'string',
229
+ description: 'Optional: Title or UID of the page to search in'
230
+ },
231
+ max_depth: {
232
+ type: 'integer',
233
+ description: 'Optional: How many levels deep to search (default: 1)',
234
+ minimum: 1,
235
+ maximum: 10
236
+ }
237
+ },
238
+ oneOf: [
239
+ { required: ['parent_uid'] },
240
+ { required: ['child_uid'] }
241
+ ]
242
+ }
243
+ }
147
244
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.14.0",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {