roam-research-mcp 2.19.1 → 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
@@ -76,7 +76,7 @@ roam get "Page Title" -g work
76
76
  roam save "Note" -g work --write-key "$ROAM_SYSTEM_WRITE_KEY"
77
77
  ```
78
78
 
79
- **Available Commands:** `get`, `search`, `save`, `refs`, `update`, `batch`, `rename`, `status`.
79
+ **Available Commands:** `get`, `search`, `save`, `refs`, `update`, `batch`, `rename`, `status`, `server`.
80
80
  Run `roam <command> --help` for details on any command.
81
81
 
82
82
  ### Installation
@@ -160,30 +160,97 @@ Protected graphs require the `write_key` parameter matching `ROAM_SYSTEM_WRITE_K
160
160
 
161
161
  *Optional:*
162
162
  - `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
163
- - `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).
164
166
 
165
167
  ### Running the Server
166
168
 
167
- **1. Stdio Mode (Default)**
168
- 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`.
169
171
 
170
172
  ```bash
171
173
  npx roam-research-mcp
172
174
  ```
173
175
 
174
- 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.
175
178
 
176
- **2. HTTP Stream Mode**
177
- Best for remote access or web clients.
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`.
241
+
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:
178
244
 
179
245
  ```bash
180
- 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
181
248
  ```
182
249
 
183
250
  **3. Docker**
184
251
 
185
252
  ```bash
186
- docker run -p 8088:8088 --env-file .env roam-research-mcp
253
+ docker run -p 8088:8088 --env-file .env roam-research-mcp --server
187
254
  ```
188
255
 
189
256
  ### Configuring in LLMs
@@ -243,8 +243,6 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
243
243
  **Open question:** `{{[[TODO]]}} Research: <question> #[[open questions]]`
244
244
 
245
245
  ---
246
- <personalization_layer>
247
-
248
246
  # Roam Preferences — Personalization Layer
249
247
 
250
248
  > This section contains YOUR specific conventions, tagging philosophy, and graph-specific rules. Customize to match your workflow.
@@ -253,21 +251,199 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
253
251
 
254
252
  ## Graph-Level Behaviors
255
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
+ ---
256
266
 
257
267
  ## Tagging Philosophy
258
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
+ ---
259
341
 
260
342
  ## Formatting Conventions
261
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
+ ---
262
377
 
263
378
  ## Constraints & Guardrails
264
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
+ ---
265
395
 
266
396
  ## Custom Rules
267
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
+ ---
268
437
 
269
438
  ## Integration Notes
270
439
 
271
440
  <!-- CUSTOMIZE: Any rules about how Roam integrates with your other tools/systems -->
272
441
 
273
- </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*
@@ -0,0 +1,240 @@
1
+ import { Command } from 'commander';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync, } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join, dirname } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { get as httpGet } from 'node:http';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ // Path to the MCP server entry (build/index.js) relative to this command
11
+ // (build/cli/commands/server.js -> build/index.js).
12
+ const SERVER_ENTRY = join(__dirname, '../../index.js');
13
+ // State dir for the CLI-managed daemon (pidfile + logfile). Local path by
14
+ // default — overridable via ROAM_HOME. Kept off external volumes so launchd /
15
+ // the daemon never hits the macOS provenance-xattr EPERM trap.
16
+ const STATE_DIR = process.env.ROAM_HOME || join(homedir(), '.roam');
17
+ const PID_FILE = join(STATE_DIR, 'server.pid');
18
+ const LOG_FILE = join(STATE_DIR, 'server.log');
19
+ const DEFAULT_PORT = process.env.HTTP_STREAM_PORT || '8088';
20
+ const DEFAULT_HOST = process.env.HTTP_STREAM_HOST || '127.0.0.1';
21
+ /** Probe GET /health. Returns parsed info, or null if nothing is serving. */
22
+ function checkHealth(host, port, timeoutMs = 2000) {
23
+ // 0.0.0.0 is a bind address, not a connect address — probe loopback instead.
24
+ const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
25
+ return new Promise((resolve) => {
26
+ const req = httpGet({ host: target, port, path: '/health', timeout: timeoutMs }, (res) => {
27
+ let body = '';
28
+ res.on('data', (c) => (body += c));
29
+ res.on('end', () => {
30
+ try {
31
+ resolve(JSON.parse(body));
32
+ }
33
+ catch {
34
+ resolve(null);
35
+ }
36
+ });
37
+ });
38
+ req.on('error', () => resolve(null));
39
+ req.on('timeout', () => {
40
+ req.destroy();
41
+ resolve(null);
42
+ });
43
+ });
44
+ }
45
+ function readPid() {
46
+ if (!existsSync(PID_FILE))
47
+ return null;
48
+ const raw = readFileSync(PID_FILE, 'utf8').trim();
49
+ const pid = parseInt(raw, 10);
50
+ return Number.isFinite(pid) ? pid : null;
51
+ }
52
+ function isAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch (err) {
58
+ // EPERM means it exists but we can't signal it; ESRCH means gone.
59
+ return err.code === 'EPERM';
60
+ }
61
+ }
62
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
63
+ function createStartCommand() {
64
+ return new Command('start')
65
+ .description('Start the shared HTTP MCP server (HTTP-only daemon)')
66
+ .option('-p, --port <port>', 'Port to bind', DEFAULT_PORT)
67
+ .option('-H, --host <host>', 'Host to bind (use 0.0.0.0 to expose on LAN)', DEFAULT_HOST)
68
+ .option('-f, --foreground', 'Run in the foreground instead of detaching', false)
69
+ .action(async (options) => {
70
+ const { port, host, foreground } = options;
71
+ // Don't start a second copy if one is already serving this address.
72
+ const existing = await checkHealth(host, port);
73
+ if (existing) {
74
+ console.log(`Already running on http://${host}:${port}/ (v${existing.version ?? '?'}, ${existing.activeSessions ?? 0} session(s)).`);
75
+ return;
76
+ }
77
+ if (!existsSync(SERVER_ENTRY)) {
78
+ console.error(`Error: server entry not found at ${SERVER_ENTRY}. Run "npm run build" first.`);
79
+ process.exit(1);
80
+ }
81
+ const env = { ...process.env, HTTP_STREAM_PORT: port, HTTP_STREAM_HOST: host };
82
+ if (foreground) {
83
+ const child = spawn(process.execPath, [SERVER_ENTRY, '--server'], { env, stdio: 'inherit' });
84
+ child.on('exit', (code) => process.exit(code ?? 0));
85
+ return;
86
+ }
87
+ // Detached background launch: logs to LOG_FILE, pid recorded in PID_FILE.
88
+ if (!existsSync(STATE_DIR))
89
+ mkdirSync(STATE_DIR, { recursive: true });
90
+ const out = openSync(LOG_FILE, 'a');
91
+ const child = spawn(process.execPath, [SERVER_ENTRY, '--server'], {
92
+ env,
93
+ detached: true,
94
+ stdio: ['ignore', out, out],
95
+ });
96
+ child.unref();
97
+ if (child.pid)
98
+ writeFileSync(PID_FILE, String(child.pid));
99
+ // Confirm it actually came up (port-in-use fails loudly and exits).
100
+ let health = null;
101
+ for (let i = 0; i < 10 && !health; i++) {
102
+ await sleep(300);
103
+ if (child.pid && !isAlive(child.pid))
104
+ break;
105
+ health = await checkHealth(host, port);
106
+ }
107
+ if (health) {
108
+ console.log(`Started (pid ${child.pid}) on http://${host}:${port}/`);
109
+ console.log(` health: http://${host}:${port}/health`);
110
+ console.log(` logs: ${LOG_FILE} (roam server logs -f)`);
111
+ }
112
+ else {
113
+ console.error(`Failed to confirm startup. Check logs: ${LOG_FILE}`);
114
+ if (existsSync(PID_FILE))
115
+ unlinkSync(PID_FILE);
116
+ process.exit(1);
117
+ }
118
+ });
119
+ }
120
+ function createStopCommand() {
121
+ return new Command('stop')
122
+ .description('Stop a CLI-started server (see notes for service-managed daemons)')
123
+ .option('-H, --host <host>', 'Host to probe', DEFAULT_HOST)
124
+ .option('-p, --port <port>', 'Port to probe', DEFAULT_PORT)
125
+ .action(async (options) => {
126
+ const pid = readPid();
127
+ if (pid && isAlive(pid)) {
128
+ process.kill(pid, 'SIGTERM');
129
+ for (let i = 0; i < 20 && isAlive(pid); i++)
130
+ await sleep(100);
131
+ if (isAlive(pid)) {
132
+ console.error(`Process ${pid} did not stop after SIGTERM.`);
133
+ process.exit(1);
134
+ }
135
+ if (existsSync(PID_FILE))
136
+ unlinkSync(PID_FILE);
137
+ console.log(`Stopped (pid ${pid}).`);
138
+ return;
139
+ }
140
+ if (existsSync(PID_FILE))
141
+ unlinkSync(PID_FILE);
142
+ // No CLI-managed process — but something may still be serving via a
143
+ // service manager (launchd/systemd). Don't pretend we stopped it.
144
+ const health = await checkHealth(options.host, options.port);
145
+ if (health) {
146
+ console.log(`Not started by this CLI, but a server is responding on http://${options.host}:${options.port}/.`);
147
+ console.log(`It is likely managed by a service manager (e.g. launchd/systemd) — stop it there.`);
148
+ return;
149
+ }
150
+ console.log('Not running.');
151
+ });
152
+ }
153
+ function createStatusCommand() {
154
+ return new Command('status')
155
+ .description('Check whether the shared HTTP server is running')
156
+ .option('-H, --host <host>', 'Host to probe', DEFAULT_HOST)
157
+ .option('-p, --port <port>', 'Port to probe', DEFAULT_PORT)
158
+ .option('--json', 'Output as JSON', false)
159
+ .action(async (options) => {
160
+ const { host, port } = options;
161
+ const health = await checkHealth(host, port);
162
+ const pid = readPid();
163
+ const pidAlive = pid !== null && isAlive(pid);
164
+ if (options.json) {
165
+ console.log(JSON.stringify({
166
+ running: !!health,
167
+ url: `http://${host}:${port}/mcp`,
168
+ managedPid: pidAlive ? pid : null,
169
+ health: health ?? null,
170
+ }, null, 2));
171
+ return;
172
+ }
173
+ if (health) {
174
+ console.log(`● running — http://${host}:${port}/`);
175
+ console.log(` version: ${health.version ?? '?'}`);
176
+ console.log(` mode: ${health.mode ?? '?'}`);
177
+ console.log(` auth: ${health.auth ?? 'none'}`);
178
+ console.log(` graphs: ${(health.graphs ?? []).join(', ')}`);
179
+ console.log(` default graph: ${health.defaultGraph ?? '?'}`);
180
+ console.log(` active sessions:${' '}${health.activeSessions ?? 0}`);
181
+ console.log(` endpoint: http://${host}:${port}/mcp`);
182
+ console.log(pidAlive
183
+ ? ` managed by: this CLI (pid ${pid})`
184
+ : ` managed by: external supervisor (not this CLI)`);
185
+ }
186
+ else {
187
+ console.log(`○ not running on http://${host}:${port}/`);
188
+ if (pidAlive)
189
+ console.log(` (stale pidfile process ${pid} alive but not serving — try: roam server stop)`);
190
+ }
191
+ });
192
+ }
193
+ function createLogsCommand() {
194
+ return new Command('logs')
195
+ .description('Tail the CLI-managed server log')
196
+ .option('-f, --follow', 'Follow the log (like tail -f)', false)
197
+ .option('-n, --lines <n>', 'Number of lines to show', '50')
198
+ .action((options) => {
199
+ if (!existsSync(LOG_FILE)) {
200
+ console.error(`No CLI log at ${LOG_FILE}.`);
201
+ console.error('The server may not have been started via "roam server start"');
202
+ console.error('(e.g. a launchd/systemd service logs to its own configured path).');
203
+ process.exit(1);
204
+ }
205
+ const args = ['-n', options.lines];
206
+ if (options.follow)
207
+ args.push('-f');
208
+ args.push(LOG_FILE);
209
+ const child = spawn('tail', args, { stdio: 'inherit' });
210
+ child.on('exit', (code) => process.exit(code ?? 0));
211
+ });
212
+ }
213
+ export function createServerCommand() {
214
+ const server = new Command('server')
215
+ .description('Run/manage the shared HTTP MCP server (start/stop/status/logs)')
216
+ .addHelpText('after', `
217
+ The shared server is a single long-lived, HTTP-only MCP daemon that multiple
218
+ clients connect to over HTTP — instead of each session spawning its own copy.
219
+ Point clients at it with: { "type": "http", "url": "http://${DEFAULT_HOST}:${DEFAULT_PORT}/mcp" }
220
+
221
+ Examples:
222
+ roam server start # start in the background (port ${DEFAULT_PORT})
223
+ roam server start -p 9000 -f # foreground on port 9000
224
+ roam server start -H 0.0.0.0 # expose on the LAN (no transport auth!)
225
+ roam server status # is it up? version, graphs, sessions
226
+ roam server logs -f # follow the log
227
+ roam server stop # stop a CLI-started daemon
228
+
229
+ Config comes from the environment (ROAM_GRAPHS / ROAM_API_TOKEN, etc.), same
230
+ as the rest of the CLI. State dir: ${STATE_DIR} (override with ROAM_HOME).
231
+ For an always-on daemon, run "roam server start" from a launchd/systemd unit.
232
+ `);
233
+ server.addCommand(createStartCommand());
234
+ server.addCommand(createStopCommand());
235
+ server.addCommand(createStatusCommand());
236
+ server.addCommand(createLogsCommand());
237
+ // No subcommand → show help rather than erroring.
238
+ server.action(() => server.help());
239
+ return server;
240
+ }
package/build/cli/roam.js CHANGED
@@ -11,6 +11,7 @@ import { createUpdateCommand } from './commands/update.js';
11
11
  import { createBatchCommand } from './commands/batch.js';
12
12
  import { createRenameCommand } from './commands/rename.js';
13
13
  import { createStatusCommand } from './commands/status.js';
14
+ import { createServerCommand } from './commands/server.js';
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
16
17
  // Read package.json to get the version
@@ -31,5 +32,6 @@ program.addCommand(createUpdateCommand());
31
32
  program.addCommand(createBatchCommand());
32
33
  program.addCommand(createRenameCommand());
33
34
  program.addCommand(createStatusCommand());
35
+ program.addCommand(createServerCommand());
34
36
  // Parse arguments
35
37
  program.parse();
@@ -11,6 +11,14 @@ if (existsSync(envPath)) {
11
11
  }
12
12
  // HTTP server configuration
13
13
  const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088';
14
+ // Host to bind the HTTP transport to. Only applied in --server (daemon) mode.
15
+ // Defaults to loopback so a shared server is not exposed beyond this machine.
16
+ const HTTP_STREAM_HOST = process.env.HTTP_STREAM_HOST || '127.0.0.1';
17
+ // Optional transport-level bearer token (authentication). When set, every HTTP
18
+ // MCP request must send `Authorization: Bearer <token>` (the /health probe stays
19
+ // open). Unset = open, i.e. unchanged. This is the perimeter lock; it is separate
20
+ // from ROAM_SYSTEM_WRITE_KEY, which is per-graph write authorization.
21
+ const HTTP_AUTH_TOKEN = process.env.HTTP_AUTH_TOKEN;
14
22
  const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5678,https://roamresearch.com')
15
23
  .split(',')
16
24
  .map(origin => origin.trim());
@@ -81,4 +89,4 @@ export function validateEnvironment() {
81
89
  }
82
90
  }
83
91
  }
84
- export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, CORS_ORIGINS, ROAM_GRAPHS, ROAM_DEFAULT_GRAPH };
92
+ export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, HTTP_STREAM_HOST, HTTP_AUTH_TOKEN, CORS_ORIGINS, ROAM_GRAPHS, ROAM_DEFAULT_GRAPH };
package/build/index.js CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { RoamServer } from './server/roam-server.js';
3
+ // `--server` runs a long-lived, HTTP-only daemon (no stdio transport) bound to a
4
+ // pinned host:port — meant to be kept running (e.g. by a LaunchAgent) and shared
5
+ // by multiple MCP clients. Without the flag, behavior is unchanged: stdio + an
6
+ // auto-discovered HTTP port.
7
+ const serverMode = process.argv.includes('--server');
3
8
  const server = new RoamServer();
4
- server.run().catch((error) => {
9
+ server.run({ serverMode }).catch((error) => {
5
10
  console.error("Fatal error running server:", error);
6
11
  process.exit(1);
7
12
  });
@@ -2,7 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
- import { HTTP_STREAM_PORT, validateEnvironment } from '../config/environment.js';
5
+ import { HTTP_STREAM_PORT, HTTP_STREAM_HOST, HTTP_AUTH_TOKEN, validateEnvironment } from '../config/environment.js';
6
+ import { isBearerAuthorized } from '../utils/auth.js';
6
7
  import { createRegistryFromEnv } from '../config/graph-registry.js';
7
8
  import { toolSchemas } from '../tools/schemas.js';
8
9
  import { ToolHandlers } from '../tools/tool-handlers.js';
@@ -10,7 +11,7 @@ import { readFileSync } from 'node:fs';
10
11
  import { join, dirname } from 'node:path';
11
12
  import { createServer } from 'node:http';
12
13
  import { fileURLToPath } from 'node:url';
13
- import { findAvailablePort } from '../utils/net.js';
14
+ import { findAvailablePort, isPortInUse } from '../utils/net.js';
14
15
  import { CORS_ORIGINS } from '../config/environment.js';
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = dirname(__filename);
@@ -306,11 +307,16 @@ export class RoamServer {
306
307
  }
307
308
  });
308
309
  }
309
- async run() {
310
+ async run(options = {}) {
311
+ const { serverMode = false } = options;
310
312
  try {
311
- const stdioMcpServer = this.createMcpServer();
312
- const stdioTransport = new StdioServerTransport();
313
- await stdioMcpServer.connect(stdioTransport);
313
+ // In --server (daemon) mode we run HTTP-only: no client reads the stdio
314
+ // transport, so we skip it. Otherwise behavior is unchanged (stdio + HTTP).
315
+ if (!serverMode) {
316
+ const stdioMcpServer = this.createMcpServer();
317
+ const stdioTransport = new StdioServerTransport();
318
+ await stdioMcpServer.connect(stdioTransport);
319
+ }
314
320
  // Track active transports by session ID for proper session management
315
321
  const activeSessions = new Map();
316
322
  const httpServer = createServer(async (req, res) => {
@@ -332,6 +338,38 @@ export class RoamServer {
332
338
  res.end();
333
339
  return;
334
340
  }
341
+ // Liveness probe — cheap, no MCP handshake required. Useful for the
342
+ // LaunchAgent/health checks (a bare GET on the MCP endpoint returns 406).
343
+ const requestPath = (req.url || '/').split('?')[0];
344
+ if (req.method === 'GET' && requestPath === '/health') {
345
+ res.writeHead(200, { 'Content-Type': 'application/json' });
346
+ res.end(JSON.stringify({
347
+ status: 'ok',
348
+ name: 'roam-research-mcp',
349
+ version: serverVersion,
350
+ mode: serverMode ? 'server' : 'stdio+http',
351
+ auth: HTTP_AUTH_TOKEN ? 'required' : 'none',
352
+ graphs: this.registry.getAvailableGraphs(),
353
+ defaultGraph: this.registry.defaultKey,
354
+ activeSessions: activeSessions.size,
355
+ }));
356
+ return;
357
+ }
358
+ // Transport authentication (perimeter): when HTTP_AUTH_TOKEN is set,
359
+ // every MCP request must carry `Authorization: Bearer <token>`. /health
360
+ // and OPTIONS are intentionally exempt (handled above). Unset = open.
361
+ if (HTTP_AUTH_TOKEN && !isBearerAuthorized(req.headers['authorization'], HTTP_AUTH_TOKEN)) {
362
+ res.writeHead(401, {
363
+ 'Content-Type': 'application/json',
364
+ 'WWW-Authenticate': 'Bearer',
365
+ });
366
+ res.end(JSON.stringify({
367
+ jsonrpc: '2.0',
368
+ error: { code: -32001, message: 'Unauthorized: missing or invalid bearer token' },
369
+ id: null,
370
+ }));
371
+ return;
372
+ }
335
373
  // Check for existing session ID in header
336
374
  const sessionId = req.headers['mcp-session-id'];
337
375
  // Handle session termination (DELETE request)
@@ -384,9 +422,25 @@ export class RoamServer {
384
422
  }
385
423
  }
386
424
  });
387
- const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT));
388
- httpServer.listen(availableHttpPort, () => {
389
- });
425
+ const desiredPort = parseInt(HTTP_STREAM_PORT);
426
+ if (serverMode) {
427
+ // A shared daemon must own a stable URL — never silently drift to another
428
+ // port. Fail loudly if the configured port is already taken on our bind
429
+ // host (a listener on a different interface is not our conflict).
430
+ if (await isPortInUse(desiredPort, HTTP_STREAM_HOST)) {
431
+ throw new McpError(ErrorCode.InternalError, `--server: port ${desiredPort} (HTTP_STREAM_PORT) is already in use. ` +
432
+ `Stop the process using it, or set HTTP_STREAM_PORT to a free port.`);
433
+ }
434
+ httpServer.listen(desiredPort, HTTP_STREAM_HOST, () => {
435
+ console.error(`roam-research-mcp v${serverVersion} (--server) listening on ` +
436
+ `http://${HTTP_STREAM_HOST}:${desiredPort}/ (health: /health)`);
437
+ });
438
+ }
439
+ else {
440
+ const availableHttpPort = await findAvailablePort(desiredPort);
441
+ httpServer.listen(availableHttpPort, () => {
442
+ });
443
+ }
390
444
  }
391
445
  catch (error) {
392
446
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,24 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * Validate an HTTP `Authorization` header against an expected bearer token.
4
+ *
5
+ * Uses a constant-time comparison so the token can't be recovered via response
6
+ * timing. Returns true only when the header is exactly `Bearer <token>` (scheme
7
+ * is case-insensitive; surrounding whitespace tolerated) and matches.
8
+ *
9
+ * This is transport-level AUTHENTICATION (who may talk to the server) — distinct
10
+ * from ROAM_SYSTEM_WRITE_KEY, which is per-graph write AUTHORIZATION.
11
+ */
12
+ export function isBearerAuthorized(authHeader, expectedToken) {
13
+ if (!authHeader || !expectedToken)
14
+ return false;
15
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader.trim());
16
+ if (!match)
17
+ return false;
18
+ const provided = Buffer.from(match[1]);
19
+ const expected = Buffer.from(expectedToken);
20
+ // Length is not secret; guard first because timingSafeEqual throws on mismatch.
21
+ if (provided.length !== expected.length)
22
+ return false;
23
+ return timingSafeEqual(provided, expected);
24
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isBearerAuthorized } from './auth.js';
3
+ describe('isBearerAuthorized', () => {
4
+ const token = 'secret-token-123';
5
+ it('accepts a correct Bearer token', () => {
6
+ expect(isBearerAuthorized(`Bearer ${token}`, token)).toBe(true);
7
+ });
8
+ it('is case-insensitive on the scheme', () => {
9
+ expect(isBearerAuthorized(`bearer ${token}`, token)).toBe(true);
10
+ expect(isBearerAuthorized(`BEARER ${token}`, token)).toBe(true);
11
+ });
12
+ it('tolerates surrounding and inter-token whitespace', () => {
13
+ expect(isBearerAuthorized(` Bearer ${token} `, token)).toBe(true);
14
+ });
15
+ it('rejects a wrong token of the same length', () => {
16
+ expect(isBearerAuthorized('Bearer secret-token-124', token)).toBe(false);
17
+ });
18
+ it('rejects a token of a different length', () => {
19
+ expect(isBearerAuthorized(`Bearer ${token}extra`, token)).toBe(false);
20
+ });
21
+ it('rejects a missing/empty header', () => {
22
+ expect(isBearerAuthorized(undefined, token)).toBe(false);
23
+ expect(isBearerAuthorized('', token)).toBe(false);
24
+ });
25
+ it('rejects a non-Bearer scheme', () => {
26
+ expect(isBearerAuthorized(`Basic ${token}`, token)).toBe(false);
27
+ });
28
+ it('rejects a bare token without the scheme', () => {
29
+ expect(isBearerAuthorized(token, token)).toBe(false);
30
+ });
31
+ it('rejects when no expected token is configured', () => {
32
+ expect(isBearerAuthorized(`Bearer ${token}`, '')).toBe(false);
33
+ });
34
+ });
@@ -2,9 +2,14 @@ import { createServer } from 'node:net';
2
2
  /**
3
3
  * Checks if a given port is currently in use.
4
4
  * @param port The port to check.
5
+ * @param host Optional host to probe. When omitted, probes the wildcard address
6
+ * (any interface) — matching `findAvailablePort`'s "globally free" semantics.
7
+ * Pass a specific host (e.g. `127.0.0.1`) to check only that interface, so a
8
+ * listener bound to a different interface (e.g. wildcard) doesn't register as
9
+ * a conflict for a host-specific bind.
5
10
  * @returns A promise that resolves to true if the port is in use, and false otherwise.
6
11
  */
7
- export function isPortInUse(port) {
12
+ export function isPortInUse(port, host) {
8
13
  return new Promise((resolve) => {
9
14
  const server = createServer();
10
15
  server.once('error', (err) => {
@@ -20,7 +25,12 @@ export function isPortInUse(port) {
20
25
  server.close();
21
26
  resolve(false);
22
27
  });
23
- server.listen(port);
28
+ if (host) {
29
+ server.listen(port, host);
30
+ }
31
+ else {
32
+ server.listen(port);
33
+ }
24
34
  });
25
35
  }
26
36
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "2.19.1",
3
+ "version": "2.22.0",
4
4
  "description": "MCP server and CLI for Roam Research",
5
5
  "private": false,
6
6
  "repository": {
@@ -56,4 +56,4 @@
56
56
  "typescript": "^5.3.3",
57
57
  "vitest": "^3.2.4"
58
58
  }
59
- }
59
+ }