roam-research-mcp 0.32.0 → 0.35.1
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 +11 -1
- package/build/Roam_Markdown_Cheatsheet.md +7 -1
- package/build/markdown-utils.js +37 -47
- package/build/tools/helpers/text.js +59 -0
- package/build/tools/operations/block-retrieval.js +1 -1
- package/build/tools/operations/outline.js +257 -127
- package/build/tools/operations/pages.js +43 -1
- package/build/tools/schemas.js +5 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ docker run -p 3000:3000 -p 8088:8088 -p 8087:8087 \
|
|
|
73
73
|
-e ROAM_API_TOKEN="your-api-token" \
|
|
74
74
|
-e ROAM_GRAPH_NAME="your-graph-name" \
|
|
75
75
|
-e MEMORIES_TAG="#[[LLM/Memories]]" \
|
|
76
|
+
-e CUSTOM_INSTRUCTIONS_PATH="/path/to/your/custom_instructions_file.md" \
|
|
76
77
|
-e HTTP_STREAM_PORT="8088" \
|
|
77
78
|
-e SSE_PORT="8087" \
|
|
78
79
|
roam-research-mcp
|
|
@@ -106,10 +107,11 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
106
107
|
- Efficient batch operations
|
|
107
108
|
- Hierarchical outline creation
|
|
108
109
|
- Enhanced documentation for Roam Tables in `Roam_Markdown_Cheatsheet.md` for clearer guidance on nesting.
|
|
110
|
+
- Custom instruction appended to the cheat sheet about your specific Roam notes.
|
|
109
111
|
|
|
110
112
|
1. `roam_fetch_page_by_title`: Fetch page content by title. Returns content in the specified format.
|
|
111
113
|
2. `roam_fetch_block_with_children`: Fetch a block by its UID along with its hierarchical children down to a specified depth. Automatically handles `((UID))` formatting.
|
|
112
|
-
3. `roam_create_page`: Create new pages with optional content and headings.
|
|
114
|
+
3. `roam_create_page`: Create new pages with optional content and headings. Now creates a block on the daily page linking to the newly created page.
|
|
113
115
|
4. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
|
|
114
116
|
5. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
|
|
115
117
|
6. `roam_create_outline`: Add a structured outline to an existing page or block, with support for `children_view_type`. Best for simpler, sequential outlines. For complex nesting (e.g., tables), consider `roam_process_batch_actions`. If `page_title_uid` and `block_text_uid` are both blank, content defaults to the daily page. (Internally uses `roam_process_batch_actions`.)
|
|
@@ -242,6 +244,7 @@ This demonstrates moving a block from one location to another and simultaneously
|
|
|
242
244
|
ROAM_API_TOKEN=your-api-token
|
|
243
245
|
ROAM_GRAPH_NAME=your-graph-name
|
|
244
246
|
MEMORIES_TAG='#[[LLM/Memories]]'
|
|
247
|
+
CUSTOM_INSTRUCTIONS_PATH='/path/to/your/custom_instructions_file.md'
|
|
245
248
|
HTTP_STREAM_PORT=8088 # Or your desired port for HTTP Stream communication
|
|
246
249
|
SSE_PORT=8087 # Or your desired port for SSE communication
|
|
247
250
|
```
|
|
@@ -262,6 +265,7 @@ This demonstrates moving a block from one location to another and simultaneously
|
|
|
262
265
|
"ROAM_API_TOKEN": "your-api-token",
|
|
263
266
|
"ROAM_GRAPH_NAME": "your-graph-name",
|
|
264
267
|
"MEMORIES_TAG": "#[[LLM/Memories]]",
|
|
268
|
+
"CUSTOM_INSTRUCTIONS_PATH": "/path/to/your/custom_instructions_file.md",
|
|
265
269
|
"HTTP_STREAM_PORT": "8088",
|
|
266
270
|
"SSE_PORT": "8087"
|
|
267
271
|
}
|
|
@@ -347,3 +351,9 @@ This will:
|
|
|
347
351
|
## License
|
|
348
352
|
|
|
349
353
|
MIT License
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## About the Author
|
|
358
|
+
|
|
359
|
+
This project is maintained by [Ian Shen](https://github.com/2b3pro).
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
- {{[[TODO]]}} todo text
|
|
16
16
|
- {{[[DONE]]}} todo text
|
|
17
17
|
- LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$`
|
|
18
|
+
- Bullet points use dashes not asterisks.
|
|
18
19
|
|
|
19
20
|
## Roam-specific Markdown:
|
|
20
21
|
|
|
@@ -112,7 +113,12 @@ The provided markdown structure represents a Roam Research Kanban board. It star
|
|
|
112
113
|
This markdown structure allows embedding custom HTML or other content using Hiccup syntax. The `:hiccup` keyword is followed by a Clojure-like vector defining the HTML elements and their attributes in one block. This provides a powerful way to inject dynamic or custom components into your Roam graph. Example: `:hiccup [:iframe {:width "600" :height "400" :src "https://www.example.com"}]`
|
|
113
114
|
|
|
114
115
|
## Specific notes and preferences concerning my Roam Research graph
|
|
116
|
+
### What To Tag
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
NONE
|
|
119
|
+
|
|
120
|
+
### Don't Include…
|
|
121
|
+
|
|
122
|
+
NONE
|
|
117
123
|
|
|
118
124
|
⭐️📋 END (Cheat Sheet LOADED) < < < 📋⭐️
|
package/build/markdown-utils.js
CHANGED
|
@@ -75,73 +75,69 @@ function convertToRoamMarkdown(text) {
|
|
|
75
75
|
return text;
|
|
76
76
|
}
|
|
77
77
|
function parseMarkdown(markdown) {
|
|
78
|
-
// Convert markdown syntax first
|
|
79
78
|
markdown = convertToRoamMarkdown(markdown);
|
|
80
|
-
const
|
|
79
|
+
const originalLines = markdown.split('\n');
|
|
80
|
+
const processedLines = [];
|
|
81
|
+
// Pre-process lines to handle mid-line code blocks without splice
|
|
82
|
+
for (const line of originalLines) {
|
|
83
|
+
const trimmedLine = line.trimEnd();
|
|
84
|
+
const codeStartIndex = trimmedLine.indexOf('```');
|
|
85
|
+
if (codeStartIndex > 0) {
|
|
86
|
+
const indentationWhitespace = line.match(/^\s*/)?.[0] ?? '';
|
|
87
|
+
processedLines.push(indentationWhitespace + trimmedLine.substring(0, codeStartIndex));
|
|
88
|
+
processedLines.push(indentationWhitespace + trimmedLine.substring(codeStartIndex));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
processedLines.push(line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
81
94
|
const rootNodes = [];
|
|
82
95
|
const stack = [];
|
|
83
96
|
let inCodeBlock = false;
|
|
84
97
|
let codeBlockContent = '';
|
|
85
98
|
let codeBlockIndentation = 0;
|
|
86
99
|
let codeBlockParentLevel = 0;
|
|
87
|
-
for (let i = 0; i <
|
|
88
|
-
const line =
|
|
100
|
+
for (let i = 0; i < processedLines.length; i++) {
|
|
101
|
+
const line = processedLines[i];
|
|
89
102
|
const trimmedLine = line.trimEnd();
|
|
90
|
-
// Handle code blocks
|
|
91
103
|
if (trimmedLine.match(/^(\s*)```/)) {
|
|
92
104
|
if (!inCodeBlock) {
|
|
93
|
-
// Start of code block
|
|
94
105
|
inCodeBlock = true;
|
|
95
|
-
// Store the opening backticks without indentation
|
|
96
106
|
codeBlockContent = trimmedLine.trimStart() + '\n';
|
|
97
107
|
codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0;
|
|
98
|
-
// Save current parent level
|
|
99
108
|
codeBlockParentLevel = stack.length;
|
|
100
109
|
}
|
|
101
110
|
else {
|
|
102
|
-
// End of code block
|
|
103
111
|
inCodeBlock = false;
|
|
104
|
-
// Add closing backticks without indentation
|
|
105
112
|
codeBlockContent += trimmedLine.trimStart();
|
|
106
|
-
|
|
107
|
-
const lines = codeBlockContent.split('\n');
|
|
108
|
-
// Find the first non-empty code line to determine base indentation
|
|
113
|
+
const linesInCodeBlock = codeBlockContent.split('\n');
|
|
109
114
|
let baseIndentation = '';
|
|
110
|
-
let
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const indentMatch = line.match(/^[\t ]*/);
|
|
115
|
+
for (let j = 1; j < linesInCodeBlock.length - 1; j++) {
|
|
116
|
+
const codeLine = linesInCodeBlock[j];
|
|
117
|
+
if (codeLine.trim().length > 0) {
|
|
118
|
+
const indentMatch = codeLine.match(/^[\t ]*/);
|
|
115
119
|
if (indentMatch) {
|
|
116
120
|
baseIndentation = indentMatch[0];
|
|
117
|
-
codeStartIndex = i;
|
|
118
121
|
break;
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
return line.trimStart();
|
|
127
|
-
// Empty lines should be completely trimmed
|
|
128
|
-
if (line.trim().length === 0)
|
|
125
|
+
const processedCodeLines = linesInCodeBlock.map((codeLine, index) => {
|
|
126
|
+
if (index === 0 || index === linesInCodeBlock.length - 1)
|
|
127
|
+
return codeLine.trimStart();
|
|
128
|
+
if (codeLine.trim().length === 0)
|
|
129
129
|
return '';
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return line.slice(baseIndentation.length);
|
|
130
|
+
if (codeLine.startsWith(baseIndentation)) {
|
|
131
|
+
return codeLine.slice(baseIndentation.length);
|
|
133
132
|
}
|
|
134
|
-
|
|
135
|
-
return line.trimStart();
|
|
133
|
+
return codeLine.trimStart();
|
|
136
134
|
});
|
|
137
|
-
// Create node for the entire code block
|
|
138
135
|
const level = Math.floor(codeBlockIndentation / 2);
|
|
139
136
|
const node = {
|
|
140
|
-
content:
|
|
137
|
+
content: processedCodeLines.join('\n'),
|
|
141
138
|
level,
|
|
142
139
|
children: []
|
|
143
140
|
};
|
|
144
|
-
// Restore to code block's parent level
|
|
145
141
|
while (stack.length > codeBlockParentLevel) {
|
|
146
142
|
stack.pop();
|
|
147
143
|
}
|
|
@@ -169,37 +165,30 @@ function parseMarkdown(markdown) {
|
|
|
169
165
|
codeBlockContent += line + '\n';
|
|
170
166
|
continue;
|
|
171
167
|
}
|
|
172
|
-
// Skip truly empty lines (no spaces)
|
|
173
168
|
if (trimmedLine === '') {
|
|
174
169
|
continue;
|
|
175
170
|
}
|
|
176
|
-
// Calculate indentation level (2 spaces = 1 level)
|
|
177
171
|
const indentation = line.match(/^\s*/)?.[0].length ?? 0;
|
|
178
172
|
let level = Math.floor(indentation / 2);
|
|
179
173
|
let contentToParse;
|
|
180
174
|
const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
|
|
181
175
|
if (bulletMatch) {
|
|
182
|
-
// If it's a bullet point, adjust level based on bullet indentation
|
|
183
176
|
level = Math.floor(bulletMatch[1].length / 2);
|
|
184
|
-
contentToParse = trimmedLine.substring(bulletMatch[0].length);
|
|
177
|
+
contentToParse = trimmedLine.substring(bulletMatch[0].length);
|
|
185
178
|
}
|
|
186
179
|
else {
|
|
187
|
-
contentToParse = trimmedLine;
|
|
180
|
+
contentToParse = trimmedLine;
|
|
188
181
|
}
|
|
189
|
-
// Now, from the content after bullet/initial indentation, check for heading
|
|
190
182
|
const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse);
|
|
191
|
-
// Create node
|
|
192
183
|
const node = {
|
|
193
|
-
content: finalContent,
|
|
194
|
-
level,
|
|
184
|
+
content: finalContent,
|
|
185
|
+
level,
|
|
195
186
|
...(heading_level > 0 && { heading_level }),
|
|
196
187
|
children: []
|
|
197
188
|
};
|
|
198
|
-
// Pop stack until we find the parent level
|
|
199
189
|
while (stack.length > level) {
|
|
200
190
|
stack.pop();
|
|
201
191
|
}
|
|
202
|
-
// Add to appropriate parent
|
|
203
192
|
if (level === 0 || !stack[level - 1]) {
|
|
204
193
|
rootNodes.push(node);
|
|
205
194
|
stack[0] = node;
|
|
@@ -273,13 +262,14 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
273
262
|
const actions = [];
|
|
274
263
|
// Helper function to recursively create actions
|
|
275
264
|
function createBlockActions(blocks, parentUid, order) {
|
|
276
|
-
for (
|
|
265
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
266
|
+
const block = blocks[i];
|
|
277
267
|
// Create the current block
|
|
278
268
|
const action = {
|
|
279
269
|
action: 'create-block',
|
|
280
270
|
location: {
|
|
281
271
|
'parent-uid': parentUid,
|
|
282
|
-
order
|
|
272
|
+
order: typeof order === 'number' ? order + i : i
|
|
283
273
|
},
|
|
284
274
|
block: {
|
|
285
275
|
uid: block.uid,
|
|
@@ -1,6 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capitalizes each word in a string
|
|
3
|
+
*/
|
|
4
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
1
5
|
/**
|
|
2
6
|
* Capitalizes each word in a string
|
|
3
7
|
*/
|
|
4
8
|
export const capitalizeWords = (str) => {
|
|
5
9
|
return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
6
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Retrieves a block's UID based on its exact text content.
|
|
13
|
+
* This function is intended for internal use by other MCP tools.
|
|
14
|
+
* @param graph The Roam graph instance.
|
|
15
|
+
* @param blockText The exact text content of the block to find.
|
|
16
|
+
* @returns The UID of the block if found, otherwise null.
|
|
17
|
+
*/
|
|
18
|
+
export const getBlockUidByText = async (graph, blockText) => {
|
|
19
|
+
const query = `[:find ?uid .
|
|
20
|
+
:in $ ?blockString
|
|
21
|
+
:where [?b :block/string ?blockString]
|
|
22
|
+
[?b :block/uid ?uid]]`;
|
|
23
|
+
const result = await q(graph, query, [blockText]);
|
|
24
|
+
return result && result.length > 0 ? result[0][0] : null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Retrieves all UIDs nested under a given block_uid or block_text (exact match).
|
|
28
|
+
* This function is intended for internal use by other MCP tools.
|
|
29
|
+
* @param graph The Roam graph instance.
|
|
30
|
+
* @param rootIdentifier The UID or exact text content of the root block.
|
|
31
|
+
* @returns An array of UIDs of all descendant blocks, including the root block's UID.
|
|
32
|
+
*/
|
|
33
|
+
export const getNestedUids = async (graph, rootIdentifier) => {
|
|
34
|
+
let rootUid = rootIdentifier;
|
|
35
|
+
// If the rootIdentifier is not a UID (simple check for 9 alphanumeric characters), try to resolve it as block text
|
|
36
|
+
if (!rootIdentifier.match(/^[a-zA-Z0-9]{9}$/)) {
|
|
37
|
+
rootUid = await getBlockUidByText(graph, rootIdentifier);
|
|
38
|
+
}
|
|
39
|
+
if (!rootUid) {
|
|
40
|
+
return []; // No root block found
|
|
41
|
+
}
|
|
42
|
+
const query = `[:find ?child-uid
|
|
43
|
+
:in $ ?root-uid
|
|
44
|
+
:where
|
|
45
|
+
[?root-block :block/uid ?root-uid]
|
|
46
|
+
[?root-block :block/children ?child-block]
|
|
47
|
+
[?child-block :block/uid ?child-uid]]`;
|
|
48
|
+
const results = await q(graph, query, [rootUid]);
|
|
49
|
+
return results.map(r => r[0]);
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Retrieves all UIDs nested under a given block_text (exact match).
|
|
53
|
+
* This function is intended for internal use by other MCP tools.
|
|
54
|
+
* It strictly requires an exact text match for the root block.
|
|
55
|
+
* @param graph The Roam graph instance.
|
|
56
|
+
* @param blockText The exact text content of the root block.
|
|
57
|
+
* @returns An array of UIDs of all descendant blocks, including the root block's UID.
|
|
58
|
+
*/
|
|
59
|
+
export const getNestedUidsByText = async (graph, blockText) => {
|
|
60
|
+
const rootUid = await getBlockUidByText(graph, blockText);
|
|
61
|
+
if (!rootUid) {
|
|
62
|
+
return []; // No root block found with exact text match
|
|
63
|
+
}
|
|
64
|
+
return getNestedUids(graph, rootUid);
|
|
65
|
+
};
|
|
@@ -16,7 +16,7 @@ export class BlockRetrievalOperations {
|
|
|
16
16
|
const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading
|
|
17
17
|
:in $ [?parentUid ...]
|
|
18
18
|
:where [?parent :block/uid ?parentUid]
|
|
19
|
-
[?
|
|
19
|
+
[?parent :block/children ?child]
|
|
20
20
|
[?child :block/uid ?childUid]
|
|
21
21
|
[?child :block/string ?childString]
|
|
22
22
|
[?child :block/order ?childOrder]
|
|
@@ -6,6 +6,188 @@ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../.
|
|
|
6
6
|
export class OutlineOperations {
|
|
7
7
|
constructor(graph) {
|
|
8
8
|
this.graph = graph;
|
|
9
|
+
/**
|
|
10
|
+
* Helper function to check if string is a valid Roam UID (9 characters)
|
|
11
|
+
*/
|
|
12
|
+
this.isValidUid = (str) => {
|
|
13
|
+
return typeof str === 'string' && str.length === 9;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Helper function to find block with improved relationship checks
|
|
18
|
+
*/
|
|
19
|
+
async findBlockWithRetry(pageUid, blockString, maxRetries = 5, initialDelay = 1000) {
|
|
20
|
+
// Try multiple query strategies
|
|
21
|
+
const queries = [
|
|
22
|
+
// Strategy 1: Direct page and string match
|
|
23
|
+
`[:find ?b-uid ?order
|
|
24
|
+
:where [?p :block/uid "${pageUid}"]
|
|
25
|
+
[?b :block/page ?p]
|
|
26
|
+
[?b :block/string "${blockString}"]
|
|
27
|
+
[?b :block/order ?order]
|
|
28
|
+
[?b :block/uid ?b-uid]]`,
|
|
29
|
+
// Strategy 2: Parent-child relationship
|
|
30
|
+
`[:find ?b-uid ?order
|
|
31
|
+
:where [?p :block/uid "${pageUid}"]
|
|
32
|
+
[?b :block/parents ?p]
|
|
33
|
+
[?b :block/string "${blockString}"]
|
|
34
|
+
[?b :block/order ?order]
|
|
35
|
+
[?b :block/uid ?b-uid]]`,
|
|
36
|
+
// Strategy 3: Broader page relationship
|
|
37
|
+
`[:find ?b-uid ?order
|
|
38
|
+
:where [?p :block/uid "${pageUid}"]
|
|
39
|
+
[?b :block/page ?page]
|
|
40
|
+
[?p :block/page ?page]
|
|
41
|
+
[?b :block/string "${blockString}"]
|
|
42
|
+
[?b :block/order ?order]
|
|
43
|
+
[?b :block/uid ?b-uid]]`
|
|
44
|
+
];
|
|
45
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
46
|
+
// Try each query strategy
|
|
47
|
+
for (const queryStr of queries) {
|
|
48
|
+
const blockResults = await q(this.graph, queryStr, []);
|
|
49
|
+
if (blockResults && blockResults.length > 0) {
|
|
50
|
+
// Use the most recently created block
|
|
51
|
+
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
|
|
52
|
+
return sorted[0][0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Exponential backoff
|
|
56
|
+
const delay = initialDelay * Math.pow(2, retry);
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
58
|
+
}
|
|
59
|
+
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
|
|
60
|
+
}
|
|
61
|
+
;
|
|
62
|
+
/**
|
|
63
|
+
* Helper function to create and verify block with improved error handling
|
|
64
|
+
*/
|
|
65
|
+
async createAndVerifyBlock(content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) {
|
|
66
|
+
try {
|
|
67
|
+
// Initial delay before any operations
|
|
68
|
+
if (!isRetry) {
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, initialDelay));
|
|
70
|
+
}
|
|
71
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
72
|
+
console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
|
|
73
|
+
// Create block using batchActions
|
|
74
|
+
const batchResult = await batchActions(this.graph, {
|
|
75
|
+
action: 'batch-actions',
|
|
76
|
+
actions: [{
|
|
77
|
+
action: 'create-block',
|
|
78
|
+
location: {
|
|
79
|
+
'parent-uid': parentUid,
|
|
80
|
+
order: 'last'
|
|
81
|
+
},
|
|
82
|
+
block: { string: content }
|
|
83
|
+
}]
|
|
84
|
+
});
|
|
85
|
+
if (!batchResult) {
|
|
86
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
|
|
87
|
+
}
|
|
88
|
+
// Wait with exponential backoff
|
|
89
|
+
const delay = initialDelay * Math.pow(2, retry);
|
|
90
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
91
|
+
try {
|
|
92
|
+
// Try to find the block using our improved findBlockWithRetry
|
|
93
|
+
return await this.findBlockWithRetry(parentUid, content);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
97
|
+
// console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
|
|
98
|
+
if (retry === maxRetries - 1)
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// If this is already a retry, throw the error
|
|
106
|
+
if (isRetry)
|
|
107
|
+
throw error;
|
|
108
|
+
// Otherwise, try one more time with a clean slate
|
|
109
|
+
// console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
|
|
111
|
+
return this.createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
;
|
|
115
|
+
/**
|
|
116
|
+
* Helper function to fetch a block and its children recursively
|
|
117
|
+
*/
|
|
118
|
+
async fetchBlockWithChildren(blockUid, level = 1) {
|
|
119
|
+
const query = `
|
|
120
|
+
[:find ?childUid ?childString ?childOrder
|
|
121
|
+
:in $ ?parentUid
|
|
122
|
+
:where
|
|
123
|
+
[?parentEntity :block/uid ?parentUid]
|
|
124
|
+
[?parentEntity :block/children ?childEntity] ; This ensures direct children
|
|
125
|
+
[?childEntity :block/uid ?childUid]
|
|
126
|
+
[?childEntity :block/string ?childString]
|
|
127
|
+
[?childEntity :block/order ?childOrder]]
|
|
128
|
+
`;
|
|
129
|
+
const blockQuery = `
|
|
130
|
+
[:find ?string
|
|
131
|
+
:in $ ?uid
|
|
132
|
+
:where
|
|
133
|
+
[?e :block/uid ?uid]
|
|
134
|
+
[?e :block/string ?string]]
|
|
135
|
+
`;
|
|
136
|
+
try {
|
|
137
|
+
const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
|
|
138
|
+
if (!blockStringResult || blockStringResult.length === 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const text = blockStringResult[0][0];
|
|
142
|
+
const childrenResults = await q(this.graph, query, [blockUid]);
|
|
143
|
+
const children = [];
|
|
144
|
+
if (childrenResults && childrenResults.length > 0) {
|
|
145
|
+
// Sort children by order
|
|
146
|
+
const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
|
|
147
|
+
for (const childResult of sortedChildren) {
|
|
148
|
+
const childUid = childResult[0];
|
|
149
|
+
const nestedChild = await this.fetchBlockWithChildren(childUid, level + 1);
|
|
150
|
+
if (nestedChild) {
|
|
151
|
+
children.push(nestedChild);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// The order of the root block is not available from this query, so we set it to 0
|
|
156
|
+
return { uid: blockUid, text, level, order: 0, children: children.length > 0 ? children : undefined };
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
;
|
|
163
|
+
/**
|
|
164
|
+
* Recursively fetches a nested structure of blocks under a given root block UID.
|
|
165
|
+
*/
|
|
166
|
+
async fetchNestedStructure(rootUid) {
|
|
167
|
+
const query = `[:find ?child-uid ?child-string ?child-order
|
|
168
|
+
:in $ ?parent-uid
|
|
169
|
+
:where
|
|
170
|
+
[?parent :block/uid ?parent-uid]
|
|
171
|
+
[?parent :block/children ?child]
|
|
172
|
+
[?child :block/uid ?child-uid]
|
|
173
|
+
[?child :block/string ?child-string]
|
|
174
|
+
[?child :block/order ?child-order]]`;
|
|
175
|
+
const directChildrenResult = await q(this.graph, query, [rootUid]);
|
|
176
|
+
if (directChildrenResult.length === 0) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const nestedBlocks = [];
|
|
180
|
+
for (const [childUid, childString, childOrder] of directChildrenResult) {
|
|
181
|
+
const children = await this.fetchNestedStructure(childUid);
|
|
182
|
+
nestedBlocks.push({
|
|
183
|
+
uid: childUid,
|
|
184
|
+
text: childString,
|
|
185
|
+
level: 0, // Level is not easily determined here, so we set it to 0
|
|
186
|
+
children: children,
|
|
187
|
+
order: childOrder
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return nestedBlocks.sort((a, b) => a.order - b.order);
|
|
9
191
|
}
|
|
10
192
|
/**
|
|
11
193
|
* Creates an outline structure on a Roam Research page, optionally under a specific block.
|
|
@@ -21,6 +203,7 @@ export class OutlineOperations {
|
|
|
21
203
|
* no matching block is found, a new block with that text will be created
|
|
22
204
|
* on the page to serve as the parent. If a UID is provided and the block
|
|
23
205
|
* is not found, an error will be thrown.
|
|
206
|
+
* @returns An object containing success status, page UID, parent UID, and a nested array of created block UIDs.
|
|
24
207
|
*/
|
|
25
208
|
async createOutline(outline, page_title_uid, block_text_uid) {
|
|
26
209
|
// Validate input
|
|
@@ -85,104 +268,6 @@ export class OutlineOperations {
|
|
|
85
268
|
};
|
|
86
269
|
// Get or create the target page
|
|
87
270
|
const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
|
|
88
|
-
// Helper function to find block with improved relationship checks
|
|
89
|
-
const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
|
|
90
|
-
// Try multiple query strategies
|
|
91
|
-
const queries = [
|
|
92
|
-
// Strategy 1: Direct page and string match
|
|
93
|
-
`[:find ?b-uid ?order
|
|
94
|
-
:where [?p :block/uid "${pageUid}"]
|
|
95
|
-
[?b :block/page ?p]
|
|
96
|
-
[?b :block/string "${blockString}"]
|
|
97
|
-
[?b :block/order ?order]
|
|
98
|
-
[?b :block/uid ?b-uid]]`,
|
|
99
|
-
// Strategy 2: Parent-child relationship
|
|
100
|
-
`[:find ?b-uid ?order
|
|
101
|
-
:where [?p :block/uid "${pageUid}"]
|
|
102
|
-
[?b :block/parents ?p]
|
|
103
|
-
[?b :block/string "${blockString}"]
|
|
104
|
-
[?b :block/order ?order]
|
|
105
|
-
[?b :block/uid ?b-uid]]`,
|
|
106
|
-
// Strategy 3: Broader page relationship
|
|
107
|
-
`[:find ?b-uid ?order
|
|
108
|
-
:where [?p :block/uid "${pageUid}"]
|
|
109
|
-
[?b :block/page ?page]
|
|
110
|
-
[?p :block/page ?page]
|
|
111
|
-
[?b :block/string "${blockString}"]
|
|
112
|
-
[?b :block/order ?order]
|
|
113
|
-
[?b :block/uid ?b-uid]]`
|
|
114
|
-
];
|
|
115
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
116
|
-
// Try each query strategy
|
|
117
|
-
for (const queryStr of queries) {
|
|
118
|
-
const blockResults = await q(this.graph, queryStr, []);
|
|
119
|
-
if (blockResults && blockResults.length > 0) {
|
|
120
|
-
// Use the most recently created block
|
|
121
|
-
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
|
|
122
|
-
return sorted[0][0];
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
// Exponential backoff
|
|
126
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
127
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
128
|
-
console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
|
|
129
|
-
}
|
|
130
|
-
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
|
|
131
|
-
};
|
|
132
|
-
// Helper function to create and verify block with improved error handling
|
|
133
|
-
const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
|
|
134
|
-
try {
|
|
135
|
-
// Initial delay before any operations
|
|
136
|
-
if (!isRetry) {
|
|
137
|
-
await new Promise(resolve => setTimeout(resolve, initialDelay));
|
|
138
|
-
}
|
|
139
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
140
|
-
console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
|
|
141
|
-
// Create block using batchActions
|
|
142
|
-
const batchResult = await batchActions(this.graph, {
|
|
143
|
-
action: 'batch-actions',
|
|
144
|
-
actions: [{
|
|
145
|
-
action: 'create-block',
|
|
146
|
-
location: {
|
|
147
|
-
'parent-uid': parentUid,
|
|
148
|
-
order: 'last'
|
|
149
|
-
},
|
|
150
|
-
block: { string: content }
|
|
151
|
-
}]
|
|
152
|
-
});
|
|
153
|
-
if (!batchResult) {
|
|
154
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
|
|
155
|
-
}
|
|
156
|
-
// Wait with exponential backoff
|
|
157
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
158
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
159
|
-
try {
|
|
160
|
-
// Try to find the block using our improved findBlockWithRetry
|
|
161
|
-
return await findBlockWithRetry(parentUid, content);
|
|
162
|
-
}
|
|
163
|
-
catch (error) {
|
|
164
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
165
|
-
console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`);
|
|
166
|
-
if (retry === maxRetries - 1)
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
// If this is already a retry, throw the error
|
|
174
|
-
if (isRetry)
|
|
175
|
-
throw error;
|
|
176
|
-
// Otherwise, try one more time with a clean slate
|
|
177
|
-
console.log(`Retrying block creation for "${content}" with fresh attempt`);
|
|
178
|
-
await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
|
|
179
|
-
return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
// Helper function to check if string is a valid Roam UID (9 characters)
|
|
183
|
-
const isValidUid = (str) => {
|
|
184
|
-
return typeof str === 'string' && str.length === 9;
|
|
185
|
-
};
|
|
186
271
|
// Get or create the parent block
|
|
187
272
|
let targetParentUid;
|
|
188
273
|
if (!block_text_uid) {
|
|
@@ -190,7 +275,7 @@ export class OutlineOperations {
|
|
|
190
275
|
}
|
|
191
276
|
else {
|
|
192
277
|
try {
|
|
193
|
-
if (isValidUid(block_text_uid)) {
|
|
278
|
+
if (this.isValidUid(block_text_uid)) {
|
|
194
279
|
// First try to find block by UID
|
|
195
280
|
const uidQuery = `[:find ?uid
|
|
196
281
|
:where [?e :block/uid "${block_text_uid}"]
|
|
@@ -206,12 +291,12 @@ export class OutlineOperations {
|
|
|
206
291
|
}
|
|
207
292
|
else {
|
|
208
293
|
// Create header block and get its UID if not a valid UID
|
|
209
|
-
targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
|
|
294
|
+
targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid);
|
|
210
295
|
}
|
|
211
296
|
}
|
|
212
297
|
catch (error) {
|
|
213
298
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
214
|
-
throw new McpError(ErrorCode.InternalError, `Failed to ${isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
|
|
299
|
+
throw new McpError(ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
|
|
215
300
|
}
|
|
216
301
|
}
|
|
217
302
|
// Initialize result variable
|
|
@@ -235,7 +320,9 @@ export class OutlineOperations {
|
|
|
235
320
|
const indent = ' '.repeat(item.level - 1);
|
|
236
321
|
// If the item text starts with a markdown heading (e.g., #, ##, ###),
|
|
237
322
|
// treat it as a direct heading without adding a bullet or outline indentation.
|
|
238
|
-
|
|
323
|
+
// NEW CHANGE: Handle standalone code blocks - do not prepend bullet
|
|
324
|
+
const isCodeBlock = item.text?.startsWith('```') && item.text.endsWith('```') && item.text.includes('\n');
|
|
325
|
+
return isCodeBlock ? `${indent}${item.text?.trim()}` : `${indent}- ${item.text?.trim()}`;
|
|
239
326
|
})
|
|
240
327
|
.join('\n');
|
|
241
328
|
// Convert to Roam markdown format
|
|
@@ -243,13 +330,16 @@ export class OutlineOperations {
|
|
|
243
330
|
// Parse markdown into hierarchical structure
|
|
244
331
|
// We pass the original OutlineItem properties (heading, children_view_type)
|
|
245
332
|
// along with the parsed content to the nodes.
|
|
246
|
-
const nodes = parseMarkdown(convertedContent).map((node, index) =>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
333
|
+
const nodes = parseMarkdown(convertedContent).map((node, index) => {
|
|
334
|
+
const outlineItem = validOutline[index];
|
|
335
|
+
return {
|
|
336
|
+
...node,
|
|
337
|
+
...(outlineItem?.heading && { heading_level: outlineItem.heading }),
|
|
338
|
+
...(outlineItem?.children_view_type && { children_view_type: outlineItem.children_view_type })
|
|
339
|
+
};
|
|
340
|
+
});
|
|
251
341
|
// Convert nodes to batch actions
|
|
252
|
-
const actions = convertToRoamActions(nodes, targetParentUid, '
|
|
342
|
+
const actions = convertToRoamActions(nodes, targetParentUid, 'last');
|
|
253
343
|
if (actions.length === 0) {
|
|
254
344
|
throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
|
|
255
345
|
}
|
|
@@ -269,16 +359,35 @@ export class OutlineOperations {
|
|
|
269
359
|
throw error;
|
|
270
360
|
throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
|
|
271
361
|
}
|
|
272
|
-
//
|
|
273
|
-
const
|
|
362
|
+
// Post-creation verification to get actual UIDs for top-level blocks and their children
|
|
363
|
+
const createdBlocks = [];
|
|
364
|
+
// Only query for top-level blocks (level 1) based on the original outline input
|
|
365
|
+
const topLevelOutlineItems = validOutline.filter(item => item.level === 1);
|
|
366
|
+
for (const item of topLevelOutlineItems) {
|
|
367
|
+
try {
|
|
368
|
+
// Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
|
|
369
|
+
const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
|
|
370
|
+
if (foundUid) {
|
|
371
|
+
const nestedBlock = await this.fetchBlockWithChildren(foundUid);
|
|
372
|
+
if (nestedBlock) {
|
|
373
|
+
createdBlocks.push(nestedBlock);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
// This is a warning because even if one block fails to fetch, others might succeed.
|
|
379
|
+
// The error will be logged but not re-thrown to allow partial success reporting.
|
|
380
|
+
// console.warn(`Could not fetch nested block for "${item.text}": ${error.message}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
274
383
|
return {
|
|
275
384
|
success: true,
|
|
276
385
|
page_uid: targetPageUid,
|
|
277
386
|
parent_uid: targetParentUid,
|
|
278
|
-
created_uids:
|
|
387
|
+
created_uids: createdBlocks
|
|
279
388
|
};
|
|
280
389
|
}
|
|
281
|
-
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = '
|
|
390
|
+
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
|
|
282
391
|
// First get the page UID
|
|
283
392
|
let targetPageUid = page_uid;
|
|
284
393
|
if (!targetPageUid && page_title) {
|
|
@@ -325,15 +434,20 @@ export class OutlineOperations {
|
|
|
325
434
|
throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
|
|
326
435
|
}
|
|
327
436
|
// Find block by exact string match within the page
|
|
328
|
-
const findBlockQuery = `[:find ?uid
|
|
329
|
-
:
|
|
437
|
+
const findBlockQuery = `[:find ?b-uid
|
|
438
|
+
:in $ ?page-uid ?block-string
|
|
439
|
+
:where [?p :block/uid ?page-uid]
|
|
330
440
|
[?b :block/page ?p]
|
|
331
|
-
[?b :block/string
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
441
|
+
[?b :block/string ?block-string]
|
|
442
|
+
[?b :block/uid ?b-uid]]`;
|
|
443
|
+
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, parent_string]);
|
|
444
|
+
if (blockResults && blockResults.length > 0) {
|
|
445
|
+
targetParentUid = blockResults[0][0];
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
// If parent_string block doesn't exist, create it
|
|
449
|
+
targetParentUid = await this.createAndVerifyBlock(parent_string, targetPageUid);
|
|
335
450
|
}
|
|
336
|
-
targetParentUid = blockResults[0][0];
|
|
337
451
|
}
|
|
338
452
|
// If no parent specified, use page as parent
|
|
339
453
|
if (!targetParentUid) {
|
|
@@ -355,8 +469,8 @@ export class OutlineOperations {
|
|
|
355
469
|
if (!result) {
|
|
356
470
|
throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
|
|
357
471
|
}
|
|
358
|
-
//
|
|
359
|
-
const createdUids =
|
|
472
|
+
// After successful batch action, get all nested UIDs under the parent
|
|
473
|
+
const createdUids = await this.fetchNestedStructure(targetParentUid);
|
|
360
474
|
return {
|
|
361
475
|
success: true,
|
|
362
476
|
page_uid: targetPageUid,
|
|
@@ -370,26 +484,42 @@ export class OutlineOperations {
|
|
|
370
484
|
action: 'create-block',
|
|
371
485
|
location: {
|
|
372
486
|
"parent-uid": targetParentUid,
|
|
373
|
-
order
|
|
487
|
+
"order": order
|
|
374
488
|
},
|
|
375
489
|
block: { string: content }
|
|
376
490
|
}];
|
|
377
491
|
try {
|
|
378
|
-
|
|
492
|
+
await batchActions(this.graph, {
|
|
379
493
|
action: 'batch-actions',
|
|
380
494
|
actions
|
|
381
495
|
});
|
|
382
|
-
if (!result) {
|
|
383
|
-
throw new McpError(ErrorCode.InternalError, 'Failed to create content block via batch action');
|
|
384
|
-
}
|
|
385
496
|
}
|
|
386
497
|
catch (error) {
|
|
387
498
|
throw new McpError(ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}`);
|
|
388
499
|
}
|
|
500
|
+
// For single-line content, we still need to fetch the UID and construct a NestedBlock
|
|
501
|
+
const createdUids = [];
|
|
502
|
+
try {
|
|
503
|
+
const foundUid = await this.findBlockWithRetry(targetParentUid, content);
|
|
504
|
+
if (foundUid) {
|
|
505
|
+
createdUids.push({
|
|
506
|
+
uid: foundUid,
|
|
507
|
+
text: content,
|
|
508
|
+
level: 0,
|
|
509
|
+
order: 0,
|
|
510
|
+
children: []
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
// Log warning but don't re-throw, as the block might be created, just not immediately verifiable
|
|
516
|
+
// console.warn(`Could not verify single block creation for "${content}": ${error.message}`);
|
|
517
|
+
}
|
|
389
518
|
return {
|
|
390
519
|
success: true,
|
|
391
520
|
page_uid: targetPageUid,
|
|
392
|
-
parent_uid: targetParentUid
|
|
521
|
+
parent_uid: targetParentUid,
|
|
522
|
+
created_uids: createdUids
|
|
393
523
|
};
|
|
394
524
|
}
|
|
395
525
|
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { q, createPage as createRoamPage, batchActions } from '@roam-research/roam-api-sdk';
|
|
1
|
+
import { q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { capitalizeWords } from '../helpers/text.js';
|
|
4
4
|
import { resolveRefs } from '../helpers/refs.js';
|
|
5
5
|
import { convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
6
|
+
// Helper to get ordinal suffix for dates
|
|
7
|
+
function getOrdinalSuffix(day) {
|
|
8
|
+
if (day > 3 && day < 21)
|
|
9
|
+
return 'th'; // Handles 11th, 12th, 13th
|
|
10
|
+
switch (day % 10) {
|
|
11
|
+
case 1: return 'st';
|
|
12
|
+
case 2: return 'nd';
|
|
13
|
+
case 3: return 'rd';
|
|
14
|
+
default: return 'th';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
6
17
|
export class PageOperations {
|
|
7
18
|
constructor(graph) {
|
|
8
19
|
this.graph = graph;
|
|
@@ -124,6 +135,37 @@ export class PageOperations {
|
|
|
124
135
|
throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
|
|
125
136
|
}
|
|
126
137
|
}
|
|
138
|
+
// Add a link to the created page on today's daily page
|
|
139
|
+
try {
|
|
140
|
+
const today = new Date();
|
|
141
|
+
const day = today.getDate();
|
|
142
|
+
const month = today.toLocaleString('en-US', { month: 'long' });
|
|
143
|
+
const year = today.getFullYear();
|
|
144
|
+
const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
|
145
|
+
const dailyPageQuery = `[:find ?uid .
|
|
146
|
+
:where [?e :node/title "${formattedTodayTitle}"]
|
|
147
|
+
[?e :block/uid ?uid]]`;
|
|
148
|
+
const dailyPageResult = await q(this.graph, dailyPageQuery, []);
|
|
149
|
+
const dailyPageUid = dailyPageResult ? String(dailyPageResult) : null;
|
|
150
|
+
if (dailyPageUid) {
|
|
151
|
+
await createBlock(this.graph, {
|
|
152
|
+
action: 'create-block',
|
|
153
|
+
block: {
|
|
154
|
+
string: `Created page: [[${pageTitle}]]`
|
|
155
|
+
},
|
|
156
|
+
location: {
|
|
157
|
+
'parent-uid': dailyPageUid,
|
|
158
|
+
order: 'last'
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
console.warn(`Could not find daily page with title: ${formattedTodayTitle}. Link to created page not added.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.error(`Failed to add link to daily page: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
}
|
|
127
169
|
return { success: true, uid: pageUid };
|
|
128
170
|
}
|
|
129
171
|
async fetchPageByTitle(title, format = 'raw') {
|
package/build/tools/schemas.js
CHANGED
|
@@ -40,7 +40,7 @@ export const toolSchemas = {
|
|
|
40
40
|
},
|
|
41
41
|
roam_create_page: {
|
|
42
42
|
name: 'roam_create_page',
|
|
43
|
-
description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
43
|
+
description: 'Create a new standalone page in Roam with optional content, including structured outlines, using explicit nesting levels and headings (H1-H3). This is the preferred method for creating a new page with an outline in a single step. Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
44
44
|
inputSchema: {
|
|
45
45
|
type: 'object',
|
|
46
46
|
properties: {
|
|
@@ -80,7 +80,7 @@ export const toolSchemas = {
|
|
|
80
80
|
},
|
|
81
81
|
roam_create_outline: {
|
|
82
82
|
name: 'roam_create_outline',
|
|
83
|
-
description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
83
|
+
description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. To create a new page with an outline, use the `roam_create_page` tool instead. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
84
84
|
inputSchema: {
|
|
85
85
|
type: 'object',
|
|
86
86
|
properties: {
|
|
@@ -129,7 +129,7 @@ export const toolSchemas = {
|
|
|
129
129
|
},
|
|
130
130
|
roam_import_markdown: {
|
|
131
131
|
name: 'roam_import_markdown',
|
|
132
|
-
description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
132
|
+
description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page. If a `parent_string` is provided and the block does not exist, it will be created. Returns a nested structure of the created blocks.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
133
133
|
inputSchema: {
|
|
134
134
|
type: 'object',
|
|
135
135
|
properties: {
|
|
@@ -151,7 +151,7 @@ export const toolSchemas = {
|
|
|
151
151
|
},
|
|
152
152
|
parent_string: {
|
|
153
153
|
type: 'string',
|
|
154
|
-
description: 'Optional: Exact string content of
|
|
154
|
+
description: 'Optional: Exact string content of an existing parent block to add content under (used if parent_uid is not provided; requires page_uid or page_title). If the block does not exist, it will be created.'
|
|
155
155
|
},
|
|
156
156
|
order: {
|
|
157
157
|
type: 'string',
|
|
@@ -463,7 +463,7 @@ export const toolSchemas = {
|
|
|
463
463
|
},
|
|
464
464
|
roam_fetch_block_with_children: {
|
|
465
465
|
name: 'roam_fetch_block_with_children',
|
|
466
|
-
description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth.',
|
|
466
|
+
description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth. Returns a nested object structure containing the block\'s UID, text, order, and an array of its children.',
|
|
467
467
|
inputSchema: {
|
|
468
468
|
type: 'object',
|
|
469
469
|
properties: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roam-research-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for Roam Research API integration",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"build"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"build": "tsc &&
|
|
31
|
+
"build": "tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
|
|
32
32
|
"clean": "rm -rf build",
|
|
33
33
|
"watch": "tsc --watch",
|
|
34
34
|
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
|