roam-research-mcp 2.18.0 → 2.22.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
@@ -46,6 +46,12 @@ roam search --namespace "Convention" # Finds all Convention/* pages
46
46
  # Fetch a page by title
47
47
  roam get "Roam Research"
48
48
 
49
+ # Fetch daily pages using any date format (auto-normalized)
50
+ roam get today # Today's daily page
51
+ roam get 2026-03-21 # ISO date → "March 21st, 2026"
52
+ roam get "03/21/2026" # US date → "March 21st, 2026"
53
+ roam get "March 21" # Named (assumes current year)
54
+
49
55
  # Fetch a block with ancestors (parent chain to page root)
50
56
  roam get abc123def -a # Block + children + ancestors
51
57
  roam get abc123def -a -d 0 # Ancestors only, no children
@@ -70,7 +76,7 @@ roam get "Page Title" -g work
70
76
  roam save "Note" -g work --write-key "$ROAM_SYSTEM_WRITE_KEY"
71
77
  ```
72
78
 
73
- **Available Commands:** `get`, `search`, `save`, `refs`, `update`, `batch`, `rename`, `status`.
79
+ **Available Commands:** `get`, `search`, `save`, `refs`, `update`, `batch`, `rename`, `status`, `server`.
74
80
  Run `roam <command> --help` for details on any command.
75
81
 
76
82
  ### Installation
@@ -154,30 +160,97 @@ Protected graphs require the `write_key` parameter matching `ROAM_SYSTEM_WRITE_K
154
160
 
155
161
  *Optional:*
156
162
  - `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
157
- - `HTTP_STREAM_PORT`: To enable HTTP Stream (defaults to 8088).
163
+ - `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088).
164
+ - `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.
165
+ - `HTTP_AUTH_TOKEN`: Optional bearer token for the HTTP MCP endpoint (**authentication**). Unset = open (fine for loopback). When set, every HTTP MCP request must send `Authorization: Bearer <token>` (`GET /health` stays open). Use it whenever you bind beyond `127.0.0.1`. This is separate from `ROAM_SYSTEM_WRITE_KEY` (per-graph write authorization).
158
166
 
159
167
  ### Running the Server
160
168
 
161
- **1. Stdio Mode (Default)**
162
- Best for local integration (e.g., Claude Desktop, IDE extensions).
169
+ **1. Default Mode (stdio + HTTP)**
170
+ 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`.
163
171
 
164
172
  ```bash
165
173
  npx roam-research-mcp
166
174
  ```
167
175
 
168
- Note: Stdio mode does not use any network ports.
176
+ **2. Shared Server Mode (`--server`)**
177
+ Best for a single long-lived, HTTP-only daemon that **multiple MCP clients share** — instead of each session spawning its own subprocess. This saves memory and gives clients a stable URL.
178
+
179
+ ```bash
180
+ HTTP_STREAM_PORT=8088 npx roam-research-mcp --server
181
+ ```
182
+
183
+ Or manage it through the `roam` CLI, which adds start/stop/status/logs:
184
+
185
+ ```bash
186
+ roam server start # start the shared daemon in the background
187
+ roam server start -H 0.0.0.0 # expose on the LAN (no transport auth!)
188
+ roam server status # is it up? version, graphs, active sessions
189
+ roam server logs -f # follow the log
190
+ roam server stop # stop a CLI-started daemon
191
+ ```
192
+
193
+ `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`).
194
+
195
+ In `--server` mode the server:
196
+ - runs **HTTP-only** (no stdio transport),
197
+ - 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),
198
+ - exposes `GET /health` → `{"status":"ok", ...}` for liveness checks.
199
+
200
+ Point MCP clients at it with an HTTP transport config:
201
+
202
+ ```json
203
+ {
204
+ "mcpServers": {
205
+ "roam-research-mcp": {
206
+ "type": "http",
207
+ "url": "http://127.0.0.1:8088/mcp"
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ Env vars (tokens, graphs) live with the **server** process, not the client config.
214
+
215
+ **Securing an exposed server (two layers):**
216
+ If you bind beyond loopback (`-H 0.0.0.0`), add the perimeter lock:
217
+
218
+ ```bash
219
+ HTTP_AUTH_TOKEN=$(openssl rand -hex 32) roam server start -H 0.0.0.0
220
+ ```
221
+
222
+ Clients then send the token as a header:
223
+
224
+ ```json
225
+ {
226
+ "mcpServers": {
227
+ "roam-research-mcp": {
228
+ "type": "http",
229
+ "url": "http://<host>:8088/mcp",
230
+ "headers": { "Authorization": "Bearer <token>" }
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ These are two **distinct** layers — keep both:
237
+ - **`HTTP_AUTH_TOKEN`** = *authentication* (who may connect). Gates **all** requests — reads and writes, every graph.
238
+ - **`ROAM_SYSTEM_WRITE_KEY`** = *authorization* (what a connected caller may do). Gates only **writes** to `protected` graphs; it does **not** protect reads.
239
+
240
+ > ⚠️ `write_key` is **not** transport auth. On an exposed server without `HTTP_AUTH_TOKEN`, anyone on the network can still **read every graph** and write non-protected graphs. For anything beyond loopback, set `HTTP_AUTH_TOKEN`.
169
241
 
170
- **2. HTTP Stream Mode**
171
- Best for remote access or web clients.
242
+ **Keeping it running (macOS LaunchAgent):**
243
+ Create `~/Library/LaunchAgents/com.example.roam-mcp.plist` with `RunAtLoad` + `KeepAlive`, your env vars under `EnvironmentVariables`, and `--server` as the last `ProgramArguments` entry. Keep `StandardOutPath`/`StandardErrorPath` on a **local** path (e.g. `~/Library/Logs/`), then:
172
244
 
173
245
  ```bash
174
- HTTP_STREAM_PORT=8088 npx roam-research-mcp
246
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.roam-mcp.plist
247
+ curl -s http://127.0.0.1:8088/health # verify
175
248
  ```
176
249
 
177
250
  **3. Docker**
178
251
 
179
252
  ```bash
180
- docker run -p 8088:8088 --env-file .env roam-research-mcp
253
+ docker run -p 8088:8088 --env-file .env roam-research-mcp --server
181
254
  ```
182
255
 
183
256
  ### Configuring in LLMs
@@ -184,6 +184,12 @@ CREATING:
184
184
  ├─ Memory → roam_remember
185
185
  └─ Todos → roam_add_todo
186
186
 
187
+ NESTING (roam_import_markdown): nests by BOTH indentation AND markdown heading
188
+ level — content and deeper headings fold under their heading (## under #, ### under
189
+ ##). Use it for heading-structured docs. roam_create_page nests ONLY by explicit
190
+ per-item `level` integers, so heading-structured markdown sent to create_page
191
+ imports flat. Don't use `---` dividers; they become horizontal-rule blocks.
192
+
187
193
  SEARCHING:
188
194
  ├─ By tag → roam_search_for_tag
189
195
  ├─ By text → roam_search_by_text
@@ -237,8 +243,6 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
237
243
  **Open question:** `{{[[TODO]]}} Research: <question> #[[open questions]]`
238
244
 
239
245
  ---
240
- <personalization_layer>
241
-
242
246
  # Roam Preferences — Personalization Layer
243
247
 
244
248
  > This section contains YOUR specific conventions, tagging philosophy, and graph-specific rules. Customize to match your workflow.
@@ -247,21 +251,199 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
247
251
 
248
252
  ## Graph-Level Behaviors
249
253
 
254
+ ### On Creating New Pages
255
+ <!-- CUSTOMIZE: What should happen when a new page is created? -->
256
+ - After creating a new page, add a reference block on today's daily page: `Created page: [[New Page Name]]`
257
+ - <!-- Add any naming conventions, required metadata, etc. -->
258
+
259
+ ### On Adding Content
260
+ <!-- CUSTOMIZE: Any rules about where/how content gets added? -->
261
+ - Default location for quick captures: Daily page
262
+ - Long-form content: Create dedicated page, link from daily page
263
+ - <!-- Your preferences here -->
264
+
265
+ ---
250
266
 
251
267
  ## Tagging Philosophy
252
268
 
269
+ ### Core Principle
270
+ > Tag for **intellectual collision** and **future discovery**, not just categorization. Every tag should maximize potential for unexpected connections.
271
+
272
+ ### The Serendipity Test
273
+ Before tagging, ask: *"Could this concept surprise me by connecting to something completely unrelated?"*
274
+
275
+ ### What To Tag — Decision Framework
276
+
277
+ ```
278
+ ASK YOURSELF:
279
+ ┌─ How will Future Me find this?
280
+ │ └─ Tag by retrieval context, not just content
281
+
282
+ ├─ What domain does this belong to?
283
+ │ └─ Use broad category tags: #[[knowledge management]], #[[decision-making]]
284
+
285
+ ├─ Is this a proper noun?
286
+ │ └─ YES → Wrap name (no titles): [[Werner Erhard]], [[NASA]]
287
+ │ └─ For abbreviations: [NASA]([[National Aeronautics and Space Administration (NASA)]])
288
+
289
+ ├─ Could this alias to existing page?
290
+ │ └─ YES → [displayed phrase]([[existing page name]])
291
+ │ └─ Example: [frameworks for decisions]([[decision-making frameworks]])
292
+
293
+ └─ Parent block with children?
294
+ └─ Tag parent when category applies to all children
295
+ └─ Tag individual children for specific categorization
296
+ ```
297
+
298
+ ### Tag Type Selection
299
+
300
+ | Use This | When |
301
+ |----------|------|
302
+ | `[[Page Reference]]` | Concept deserves its own page, will be expanded |
303
+ | `#[[hashtag]]` | Categorization, filtering, won't be a standalone page |
304
+ | `#single-word` | Simple, unambiguous category |
305
+ | Attribute `Type::` | Structured metadata for queries |
306
+
307
+ ### WHEN creating Endnotes/Footnotes:
308
+ - 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.)
309
+ - If not known, retrieve the block_uid reference for this footnote item.
310
+ - In the block referencing the footnote, append the reference with footnote-item-block_id, example: "- <block_text> #ref ((block_uid))"
311
+
312
+ ### Structural Tagging (Beyond Content)
313
+
314
+ Tag by **patterns and mechanisms**, not just subjects:
315
+
316
+ | Structural Tag | Connects |
317
+ |----------------|----------|
318
+ | `#[[has feedback loops]]` | Systems, habits, markets, conversations |
319
+ | `#[[requires calibration]]` | Instruments, relationships, AI prompts |
320
+ | `#[[exhibits emergence]]` | Complexity, culture, creativity |
321
+ | `#[[perspective switching]]` | Photography, negotiation, analysis |
322
+ | `#[[flow dynamics]]` | Fluids, music, conversation, sequences |
323
+
324
+ ### Problem-Oriented Tagging
325
+
326
+ Tag by problems solved, not methods used:
327
+
328
+ - `#[[breaking cognitive constraints]]`
329
+ - `#[[expanding solution spaces]]`
330
+ - `#[[preventing expert blindness]]`
331
+
332
+ ### Temporal & State-Based Tags
333
+
334
+ | Tag Type | Examples |
335
+ |----------|----------|
336
+ | Future relevance | `#[[will be relevant in 5 years]]`, `#[[connects to unborn projects]]` |
337
+ | Mental state triggers | `#[[feeling stuck in patterns]]`, `#[[needing fresh perspective]]` |
338
+ | Review scheduling | `[[For review]]: [[August 12th, 2026]]` |
339
+
340
+ ---
253
341
 
254
342
  ## Formatting Conventions
255
343
 
344
+ ### Quotes
345
+ ```
346
+ <quote text> —[[Author Name]] #quote #[[topic1]] #[[topic2]]
347
+ ```
348
+ Always include 2-3 relevant hashtags after quotes.
349
+
350
+ ### TODOs and Follow-ups
351
+ ```
352
+ {{[[TODO]]}} <action needed>
353
+ {{[[TODO]]}} #researchThis : <topic to investigate>
354
+ ```
355
+
356
+ ### Scheduled Reviews
357
+
358
+ - Any block tagged with a date will show on that respective daily page.
359
+
360
+ ```
361
+ [[For review]]: [[Date in ordinal format]]
362
+ ```
363
+ Optional labels: "Deadline", "Approved", "Pending", "Deferred", "Postponed until"
364
+
365
+ ### Aliasing for Case Sensitivity
366
+ When a tag would awkwardly affect sentence capitalization:
367
+ ```
368
+ [Cognitive biases]([[cognitive biases]]) affect decision-making...
369
+ ```
370
+
371
+ ### Definitions (OVERRIDE)
372
+ ```
373
+ #def [[<term>]] : <definition>
374
+ ```
375
+
376
+ ---
256
377
 
257
378
  ## Constraints & Guardrails
258
379
 
380
+ ### DON'T
381
+ - **Overtag** — Quality over quantity; each tag should earn its place
382
+ - **Tag obvious/redundant** — If parent block is tagged, children inherit context
383
+ - **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
384
+ - **Create orphan tags** — Check if existing page/tag serves the purpose
385
+ - **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
386
+ - **Separators** - `---` Don't use them.
387
+
388
+ ### DO
389
+ - **Think retrieval-first** — How will you search for this later?
390
+ - **Cross-pollinate domains** — Force unlikely intellectual meetings
391
+ - **Update aging tags** — As interests evolve, so should tag vocabulary
392
+ - **Track surprise discoveries** — When unexpected connections yield insights, engineer more of those patterns
393
+
394
+ ---
259
395
 
260
396
  ## Custom Rules
261
397
 
398
+ <!--
399
+ CUSTOMIZE THIS SECTION with your specific conventions:
400
+ - Naming patterns for certain page types
401
+ - Required attributes for books/articles/people
402
+ - Project-specific tagging schemes
403
+ - Integration rules with other tools
404
+ - etc.
405
+ -->
406
+
407
+ ### Example Custom Rules (modify as needed):
408
+
409
+ **Books:**
410
+ ```
411
+ [[Book/<title> | <author>]]
412
+ Type:: Book
413
+ Author:: [[Author Name]]
414
+ Status:: Reading | Completed | Abandoned
415
+ Rating:: X/5
416
+ ```
417
+
418
+ **People:**
419
+ ```
420
+ [[Person Name]]
421
+ Type:: Person
422
+ Context:: How I know them
423
+ ```
424
+ - When linking bibliographic references —>
425
+ Example: `McAdams, D.P. (2001) [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100) — foundational paper`
426
+ - [McAdams, D.P.]([[Dan McAdams]]) - author's name in the graph
427
+ - If source URL, link to source: [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100)
428
+ - If notes page exists or will exist in Roam: append ` | [Notes]([[Article/The Psychology of Life Stories]]), if not, just leave it without link.
429
+
430
+ **Projects:**
431
+ ```
432
+ [[Project/<project anme>]]
433
+ Status:: Active | Paused | Completed
434
+ Start:: [[Date]]
435
+ ```
436
+ ---
262
437
 
263
438
  ## Integration Notes
264
439
 
265
440
  <!-- CUSTOMIZE: Any rules about how Roam integrates with your other tools/systems -->
266
441
 
267
- </personalization_layer>
442
+ - Daily pages serve as: <!-- inbox / journal / task list / etc. -->
443
+ - Weekly reviews occur on: <!-- day of week -->
444
+ - Content flows from: <!-- capture tools, read-later apps, etc. -->
445
+ - Content flows to: <!-- publishing, archives, etc. -->
446
+
447
+ ---
448
+
449
+ *End of Personalization Layer*
@@ -45,6 +45,21 @@ export async function resolveDailyPageUid(graph) {
45
45
  const dailyTitle = getDailyPageTitle();
46
46
  return resolvePageUid(graph, dailyTitle);
47
47
  }
48
+ /**
49
+ * Check if a string looks like a valid Roam UID (not a page title)
50
+ */
51
+ export function isUidFormat(ref) {
52
+ // 9 alphanumeric characters (standard block UID)
53
+ if (/^[a-zA-Z0-9_-]{9}$/.test(ref))
54
+ return true;
55
+ // MM-DD-YYYY daily page UID
56
+ if (/^\d{2}-\d{2}-\d{4}$/.test(ref))
57
+ return true;
58
+ // Placeholder {{name}}
59
+ if (/^\{\{[^}]+\}\}$/.test(ref))
60
+ return true;
61
+ return false;
62
+ }
48
63
  /**
49
64
  * Collect all unique page titles that need resolution from commands
50
65
  */
@@ -56,6 +71,13 @@ export function collectPageTitles(commands) {
56
71
  if ('page' in params && typeof params.page === 'string') {
57
72
  titles.add(params.page);
58
73
  }
74
+ // Commands that can have 'parent' param — if it looks like a page title, resolve it
75
+ if ('parent' in params && typeof params.parent === 'string') {
76
+ const parent = params.parent;
77
+ if (!isUidFormat(parent)) {
78
+ titles.add(parent);
79
+ }
80
+ }
59
81
  // Remember command can have heading that needs parent page resolution
60
82
  // But heading lookup is handled separately
61
83
  // Todo/remember without explicit page need daily page
@@ -126,8 +148,13 @@ export function resolveParentRef(ref, context) {
126
148
  if (context.pageUids.has(ref)) {
127
149
  return context.pageUids.get(ref);
128
150
  }
129
- // Assume it's a direct UID
130
- return ref;
151
+ // If it looks like a UID, return as-is
152
+ if (isUidFormat(ref)) {
153
+ return ref;
154
+ }
155
+ // Not a UID and not resolved — this is a page title that wasn't collected/resolved
156
+ // Return null so callers can handle it (should not happen if collectPageTitles is correct)
157
+ return null;
131
158
  }
132
159
  /**
133
160
  * Generate a placeholder UID for tracking
@@ -39,7 +39,10 @@ export function translateCommand(command, context) {
39
39
  function getParentUid(params, context) {
40
40
  // Direct parent UID or placeholder
41
41
  if (params.parent) {
42
- return resolveParentRef(params.parent, context) || params.parent;
42
+ const resolved = resolveParentRef(params.parent, context);
43
+ if (resolved)
44
+ return resolved;
45
+ throw new Error(`Parent "${params.parent}" could not be resolved to a UID. Page may not exist.`);
43
46
  }
44
47
  // Page UID
45
48
  if (params.pageUid) {
@@ -227,7 +227,7 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
227
227
  return;
228
228
  }
229
229
  const graph = resolveGraph(options, true);
230
- // Phase 1: Collect and resolve page titles
230
+ // Phase 1: Collect and resolve page titles (from 'page' AND 'parent' params)
231
231
  const context = createResolutionContext();
232
232
  const pageTitles = collectPageTitles(commands);
233
233
  if (pageTitles.size > 0) {
@@ -238,10 +238,25 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
238
238
  for (const [title, uid] of resolved) {
239
239
  context.pageUids.set(title, uid);
240
240
  }
241
- // Check for unresolved pages
241
+ // Auto-create unresolved pages (e.g., parent: "Page Title" for a page that doesn't exist yet)
242
242
  const unresolved = Array.from(pageTitles).filter(t => !context.pageUids.has(t));
243
243
  if (unresolved.length > 0) {
244
- exitWithError(`Page(s) not found: ${unresolved.map(t => `"${t}"`).join(', ')}`);
244
+ if (options.debug) {
245
+ printDebug('Auto-creating pages', unresolved);
246
+ }
247
+ const pageOps = new PageOperations(graph);
248
+ for (const title of unresolved) {
249
+ const result = await pageOps.createPage(title);
250
+ if (result.success && result.uid) {
251
+ context.pageUids.set(title, result.uid);
252
+ if (options.debug) {
253
+ printDebug(`Auto-created "${title}"`, result.uid);
254
+ }
255
+ }
256
+ else {
257
+ exitWithError(`Failed to create page "${title}"`);
258
+ }
259
+ }
245
260
  }
246
261
  if (options.debug) {
247
262
  printDebug('Resolved pages', Object.fromEntries(context.pageUids));