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 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 in `--server` mode (defaults to `127.0.0.1`, loopback-only). Set to `0.0.0.0` to expose on the LAN.
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 + HTTP)**
263
- Best for local integration (e.g., Claude Desktop, IDE extensions). The MCP client launches the process per session over stdio; an HTTP Stream transport is also opened on an auto-discovered port near `HTTP_STREAM_PORT`.
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
- </personalization_layer>
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.uid) {
251
- context.pageUids.set(title, result.uid);
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.uid);
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.uid });
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.uid);
318
+ context.placeholders.set(pc.params.as, result.page_uid);
319
319
  }
320
320
  if (options.debug) {
321
- printDebug(`Created "${pc.params.title}"`, result.uid);
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.preservedUids.length > 0) {
434
- console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
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.uid})`);
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 { findAvailablePort, isPortInUse } from '../utils/net.js';
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
- // In --server (daemon) mode we run HTTP-only: no client reads the stdio
329
- // transport, so we skip it. Otherwise behavior is unchanged (stdio + HTTP).
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
- mode: serverMode ? 'server' : 'stdio+http',
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
- if (serverMode) {
459
- // A shared daemon must own a stable URL — never silently drift to another
460
- // port. Fail loudly if the configured port is already taken on our bind
461
- // host (a listener on a different interface is not our conflict).
462
- if (await isPortInUse(desiredPort, HTTP_STREAM_HOST)) {
463
- throw new McpError(ErrorCode.InternalError, `--server: port ${desiredPort} (HTTP_STREAM_PORT) is already in use. ` +
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);