roam-research-mcp 2.4.0 → 2.13.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.
Files changed (53) hide show
  1. package/README.md +175 -667
  2. package/build/Roam_Markdown_Cheatsheet.md +138 -289
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +3 -8
  6. package/build/cli/commands/get.js +478 -60
  7. package/build/cli/commands/refs.js +51 -31
  8. package/build/cli/commands/save.js +61 -10
  9. package/build/cli/commands/search.js +63 -58
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/commands/update.js +71 -28
  12. package/build/cli/utils/graph.js +6 -2
  13. package/build/cli/utils/input.js +10 -0
  14. package/build/cli/utils/output.js +28 -5
  15. package/build/cli/utils/sort-group.js +110 -0
  16. package/build/config/graph-registry.js +31 -13
  17. package/build/config/graph-registry.test.js +42 -5
  18. package/build/markdown-utils.js +114 -4
  19. package/build/markdown-utils.test.js +125 -0
  20. package/build/query/generator.js +330 -0
  21. package/build/query/index.js +149 -0
  22. package/build/query/parser.js +319 -0
  23. package/build/query/parser.test.js +389 -0
  24. package/build/query/types.js +4 -0
  25. package/build/search/ancestor-rule.js +14 -0
  26. package/build/search/block-ref-search.js +1 -5
  27. package/build/search/hierarchy-search.js +5 -12
  28. package/build/search/index.js +1 -0
  29. package/build/search/status-search.js +10 -9
  30. package/build/search/tag-search.js +8 -24
  31. package/build/search/text-search.js +70 -27
  32. package/build/search/types.js +13 -0
  33. package/build/search/utils.js +71 -2
  34. package/build/server/roam-server.js +4 -3
  35. package/build/shared/index.js +2 -0
  36. package/build/shared/page-validator.js +233 -0
  37. package/build/shared/page-validator.test.js +128 -0
  38. package/build/shared/staged-batch.js +144 -0
  39. package/build/tools/helpers/batch-utils.js +57 -0
  40. package/build/tools/helpers/page-resolution.js +136 -0
  41. package/build/tools/helpers/refs.js +68 -0
  42. package/build/tools/operations/batch.js +75 -3
  43. package/build/tools/operations/block-retrieval.js +15 -4
  44. package/build/tools/operations/block-retrieval.test.js +87 -0
  45. package/build/tools/operations/blocks.js +1 -288
  46. package/build/tools/operations/memory.js +32 -90
  47. package/build/tools/operations/outline.js +38 -156
  48. package/build/tools/operations/pages.js +169 -122
  49. package/build/tools/operations/todos.js +5 -37
  50. package/build/tools/schemas.js +20 -9
  51. package/build/tools/tool-handlers.js +4 -4
  52. package/build/utils/helpers.js +27 -0
  53. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Staged batch executor for Roam API.
3
+ *
4
+ * Solves the race condition where child blocks reference parent UIDs
5
+ * that are also being created in the same batch. By analyzing dependencies
6
+ * and executing in topological order (by level), we ensure parents exist
7
+ * before their children are created.
8
+ */
9
+ import { batchActions } from '@roam-research/roam-api-sdk';
10
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
11
+ /**
12
+ * Groups batch actions by dependency level.
13
+ *
14
+ * Level 0: Actions whose parent-uid is NOT created by another action in the batch
15
+ * Level 1: Actions whose parent-uid is created by a Level 0 action
16
+ * Level N: Actions whose parent-uid is created by a Level N-1 action
17
+ *
18
+ * @param actions - Flat array of batch actions
19
+ * @returns Array of action arrays, grouped by level
20
+ */
21
+ export function groupActionsByDependencyLevel(actions) {
22
+ if (actions.length === 0)
23
+ return [];
24
+ // Build set of UIDs being created in this batch
25
+ const createdUids = new Set();
26
+ for (const action of actions) {
27
+ if (action.action === 'create-block' && action.block?.uid) {
28
+ createdUids.add(action.block.uid);
29
+ }
30
+ }
31
+ // Build dependency map: action index -> parent action index (or -1 if external parent)
32
+ const dependsOn = new Map();
33
+ const uidToIndex = new Map();
34
+ // First pass: map UIDs to action indices
35
+ for (let i = 0; i < actions.length; i++) {
36
+ const action = actions[i];
37
+ if (action.action === 'create-block' && action.block?.uid) {
38
+ uidToIndex.set(action.block.uid, i);
39
+ }
40
+ }
41
+ // Second pass: determine dependencies
42
+ for (let i = 0; i < actions.length; i++) {
43
+ const action = actions[i];
44
+ const parentUid = action.location?.['parent-uid'];
45
+ if (parentUid && createdUids.has(parentUid)) {
46
+ // This action depends on another action in the batch
47
+ const parentIndex = uidToIndex.get(parentUid);
48
+ if (parentIndex !== undefined) {
49
+ dependsOn.set(i, parentIndex);
50
+ }
51
+ }
52
+ else {
53
+ // External parent (already exists in Roam)
54
+ dependsOn.set(i, -1);
55
+ }
56
+ }
57
+ // Calculate levels using BFS-like approach
58
+ const levels = new Map(); // action index -> level
59
+ // Initialize: actions with external parents are level 0
60
+ for (let i = 0; i < actions.length; i++) {
61
+ if (dependsOn.get(i) === -1) {
62
+ levels.set(i, 0);
63
+ }
64
+ }
65
+ // Propagate levels
66
+ let changed = true;
67
+ let iterations = 0;
68
+ const maxIterations = actions.length + 1; // Prevent infinite loops
69
+ while (changed && iterations < maxIterations) {
70
+ changed = false;
71
+ iterations++;
72
+ for (let i = 0; i < actions.length; i++) {
73
+ if (levels.has(i))
74
+ continue; // Already assigned
75
+ const parentIndex = dependsOn.get(i);
76
+ if (parentIndex !== undefined && parentIndex !== -1 && levels.has(parentIndex)) {
77
+ levels.set(i, levels.get(parentIndex) + 1);
78
+ changed = true;
79
+ }
80
+ }
81
+ }
82
+ // Check for unassigned actions (circular dependencies or missing parents)
83
+ for (let i = 0; i < actions.length; i++) {
84
+ if (!levels.has(i)) {
85
+ // This shouldn't happen with valid input, but handle gracefully
86
+ // Assign to level 0 as fallback
87
+ levels.set(i, 0);
88
+ }
89
+ }
90
+ // Group by level
91
+ const maxLevel = Math.max(...levels.values());
92
+ const grouped = [];
93
+ for (let level = 0; level <= maxLevel; level++) {
94
+ grouped[level] = [];
95
+ }
96
+ for (let i = 0; i < actions.length; i++) {
97
+ const level = levels.get(i) ?? 0;
98
+ grouped[level].push(actions[i]);
99
+ }
100
+ // Filter out empty levels
101
+ return grouped.filter(level => level.length > 0);
102
+ }
103
+ /**
104
+ * Executes batch actions in staged order, ensuring parent blocks exist
105
+ * before their children are created.
106
+ *
107
+ * @param graph - Roam graph connection
108
+ * @param actions - Flat array of batch actions
109
+ * @param options - Execution options
110
+ * @returns Promise that resolves when all actions are complete
111
+ */
112
+ export async function executeStagedBatch(graph, actions, options = {}) {
113
+ const { delayBetweenLevels = 100, context = 'batch operation' } = options;
114
+ if (actions.length === 0) {
115
+ return { success: true, levelsExecuted: 0, totalActions: 0 };
116
+ }
117
+ const actionsByLevel = groupActionsByDependencyLevel(actions);
118
+ for (let level = 0; level < actionsByLevel.length; level++) {
119
+ const levelActions = actionsByLevel[level];
120
+ if (levelActions.length === 0)
121
+ continue;
122
+ try {
123
+ const result = await batchActions(graph, {
124
+ action: 'batch-actions',
125
+ actions: levelActions
126
+ });
127
+ if (!result) {
128
+ throw new McpError(ErrorCode.InternalError, `Failed to execute ${context} at level ${level} - no result returned`);
129
+ }
130
+ }
131
+ catch (error) {
132
+ throw new McpError(ErrorCode.InternalError, `Failed to execute ${context} at level ${level}: ${error.message}`);
133
+ }
134
+ // Delay between levels to ensure parent blocks are committed
135
+ if (level < actionsByLevel.length - 1) {
136
+ await new Promise(resolve => setTimeout(resolve, delayBetweenLevels));
137
+ }
138
+ }
139
+ return {
140
+ success: true,
141
+ levelsExecuted: actionsByLevel.length,
142
+ totalActions: actions.length
143
+ };
144
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared utilities for batch operations with consistent error handling.
3
+ */
4
+ import { batchActions } from '@roam-research/roam-api-sdk';
5
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
6
+ /**
7
+ * Execute batch actions with consistent error handling.
8
+ * Wraps batchActions call in try-catch and throws McpError on failure.
9
+ *
10
+ * @param graph - The Roam graph instance
11
+ * @param actions - Array of batch actions to execute
12
+ * @param errorContext - Description of what operation failed (e.g., "create memory block")
13
+ * @returns The result from batchActions
14
+ * @throws McpError if the operation fails
15
+ */
16
+ export async function executeBatch(graph, actions, errorContext) {
17
+ try {
18
+ const result = await batchActions(graph, {
19
+ action: 'batch-actions',
20
+ actions
21
+ });
22
+ if (!result) {
23
+ throw new McpError(ErrorCode.InternalError, `Failed to ${errorContext}`);
24
+ }
25
+ return result;
26
+ }
27
+ catch (error) {
28
+ if (error instanceof McpError) {
29
+ throw error;
30
+ }
31
+ throw new McpError(ErrorCode.InternalError, `Failed to ${errorContext}: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ }
34
+ /**
35
+ * Execute batch actions without throwing on failure (for non-critical operations).
36
+ * Returns null on failure instead of throwing.
37
+ *
38
+ * @param graph - The Roam graph instance
39
+ * @param actions - Array of batch actions to execute
40
+ * @param errorContext - Description for logging on failure
41
+ * @returns The result from batchActions, or null on failure
42
+ */
43
+ export async function executeBatchSafe(graph, actions, errorContext) {
44
+ try {
45
+ const result = await batchActions(graph, {
46
+ action: 'batch-actions',
47
+ actions
48
+ });
49
+ return result || null;
50
+ }
51
+ catch (error) {
52
+ if (errorContext) {
53
+ console.error(`Failed to ${errorContext}: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared utilities for page UID resolution with caching and case-insensitive matching.
3
+ */
4
+ import { q, createPage } from '@roam-research/roam-api-sdk';
5
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
6
+ import { formatRoamDate } from '../../utils/helpers.js';
7
+ import { pageUidCache } from '../../cache/page-uid-cache.js';
8
+ import { capitalizeWords } from './text.js';
9
+ /**
10
+ * Find a page UID by title with case variation matching and caching.
11
+ * Tries: original, capitalized words, lowercase.
12
+ * @returns The page UID or null if not found
13
+ */
14
+ export async function getPageUid(graph, title) {
15
+ if (!title) {
16
+ return null;
17
+ }
18
+ const variations = [
19
+ title,
20
+ capitalizeWords(title),
21
+ title.toLowerCase()
22
+ ];
23
+ // Check cache first for any variation
24
+ for (const variation of variations) {
25
+ const cachedUid = pageUidCache.get(variation);
26
+ if (cachedUid) {
27
+ return cachedUid;
28
+ }
29
+ }
30
+ // Query database with OR clause for all variations
31
+ const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
32
+ const searchQuery = `[:find ?uid .
33
+ :where [?e :block/uid ?uid]
34
+ (or ${orClause})]`;
35
+ const result = await q(graph, searchQuery, []);
36
+ const uid = (result === null || result === undefined) ? null : String(result);
37
+ if (uid) {
38
+ pageUidCache.set(title, uid);
39
+ }
40
+ return uid;
41
+ }
42
+ /**
43
+ * Get or create today's daily page.
44
+ * @returns The page UID
45
+ */
46
+ export async function getOrCreateTodayPage(graph) {
47
+ const dateStr = formatRoamDate(new Date());
48
+ // Check cache first
49
+ const cachedUid = pageUidCache.get(dateStr);
50
+ if (cachedUid) {
51
+ return cachedUid;
52
+ }
53
+ // Try to find today's page
54
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
55
+ const findResults = await q(graph, findQuery, [dateStr]);
56
+ if (findResults && findResults.length > 0) {
57
+ const uid = findResults[0][0];
58
+ pageUidCache.set(dateStr, uid);
59
+ return uid;
60
+ }
61
+ // Create today's page
62
+ await createPage(graph, {
63
+ action: 'create-page',
64
+ page: { title: dateStr }
65
+ });
66
+ // Fetch the newly created page UID
67
+ const results = await q(graph, findQuery, [dateStr]);
68
+ if (!results || results.length === 0) {
69
+ throw new McpError(ErrorCode.InternalError, "Could not find created today's page");
70
+ }
71
+ const uid = results[0][0];
72
+ pageUidCache.onPageCreated(dateStr, uid);
73
+ return uid;
74
+ }
75
+ /**
76
+ * Find or create a page by title or UID with retries and caching.
77
+ * Tries case variations, checks if input is a UID, creates page if not found.
78
+ * @returns The page UID
79
+ */
80
+ export async function findOrCreatePage(graph, titleOrUid, options = {}) {
81
+ const { maxRetries = 3, delayMs = 500 } = options;
82
+ const variations = [
83
+ titleOrUid,
84
+ capitalizeWords(titleOrUid),
85
+ titleOrUid.toLowerCase()
86
+ ];
87
+ // Check cache first for any variation
88
+ for (const variation of variations) {
89
+ const cachedUid = pageUidCache.get(variation);
90
+ if (cachedUid) {
91
+ return cachedUid;
92
+ }
93
+ }
94
+ const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
95
+ for (let retry = 0; retry < maxRetries; retry++) {
96
+ // Try each case variation
97
+ for (const variation of variations) {
98
+ const findResults = await q(graph, titleQuery, [variation]);
99
+ if (findResults && findResults.length > 0) {
100
+ const uid = findResults[0][0];
101
+ pageUidCache.set(titleOrUid, uid);
102
+ return uid;
103
+ }
104
+ }
105
+ // If not found as title, try as UID
106
+ const uidQuery = `[:find ?uid
107
+ :where [?e :block/uid "${titleOrUid}"]
108
+ [?e :block/uid ?uid]]`;
109
+ const uidResult = await q(graph, uidQuery, []);
110
+ if (uidResult && uidResult.length > 0) {
111
+ return uidResult[0][0];
112
+ }
113
+ // If still not found and this is the first retry, try to create the page
114
+ if (retry === 0) {
115
+ await createPage(graph, {
116
+ action: 'create-page',
117
+ page: { title: titleOrUid }
118
+ });
119
+ await new Promise(resolve => setTimeout(resolve, delayMs));
120
+ continue;
121
+ }
122
+ if (retry < maxRetries - 1) {
123
+ await new Promise(resolve => setTimeout(resolve, delayMs));
124
+ }
125
+ }
126
+ // One more attempt to find after creation attempts
127
+ for (const variation of variations) {
128
+ const findResults = await q(graph, titleQuery, [variation]);
129
+ if (findResults && findResults.length > 0) {
130
+ const uid = findResults[0][0];
131
+ pageUidCache.onPageCreated(titleOrUid, uid);
132
+ return uid;
133
+ }
134
+ }
135
+ throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
136
+ }
@@ -64,3 +64,71 @@ export async function resolveRefs(graph, text, depth = 0, maxDepth = 4, seen = n
64
64
  return refMap.get(uid) ?? match;
65
65
  });
66
66
  }
67
+ /**
68
+ * Recursively resolves block references for a list of RoamBlocks.
69
+ * Attaches referenced blocks to the `refs` property of the referencing block.
70
+ *
71
+ * @param graph - Roam graph connection
72
+ * @param blocksToScan - List of blocks to scan for references
73
+ * @param remainingDepth - Maximum depth to resolve nested refs
74
+ */
75
+ export async function resolveBlockRefs(graph, blocksToScan, remainingDepth = 2) {
76
+ if (remainingDepth <= 0 || blocksToScan.length === 0) {
77
+ return;
78
+ }
79
+ const refMap = {}; // uid -> list of blocks referencing it
80
+ const allRefUids = new Set();
81
+ for (const block of blocksToScan) {
82
+ // Skip blocks without valid string content
83
+ if (typeof block.string !== 'string') {
84
+ continue;
85
+ }
86
+ // Reset lastIndex for REF_PATTERN reuse if using exec, or use matchAll
87
+ // matchAll is safer with global regex state
88
+ const matches = block.string.matchAll(REF_PATTERN);
89
+ for (const match of matches) {
90
+ const refUid = match[1];
91
+ if (!refMap[refUid]) {
92
+ refMap[refUid] = [];
93
+ }
94
+ refMap[refUid].push(block);
95
+ allRefUids.add(refUid);
96
+ }
97
+ }
98
+ if (allRefUids.size === 0) {
99
+ return;
100
+ }
101
+ const uidsToFetch = Array.from(allRefUids);
102
+ // Fetch referenced blocks content
103
+ const refsQuery = `[:find ?uid ?string ?heading
104
+ :in $ [?uid ...]
105
+ :where [?b :block/uid ?uid]
106
+ [?b :block/string ?string]
107
+ [(get-else $ ?b :block/heading 0) ?heading]]`;
108
+ const results = await q(graph, refsQuery, [uidsToFetch]);
109
+ const fetchedBlocks = [];
110
+ for (const [uid, string, heading] of results) {
111
+ const newBlock = {
112
+ uid,
113
+ string,
114
+ order: 0,
115
+ heading: heading || undefined,
116
+ children: [],
117
+ refs: []
118
+ };
119
+ fetchedBlocks.push(newBlock);
120
+ // Attach to parents
121
+ if (refMap[uid]) {
122
+ for (const parentBlock of refMap[uid]) {
123
+ if (!parentBlock.refs)
124
+ parentBlock.refs = [];
125
+ // Avoid duplicates
126
+ if (!parentBlock.refs.some(r => r.uid === uid)) {
127
+ parentBlock.refs.push(newBlock);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ // Recurse for the next level of references
133
+ await resolveBlockRefs(graph, fetchedBlocks, remainingDepth - 1);
134
+ }
@@ -1,7 +1,8 @@
1
1
  import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk';
2
- import { generateBlockUid } from '../../markdown-utils.js';
2
+ import { generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
3
3
  import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js';
4
4
  import { isRateLimitError, createRateLimitError } from '../../shared/errors.js';
5
+ import { ensurePagesExist } from '../../shared/page-validator.js';
5
6
  // Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
6
7
  const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g;
7
8
  const DEFAULT_RATE_LIMIT_CONFIG = {
@@ -119,6 +120,35 @@ export class BatchOperations {
119
120
  actions_attempted: 0
120
121
  };
121
122
  }
123
+ // Step 0.5: Validate parent pages exist (auto-creates daily pages)
124
+ // This uses batched queries and caching to minimize API calls
125
+ try {
126
+ const pageValidation = await ensurePagesExist(this.graph, actions, {
127
+ maxRetries: this.rateLimitConfig.maxRetries,
128
+ initialDelayMs: this.rateLimitConfig.initialDelayMs,
129
+ maxDelayMs: this.rateLimitConfig.maxDelayMs,
130
+ backoffMultiplier: this.rateLimitConfig.backoffMultiplier
131
+ });
132
+ if (pageValidation.created > 0) {
133
+ console.log(`[batch] Auto-created ${pageValidation.created} daily page(s), checked ${pageValidation.checked}, cached ${pageValidation.cached}`);
134
+ }
135
+ }
136
+ catch (error) {
137
+ // Page validation failed - return structured error
138
+ const errorMessage = error instanceof Error ? error.message : String(error);
139
+ return {
140
+ success: false,
141
+ error: {
142
+ code: 'PAGE_NOT_FOUND',
143
+ message: errorMessage,
144
+ recovery: {
145
+ suggestion: 'Create the missing page(s) first with roam_create_page, or verify the parent-uid is correct'
146
+ }
147
+ },
148
+ validation_passed: true, // Syntax validation passed, page validation failed
149
+ actions_attempted: 0
150
+ };
151
+ }
122
152
  // Step 1: Generate UIDs for all placeholders
123
153
  const uidMap = this.generateUidMap(actions);
124
154
  const hasPlaceholders = Object.keys(uidMap).length > 0;
@@ -137,12 +167,20 @@ export class BatchOperations {
137
167
  };
138
168
  }
139
169
  const block = {};
140
- if (rest.string)
141
- block.string = rest.string;
170
+ if (rest.string) {
171
+ // Parse markdown heading syntax (e.g., "### Description" -> heading: 3, string: "Description")
172
+ const { heading_level, content } = parseMarkdownHeadingLevel(rest.string);
173
+ block.string = heading_level > 0 ? content : rest.string;
174
+ // Use parsed heading level if not explicitly overridden
175
+ if (heading_level > 0 && rest.heading === undefined) {
176
+ block.heading = heading_level;
177
+ }
178
+ }
142
179
  if (rest.uid)
143
180
  block.uid = rest.uid;
144
181
  if (rest.open !== undefined)
145
182
  block.open = rest.open;
183
+ // Explicit heading parameter takes precedence over markdown syntax
146
184
  if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) {
147
185
  block.heading = rest.heading;
148
186
  }
@@ -171,6 +209,40 @@ export class BatchOperations {
171
209
  catch (error) {
172
210
  // FAILURE: Do NOT return uid_map - blocks don't exist
173
211
  const errorMessage = error instanceof Error ? error.message : String(error);
212
+ // Check for parent entity error - retry once after delay (Roam eventual consistency)
213
+ if (errorMessage.includes("Parent entity doesn't exist")) {
214
+ console.log('[batch] Parent entity not found, retrying after 400ms...');
215
+ await sleep(400);
216
+ try {
217
+ await this.executeWithRetry(batchActions);
218
+ // SUCCESS on retry
219
+ const result = {
220
+ success: true,
221
+ validation_passed: true,
222
+ actions_attempted: batchActions.length
223
+ };
224
+ if (hasPlaceholders) {
225
+ result.uid_map = uidMap;
226
+ }
227
+ return result;
228
+ }
229
+ catch (retryError) {
230
+ // Still failed after retry
231
+ const retryErrorMessage = retryError instanceof Error ? retryError.message : String(retryError);
232
+ return {
233
+ success: false,
234
+ error: {
235
+ code: 'PARENT_ENTITY_NOT_FOUND',
236
+ message: `${retryErrorMessage} (retried once after 400ms delay)`,
237
+ recovery: {
238
+ suggestion: 'Verify the parent block/page UID exists and is spelled correctly'
239
+ }
240
+ },
241
+ validation_passed: true,
242
+ actions_attempted: batchActions.length
243
+ };
244
+ }
245
+ }
174
246
  // Check if it's a rate limit error
175
247
  if (isRateLimitError(error) || error.isRateLimit) {
176
248
  return {
@@ -1,5 +1,6 @@
1
1
  import { q } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { resolveBlockRefs } from '../helpers/refs.js';
3
4
  export class BlockRetrievalOperations {
4
5
  constructor(graph) {
5
6
  this.graph = graph;
@@ -53,19 +54,29 @@ export class BlockRetrievalOperations {
53
54
  [?b :block/string ?string]
54
55
  [?b :block/order ?order]
55
56
  [(get-else $ ?b :block/heading 0) ?heading]]`;
56
- const rootBlockResult = await q(this.graph, rootBlockQuery, [block_uid]);
57
- if (!rootBlockResult) {
57
+ const rootBlockResults = await q(this.graph, rootBlockQuery, [block_uid]);
58
+ if (!rootBlockResults || rootBlockResults.length === 0) {
58
59
  return null;
59
60
  }
60
- const [rootString, rootOrder, rootHeading] = rootBlockResult;
61
+ const [rootString, rootOrder, rootHeading] = rootBlockResults[0];
61
62
  const childrenMap = await fetchChildren([block_uid], 0);
62
- return {
63
+ const rootBlock = {
63
64
  uid: block_uid,
64
65
  string: rootString,
65
66
  order: rootOrder,
66
67
  heading: rootHeading || undefined,
67
68
  children: childrenMap[block_uid] || [],
68
69
  };
70
+ // Gather all blocks in the tree to scan for references
71
+ const allBlocks = [];
72
+ const traverse = (b) => {
73
+ allBlocks.push(b);
74
+ b.children.forEach(traverse);
75
+ };
76
+ traverse(rootBlock);
77
+ // Resolve references (max depth 2)
78
+ await resolveBlockRefs(this.graph, allBlocks, 2);
79
+ return rootBlock;
69
80
  }
70
81
  catch (error) {
71
82
  throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children: ${error instanceof Error ? error.message : String(error)}`);
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BlockRetrievalOperations } from './block-retrieval.js';
3
+ // Mock roam-api-sdk
4
+ vi.mock('@roam-research/roam-api-sdk', () => ({
5
+ q: vi.fn(),
6
+ Graph: vi.fn(),
7
+ }));
8
+ import { q } from '@roam-research/roam-api-sdk';
9
+ describe('BlockRetrievalOperations', () => {
10
+ let ops;
11
+ let mockGraph;
12
+ let qMock;
13
+ beforeEach(() => {
14
+ mockGraph = {};
15
+ ops = new BlockRetrievalOperations(mockGraph);
16
+ qMock = q;
17
+ vi.clearAllMocks();
18
+ });
19
+ it('fetches a block with recursive reference resolution', async () => {
20
+ // ref UIDs must be 9 chars to match REF_PATTERN
21
+ const ref1Uid = 'ref1AAAAA';
22
+ const ref2Uid = 'ref2BBBBB';
23
+ // 1. Root block query: [:find ?string ?order ?heading ...]
24
+ qMock.mockResolvedValueOnce([[`Root Block with Ref ((${ref1Uid}))`, 0, 0]]);
25
+ // 2. Children query: [:find ?parentUid ?childUid ?childString ?childOrder ?childHeading ...]
26
+ qMock.mockResolvedValueOnce([]);
27
+ // 3. resolveBlockRefs query for ref1: [:find ?uid ?string ?heading ...]
28
+ qMock.mockResolvedValueOnce([[ref1Uid, `Ref 1 content with ((${ref2Uid}))`, 0]]);
29
+ // 4. resolveBlockRefs query for ref2 (depth 2)
30
+ qMock.mockResolvedValueOnce([[ref2Uid, 'Ref 2 content', 0]]);
31
+ const result = await ops.fetchBlockWithChildren('root-uid');
32
+ expect(result).toBeDefined();
33
+ expect(result?.uid).toBe('root-uid');
34
+ expect(result?.string).toBe(`Root Block with Ref ((${ref1Uid}))`);
35
+ expect(result?.refs).toBeDefined();
36
+ expect(result?.refs).toHaveLength(1);
37
+ const ref1 = result?.refs?.[0];
38
+ expect(ref1?.uid).toBe(ref1Uid);
39
+ expect(ref1?.string).toBe(`Ref 1 content with ((${ref2Uid}))`);
40
+ expect(ref1?.refs).toBeDefined();
41
+ expect(ref1?.refs).toHaveLength(1);
42
+ const ref2 = ref1?.refs?.[0];
43
+ expect(ref2?.uid).toBe(ref2Uid);
44
+ expect(ref2?.string).toBe('Ref 2 content');
45
+ // ref2 should have empty refs as it has no refs in string
46
+ expect(ref2?.refs).toEqual([]);
47
+ });
48
+ it('handles multiple references in same block', async () => {
49
+ const refAUid = 'refAAAAAA';
50
+ const refBUid = 'refBBBBBB';
51
+ // 1. Root block
52
+ qMock.mockResolvedValueOnce([[`Root ((${refAUid})) and ((${refBUid}))`, 0, 0]]);
53
+ // 2. Children
54
+ qMock.mockResolvedValueOnce([]);
55
+ // 3. resolveBlockRefs query for refA and refB
56
+ qMock.mockResolvedValueOnce([
57
+ [refAUid, 'Content A', 0],
58
+ [refBUid, 'Content B', 0]
59
+ ]);
60
+ // No more queries - Content A and B have no refs
61
+ const result = await ops.fetchBlockWithChildren('root-uid');
62
+ expect(result?.refs).toHaveLength(2);
63
+ const uids = result?.refs?.map(r => r.uid).sort();
64
+ expect(uids).toEqual([refAUid, refBUid].sort());
65
+ });
66
+ it('handles shared references in tree', async () => {
67
+ const sharedUid = 'sharedUID';
68
+ // 1. Root block
69
+ qMock.mockResolvedValueOnce([['Root', 0, 0]]);
70
+ // 2. Children query: Child1 and Child2 both reference 'shared'
71
+ qMock.mockResolvedValueOnce([
72
+ ['root-uid', 'child1uid', `Child 1 ((${sharedUid}))`, 0, 0],
73
+ ['root-uid', 'child2uid', `Child 2 ((${sharedUid}))`, 1, 0]
74
+ ]);
75
+ // 3. Children of Child1 and Child2 (empty - depth limit or no children)
76
+ qMock.mockResolvedValueOnce([]);
77
+ // 4. resolveBlockRefs query for 'shared' - fetched once, attached to both
78
+ qMock.mockResolvedValueOnce([[sharedUid, 'Shared Content', 0]]);
79
+ const result = await ops.fetchBlockWithChildren('root-uid');
80
+ const child1 = result?.children.find(c => c.uid === 'child1uid');
81
+ const child2 = result?.children.find(c => c.uid === 'child2uid');
82
+ expect(child1?.refs).toHaveLength(1);
83
+ expect(child1?.refs[0].uid).toBe(sharedUid);
84
+ expect(child2?.refs).toHaveLength(1);
85
+ expect(child2?.refs[0].uid).toBe(sharedUid);
86
+ });
87
+ });