roam-research-mcp 2.24.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/build/cli/commands/batch.js +6 -6
- package/build/cli/commands/save.js +3 -3
- package/build/config/graph-registry.js +15 -8
- package/build/server/roam-server.js +44 -63
- package/build/tools/operations/batch.js +17 -30
- package/build/tools/operations/guidelines.js +3 -6
- package/build/tools/operations/outline.js +6 -6
- package/build/tools/operations/pages.js +3 -3
- package/build/tools/schemas.js +152 -0
- package/build/tools/tool-handlers.js +2 -2
- package/package.json +1 -1
- package/build/cli/commands/update.test.js +0 -69
- package/build/config/graph-registry.test.js +0 -158
- package/build/diff/actions.test.js +0 -125
- package/build/diff/diff.test.js +0 -202
- package/build/diff/matcher.test.js +0 -198
- package/build/diff/parser.test.js +0 -281
- package/build/diff/types.test.js +0 -57
- package/build/markdown-utils.test.js +0 -256
- package/build/query/parser.test.js +0 -389
- package/build/server/session-404.test.js +0 -116
- package/build/shared/errors.test.js +0 -61
- package/build/shared/page-validator.test.js +0 -128
- package/build/shared/staged-batch.test.js +0 -88
- package/build/tools/helpers/hidden.test.js +0 -105
- package/build/tools/operations/block-retrieval.test.js +0 -137
- package/build/tools/operations/guidelines.test.js +0 -89
- package/build/tools/schemas.test.js +0 -153
- package/build/utils/auth.test.js +0 -34
- package/build/utils/helpers.test.js +0 -153
package/README.md
CHANGED
|
@@ -146,6 +146,24 @@ The MCP server exposes these tools to AI assistants (like Claude), enabling them
|
|
|
146
146
|
| `roam_markdown_cheatsheet` | Retrieve the Roam-flavored markdown reference. |
|
|
147
147
|
| `roam_get_guidelines` | Retrieve this graph's user-defined agent conventions. |
|
|
148
148
|
|
|
149
|
+
### Structured results from write tools (v3.0.0+)
|
|
150
|
+
|
|
151
|
+
The ten write tools declare an `outputSchema` and return `structuredContent` — a validated object — alongside the usual text. A client can read `page_uid`, `uid_map` or `success` directly instead of hunting for JSON inside a string, which makes chaining calls more reliable:
|
|
152
|
+
|
|
153
|
+
```jsonc
|
|
154
|
+
// roam_process_batch_actions
|
|
155
|
+
{ "success": true, "uid_map": { "parent1": "Xk7mN2pQ9" },
|
|
156
|
+
"validation_passed": true, "actions_attempted": 4 }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Three things worth knowing:
|
|
160
|
+
|
|
161
|
+
- **Nothing was taken away.** The text channel is unchanged, so a client that ignores `structuredContent` behaves exactly as before.
|
|
162
|
+
- **Read tools deliberately have neither.** They already serialise their whole result into the text channel, so a schema would just double the payload.
|
|
163
|
+
- **These fields are additive-only.** Some clients validate live responses against a cached tool list, so a field will be added or deprecated — never renamed or removed outside a major version.
|
|
164
|
+
|
|
165
|
+
> **Upgrading from 2.x:** three write-result fields were renamed — `uid` → `page_uid` (`roam_create_page`), `created_uids` → `created_blocks` (`roam_create_outline`, `roam_import_markdown`) and `preservedUids` → `preserved_uids` (`roam_update_page_markdown`). This only affects code that reads those names; if you use the server through an AI assistant, nothing changes. See the [changelog](CHANGELOG.md) for why.
|
|
166
|
+
|
|
149
167
|
---
|
|
150
168
|
|
|
151
169
|
## Agent guidelines (per-graph)
|
|
@@ -161,22 +179,24 @@ This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
|
|
|
161
179
|
| To change it | edit the file, restart the server | edit the page |
|
|
162
180
|
| Answers | how to write Roam markdown | how *this user* wants *this graph* handled |
|
|
163
181
|
|
|
164
|
-
**
|
|
182
|
+
**Just create the page.** With no configuration at all, `roam_get_guidelines` reads `[[roam/agent guidelines]]` — the same title Roam's own server reads, so writing it once makes both honour it. Creating a page with that exact namespaced title is the opt-in; nothing is read from the graph unless an agent explicitly calls the tool.
|
|
183
|
+
|
|
184
|
+
If the page doesn't exist, the tool returns `exists: false` rather than failing, so it is always safe to call.
|
|
165
185
|
|
|
166
|
-
Each graph can point at a different page:
|
|
186
|
+
Each graph can point at a different page, or turn it off:
|
|
167
187
|
|
|
168
188
|
```bash
|
|
169
189
|
ROAM_GRAPHS='{
|
|
170
|
-
"personal": {"token": "...", "graph": "..."
|
|
190
|
+
"personal": {"token": "...", "graph": "..."},
|
|
171
191
|
"work": {"token": "...", "graph": "...", "guidelinesPage": "work/agent rules"},
|
|
172
|
-
"
|
|
192
|
+
"private": {"token": "...", "graph": "...", "guidelinesPage": false}
|
|
173
193
|
}'
|
|
174
|
-
ROAM_GUIDELINES_PAGE='
|
|
194
|
+
ROAM_GUIDELINES_PAGE='team/agent guidelines' # change the default for every graph
|
|
175
195
|
```
|
|
176
196
|
|
|
177
|
-
Resolution order is **per-graph `guidelinesPage` → `ROAM_GUIDELINES_PAGE` →
|
|
197
|
+
Resolution order is **per-graph `guidelinesPage` → `ROAM_GUIDELINES_PAGE` → `roam/agent guidelines`**. Above: `personal` uses the env override, `work` uses its own page, and `private` has guidelines off entirely. **Only an explicit `false` disables it** — an unset value never does.
|
|
178
198
|
|
|
179
|
-
|
|
199
|
+
Results are cached for 30 seconds — an edit to the page takes effect without a restart. A starter template lives at [`.roam/agent-guidelines.template.md`](.roam/agent-guidelines.template.md).
|
|
180
200
|
|
|
181
201
|
Note that guidelines are read through the normal page path, so blocks tagged `#.rm-hide` / `#.rm-private` are withheld from them too — see below.
|
|
182
202
|
|
|
@@ -229,7 +249,7 @@ ROAM_SYSTEM_WRITE_KEY=your-secret-key
|
|
|
229
249
|
|----------|----------|-------------|
|
|
230
250
|
| `token` | Yes | Roam API token for this graph |
|
|
231
251
|
| `graph` | Yes | Graph name/database identifier |
|
|
232
|
-
| `protected` | No | If `true`, writes require `ROAM_SYSTEM_WRITE_KEY` confirmation |
|
|
252
|
+
| `protected` | No | If `true`, writes require `ROAM_SYSTEM_WRITE_KEY` confirmation — **except on the default graph**, see below |
|
|
233
253
|
| `memoriesTag` | No | Tag for `roam_remember`/`roam_recall` (overrides global default) |
|
|
234
254
|
|
|
235
255
|
**Two kinds of access control (and how they differ)**
|
|
@@ -249,6 +269,8 @@ Think of a house: the **bearer token locks the front door** (keeps strangers out
|
|
|
249
269
|
|
|
250
270
|
So: to mark a graph as needing the write key, set `protected: true` on it and configure `ROAM_SYSTEM_WRITE_KEY`; callers then pass a matching `write_key` for any write to that graph.
|
|
251
271
|
|
|
272
|
+
> ⚠️ **`protected` does nothing on your default graph.** Writes to whichever graph `ROAM_DEFAULT_GRAPH` names are always allowed, before `protected` is ever consulted — the flag guards the graphs you have to *ask* for by name, on the reasoning that reaching for a non-default graph is the deliberate act worth confirming. If you want a graph write-guarded, it must not be your default.
|
|
273
|
+
|
|
252
274
|
*Optional:*
|
|
253
275
|
- `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
|
|
254
276
|
- `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088).
|
|
@@ -348,6 +370,16 @@ docker run -p 8088:8088 --env-file .env roam-research-mcp --server
|
|
|
348
370
|
|
|
349
371
|
Add to your MCP settings file (e.g., `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
350
372
|
|
|
373
|
+
> **Pinning the version.** `npx -y roam-research-mcp` fetches the **latest** release every time your client starts the server, so a new major version arrives without warning. Pin the major to decide for yourself when to move:
|
|
374
|
+
>
|
|
375
|
+
> | `args` | You get |
|
|
376
|
+
> | :--- | :--- |
|
|
377
|
+
> | `["-y", "roam-research-mcp"]` | Latest, always — including the next major |
|
|
378
|
+
> | `["-y", "roam-research-mcp@3"]` | 3.x only; majors need an edit here |
|
|
379
|
+
> | `["-y", "roam-research-mcp@3.0.0"]` | Exactly this build |
|
|
380
|
+
>
|
|
381
|
+
> Pinning the major is the sensible default: you still get fixes and new tools, but a breaking change becomes something you opt into. The examples below stay unpinned to match what most people paste in first.
|
|
382
|
+
|
|
351
383
|
*Single Graph:*
|
|
352
384
|
```json
|
|
353
385
|
{
|
|
@@ -247,10 +247,10 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
|
|
|
247
247
|
const pageOps = new PageOperations(graph);
|
|
248
248
|
for (const title of unresolved) {
|
|
249
249
|
const result = await pageOps.createPage(title);
|
|
250
|
-
if (result.success && result.
|
|
251
|
-
context.pageUids.set(title, result.
|
|
250
|
+
if (result.success && result.page_uid) {
|
|
251
|
+
context.pageUids.set(title, result.page_uid);
|
|
252
252
|
if (options.debug) {
|
|
253
|
-
printDebug(`Auto-created "${title}"`, result.
|
|
253
|
+
printDebug(`Auto-created "${title}"`, result.page_uid);
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
else {
|
|
@@ -313,12 +313,12 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
|
|
|
313
313
|
// Report partial results before exiting
|
|
314
314
|
outputPartialResults(pageResults, pc.params.title);
|
|
315
315
|
}
|
|
316
|
-
pageResults.push({ title: pc.params.title, uid: result.
|
|
316
|
+
pageResults.push({ title: pc.params.title, uid: result.page_uid });
|
|
317
317
|
if (pc.params.as) {
|
|
318
|
-
context.placeholders.set(pc.params.as, result.
|
|
318
|
+
context.placeholders.set(pc.params.as, result.page_uid);
|
|
319
319
|
}
|
|
320
320
|
if (options.debug) {
|
|
321
|
-
printDebug(`Created "${pc.params.title}"`, result.
|
|
321
|
+
printDebug(`Created "${pc.params.title}"`, result.page_uid);
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
}
|
|
@@ -430,8 +430,8 @@ JSON format (--json):
|
|
|
430
430
|
if (result.success) {
|
|
431
431
|
console.log(`Updated page '${pageTitle}'`);
|
|
432
432
|
console.log(` ${result.summary}`);
|
|
433
|
-
if (result.
|
|
434
|
-
console.log(` Preserved ${result.
|
|
433
|
+
if (result.preserved_uids.length > 0) {
|
|
434
|
+
console.log(` Preserved ${result.preserved_uids.length} block UID(s)`);
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
else {
|
|
@@ -441,7 +441,7 @@ JSON format (--json):
|
|
|
441
441
|
else {
|
|
442
442
|
const result = await pageOps.createPage(pageTitle, contentBlocks);
|
|
443
443
|
if (result.success) {
|
|
444
|
-
console.log(`Created page '${pageTitle}' (uid: ${result.
|
|
444
|
+
console.log(`Created page '${pageTitle}' (uid: ${result.page_uid})`);
|
|
445
445
|
}
|
|
446
446
|
else {
|
|
447
447
|
exitWithError(`Failed to create page '${pageTitle}'`);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
11
11
|
import { RoamError } from '../shared/errors.js';
|
|
12
|
+
import { DEFAULT_GUIDELINES_PAGE } from '../tools/operations/guidelines.js';
|
|
12
13
|
/** List of tool names that perform write operations */
|
|
13
14
|
export const WRITE_OPERATIONS = [
|
|
14
15
|
'roam_create_page',
|
|
@@ -70,15 +71,21 @@ export class GraphRegistry {
|
|
|
70
71
|
return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
|
-
* Page holding a graph's agent conventions, or null when disabled.
|
|
74
|
+
* Page holding a graph's agent conventions, or null when explicitly disabled.
|
|
74
75
|
*
|
|
75
|
-
* Precedence: per-graph config > ROAM_GUIDELINES_PAGE env var >
|
|
76
|
+
* Precedence: per-graph config > ROAM_GUIDELINES_PAGE env var >
|
|
77
|
+
* "roam/agent guidelines" (the shared convention).
|
|
76
78
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* Reading the conventional title by default is deliberate. It is the same
|
|
80
|
+
* page Roam's own MCP server reads unconditionally, so writing it once makes
|
|
81
|
+
* both servers honour it — which is the entire point of a convention, and it
|
|
82
|
+
* stops working the moment it needs private configuration. Creating a page
|
|
83
|
+
* with that exact namespaced title IS the opt-in; nobody makes one by
|
|
84
|
+
* accident. And nothing here reads the graph unprompted: the lookup only
|
|
85
|
+
* happens when an agent explicitly calls roam_get_guidelines, so the tool
|
|
86
|
+
* call is already the consent.
|
|
87
|
+
*
|
|
88
|
+
* Set `guidelinesPage: false` to turn it off for a graph.
|
|
82
89
|
*/
|
|
83
90
|
getGuidelinesPage(key) {
|
|
84
91
|
const resolvedKey = key ?? this.defaultKey;
|
|
@@ -86,7 +93,7 @@ export class GraphRegistry {
|
|
|
86
93
|
if (config?.guidelinesPage === false) {
|
|
87
94
|
return null;
|
|
88
95
|
}
|
|
89
|
-
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ??
|
|
96
|
+
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? DEFAULT_GUIDELINES_PAGE;
|
|
90
97
|
}
|
|
91
98
|
/**
|
|
92
99
|
* Get an initialized Graph instance, creating it lazily if needed
|
|
@@ -20,6 +20,29 @@ const __dirname = dirname(__filename);
|
|
|
20
20
|
const packageJsonPath = join(__dirname, '../../package.json');
|
|
21
21
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
22
22
|
const serverVersion = packageJson.version;
|
|
23
|
+
/**
|
|
24
|
+
* The single place a tool result is built, and the only place
|
|
25
|
+
* `structuredContent` may be attached.
|
|
26
|
+
*
|
|
27
|
+
* The wire invariant is **`structuredContent` is present iff the tool declares
|
|
28
|
+
* an `outputSchema`**. A schema-bearing tool that returns none, or a
|
|
29
|
+
* schema-less tool that returns some, is a protocol violation a strict client
|
|
30
|
+
* will reject. We use the low-level `Server` rather than `McpServer`, so the
|
|
31
|
+
* SDK does not enforce this for us — routing every result through here is what
|
|
32
|
+
* enforces it. Adding a schema in schemas.ts is therefore sufficient; no switch
|
|
33
|
+
* case needs touching.
|
|
34
|
+
*
|
|
35
|
+
* The text channel is unchanged either way, so clients that never look at
|
|
36
|
+
* structuredContent see exactly what they saw before.
|
|
37
|
+
*/
|
|
38
|
+
function toolResult(toolName, result) {
|
|
39
|
+
const declaresSchema = Boolean(toolSchemas[toolName]?.outputSchema);
|
|
40
|
+
const canStructure = declaresSchema && result !== null && typeof result === 'object';
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
43
|
+
...(canStructure ? { structuredContent: result } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
23
46
|
export class RoamServer {
|
|
24
47
|
constructor() {
|
|
25
48
|
this.toolHandlersCache = new Map();
|
|
@@ -126,9 +149,7 @@ export class RoamServer {
|
|
|
126
149
|
case 'roam_remember': {
|
|
127
150
|
const { memory, categories, heading, parent_uid, include_memories_tag } = cleanedArgs;
|
|
128
151
|
const result = await toolHandlers.remember(memory, categories, heading, parent_uid, include_memories_tag);
|
|
129
|
-
return
|
|
130
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
131
|
-
};
|
|
152
|
+
return toolResult(request.params.name, result);
|
|
132
153
|
}
|
|
133
154
|
case 'roam_fetch_page_full_view': {
|
|
134
155
|
const { title, children_depth, max_references } = cleanedArgs;
|
|
@@ -146,9 +167,7 @@ export class RoamServer {
|
|
|
146
167
|
}
|
|
147
168
|
case 'roam_get_guidelines': {
|
|
148
169
|
const result = await toolHandlers.getGuidelines();
|
|
149
|
-
return
|
|
150
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
151
|
-
};
|
|
170
|
+
return toolResult(request.params.name, result);
|
|
152
171
|
}
|
|
153
172
|
case 'roam_fetch_page_by_title': {
|
|
154
173
|
const { title, format } = cleanedArgs;
|
|
@@ -160,30 +179,22 @@ export class RoamServer {
|
|
|
160
179
|
case 'roam_create_page': {
|
|
161
180
|
const { title, content } = cleanedArgs;
|
|
162
181
|
const result = await toolHandlers.createPage(title, content);
|
|
163
|
-
return
|
|
164
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
165
|
-
};
|
|
182
|
+
return toolResult(request.params.name, result);
|
|
166
183
|
}
|
|
167
184
|
case 'roam_import_markdown': {
|
|
168
185
|
const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = cleanedArgs;
|
|
169
186
|
const result = await toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
|
|
170
|
-
return
|
|
171
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
172
|
-
};
|
|
187
|
+
return toolResult(request.params.name, result);
|
|
173
188
|
}
|
|
174
189
|
case 'roam_add_todo': {
|
|
175
190
|
const { todos } = cleanedArgs;
|
|
176
191
|
const result = await toolHandlers.addTodos(todos);
|
|
177
|
-
return
|
|
178
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
179
|
-
};
|
|
192
|
+
return toolResult(request.params.name, result);
|
|
180
193
|
}
|
|
181
194
|
case 'roam_create_outline': {
|
|
182
195
|
const { outline, page_title_uid, block_text_uid, order } = cleanedArgs;
|
|
183
196
|
const result = await toolHandlers.createOutline(outline, page_title_uid, block_text_uid, order);
|
|
184
|
-
return
|
|
185
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
186
|
-
};
|
|
197
|
+
return toolResult(request.params.name, result);
|
|
187
198
|
}
|
|
188
199
|
case 'roam_search_for_tag': {
|
|
189
200
|
const { primary_tag, page_title_uid, near_tag } = cleanedArgs;
|
|
@@ -191,23 +202,17 @@ export class RoamServer {
|
|
|
191
202
|
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: primary_tag (the tag to search for). Use page_title_uid to limit search to a specific page.');
|
|
192
203
|
}
|
|
193
204
|
const result = await toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
|
|
194
|
-
return
|
|
195
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
196
|
-
};
|
|
205
|
+
return toolResult(request.params.name, result);
|
|
197
206
|
}
|
|
198
207
|
case 'roam_search_by_status': {
|
|
199
208
|
const { status, page_title_uid, include, exclude } = cleanedArgs;
|
|
200
209
|
const result = await toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
|
|
201
|
-
return
|
|
202
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
203
|
-
};
|
|
210
|
+
return toolResult(request.params.name, result);
|
|
204
211
|
}
|
|
205
212
|
case 'roam_search_block_refs': {
|
|
206
213
|
const params = cleanedArgs;
|
|
207
214
|
const result = await toolHandlers.searchBlockRefs(params);
|
|
208
|
-
return
|
|
209
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
210
|
-
};
|
|
215
|
+
return toolResult(request.params.name, result);
|
|
211
216
|
}
|
|
212
217
|
case 'roam_search_hierarchy': {
|
|
213
218
|
const params = cleanedArgs;
|
|
@@ -216,58 +221,42 @@ export class RoamServer {
|
|
|
216
221
|
throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
|
|
217
222
|
}
|
|
218
223
|
const result = await toolHandlers.searchHierarchy(params);
|
|
219
|
-
return
|
|
220
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
221
|
-
};
|
|
224
|
+
return toolResult(request.params.name, result);
|
|
222
225
|
}
|
|
223
226
|
case 'roam_find_pages_modified_today': {
|
|
224
227
|
const { max_num_pages } = cleanedArgs;
|
|
225
228
|
const result = await toolHandlers.findPagesModifiedToday(max_num_pages || 50);
|
|
226
|
-
return
|
|
227
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
228
|
-
};
|
|
229
|
+
return toolResult(request.params.name, result);
|
|
229
230
|
}
|
|
230
231
|
case 'roam_search_by_text': {
|
|
231
232
|
const params = cleanedArgs;
|
|
232
233
|
const result = await toolHandlers.searchByText(params);
|
|
233
|
-
return
|
|
234
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
235
|
-
};
|
|
234
|
+
return toolResult(request.params.name, result);
|
|
236
235
|
}
|
|
237
236
|
case 'roam_search_by_date': {
|
|
238
237
|
const params = cleanedArgs;
|
|
239
238
|
const result = await toolHandlers.searchByDate(params);
|
|
240
|
-
return
|
|
241
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
242
|
-
};
|
|
239
|
+
return toolResult(request.params.name, result);
|
|
243
240
|
}
|
|
244
241
|
case 'roam_recall': {
|
|
245
242
|
const { sort_by = 'newest', filter_tag } = cleanedArgs;
|
|
246
243
|
const result = await toolHandlers.recall(sort_by, filter_tag);
|
|
247
|
-
return
|
|
248
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
249
|
-
};
|
|
244
|
+
return toolResult(request.params.name, result);
|
|
250
245
|
}
|
|
251
246
|
case 'roam_datomic_query': {
|
|
252
247
|
const { query, inputs } = cleanedArgs;
|
|
253
248
|
const result = await toolHandlers.executeDatomicQuery({ query, inputs });
|
|
254
|
-
return
|
|
255
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
256
|
-
};
|
|
249
|
+
return toolResult(request.params.name, result);
|
|
257
250
|
}
|
|
258
251
|
case 'roam_process_batch_actions': {
|
|
259
252
|
const { actions } = cleanedArgs;
|
|
260
253
|
const result = await toolHandlers.processBatch(actions);
|
|
261
|
-
return
|
|
262
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
263
|
-
};
|
|
254
|
+
return toolResult(request.params.name, result);
|
|
264
255
|
}
|
|
265
256
|
case 'roam_fetch_block': {
|
|
266
257
|
const { block_uid, depth, include_ancestors } = cleanedArgs;
|
|
267
258
|
const result = await toolHandlers.fetchBlock(block_uid, depth, include_ancestors);
|
|
268
|
-
return
|
|
269
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
270
|
-
};
|
|
259
|
+
return toolResult(request.params.name, result);
|
|
271
260
|
}
|
|
272
261
|
case 'roam_create_table': {
|
|
273
262
|
const { parent_uid, order, headers, rows } = cleanedArgs;
|
|
@@ -277,30 +266,22 @@ export class RoamServer {
|
|
|
277
266
|
headers,
|
|
278
267
|
rows
|
|
279
268
|
});
|
|
280
|
-
return
|
|
281
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
282
|
-
};
|
|
269
|
+
return toolResult(request.params.name, result);
|
|
283
270
|
}
|
|
284
271
|
case 'roam_move_block': {
|
|
285
272
|
const { block_uid, parent_uid, order = 'last' } = cleanedArgs;
|
|
286
273
|
const result = await toolHandlers.moveBlock(block_uid, parent_uid, order);
|
|
287
|
-
return
|
|
288
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
289
|
-
};
|
|
274
|
+
return toolResult(request.params.name, result);
|
|
290
275
|
}
|
|
291
276
|
case 'roam_update_page_markdown': {
|
|
292
277
|
const { title, markdown, dry_run = false } = cleanedArgs;
|
|
293
278
|
const result = await toolHandlers.updatePageMarkdown(title, markdown, dry_run);
|
|
294
|
-
return
|
|
295
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
296
|
-
};
|
|
279
|
+
return toolResult(request.params.name, result);
|
|
297
280
|
}
|
|
298
281
|
case 'roam_rename_page': {
|
|
299
282
|
const { old_title, uid, new_title } = cleanedArgs;
|
|
300
283
|
const result = await toolHandlers.renamePage({ old_title, uid, new_title });
|
|
301
|
-
return
|
|
302
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
303
|
-
};
|
|
284
|
+
return toolResult(request.params.name, result);
|
|
304
285
|
}
|
|
305
286
|
default:
|
|
306
287
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
@@ -3,6 +3,7 @@ import { generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-util
|
|
|
3
3
|
import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js';
|
|
4
4
|
import { isRateLimitError, createRateLimitError } from '../../shared/errors.js';
|
|
5
5
|
import { ensurePagesExist } from '../../shared/page-validator.js';
|
|
6
|
+
import { withRateLimitRetry } from '../../shared/retry.js';
|
|
6
7
|
// Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
|
|
7
8
|
const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g;
|
|
8
9
|
const DEFAULT_RATE_LIMIT_CONFIG = {
|
|
@@ -69,36 +70,22 @@ export class BatchOperations {
|
|
|
69
70
|
return obj;
|
|
70
71
|
}
|
|
71
72
|
/**
|
|
72
|
-
* Executes the batch operation
|
|
73
|
+
* Executes the batch operation, retrying while Roam throttles us.
|
|
74
|
+
*
|
|
75
|
+
* This used to be a hand-rolled loop identical to the one in
|
|
76
|
+
* `withRateLimitRetry`, which is precisely how `executeStagedBatch` came to
|
|
77
|
+
* be written with no retry at all — the logic was private here, so the next
|
|
78
|
+
* write path could not reuse it and simply went without. A half-written page
|
|
79
|
+
* and no undo was the cost.
|
|
80
|
+
*
|
|
81
|
+
* On exhaustion the shared helper rethrows Roam's original error rather than
|
|
82
|
+
* a synthesised one carrying `retryAfterMs`. `processBatch` never used that
|
|
83
|
+
* field's value for anything the caller saw except the backoff hint, which
|
|
84
|
+
* now comes from this instance's own config — a more honest source than
|
|
85
|
+
* whatever happened to be stapled to an error object.
|
|
73
86
|
*/
|
|
74
87
|
async executeWithRetry(batchActions) {
|
|
75
|
-
|
|
76
|
-
let delay = this.rateLimitConfig.initialDelayMs;
|
|
77
|
-
for (let attempt = 0; attempt <= this.rateLimitConfig.maxRetries; attempt++) {
|
|
78
|
-
try {
|
|
79
|
-
await roamBatchActions(this.graph, { actions: batchActions });
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
if (!isRateLimitError(error)) {
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
86
|
-
lastError = error;
|
|
87
|
-
if (attempt < this.rateLimitConfig.maxRetries) {
|
|
88
|
-
const waitTime = Math.min(delay, this.rateLimitConfig.maxDelayMs);
|
|
89
|
-
console.log(`[batch] Rate limited, retrying in ${waitTime}ms (attempt ${attempt + 1}/${this.rateLimitConfig.maxRetries})`);
|
|
90
|
-
await sleep(waitTime);
|
|
91
|
-
delay *= this.rateLimitConfig.backoffMultiplier;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
// Throw with rate limit context after all retries exhausted
|
|
96
|
-
const rateLimitError = new Error(`Rate limit exceeded after ${this.rateLimitConfig.maxRetries} retries. ` +
|
|
97
|
-
`Last error: ${lastError?.message || 'Unknown error'}. ` +
|
|
98
|
-
`Retry after ${this.rateLimitConfig.maxDelayMs}ms.`);
|
|
99
|
-
rateLimitError.isRateLimit = true;
|
|
100
|
-
rateLimitError.retryAfterMs = this.rateLimitConfig.maxDelayMs;
|
|
101
|
-
throw rateLimitError;
|
|
88
|
+
await withRateLimitRetry(() => roamBatchActions(this.graph, { actions: batchActions }), this.rateLimitConfig, (attempt, waitMs) => console.log(`[batch] Rate limited, retrying in ${waitMs}ms (attempt ${attempt}/${this.rateLimitConfig.maxRetries})`));
|
|
102
89
|
}
|
|
103
90
|
async processBatch(actions) {
|
|
104
91
|
// Step 0: Pre-validate all actions before any execution
|
|
@@ -244,10 +231,10 @@ export class BatchOperations {
|
|
|
244
231
|
}
|
|
245
232
|
}
|
|
246
233
|
// Check if it's a rate limit error
|
|
247
|
-
if (isRateLimitError(error)
|
|
234
|
+
if (isRateLimitError(error)) {
|
|
248
235
|
return {
|
|
249
236
|
success: false,
|
|
250
|
-
error: createRateLimitError(
|
|
237
|
+
error: createRateLimitError(this.rateLimitConfig.maxDelayMs),
|
|
251
238
|
validation_passed: true,
|
|
252
239
|
actions_attempted: batchActions.length
|
|
253
240
|
// No uid_map - nothing was committed
|
|
@@ -10,14 +10,11 @@
|
|
|
10
10
|
* server's mechanics and lives in a file. Guidelines are per-graph, live-edited
|
|
11
11
|
* from inside Roam, and answer "how does this user want their graph handled".
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* With neither set the tool reports disabled and never touches the graph.
|
|
13
|
+
* Read by default; set `guidelinesPage: false` on a graph to disable it there.
|
|
15
14
|
*/
|
|
16
15
|
import { PageOperations } from './pages.js';
|
|
17
16
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
18
|
-
/** The
|
|
19
|
-
* automatically — a graph must opt in via `guidelinesPage` or
|
|
20
|
-
* ROAM_GUIDELINES_PAGE. */
|
|
17
|
+
/** The shared convention, read by default and by Roam's own MCP server. */
|
|
21
18
|
export const DEFAULT_GUIDELINES_PAGE = 'roam/agent guidelines';
|
|
22
19
|
/**
|
|
23
20
|
* Short TTL: the point of a page over a config file is that an edit takes
|
|
@@ -30,7 +27,7 @@ export class GuidelinesOperations {
|
|
|
30
27
|
/**
|
|
31
28
|
* @param guidelinesPage Page title to read, or null when disabled for this graph.
|
|
32
29
|
*/
|
|
33
|
-
constructor(graph, guidelinesPage =
|
|
30
|
+
constructor(graph, guidelinesPage = DEFAULT_GUIDELINES_PAGE) {
|
|
34
31
|
this.graph = graph;
|
|
35
32
|
this.guidelinesPage = guidelinesPage;
|
|
36
33
|
this.pageOps = new PageOperations(graph);
|
|
@@ -313,7 +313,7 @@ export class OutlineOperations {
|
|
|
313
313
|
success: true,
|
|
314
314
|
page_uid: targetPageUid,
|
|
315
315
|
parent_uid: targetParentUid,
|
|
316
|
-
|
|
316
|
+
created_blocks: createdBlocks
|
|
317
317
|
};
|
|
318
318
|
}
|
|
319
319
|
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
|
|
@@ -377,7 +377,7 @@ export class OutlineOperations {
|
|
|
377
377
|
// Roam's write API does not echo back transacted UIDs, so we report the
|
|
378
378
|
// UIDs we generated client-side rather than issuing a fragile post-write
|
|
379
379
|
// re-query (which was also skipped entirely past VERIFICATION_THRESHOLD,
|
|
380
|
-
// leaving
|
|
380
|
+
// leaving created_blocks empty for larger imports).
|
|
381
381
|
const { actions, blocks } = convertToRoamActionsWithBlocks(nodes, targetParentUid, order);
|
|
382
382
|
// Execute batch actions to add content
|
|
383
383
|
await executeBatch(this.graph, actions, 'import nested markdown content');
|
|
@@ -385,7 +385,7 @@ export class OutlineOperations {
|
|
|
385
385
|
success: true,
|
|
386
386
|
page_uid: targetPageUid,
|
|
387
387
|
parent_uid: targetParentUid,
|
|
388
|
-
|
|
388
|
+
created_blocks: blockInfoToNestedBlocks(blocks)
|
|
389
389
|
};
|
|
390
390
|
}
|
|
391
391
|
else {
|
|
@@ -399,11 +399,11 @@ export class OutlineOperations {
|
|
|
399
399
|
block: { string: content }
|
|
400
400
|
}], 'create content block');
|
|
401
401
|
// For single-line content, we still need to fetch the UID and construct a NestedBlock
|
|
402
|
-
const
|
|
402
|
+
const createdBlocks = [];
|
|
403
403
|
try {
|
|
404
404
|
const foundUid = await this.findBlockWithRetry(targetParentUid, content);
|
|
405
405
|
if (foundUid) {
|
|
406
|
-
|
|
406
|
+
createdBlocks.push({
|
|
407
407
|
uid: foundUid,
|
|
408
408
|
text: content,
|
|
409
409
|
level: 0,
|
|
@@ -420,7 +420,7 @@ export class OutlineOperations {
|
|
|
420
420
|
success: true,
|
|
421
421
|
page_uid: targetPageUid,
|
|
422
422
|
parent_uid: targetParentUid,
|
|
423
|
-
|
|
423
|
+
created_blocks: createdBlocks
|
|
424
424
|
};
|
|
425
425
|
}
|
|
426
426
|
}
|
|
@@ -155,7 +155,7 @@ export class PageOperations {
|
|
|
155
155
|
if (existingBlockCount && existingBlockCount > 0) {
|
|
156
156
|
// Page already has content - this might be a duplicate call
|
|
157
157
|
// Return success without adding duplicate content
|
|
158
|
-
return { success: true,
|
|
158
|
+
return { success: true, page_uid: pageUid };
|
|
159
159
|
}
|
|
160
160
|
// Process content items in order, tracking position for correct placement
|
|
161
161
|
// Tables and text blocks are interleaved at their original positions
|
|
@@ -323,7 +323,7 @@ export class PageOperations {
|
|
|
323
323
|
location: { 'parent-uid': pageUid, order: 'last' },
|
|
324
324
|
block: { string: `Processed: [[${formattedTodayTitle}]]` }
|
|
325
325
|
}], 'add Processed block');
|
|
326
|
-
return { success: true,
|
|
326
|
+
return { success: true, page_uid: pageUid };
|
|
327
327
|
}
|
|
328
328
|
/**
|
|
329
329
|
* Get the UID for a page by its title.
|
|
@@ -647,7 +647,7 @@ export class PageOperations {
|
|
|
647
647
|
success: true,
|
|
648
648
|
actions,
|
|
649
649
|
stats,
|
|
650
|
-
|
|
650
|
+
preserved_uids: [...diff.preservedUids],
|
|
651
651
|
summary: dryRun ? `[DRY RUN] ${summary}` : summary
|
|
652
652
|
};
|
|
653
653
|
}
|