roam-research-mcp 1.0.0 → 1.3.2

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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Shared validation functions for Roam MCP tools.
3
+ * Provides consistent validation across all write operations.
4
+ */
5
+ // Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
6
+ const UID_PLACEHOLDER_REGEX = /^\{\{uid:[^}]+\}\}$/;
7
+ /**
8
+ * Validates a block string content.
9
+ * @param str The string to validate
10
+ * @param allowEmpty If true, allows empty strings (for intentional blank blocks)
11
+ * @returns Error message if invalid, null if valid
12
+ */
13
+ export function validateBlockString(str, allowEmpty = false) {
14
+ if (str === undefined || str === null) {
15
+ return 'string is required';
16
+ }
17
+ // Only reject truly empty strings '', not whitespace-only strings like ' '
18
+ // Whitespace-only strings are valid in Roam (e.g., empty table cells)
19
+ if (!allowEmpty && typeof str === 'string' && str === '') {
20
+ return 'string cannot be empty (use " " for intentional whitespace)';
21
+ }
22
+ return null;
23
+ }
24
+ /**
25
+ * Validates a Roam UID.
26
+ * UIDs must be either:
27
+ * - 9 alphanumeric characters (standard Roam UID)
28
+ * - A placeholder like {{uid:name}}
29
+ * @param uid The UID to validate
30
+ * @param required If true, UID is required
31
+ * @returns Error message if invalid, null if valid
32
+ */
33
+ export function validateUid(uid, required = true) {
34
+ if (!uid) {
35
+ return required ? 'uid is required' : null;
36
+ }
37
+ // Check if it's a placeholder
38
+ if (UID_PLACEHOLDER_REGEX.test(uid)) {
39
+ return null;
40
+ }
41
+ // Check if it's a valid Roam UID (9 alphanumeric characters)
42
+ if (!/^[a-zA-Z0-9_-]{9}$/.test(uid)) {
43
+ return 'uid must be 9 alphanumeric characters or a {{uid:name}} placeholder';
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Validates an outline level.
49
+ * @param level The level to validate
50
+ * @returns Error message if invalid, null if valid
51
+ */
52
+ export function validateOutlineLevel(level) {
53
+ if (level === undefined || level === null) {
54
+ return 'level is required';
55
+ }
56
+ if (!Number.isInteger(level) || level < 1 || level > 10) {
57
+ return 'level must be an integer between 1 and 10';
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Validates a location object for block creation/movement.
63
+ * @param location The location object to validate
64
+ * @returns Error message if invalid, null if valid
65
+ */
66
+ export function validateLocation(location) {
67
+ if (!location) {
68
+ return 'location is required';
69
+ }
70
+ if (!location['parent-uid']) {
71
+ return 'location.parent-uid is required';
72
+ }
73
+ const uidError = validateUid(location['parent-uid'], true);
74
+ if (uidError) {
75
+ return `location.parent-uid: ${uidError}`;
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * Validates a heading level.
81
+ * @param heading The heading level to validate
82
+ * @returns Error message if invalid, null if valid
83
+ */
84
+ export function validateHeading(heading) {
85
+ if (heading === undefined || heading === null || heading === 0) {
86
+ return null; // Heading is optional
87
+ }
88
+ if (!Number.isInteger(heading) || heading < 1 || heading > 3) {
89
+ return 'heading must be 1, 2, or 3';
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Validates all batch actions before execution.
95
+ * @param actions Array of batch actions to validate
96
+ * @returns ValidationResult with errors if any
97
+ */
98
+ export function validateBatchActions(actions) {
99
+ const errors = [];
100
+ if (!Array.isArray(actions)) {
101
+ errors.push({
102
+ field: 'actions',
103
+ message: 'actions must be an array'
104
+ });
105
+ return { valid: false, errors };
106
+ }
107
+ if (actions.length === 0) {
108
+ errors.push({
109
+ field: 'actions',
110
+ message: 'actions array cannot be empty'
111
+ });
112
+ return { valid: false, errors };
113
+ }
114
+ // Track defined placeholders for forward-reference validation
115
+ const definedPlaceholders = new Set();
116
+ for (let i = 0; i < actions.length; i++) {
117
+ const action = actions[i];
118
+ // Validate action type
119
+ if (!action.action) {
120
+ errors.push({
121
+ actionIndex: i,
122
+ field: 'action',
123
+ message: 'action type is required',
124
+ expected: 'create-block | update-block | move-block | delete-block'
125
+ });
126
+ continue;
127
+ }
128
+ const validActions = ['create-block', 'update-block', 'move-block', 'delete-block'];
129
+ if (!validActions.includes(action.action)) {
130
+ errors.push({
131
+ actionIndex: i,
132
+ field: 'action',
133
+ message: `invalid action type: ${action.action}`,
134
+ expected: 'create-block | update-block | move-block | delete-block',
135
+ received: action.action
136
+ });
137
+ continue;
138
+ }
139
+ // Track placeholder definitions
140
+ if (action.uid && UID_PLACEHOLDER_REGEX.test(action.uid)) {
141
+ const placeholderMatch = action.uid.match(/\{\{uid:([^}]+)\}\}/);
142
+ if (placeholderMatch) {
143
+ definedPlaceholders.add(placeholderMatch[1]);
144
+ }
145
+ }
146
+ // Validate based on action type
147
+ switch (action.action) {
148
+ case 'create-block': {
149
+ // create-block requires string and location
150
+ const stringError = validateBlockString(action.string);
151
+ if (stringError) {
152
+ errors.push({
153
+ actionIndex: i,
154
+ field: 'string',
155
+ message: stringError
156
+ });
157
+ }
158
+ const locationError = validateLocation(action.location);
159
+ if (locationError) {
160
+ errors.push({
161
+ actionIndex: i,
162
+ field: 'location',
163
+ message: locationError
164
+ });
165
+ }
166
+ // Check for forward-reference to undefined placeholder
167
+ if (action.location?.['parent-uid']) {
168
+ const parentUid = action.location['parent-uid'];
169
+ const placeholderMatch = parentUid.match(/\{\{uid:([^}]+)\}\}/);
170
+ if (placeholderMatch && !definedPlaceholders.has(placeholderMatch[1])) {
171
+ errors.push({
172
+ actionIndex: i,
173
+ field: 'location.parent-uid',
174
+ message: `Placeholder {{uid:${placeholderMatch[1]}}} referenced before definition`
175
+ });
176
+ }
177
+ }
178
+ const headingError = validateHeading(action.heading);
179
+ if (headingError) {
180
+ errors.push({
181
+ actionIndex: i,
182
+ field: 'heading',
183
+ message: headingError
184
+ });
185
+ }
186
+ break;
187
+ }
188
+ case 'update-block': {
189
+ // update-block requires uid
190
+ const uidError = validateUid(action.uid);
191
+ if (uidError) {
192
+ errors.push({
193
+ actionIndex: i,
194
+ field: 'uid',
195
+ message: uidError
196
+ });
197
+ }
198
+ // string is optional for update but if provided, validate it
199
+ if (action.string !== undefined) {
200
+ const stringError = validateBlockString(action.string);
201
+ if (stringError) {
202
+ errors.push({
203
+ actionIndex: i,
204
+ field: 'string',
205
+ message: stringError
206
+ });
207
+ }
208
+ }
209
+ const headingError = validateHeading(action.heading);
210
+ if (headingError) {
211
+ errors.push({
212
+ actionIndex: i,
213
+ field: 'heading',
214
+ message: headingError
215
+ });
216
+ }
217
+ break;
218
+ }
219
+ case 'move-block': {
220
+ // move-block requires uid and location
221
+ const uidError = validateUid(action.uid);
222
+ if (uidError) {
223
+ errors.push({
224
+ actionIndex: i,
225
+ field: 'uid',
226
+ message: uidError
227
+ });
228
+ }
229
+ const locationError = validateLocation(action.location);
230
+ if (locationError) {
231
+ errors.push({
232
+ actionIndex: i,
233
+ field: 'location',
234
+ message: locationError
235
+ });
236
+ }
237
+ break;
238
+ }
239
+ case 'delete-block': {
240
+ // delete-block requires uid
241
+ const uidError = validateUid(action.uid);
242
+ if (uidError) {
243
+ errors.push({
244
+ actionIndex: i,
245
+ field: 'uid',
246
+ message: uidError
247
+ });
248
+ }
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ return {
254
+ valid: errors.length === 0,
255
+ errors
256
+ };
257
+ }
258
+ /**
259
+ * Formats validation errors into a human-readable string.
260
+ */
261
+ export function formatValidationErrors(errors) {
262
+ return errors
263
+ .map(err => {
264
+ const prefix = err.actionIndex !== undefined ? `Action ${err.actionIndex}: ` : '';
265
+ return `${prefix}[${err.field}] ${err.message}`;
266
+ })
267
+ .join('; ');
268
+ }
@@ -1,10 +1,133 @@
1
1
  import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk';
2
+ import { generateBlockUid } from '../../markdown-utils.js';
3
+ import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js';
4
+ import { isRateLimitError, createRateLimitError } from '../../shared/errors.js';
5
+ // Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
6
+ const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g;
7
+ const DEFAULT_RATE_LIMIT_CONFIG = {
8
+ maxRetries: 3,
9
+ initialDelayMs: 1000,
10
+ maxDelayMs: 60000,
11
+ backoffMultiplier: 2
12
+ };
13
+ /**
14
+ * Sleep for a specified number of milliseconds.
15
+ */
16
+ function sleep(ms) {
17
+ return new Promise(resolve => setTimeout(resolve, ms));
18
+ }
2
19
  export class BatchOperations {
3
- constructor(graph) {
20
+ constructor(graph, rateLimitConfig) {
4
21
  this.graph = graph;
22
+ this.rateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG, ...rateLimitConfig };
23
+ }
24
+ /**
25
+ * Finds all unique UID placeholders in the actions and generates real UIDs for them.
26
+ * Returns a map of placeholder name → generated UID.
27
+ */
28
+ generateUidMap(actions) {
29
+ const placeholders = new Set();
30
+ const actionsJson = JSON.stringify(actions);
31
+ let match;
32
+ // Reset regex lastIndex to ensure fresh matching
33
+ UID_PLACEHOLDER_REGEX.lastIndex = 0;
34
+ while ((match = UID_PLACEHOLDER_REGEX.exec(actionsJson)) !== null) {
35
+ placeholders.add(match[1]); // The placeholder name (e.g., "parent1")
36
+ }
37
+ const uidMap = {};
38
+ for (const placeholder of placeholders) {
39
+ uidMap[placeholder] = generateBlockUid();
40
+ }
41
+ return uidMap;
42
+ }
43
+ /**
44
+ * Replaces all {{uid:*}} placeholders in a string with their generated UIDs.
45
+ */
46
+ replacePlaceholders(value, uidMap) {
47
+ return value.replace(UID_PLACEHOLDER_REGEX, (_, name) => {
48
+ return uidMap[name] || _; // Return original if not found (shouldn't happen)
49
+ });
50
+ }
51
+ /**
52
+ * Recursively replaces placeholders in an object/array.
53
+ */
54
+ replacePlaceholdersInObject(obj, uidMap) {
55
+ if (typeof obj === 'string') {
56
+ return this.replacePlaceholders(obj, uidMap);
57
+ }
58
+ if (Array.isArray(obj)) {
59
+ return obj.map(item => this.replacePlaceholdersInObject(item, uidMap));
60
+ }
61
+ if (obj && typeof obj === 'object') {
62
+ const result = {};
63
+ for (const key of Object.keys(obj)) {
64
+ result[key] = this.replacePlaceholdersInObject(obj[key], uidMap);
65
+ }
66
+ return result;
67
+ }
68
+ return obj;
69
+ }
70
+ /**
71
+ * Executes the batch operation with retry logic for rate limiting.
72
+ */
73
+ async executeWithRetry(batchActions) {
74
+ let lastError;
75
+ let delay = this.rateLimitConfig.initialDelayMs;
76
+ for (let attempt = 0; attempt <= this.rateLimitConfig.maxRetries; attempt++) {
77
+ try {
78
+ await roamBatchActions(this.graph, { actions: batchActions });
79
+ return;
80
+ }
81
+ catch (error) {
82
+ if (!isRateLimitError(error)) {
83
+ throw error;
84
+ }
85
+ lastError = error;
86
+ if (attempt < this.rateLimitConfig.maxRetries) {
87
+ const waitTime = Math.min(delay, this.rateLimitConfig.maxDelayMs);
88
+ console.log(`[batch] Rate limited, retrying in ${waitTime}ms (attempt ${attempt + 1}/${this.rateLimitConfig.maxRetries})`);
89
+ await sleep(waitTime);
90
+ delay *= this.rateLimitConfig.backoffMultiplier;
91
+ }
92
+ }
93
+ }
94
+ // Throw with rate limit context after all retries exhausted
95
+ const rateLimitError = new Error(`Rate limit exceeded after ${this.rateLimitConfig.maxRetries} retries. ` +
96
+ `Last error: ${lastError?.message || 'Unknown error'}. ` +
97
+ `Retry after ${this.rateLimitConfig.maxDelayMs}ms.`);
98
+ rateLimitError.isRateLimit = true;
99
+ rateLimitError.retryAfterMs = this.rateLimitConfig.maxDelayMs;
100
+ throw rateLimitError;
5
101
  }
6
102
  async processBatch(actions) {
7
- const batchActions = actions.map(action => {
103
+ // Step 0: Pre-validate all actions before any execution
104
+ const validationResult = validateBatchActions(actions);
105
+ if (!validationResult.valid) {
106
+ return {
107
+ success: false,
108
+ error: {
109
+ code: 'VALIDATION_ERROR',
110
+ message: formatValidationErrors(validationResult.errors),
111
+ details: validationResult.errors.length > 0 ? {
112
+ action_index: validationResult.errors[0].actionIndex,
113
+ field: validationResult.errors[0].field,
114
+ expected: validationResult.errors[0].expected,
115
+ received: validationResult.errors[0].received
116
+ } : undefined
117
+ },
118
+ validation_passed: false,
119
+ actions_attempted: 0
120
+ };
121
+ }
122
+ // Step 1: Generate UIDs for all placeholders
123
+ const uidMap = this.generateUidMap(actions);
124
+ const hasPlaceholders = Object.keys(uidMap).length > 0;
125
+ // Step 2: Replace placeholders with real UIDs
126
+ const processedActions = hasPlaceholders
127
+ ? this.replacePlaceholdersInObject(actions, uidMap)
128
+ : actions;
129
+ // Step 3: Convert to Roam batch actions format
130
+ const batchActions = processedActions.map((action) => {
8
131
  const { action: actionType, ...rest } = action;
9
132
  const roamAction = { action: actionType };
10
133
  if (rest.location) {
@@ -32,6 +155,45 @@ export class BatchOperations {
32
155
  }
33
156
  return roamAction;
34
157
  });
35
- return await roamBatchActions(this.graph, { actions: batchActions });
158
+ try {
159
+ await this.executeWithRetry(batchActions);
160
+ // SUCCESS: Return uid_map only on success
161
+ const result = {
162
+ success: true,
163
+ validation_passed: true,
164
+ actions_attempted: batchActions.length
165
+ };
166
+ if (hasPlaceholders) {
167
+ result.uid_map = uidMap;
168
+ }
169
+ return result;
170
+ }
171
+ catch (error) {
172
+ // FAILURE: Do NOT return uid_map - blocks don't exist
173
+ const errorMessage = error instanceof Error ? error.message : String(error);
174
+ // Check if it's a rate limit error
175
+ if (isRateLimitError(error) || error.isRateLimit) {
176
+ return {
177
+ success: false,
178
+ error: createRateLimitError(error.retryAfterMs),
179
+ validation_passed: true,
180
+ actions_attempted: batchActions.length
181
+ // No uid_map - nothing was committed
182
+ };
183
+ }
184
+ return {
185
+ success: false,
186
+ error: {
187
+ code: 'TRANSACTION_FAILED',
188
+ message: errorMessage,
189
+ recovery: {
190
+ suggestion: 'Check the error message and retry with corrected actions'
191
+ }
192
+ },
193
+ validation_passed: true,
194
+ actions_attempted: batchActions.length
195
+ // No uid_map - nothing was committed (or we can't verify what was)
196
+ };
197
+ }
36
198
  }
37
199
  }
@@ -3,6 +3,7 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { resolveRefs } from '../helpers/refs.js';
5
5
  import { SearchOperations } from './search/index.js';
6
+ import { pageUidCache } from '../../cache/page-uid-cache.js';
6
7
  export class MemoryOperations {
7
8
  constructor(graph) {
8
9
  this.graph = graph;
@@ -12,29 +13,38 @@ export class MemoryOperations {
12
13
  // Get today's date
13
14
  const today = new Date();
14
15
  const dateStr = formatRoamDate(today);
15
- // Try to find today's page
16
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
17
- const findResults = await q(this.graph, findQuery, [dateStr]);
18
16
  let pageUid;
19
- if (findResults && findResults.length > 0) {
20
- pageUid = findResults[0][0];
17
+ // Check cache first for today's page
18
+ const cachedUid = pageUidCache.get(dateStr);
19
+ if (cachedUid) {
20
+ pageUid = cachedUid;
21
21
  }
22
22
  else {
23
- // Create today's page if it doesn't exist
24
- try {
25
- await createPage(this.graph, {
26
- action: 'create-page',
27
- page: { title: dateStr }
28
- });
29
- // Get the new page's UID
30
- const results = await q(this.graph, findQuery, [dateStr]);
31
- if (!results || results.length === 0) {
32
- throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
33
- }
34
- pageUid = results[0][0];
23
+ // Try to find today's page
24
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
25
+ const findResults = await q(this.graph, findQuery, [dateStr]);
26
+ if (findResults && findResults.length > 0) {
27
+ pageUid = findResults[0][0];
28
+ pageUidCache.set(dateStr, pageUid);
35
29
  }
36
- catch (error) {
37
- throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
30
+ else {
31
+ // Create today's page if it doesn't exist
32
+ try {
33
+ await createPage(this.graph, {
34
+ action: 'create-page',
35
+ page: { title: dateStr }
36
+ });
37
+ // Get the new page's UID
38
+ const results = await q(this.graph, findQuery, [dateStr]);
39
+ if (!results || results.length === 0) {
40
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
41
+ }
42
+ pageUid = results[0][0];
43
+ pageUidCache.onPageCreated(dateStr, pageUid);
44
+ }
45
+ catch (error) {
46
+ throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
47
+ }
38
48
  }
39
49
  }
40
50
  // Get memories tag from environment