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 +76 -9
- package/build/Roam_Markdown_Cheatsheet.md +179 -3
- package/build/cli/commands/server.js +240 -0
- package/build/cli/roam.js +2 -0
- package/build/config/environment.js +9 -1
- package/build/index.js +6 -1
- package/build/server/roam-server.js +63 -9
- package/build/utils/auth.js +24 -0
- package/build/utils/auth.test.js +34 -0
- package/build/utils/net.js +12 -2
- package/package.json +2 -2
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`:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
388
|
-
|
|
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
|
+
});
|
package/build/utils/net.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
}
|