roam-research-mcp 2.25.0 → 3.1.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 +38 -6
- package/build/Roam_Markdown_Cheatsheet.md +179 -3
- package/build/cli/commands/batch.js +6 -6
- package/build/cli/commands/save.js +3 -3
- package/build/server/roam-server.js +68 -84
- package/build/tools/operations/batch.js +17 -30
- 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/utils/net.js +8 -17
- package/package.json +1 -1
- package/build/cli/commands/update.test.js +0 -69
- package/build/config/graph-registry.test.js +0 -172
- 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 -96
- 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)
|
|
@@ -231,7 +249,7 @@ ROAM_SYSTEM_WRITE_KEY=your-secret-key
|
|
|
231
249
|
|----------|----------|-------------|
|
|
232
250
|
| `token` | Yes | Roam API token for this graph |
|
|
233
251
|
| `graph` | Yes | Graph name/database identifier |
|
|
234
|
-
| `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 |
|
|
235
253
|
| `memoriesTag` | No | Tag for `roam_remember`/`roam_recall` (overrides global default) |
|
|
236
254
|
|
|
237
255
|
**Two kinds of access control (and how they differ)**
|
|
@@ -251,16 +269,20 @@ Think of a house: the **bearer token locks the front door** (keeps strangers out
|
|
|
251
269
|
|
|
252
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.
|
|
253
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
|
+
|
|
254
274
|
*Optional:*
|
|
255
275
|
- `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
|
|
256
|
-
- `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088).
|
|
257
|
-
- `HTTP_STREAM_HOST`: Host to bind the HTTP transport to
|
|
276
|
+
- `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088). **`--server` mode only** — stdio mode opens no socket, so this is ignored there.
|
|
277
|
+
- `HTTP_STREAM_HOST`: Host to bind the HTTP transport to (defaults to `127.0.0.1`, loopback-only). **`--server` mode only.** Set to `0.0.0.0` to expose on the LAN, and set `HTTP_AUTH_TOKEN` when you do.
|
|
258
278
|
- `HTTP_AUTH_TOKEN`: Optional bearer token that locks the **whole** HTTP endpoint. Unset = open (fine for loopback). When set, every MCP request must send `Authorization: Bearer <token>` (`GET /health` stays open). Use it whenever you bind beyond `127.0.0.1`. Different from `ROAM_SYSTEM_WRITE_KEY` — see [Two kinds of access control](#two-kinds-of-access-control-and-how-they-differ).
|
|
259
279
|
|
|
260
280
|
### Running the Server
|
|
261
281
|
|
|
262
|
-
**1. Default Mode (stdio
|
|
263
|
-
Best for local integration (e.g., Claude Desktop, IDE extensions). The MCP client launches the process per session
|
|
282
|
+
**1. Default Mode (stdio)**
|
|
283
|
+
Best for local integration (e.g., Claude Desktop, IDE extensions). The MCP client launches the process per session and talks to it over stdin/stdout. **No port is opened** — nothing about MCP over stdio needs one.
|
|
284
|
+
|
|
285
|
+
> Before 3.1.0 this mode *also* opened an HTTP listener, and bound it to every interface. If you were using that endpoint, run a `--server` daemon instead; see below.
|
|
264
286
|
|
|
265
287
|
```bash
|
|
266
288
|
npx roam-research-mcp
|
|
@@ -285,7 +307,7 @@ roam server stop # stop a CLI-started daemon
|
|
|
285
307
|
|
|
286
308
|
`roam server status` works no matter how the daemon was launched (it probes `/health`), so it also reports a daemon started by a LaunchAgent/systemd unit. State (pidfile + log) lives in `~/.roam/` (override with `ROAM_HOME`).
|
|
287
309
|
|
|
288
|
-
In `--server` mode the server:
|
|
310
|
+
The two modes are mutually exclusive, and each opens exactly one transport: stdio mode speaks stdio and binds nothing, `--server` speaks HTTP and reads no stdin. In `--server` mode the server:
|
|
289
311
|
- runs **HTTP-only** (no stdio transport),
|
|
290
312
|
- binds the **exact** `HTTP_STREAM_PORT` on `HTTP_STREAM_HOST` and **exits non-zero if the port is taken** (no silent drift — a shared daemon must keep a stable URL),
|
|
291
313
|
- exposes `GET /health` → `{"status":"ok", ...}` for liveness checks.
|
|
@@ -350,6 +372,16 @@ docker run -p 8088:8088 --env-file .env roam-research-mcp --server
|
|
|
350
372
|
|
|
351
373
|
Add to your MCP settings file (e.g., `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
352
374
|
|
|
375
|
+
> **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:
|
|
376
|
+
>
|
|
377
|
+
> | `args` | You get |
|
|
378
|
+
> | :--- | :--- |
|
|
379
|
+
> | `["-y", "roam-research-mcp"]` | Latest, always — including the next major |
|
|
380
|
+
> | `["-y", "roam-research-mcp@3"]` | 3.x only; majors need an edit here |
|
|
381
|
+
> | `["-y", "roam-research-mcp@3.0.0"]` | Exactly this build |
|
|
382
|
+
>
|
|
383
|
+
> 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.
|
|
384
|
+
|
|
353
385
|
*Single Graph:*
|
|
354
386
|
```json
|
|
355
387
|
{
|
|
@@ -244,8 +244,6 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
|
|
|
244
244
|
**Open question:** `{{[[TODO]]}} Research: <question> #[[open questions]]`
|
|
245
245
|
|
|
246
246
|
---
|
|
247
|
-
<personalization_layer>
|
|
248
|
-
|
|
249
247
|
# Roam Preferences — Personalization Layer
|
|
250
248
|
|
|
251
249
|
> This section contains YOUR specific conventions, tagging philosophy, and graph-specific rules. Customize to match your workflow.
|
|
@@ -254,21 +252,199 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
|
|
|
254
252
|
|
|
255
253
|
## Graph-Level Behaviors
|
|
256
254
|
|
|
255
|
+
### On Creating New Pages
|
|
256
|
+
<!-- CUSTOMIZE: What should happen when a new page is created? -->
|
|
257
|
+
- After creating a new page, add a reference block on today's daily page: `Created page: [[New Page Name]]`
|
|
258
|
+
- <!-- Add any naming conventions, required metadata, etc. -->
|
|
259
|
+
|
|
260
|
+
### On Adding Content
|
|
261
|
+
<!-- CUSTOMIZE: Any rules about where/how content gets added? -->
|
|
262
|
+
- Default location for quick captures: Daily page
|
|
263
|
+
- Long-form content: Create dedicated page, link from daily page
|
|
264
|
+
- <!-- Your preferences here -->
|
|
265
|
+
|
|
266
|
+
---
|
|
257
267
|
|
|
258
268
|
## Tagging Philosophy
|
|
259
269
|
|
|
270
|
+
### Core Principle
|
|
271
|
+
> Tag for **intellectual collision** and **future discovery**, not just categorization. Every tag should maximize potential for unexpected connections.
|
|
272
|
+
|
|
273
|
+
### The Serendipity Test
|
|
274
|
+
Before tagging, ask: *"Could this concept surprise me by connecting to something completely unrelated?"*
|
|
275
|
+
|
|
276
|
+
### What To Tag — Decision Framework
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
ASK YOURSELF:
|
|
280
|
+
┌─ How will Future Me find this?
|
|
281
|
+
│ └─ Tag by retrieval context, not just content
|
|
282
|
+
│
|
|
283
|
+
├─ What domain does this belong to?
|
|
284
|
+
│ └─ Use broad category tags: #[[knowledge management]], #[[decision-making]]
|
|
285
|
+
│
|
|
286
|
+
├─ Is this a proper noun?
|
|
287
|
+
│ └─ YES → Wrap name (no titles): [[Werner Erhard]], [[NASA]]
|
|
288
|
+
│ └─ For abbreviations: [NASA]([[National Aeronautics and Space Administration (NASA)]])
|
|
289
|
+
│
|
|
290
|
+
├─ Could this alias to existing page?
|
|
291
|
+
│ └─ YES → [displayed phrase]([[existing page name]])
|
|
292
|
+
│ └─ Example: [frameworks for decisions]([[decision-making frameworks]])
|
|
293
|
+
│
|
|
294
|
+
└─ Parent block with children?
|
|
295
|
+
└─ Tag parent when category applies to all children
|
|
296
|
+
└─ Tag individual children for specific categorization
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Tag Type Selection
|
|
300
|
+
|
|
301
|
+
| Use This | When |
|
|
302
|
+
|----------|------|
|
|
303
|
+
| `[[Page Reference]]` | Concept deserves its own page, will be expanded |
|
|
304
|
+
| `#[[hashtag]]` | Categorization, filtering, won't be a standalone page |
|
|
305
|
+
| `#single-word` | Simple, unambiguous category |
|
|
306
|
+
| Attribute `Type::` | Structured metadata for queries |
|
|
307
|
+
|
|
308
|
+
### WHEN creating Endnotes/Footnotes:
|
|
309
|
+
- Find/Create the block with heading "Footnotes::" and nest footnote item below. (Footnotes do not need to be on the same page as the block to which it references. Typically on the same page unless instructed otherwise.)
|
|
310
|
+
- If not known, retrieve the block_uid reference for this footnote item.
|
|
311
|
+
- In the block referencing the footnote, append the reference with footnote-item-block_id, example: "- <block_text> #ref ((block_uid))"
|
|
312
|
+
|
|
313
|
+
### Structural Tagging (Beyond Content)
|
|
314
|
+
|
|
315
|
+
Tag by **patterns and mechanisms**, not just subjects:
|
|
316
|
+
|
|
317
|
+
| Structural Tag | Connects |
|
|
318
|
+
|----------------|----------|
|
|
319
|
+
| `#[[has feedback loops]]` | Systems, habits, markets, conversations |
|
|
320
|
+
| `#[[requires calibration]]` | Instruments, relationships, AI prompts |
|
|
321
|
+
| `#[[exhibits emergence]]` | Complexity, culture, creativity |
|
|
322
|
+
| `#[[perspective switching]]` | Photography, negotiation, analysis |
|
|
323
|
+
| `#[[flow dynamics]]` | Fluids, music, conversation, sequences |
|
|
324
|
+
|
|
325
|
+
### Problem-Oriented Tagging
|
|
326
|
+
|
|
327
|
+
Tag by problems solved, not methods used:
|
|
328
|
+
|
|
329
|
+
- `#[[breaking cognitive constraints]]`
|
|
330
|
+
- `#[[expanding solution spaces]]`
|
|
331
|
+
- `#[[preventing expert blindness]]`
|
|
332
|
+
|
|
333
|
+
### Temporal & State-Based Tags
|
|
334
|
+
|
|
335
|
+
| Tag Type | Examples |
|
|
336
|
+
|----------|----------|
|
|
337
|
+
| Future relevance | `#[[will be relevant in 5 years]]`, `#[[connects to unborn projects]]` |
|
|
338
|
+
| Mental state triggers | `#[[feeling stuck in patterns]]`, `#[[needing fresh perspective]]` |
|
|
339
|
+
| Review scheduling | `[[For review]]: [[August 12th, 2026]]` |
|
|
340
|
+
|
|
341
|
+
---
|
|
260
342
|
|
|
261
343
|
## Formatting Conventions
|
|
262
344
|
|
|
345
|
+
### Quotes
|
|
346
|
+
```
|
|
347
|
+
<quote text> —[[Author Name]] #quote #[[topic1]] #[[topic2]]
|
|
348
|
+
```
|
|
349
|
+
Always include 2-3 relevant hashtags after quotes.
|
|
350
|
+
|
|
351
|
+
### TODOs and Follow-ups
|
|
352
|
+
```
|
|
353
|
+
{{[[TODO]]}} <action needed>
|
|
354
|
+
{{[[TODO]]}} #researchThis : <topic to investigate>
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Scheduled Reviews
|
|
358
|
+
|
|
359
|
+
- Any block tagged with a date will show on that respective daily page.
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
[[For review]]: [[Date in ordinal format]]
|
|
363
|
+
```
|
|
364
|
+
Optional labels: "Deadline", "Approved", "Pending", "Deferred", "Postponed until"
|
|
365
|
+
|
|
366
|
+
### Aliasing for Case Sensitivity
|
|
367
|
+
When a tag would awkwardly affect sentence capitalization:
|
|
368
|
+
```
|
|
369
|
+
[Cognitive biases]([[cognitive biases]]) affect decision-making...
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Definitions (OVERRIDE)
|
|
373
|
+
```
|
|
374
|
+
#def [[<term>]] : <definition>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
263
378
|
|
|
264
379
|
## Constraints & Guardrails
|
|
265
380
|
|
|
381
|
+
### DON'T
|
|
382
|
+
- **Overtag** — Quality over quantity; each tag should earn its place
|
|
383
|
+
- **Tag obvious/redundant** — If parent block is tagged, children inherit context
|
|
384
|
+
- **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
|
|
385
|
+
- **Create orphan tags** — Check if existing page/tag serves the purpose
|
|
386
|
+
- **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
|
|
387
|
+
- **Separators** - `---` Don't use them.
|
|
388
|
+
|
|
389
|
+
### DO
|
|
390
|
+
- **Think retrieval-first** — How will you search for this later?
|
|
391
|
+
- **Cross-pollinate domains** — Force unlikely intellectual meetings
|
|
392
|
+
- **Update aging tags** — As interests evolve, so should tag vocabulary
|
|
393
|
+
- **Track surprise discoveries** — When unexpected connections yield insights, engineer more of those patterns
|
|
394
|
+
|
|
395
|
+
---
|
|
266
396
|
|
|
267
397
|
## Custom Rules
|
|
268
398
|
|
|
399
|
+
<!--
|
|
400
|
+
CUSTOMIZE THIS SECTION with your specific conventions:
|
|
401
|
+
- Naming patterns for certain page types
|
|
402
|
+
- Required attributes for books/articles/people
|
|
403
|
+
- Project-specific tagging schemes
|
|
404
|
+
- Integration rules with other tools
|
|
405
|
+
- etc.
|
|
406
|
+
-->
|
|
407
|
+
|
|
408
|
+
### Example Custom Rules (modify as needed):
|
|
409
|
+
|
|
410
|
+
**Books:**
|
|
411
|
+
```
|
|
412
|
+
[[Book/<title> | <author>]]
|
|
413
|
+
Type:: Book
|
|
414
|
+
Author:: [[Author Name]]
|
|
415
|
+
Status:: Reading | Completed | Abandoned
|
|
416
|
+
Rating:: X/5
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**People:**
|
|
420
|
+
```
|
|
421
|
+
[[Person Name]]
|
|
422
|
+
Type:: Person
|
|
423
|
+
Context:: How I know them
|
|
424
|
+
```
|
|
425
|
+
- When linking bibliographic references —>
|
|
426
|
+
Example: `McAdams, D.P. (2001) [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100) — foundational paper`
|
|
427
|
+
- [McAdams, D.P.]([[Dan McAdams]]) - author's name in the graph
|
|
428
|
+
- If source URL, link to source: [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100)
|
|
429
|
+
- If notes page exists or will exist in Roam: append ` | [Notes]([[Article/The Psychology of Life Stories]]), if not, just leave it without link.
|
|
430
|
+
|
|
431
|
+
**Projects:**
|
|
432
|
+
```
|
|
433
|
+
[[Project/<project anme>]]
|
|
434
|
+
Status:: Active | Paused | Completed
|
|
435
|
+
Start:: [[Date]]
|
|
436
|
+
```
|
|
437
|
+
---
|
|
269
438
|
|
|
270
439
|
## Integration Notes
|
|
271
440
|
|
|
272
441
|
<!-- CUSTOMIZE: Any rules about how Roam integrates with your other tools/systems -->
|
|
273
442
|
|
|
274
|
-
|
|
443
|
+
- Daily pages serve as: <!-- inbox / journal / task list / etc. -->
|
|
444
|
+
- Weekly reviews occur on: <!-- day of week -->
|
|
445
|
+
- Content flows from: <!-- capture tools, read-later apps, etc. -->
|
|
446
|
+
- Content flows to: <!-- publishing, archives, etc. -->
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
*End of Personalization Layer*
|
|
@@ -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}'`);
|
|
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
|
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { createServer } from 'node:http';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import {
|
|
15
|
+
import { isPortInUse } from '../utils/net.js';
|
|
16
16
|
import { CORS_ORIGINS } from '../config/environment.js';
|
|
17
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
18
|
const __dirname = dirname(__filename);
|
|
@@ -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}`);
|
|
@@ -325,12 +306,18 @@ export class RoamServer {
|
|
|
325
306
|
async run(options = {}) {
|
|
326
307
|
const { serverMode = false } = options;
|
|
327
308
|
try {
|
|
328
|
-
//
|
|
329
|
-
//
|
|
309
|
+
// The two transports are mutually exclusive, and each mode opens exactly
|
|
310
|
+
// one. Stdio mode talks to the client that spawned it over stdin/stdout
|
|
311
|
+
// and returns here — it opens no socket at all. Nothing about MCP over
|
|
312
|
+
// stdio needs one, and a listener nobody asked for is pure attack
|
|
313
|
+
// surface: until 3.1.0 stdio mode also bound an HTTP port, which shipped
|
|
314
|
+
// a token-free MCP endpoint per spawned instance. Run `--server` when
|
|
315
|
+
// you want HTTP; that is what it is for.
|
|
330
316
|
if (!serverMode) {
|
|
331
317
|
const stdioMcpServer = this.createMcpServer();
|
|
332
318
|
const stdioTransport = new StdioServerTransport();
|
|
333
319
|
await stdioMcpServer.connect(stdioTransport);
|
|
320
|
+
return;
|
|
334
321
|
}
|
|
335
322
|
// Track active transports by session ID for proper session management
|
|
336
323
|
const activeSessions = new Map();
|
|
@@ -362,7 +349,11 @@ export class RoamServer {
|
|
|
362
349
|
status: 'ok',
|
|
363
350
|
name: 'roam-research-mcp',
|
|
364
351
|
version: serverVersion,
|
|
365
|
-
|
|
352
|
+
// Always 'server' since 3.1.0: this handler is only reachable in
|
|
353
|
+
// --server mode. Kept as a field because clients read it, and the
|
|
354
|
+
// retired 'stdio+http' value is how they can tell they are talking
|
|
355
|
+
// to an older build that still had the stdio-mode listener.
|
|
356
|
+
mode: 'server',
|
|
366
357
|
auth: HTTP_AUTH_TOKEN ? 'required' : 'none',
|
|
367
358
|
graphs: this.registry.getAvailableGraphs(),
|
|
368
359
|
defaultGraph: this.registry.defaultKey,
|
|
@@ -455,24 +446,17 @@ export class RoamServer {
|
|
|
455
446
|
}
|
|
456
447
|
});
|
|
457
448
|
const desiredPort = parseInt(HTTP_STREAM_PORT);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
`Stop the process using it, or set HTTP_STREAM_PORT to a free port.`);
|
|
465
|
-
}
|
|
466
|
-
httpServer.listen(desiredPort, HTTP_STREAM_HOST, () => {
|
|
467
|
-
console.error(`roam-research-mcp v${serverVersion} (--server) listening on ` +
|
|
468
|
-
`http://${HTTP_STREAM_HOST}:${desiredPort}/ (health: /health)`);
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
else {
|
|
472
|
-
const availableHttpPort = await findAvailablePort(desiredPort);
|
|
473
|
-
httpServer.listen(availableHttpPort, () => {
|
|
474
|
-
});
|
|
449
|
+
// A shared daemon must own a stable URL — never silently drift to another
|
|
450
|
+
// port. Fail loudly if the configured port is already taken on our bind
|
|
451
|
+
// host (a listener on a different interface is not our conflict).
|
|
452
|
+
if (await isPortInUse(desiredPort, HTTP_STREAM_HOST)) {
|
|
453
|
+
throw new McpError(ErrorCode.InternalError, `--server: port ${desiredPort} (HTTP_STREAM_PORT) is already in use. ` +
|
|
454
|
+
`Stop the process using it, or set HTTP_STREAM_PORT to a free port.`);
|
|
475
455
|
}
|
|
456
|
+
httpServer.listen(desiredPort, HTTP_STREAM_HOST, () => {
|
|
457
|
+
console.error(`roam-research-mcp v${serverVersion} (--server) listening on ` +
|
|
458
|
+
`http://${HTTP_STREAM_HOST}:${desiredPort}/ (health: /health)`);
|
|
459
|
+
});
|
|
476
460
|
}
|
|
477
461
|
catch (error) {
|
|
478
462
|
const errorMessage = error instanceof Error ? error.message : String(error);
|