roam-research-mcp 0.19.0 → 0.22.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 -20
- package/build/search/text-search.js +3 -4
- package/build/server/roam-server.js +20 -10
- package/build/tools/helpers/refs.js +47 -0
- package/build/tools/helpers/text.js +6 -0
- package/build/tools/operations/blocks.js +269 -0
- package/build/tools/operations/memory.js +102 -0
- package/build/tools/operations/outline.js +347 -0
- package/build/tools/operations/pages.js +213 -0
- package/build/tools/operations/search/handlers.js +56 -0
- package/build/tools/operations/search/index.js +95 -0
- package/build/tools/operations/search/types.js +1 -0
- package/build/tools/operations/search.js +285 -0
- package/build/tools/operations/todos.js +82 -0
- package/build/tools/schemas.js +36 -24
- package/build/tools/tool-handlers.js +58 -1145
- package/build/tools/types/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ npm run build
|
|
|
28
28
|
|
|
29
29
|
## Features
|
|
30
30
|
|
|
31
|
-
The server provides
|
|
31
|
+
The server provides fourteen powerful tools for interacting with Roam Research:
|
|
32
32
|
|
|
33
33
|
1. `roam_fetch_page_by_title`: Fetch and read a page's content by title, recursively resolving block references up to 4 levels deep
|
|
34
34
|
2. `roam_create_page`: Create new pages with optional content
|
|
@@ -42,13 +42,16 @@ The server provides twelve powerful tools for interacting with Roam Research:
|
|
|
42
42
|
10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
|
|
43
43
|
11. `roam_update_block`: Update block content with direct text or pattern-based transformations
|
|
44
44
|
12. `roam_search_by_date`: Search for blocks and pages based on creation or modification dates
|
|
45
|
+
13. `roam_search_for_tag`: Search for blocks containing specific tags with optional filtering by nearby tags
|
|
46
|
+
14. `roam_remember`: Store and categorize memories or information with automatic tagging
|
|
47
|
+
15. `roam_recall`: Recall memories of blocks marked with tag MEMORIES_TAG (see below) or blocks on page title of the same name.
|
|
45
48
|
|
|
46
49
|
## Setup
|
|
47
50
|
|
|
48
|
-
1. Create a Roam Research API token:
|
|
51
|
+
1. Create a [Roam Research API token](https://x.com/RoamResearch/status/1789358175474327881):
|
|
49
52
|
|
|
50
53
|
- Go to your graph settings
|
|
51
|
-
- Navigate to the "API tokens" section
|
|
54
|
+
- Navigate to the "API tokens" section (Settings > "Graph" tab > "API Tokens" section and click on the "+ New API Token" button)
|
|
52
55
|
- Create a new token
|
|
53
56
|
|
|
54
57
|
2. Configure the environment variables:
|
|
@@ -60,28 +63,14 @@ The server provides twelve powerful tools for interacting with Roam Research:
|
|
|
60
63
|
```
|
|
61
64
|
ROAM_API_TOKEN=your-api-token
|
|
62
65
|
ROAM_GRAPH_NAME=your-graph-name
|
|
66
|
+
MEMORIES_TAG='#[[LLM/Memories]]'
|
|
67
|
+
PROFILE_PAGE='LLM/Profile' (not yet implemented)
|
|
63
68
|
```
|
|
64
69
|
|
|
65
70
|
Option 2: Using MCP settings (Alternative method)
|
|
66
71
|
Add the configuration to your MCP settings file:
|
|
67
72
|
|
|
68
73
|
- For Cline (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`):
|
|
69
|
-
|
|
70
|
-
```json
|
|
71
|
-
{
|
|
72
|
-
"mcpServers": {
|
|
73
|
-
"roam-research": {
|
|
74
|
-
"command": "node",
|
|
75
|
-
"args": ["/path/to/roam-research/build/index.js"],
|
|
76
|
-
"env": {
|
|
77
|
-
"ROAM_API_TOKEN": "your-api-token",
|
|
78
|
-
"ROAM_GRAPH_NAME": "your-graph-name"
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
74
|
- For Claude desktop app (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
86
75
|
|
|
87
76
|
```json
|
|
@@ -92,7 +81,9 @@ The server provides twelve powerful tools for interacting with Roam Research:
|
|
|
92
81
|
"args": ["/path/to/roam-research/build/index.js"],
|
|
93
82
|
"env": {
|
|
94
83
|
"ROAM_API_TOKEN": "your-api-token",
|
|
95
|
-
"ROAM_GRAPH_NAME": "your-graph-name"
|
|
84
|
+
"ROAM_GRAPH_NAME": "your-graph-name",
|
|
85
|
+
"MEMORIES_TAG": "#[[LLM/Memories]]",
|
|
86
|
+
"PROFILE_PAGE": "LLM/Profile"
|
|
96
87
|
}
|
|
97
88
|
}
|
|
98
89
|
}
|
|
@@ -421,6 +412,86 @@ Returns:
|
|
|
421
412
|
}
|
|
422
413
|
```
|
|
423
414
|
|
|
415
|
+
### Search For Tags
|
|
416
|
+
|
|
417
|
+
Search for blocks containing specific tags with optional filtering by nearby tags:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
use_mcp_tool roam-research roam_search_for_tag {
|
|
421
|
+
"primary_tag": "Project/Tasks",
|
|
422
|
+
"page_title_uid": "optional-page-title-or-uid",
|
|
423
|
+
"near_tag": "optional-secondary-tag",
|
|
424
|
+
"case_sensitive": true
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Features:
|
|
429
|
+
|
|
430
|
+
- Search for blocks containing specific tags
|
|
431
|
+
- Optional filtering by presence of another tag
|
|
432
|
+
- Page-scoped or graph-wide search
|
|
433
|
+
- Case-sensitive or case-insensitive search
|
|
434
|
+
- Returns block content with page context
|
|
435
|
+
- Efficient tag matching using Datalog queries
|
|
436
|
+
|
|
437
|
+
Parameters:
|
|
438
|
+
|
|
439
|
+
- `primary_tag`: The main tag to search for (required)
|
|
440
|
+
- `page_title_uid`: Title or UID of the page to search in (optional)
|
|
441
|
+
- `near_tag`: Another tag to filter results by (optional)
|
|
442
|
+
- `case_sensitive`: Whether to perform case-sensitive search (optional, default: true to match Roam's native behavior)
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
|
|
446
|
+
```json
|
|
447
|
+
{
|
|
448
|
+
"success": true,
|
|
449
|
+
"matches": [
|
|
450
|
+
{
|
|
451
|
+
"block_uid": "matching-block-uid",
|
|
452
|
+
"content": "Block content containing #[[primary_tag]]",
|
|
453
|
+
"page_title": "Page containing block"
|
|
454
|
+
}
|
|
455
|
+
],
|
|
456
|
+
"message": "Found N block(s) referencing \"primary_tag\""
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Remember Information
|
|
461
|
+
|
|
462
|
+
Store memories or important information with automatic tagging and categorization:
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
use_mcp_tool roam-research roam_remember {
|
|
466
|
+
"memory": "Important information to remember",
|
|
467
|
+
"categories": ["Work", "Project/Alpha"]
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Features:
|
|
472
|
+
|
|
473
|
+
- Store information with #[[LLM/Memories]] tag
|
|
474
|
+
- Add optional category tags for organization
|
|
475
|
+
- Automatically adds to today's daily page
|
|
476
|
+
- Supports multiple categories per memory
|
|
477
|
+
- Easy retrieval using roam_search_for_tag
|
|
478
|
+
- Maintains chronological order of memories
|
|
479
|
+
|
|
480
|
+
Parameters:
|
|
481
|
+
|
|
482
|
+
- `memory`: The information to remember (required)
|
|
483
|
+
- `categories`: Optional array of categories to tag the memory with
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
|
|
487
|
+
```json
|
|
488
|
+
{
|
|
489
|
+
"success": true,
|
|
490
|
+
"block_uid": "created-block-uid",
|
|
491
|
+
"content": "Memory content with tags"
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
424
495
|
### Search By Date
|
|
425
496
|
|
|
426
497
|
Search for blocks and pages based on creation or modification dates:
|
|
@@ -8,7 +8,7 @@ export class TextSearchHandler extends BaseSearchHandler {
|
|
|
8
8
|
this.params = params;
|
|
9
9
|
}
|
|
10
10
|
async execute() {
|
|
11
|
-
const { text, page_title_uid
|
|
11
|
+
const { text, page_title_uid } = this.params;
|
|
12
12
|
// Get target page UID if provided for scoped search
|
|
13
13
|
let targetPageUid;
|
|
14
14
|
if (page_title_uid) {
|
|
@@ -19,14 +19,13 @@ export class TextSearchHandler extends BaseSearchHandler {
|
|
|
19
19
|
:in $ ?search-text
|
|
20
20
|
:where
|
|
21
21
|
[?b :block/string ?block-str]
|
|
22
|
-
[(clojure.string/includes?
|
|
23
|
-
${case_sensitive ? '?search-text' : '(clojure.string/lower-case ?search-text)'})]
|
|
22
|
+
[(clojure.string/includes? ?block-str ?search-text)]
|
|
24
23
|
[?b :block/uid ?block-uid]
|
|
25
24
|
[?b :block/page ?p]
|
|
26
25
|
[?p :node/title ?page-title]]`;
|
|
27
26
|
const queryParams = [text];
|
|
28
27
|
const results = await q(this.graph, queryStr, queryParams);
|
|
29
|
-
const searchDescription = `containing "${text}"
|
|
28
|
+
const searchDescription = `containing "${text}"`;
|
|
30
29
|
return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
|
|
31
30
|
}
|
|
32
31
|
}
|
|
@@ -5,7 +5,6 @@ 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/tool-handlers.js';
|
|
8
|
-
import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../search/index.js';
|
|
9
8
|
export class RoamServer {
|
|
10
9
|
server;
|
|
11
10
|
toolHandlers;
|
|
@@ -22,6 +21,8 @@ export class RoamServer {
|
|
|
22
21
|
}, {
|
|
23
22
|
capabilities: {
|
|
24
23
|
tools: {
|
|
24
|
+
roam_remember: {},
|
|
25
|
+
roam_recall: {},
|
|
25
26
|
roam_add_todo: {},
|
|
26
27
|
roam_fetch_page_by_title: {},
|
|
27
28
|
roam_create_page: {},
|
|
@@ -57,6 +58,13 @@ export class RoamServer {
|
|
|
57
58
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
58
59
|
try {
|
|
59
60
|
switch (request.params.name) {
|
|
61
|
+
case 'roam_remember': {
|
|
62
|
+
const { memory, categories } = request.params.arguments;
|
|
63
|
+
const result = await this.toolHandlers.remember(memory, categories);
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
60
68
|
case 'roam_fetch_page_by_title': {
|
|
61
69
|
const { title } = request.params.arguments;
|
|
62
70
|
const content = await this.toolHandlers.fetchPageByTitle(title);
|
|
@@ -100,9 +108,8 @@ export class RoamServer {
|
|
|
100
108
|
};
|
|
101
109
|
}
|
|
102
110
|
case 'roam_search_for_tag': {
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const result = await handler.execute();
|
|
111
|
+
const { primary_tag, page_title_uid, near_tag } = request.params.arguments;
|
|
112
|
+
const result = await this.toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
|
|
106
113
|
return {
|
|
107
114
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
108
115
|
};
|
|
@@ -116,16 +123,14 @@ export class RoamServer {
|
|
|
116
123
|
}
|
|
117
124
|
case 'roam_search_block_refs': {
|
|
118
125
|
const params = request.params.arguments;
|
|
119
|
-
const
|
|
120
|
-
const result = await handler.execute();
|
|
126
|
+
const result = await this.toolHandlers.searchBlockRefs(params);
|
|
121
127
|
return {
|
|
122
128
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
case 'roam_search_hierarchy': {
|
|
126
132
|
const params = request.params.arguments;
|
|
127
|
-
const
|
|
128
|
-
const result = await handler.execute();
|
|
133
|
+
const result = await this.toolHandlers.searchHierarchy(params);
|
|
129
134
|
return {
|
|
130
135
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
131
136
|
};
|
|
@@ -138,8 +143,7 @@ export class RoamServer {
|
|
|
138
143
|
}
|
|
139
144
|
case 'roam_search_by_text': {
|
|
140
145
|
const params = request.params.arguments;
|
|
141
|
-
const
|
|
142
|
-
const result = await handler.execute();
|
|
146
|
+
const result = await this.toolHandlers.searchByText(params);
|
|
143
147
|
return {
|
|
144
148
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
145
149
|
};
|
|
@@ -167,6 +171,12 @@ export class RoamServer {
|
|
|
167
171
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
168
172
|
};
|
|
169
173
|
}
|
|
174
|
+
case 'roam_recall': {
|
|
175
|
+
const result = await this.toolHandlers.recall();
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
170
180
|
case 'roam_update_blocks': {
|
|
171
181
|
const { updates } = request.params.arguments;
|
|
172
182
|
const result = await this.toolHandlers.updateBlocks(updates);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Collects all referenced block UIDs from text
|
|
4
|
+
*/
|
|
5
|
+
export const collectRefs = (text, depth = 0, refs = new Set()) => {
|
|
6
|
+
if (depth >= 4)
|
|
7
|
+
return refs; // Max recursion depth
|
|
8
|
+
const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
|
|
9
|
+
let match;
|
|
10
|
+
while ((match = refRegex.exec(text)) !== null) {
|
|
11
|
+
const [_, uid] = match;
|
|
12
|
+
refs.add(uid);
|
|
13
|
+
}
|
|
14
|
+
return refs;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Resolves block references in text by replacing them with their content
|
|
18
|
+
*/
|
|
19
|
+
export const resolveRefs = async (graph, text, depth = 0) => {
|
|
20
|
+
if (depth >= 4)
|
|
21
|
+
return text; // Max recursion depth
|
|
22
|
+
const refs = collectRefs(text, depth);
|
|
23
|
+
if (refs.size === 0)
|
|
24
|
+
return text;
|
|
25
|
+
// Get referenced block contents
|
|
26
|
+
const refQuery = `[:find ?uid ?string
|
|
27
|
+
:in $ [?uid ...]
|
|
28
|
+
:where [?b :block/uid ?uid]
|
|
29
|
+
[?b :block/string ?string]]`;
|
|
30
|
+
const refResults = await q(graph, refQuery, [Array.from(refs)]);
|
|
31
|
+
// Create lookup map of uid -> string
|
|
32
|
+
const refMap = new Map();
|
|
33
|
+
refResults.forEach(([uid, string]) => {
|
|
34
|
+
refMap.set(uid, string);
|
|
35
|
+
});
|
|
36
|
+
// Replace references with their content
|
|
37
|
+
let resolvedText = text;
|
|
38
|
+
for (const uid of refs) {
|
|
39
|
+
const refContent = refMap.get(uid);
|
|
40
|
+
if (refContent) {
|
|
41
|
+
// Recursively resolve nested references
|
|
42
|
+
const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
|
|
43
|
+
resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resolvedText;
|
|
47
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
|
+
import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
5
|
+
export class BlockOperations {
|
|
6
|
+
graph;
|
|
7
|
+
constructor(graph) {
|
|
8
|
+
this.graph = graph;
|
|
9
|
+
}
|
|
10
|
+
async createBlock(content, page_uid, title) {
|
|
11
|
+
// If page_uid provided, use it directly
|
|
12
|
+
let targetPageUid = page_uid;
|
|
13
|
+
// If no page_uid but title provided, search for page by title
|
|
14
|
+
if (!targetPageUid && title) {
|
|
15
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
16
|
+
const findResults = await q(this.graph, findQuery, [title]);
|
|
17
|
+
if (findResults && findResults.length > 0) {
|
|
18
|
+
targetPageUid = findResults[0][0];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
// Create page with provided title if it doesn't exist
|
|
22
|
+
try {
|
|
23
|
+
await createPage(this.graph, {
|
|
24
|
+
action: 'create-page',
|
|
25
|
+
page: { title }
|
|
26
|
+
});
|
|
27
|
+
// Get the new page's UID
|
|
28
|
+
const results = await q(this.graph, findQuery, [title]);
|
|
29
|
+
if (!results || results.length === 0) {
|
|
30
|
+
throw new Error('Could not find created page');
|
|
31
|
+
}
|
|
32
|
+
targetPageUid = results[0][0];
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// If neither page_uid nor title provided, use today's date page
|
|
40
|
+
if (!targetPageUid) {
|
|
41
|
+
const today = new Date();
|
|
42
|
+
const dateStr = formatRoamDate(today);
|
|
43
|
+
// Try to find today's page
|
|
44
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
45
|
+
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
46
|
+
if (findResults && findResults.length > 0) {
|
|
47
|
+
targetPageUid = findResults[0][0];
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Create today's page if it doesn't exist
|
|
51
|
+
try {
|
|
52
|
+
await createPage(this.graph, {
|
|
53
|
+
action: 'create-page',
|
|
54
|
+
page: { title: dateStr }
|
|
55
|
+
});
|
|
56
|
+
// Get the new page's UID
|
|
57
|
+
const results = await q(this.graph, findQuery, [dateStr]);
|
|
58
|
+
if (!results || results.length === 0) {
|
|
59
|
+
throw new Error('Could not find created today\'s page');
|
|
60
|
+
}
|
|
61
|
+
targetPageUid = results[0][0];
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
// If the content has multiple lines or is a table, use nested import
|
|
70
|
+
if (content.includes('\n')) {
|
|
71
|
+
// Parse and import the nested content
|
|
72
|
+
const convertedContent = convertToRoamMarkdown(content);
|
|
73
|
+
const nodes = parseMarkdown(convertedContent);
|
|
74
|
+
const actions = convertToRoamActions(nodes, targetPageUid, 'last');
|
|
75
|
+
// Execute batch actions to create the nested structure
|
|
76
|
+
const result = await batchActions(this.graph, {
|
|
77
|
+
action: 'batch-actions',
|
|
78
|
+
actions
|
|
79
|
+
});
|
|
80
|
+
if (!result) {
|
|
81
|
+
throw new Error('Failed to create nested blocks');
|
|
82
|
+
}
|
|
83
|
+
const blockUid = result.created_uids?.[0];
|
|
84
|
+
return {
|
|
85
|
+
success: true,
|
|
86
|
+
block_uid: blockUid,
|
|
87
|
+
parent_uid: targetPageUid
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// For non-table content, create a simple block
|
|
92
|
+
await createRoamBlock(this.graph, {
|
|
93
|
+
action: 'create-block',
|
|
94
|
+
location: {
|
|
95
|
+
"parent-uid": targetPageUid,
|
|
96
|
+
"order": "last"
|
|
97
|
+
},
|
|
98
|
+
block: { string: content }
|
|
99
|
+
});
|
|
100
|
+
// Get the block's UID
|
|
101
|
+
const findBlockQuery = `[:find ?uid
|
|
102
|
+
:in $ ?parent ?string
|
|
103
|
+
:where [?b :block/uid ?uid]
|
|
104
|
+
[?b :block/string ?string]
|
|
105
|
+
[?b :block/parents ?p]
|
|
106
|
+
[?p :block/uid ?parent]]`;
|
|
107
|
+
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
|
|
108
|
+
if (!blockResults || blockResults.length === 0) {
|
|
109
|
+
throw new Error('Could not find created block');
|
|
110
|
+
}
|
|
111
|
+
const blockUid = blockResults[0][0];
|
|
112
|
+
return {
|
|
113
|
+
success: true,
|
|
114
|
+
block_uid: blockUid,
|
|
115
|
+
parent_uid: targetPageUid
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create block: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async updateBlock(block_uid, content, transform) {
|
|
124
|
+
if (!block_uid) {
|
|
125
|
+
throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
|
|
126
|
+
}
|
|
127
|
+
// Get current block content
|
|
128
|
+
const blockQuery = `[:find ?string .
|
|
129
|
+
:where [?b :block/uid "${block_uid}"]
|
|
130
|
+
[?b :block/string ?string]]`;
|
|
131
|
+
const result = await q(this.graph, blockQuery, []);
|
|
132
|
+
if (result === null || result === undefined) {
|
|
133
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
134
|
+
}
|
|
135
|
+
const currentContent = String(result);
|
|
136
|
+
if (currentContent === null || currentContent === undefined) {
|
|
137
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
138
|
+
}
|
|
139
|
+
// Determine new content
|
|
140
|
+
let newContent;
|
|
141
|
+
if (content) {
|
|
142
|
+
newContent = content;
|
|
143
|
+
}
|
|
144
|
+
else if (transform) {
|
|
145
|
+
newContent = transform(currentContent);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await updateRoamBlock(this.graph, {
|
|
152
|
+
action: 'update-block',
|
|
153
|
+
block: {
|
|
154
|
+
uid: block_uid,
|
|
155
|
+
string: newContent
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
success: true,
|
|
160
|
+
content: newContent
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async updateBlocks(updates) {
|
|
168
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
169
|
+
throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
|
|
170
|
+
}
|
|
171
|
+
// Validate each update has required fields
|
|
172
|
+
updates.forEach((update, index) => {
|
|
173
|
+
if (!update.block_uid) {
|
|
174
|
+
throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
|
|
175
|
+
}
|
|
176
|
+
if (!update.content && !update.transform) {
|
|
177
|
+
throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// Get current content for all blocks
|
|
181
|
+
const blockUids = updates.map(u => u.block_uid);
|
|
182
|
+
const blockQuery = `[:find ?uid ?string
|
|
183
|
+
:in $ [?uid ...]
|
|
184
|
+
:where [?b :block/uid ?uid]
|
|
185
|
+
[?b :block/string ?string]]`;
|
|
186
|
+
const blockResults = await q(this.graph, blockQuery, [blockUids]);
|
|
187
|
+
// Create map of uid -> current content
|
|
188
|
+
const contentMap = new Map();
|
|
189
|
+
blockResults.forEach(([uid, string]) => {
|
|
190
|
+
contentMap.set(uid, string);
|
|
191
|
+
});
|
|
192
|
+
// Prepare batch actions
|
|
193
|
+
const actions = [];
|
|
194
|
+
const results = [];
|
|
195
|
+
for (const update of updates) {
|
|
196
|
+
try {
|
|
197
|
+
const currentContent = contentMap.get(update.block_uid);
|
|
198
|
+
if (!currentContent) {
|
|
199
|
+
results.push({
|
|
200
|
+
block_uid: update.block_uid,
|
|
201
|
+
content: '',
|
|
202
|
+
success: false,
|
|
203
|
+
error: `Block with UID "${update.block_uid}" not found`
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Determine new content
|
|
208
|
+
let newContent;
|
|
209
|
+
if (update.content) {
|
|
210
|
+
newContent = update.content;
|
|
211
|
+
}
|
|
212
|
+
else if (update.transform) {
|
|
213
|
+
const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
|
|
214
|
+
newContent = currentContent.replace(regex, update.transform.replace);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// This shouldn't happen due to earlier validation
|
|
218
|
+
throw new Error('Invalid update configuration');
|
|
219
|
+
}
|
|
220
|
+
// Add to batch actions
|
|
221
|
+
actions.push({
|
|
222
|
+
action: 'update-block',
|
|
223
|
+
block: {
|
|
224
|
+
uid: update.block_uid,
|
|
225
|
+
string: newContent
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
results.push({
|
|
229
|
+
block_uid: update.block_uid,
|
|
230
|
+
content: newContent,
|
|
231
|
+
success: true
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
results.push({
|
|
236
|
+
block_uid: update.block_uid,
|
|
237
|
+
content: contentMap.get(update.block_uid) || '',
|
|
238
|
+
success: false,
|
|
239
|
+
error: error.message
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Execute batch update if we have any valid actions
|
|
244
|
+
if (actions.length > 0) {
|
|
245
|
+
try {
|
|
246
|
+
const batchResult = await batchActions(this.graph, {
|
|
247
|
+
action: 'batch-actions',
|
|
248
|
+
actions
|
|
249
|
+
});
|
|
250
|
+
if (!batchResult) {
|
|
251
|
+
throw new Error('Batch update failed');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
// Mark all previously successful results as failed
|
|
256
|
+
results.forEach(result => {
|
|
257
|
+
if (result.success) {
|
|
258
|
+
result.success = false;
|
|
259
|
+
result.error = `Batch update failed: ${error.message}`;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
success: results.every(r => r.success),
|
|
266
|
+
results
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|