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 +91 -1
- 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 +40 -3
- package/build/tools/handlers.js +181 -0
- package/build/tools/schemas.js +99 -2
- package/package.json +1 -1
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
|
|
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,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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
}
|
package/build/tools/handlers.js
CHANGED
|
@@ -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');
|
package/build/tools/schemas.js
CHANGED
|
@@ -73,8 +73,8 @@ export const toolSchemas = {
|
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
75
|
roam_create_outline: {
|
|
76
|
-
name: '
|
|
77
|
-
description: 'Create a structured outline in Roam from an array of
|
|
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
|
};
|