roam-research-mcp 0.12.3 → 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 +147 -1
- package/build/config/environment.js +44 -0
- package/build/index.js +1 -823
- package/build/markdown-utils.js +6 -5
- package/build/search/block-ref-search.js +72 -0
- package/build/search/date-search.js +38 -0
- package/build/search/hierarchy-search.js +101 -0
- package/build/search/index.js +6 -0
- package/build/search/status-search.js +43 -0
- package/build/search/tag-search.js +89 -0
- package/build/search/types.js +7 -0
- package/build/search/utils.js +98 -0
- package/build/server/roam-server.js +145 -0
- package/build/test-addMarkdownText.js +1 -1
- package/build/tools/handlers.js +887 -0
- package/build/tools/schemas.js +244 -0
- package/build/types/roam.js +1 -0
- package/build/utils/helpers.js +19 -0
- package/package.json +1 -1
package/build/markdown-utils.js
CHANGED
|
@@ -69,18 +69,19 @@ function parseMarkdown(markdown) {
|
|
|
69
69
|
let level = Math.floor(indentation / 2);
|
|
70
70
|
// Extract content after bullet point or heading
|
|
71
71
|
let content = trimmedLine;
|
|
72
|
-
|
|
72
|
+
content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
73
|
+
if (trimmedLine.startsWith('#') || trimmedLine.includes('{{table}}') || (trimmedLine.startsWith('**') && trimmedLine.endsWith('**'))) {
|
|
73
74
|
// Remove bullet point if it precedes a table marker
|
|
74
|
-
content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
75
|
+
// content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
75
76
|
level = 0;
|
|
76
77
|
// Reset stack but keep heading/table as parent
|
|
77
78
|
stack.length = 1; // Keep only the heading/table
|
|
78
79
|
}
|
|
79
|
-
else if (stack[0]?.content.startsWith('#') || stack[0]?.content.includes('{{table}}')) {
|
|
80
|
-
// If previous node was a heading or table marker, increase level by 1
|
|
80
|
+
else if (stack[0]?.content.startsWith('#') || stack[0]?.content.includes('{{table}}') || (stack[0]?.content.startsWith('**') && stack[0]?.content.endsWith('**'))) {
|
|
81
|
+
// If previous node was a heading or table marker or wrapped in double-asterisks, increase level by 1
|
|
81
82
|
level = Math.max(level, 1);
|
|
82
83
|
// Remove bullet point
|
|
83
|
-
content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
84
|
+
// content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
84
85
|
}
|
|
85
86
|
else {
|
|
86
87
|
// Remove bullet point
|
|
@@ -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,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,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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
5
|
+
import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
|
|
6
|
+
import { toolSchemas } from '../tools/schemas.js';
|
|
7
|
+
import { ToolHandlers } from '../tools/handlers.js';
|
|
8
|
+
import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler } from '../search/index.js';
|
|
9
|
+
export class RoamServer {
|
|
10
|
+
server;
|
|
11
|
+
toolHandlers;
|
|
12
|
+
graph;
|
|
13
|
+
constructor() {
|
|
14
|
+
this.graph = initializeGraph({
|
|
15
|
+
token: API_TOKEN,
|
|
16
|
+
graph: GRAPH_NAME,
|
|
17
|
+
});
|
|
18
|
+
this.toolHandlers = new ToolHandlers(this.graph);
|
|
19
|
+
this.server = new Server({
|
|
20
|
+
name: 'roam-research',
|
|
21
|
+
version: '0.12.1',
|
|
22
|
+
}, {
|
|
23
|
+
capabilities: {
|
|
24
|
+
tools: {
|
|
25
|
+
roam_add_todo: {},
|
|
26
|
+
roam_fetch_page_by_title: {},
|
|
27
|
+
roam_create_page: {},
|
|
28
|
+
roam_create_block: {},
|
|
29
|
+
roam_import_markdown: {},
|
|
30
|
+
roam_create_outline: {},
|
|
31
|
+
roam_search_for_tag: {},
|
|
32
|
+
roam_search_by_status: {},
|
|
33
|
+
roam_search_block_refs: {},
|
|
34
|
+
roam_search_hierarchy: {}
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
this.setupRequestHandlers();
|
|
39
|
+
// Error handling
|
|
40
|
+
this.server.onerror = (error) => { };
|
|
41
|
+
process.on('SIGINT', async () => {
|
|
42
|
+
await this.server.close();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
setupRequestHandlers() {
|
|
47
|
+
// List available tools
|
|
48
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
49
|
+
tools: Object.values(toolSchemas),
|
|
50
|
+
}));
|
|
51
|
+
// Handle tool calls
|
|
52
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
53
|
+
try {
|
|
54
|
+
switch (request.params.name) {
|
|
55
|
+
case 'roam_fetch_page_by_title': {
|
|
56
|
+
const { title } = request.params.arguments;
|
|
57
|
+
const content = await this.toolHandlers.fetchPageByTitle(title);
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text', text: content }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
case 'roam_create_page': {
|
|
63
|
+
const { title, content } = request.params.arguments;
|
|
64
|
+
const result = await this.toolHandlers.createPage(title, content);
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
case 'roam_create_block': {
|
|
70
|
+
const { content, page_uid, title } = request.params.arguments;
|
|
71
|
+
const result = await this.toolHandlers.createBlock(content, page_uid, title);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
case 'roam_import_markdown': {
|
|
77
|
+
const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = request.params.arguments;
|
|
78
|
+
const result = await this.toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
case 'roam_add_todo': {
|
|
84
|
+
const { todos } = request.params.arguments;
|
|
85
|
+
const result = await this.toolHandlers.addTodos(todos);
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
case 'roam_create_outline': {
|
|
91
|
+
const { outline, page_title_uid, block_text_uid } = request.params.arguments;
|
|
92
|
+
const result = await this.toolHandlers.createOutline(outline, page_title_uid, block_text_uid);
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
95
|
+
};
|
|
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
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof McpError) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
137
|
+
throw new McpError(ErrorCode.InternalError, `Roam API error: ${errorMessage}`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async run() {
|
|
142
|
+
const transport = new StdioServerTransport();
|
|
143
|
+
await this.server.connect(transport);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { initializeGraph, createPage, batchActions, q } from '@roam-research/roam-api-sdk';
|
|
2
|
-
import { parseMarkdown, convertToRoamActions } from '
|
|
2
|
+
import { parseMarkdown, convertToRoamActions } from '../src/markdown-utils.js';
|
|
3
3
|
import * as dotenv from 'dotenv';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { dirname, join } from 'path';
|