roam-research-mcp 2.19.1 → 2.23.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 +131 -11
- package/build/Roam_Markdown_Cheatsheet.md +2 -1
- package/build/cli/commands/server.js +240 -0
- package/build/cli/roam.js +2 -0
- package/build/cli/utils/graph.js +4 -2
- package/build/config/environment.js +9 -1
- package/build/config/graph-registry.js +23 -7
- package/build/config/graph-registry.test.js +92 -1
- package/build/index.js +6 -1
- package/build/markdown-utils.js +4 -1
- package/build/markdown-utils.test.js +22 -1
- package/build/server/roam-server.js +96 -10
- package/build/server/session-404.test.js +116 -0
- package/build/shared/errors.js +47 -0
- package/build/shared/errors.test.js +61 -0
- package/build/shared/retry.js +55 -0
- package/build/shared/staged-batch.js +5 -2
- package/build/shared/staged-batch.test.js +88 -0
- package/build/tools/helpers/hidden.js +147 -0
- package/build/tools/helpers/hidden.test.js +105 -0
- package/build/tools/operations/guidelines.js +98 -0
- package/build/tools/operations/guidelines.test.js +89 -0
- package/build/tools/operations/pages.js +5 -1
- package/build/tools/operations/search/index.js +43 -6
- package/build/tools/schemas.js +92 -24
- package/build/tools/schemas.test.js +153 -0
- package/build/tools/tool-handlers.js +7 -1
- 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 -1
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
|
|
@@ -116,6 +116,47 @@ The MCP server exposes these tools to AI assistants (like Claude), enabling them
|
|
|
116
116
|
| `roam_remember` / `roam_recall` | specialized tools for AI memory management within Roam. |
|
|
117
117
|
| `roam_datomic_query` | Execute raw Datalog queries for advanced filtering. |
|
|
118
118
|
| `roam_markdown_cheatsheet` | Retrieve the Roam-flavored markdown reference. |
|
|
119
|
+
| `roam_get_guidelines` | Retrieve this graph's user-defined agent conventions. |
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Agent guidelines (per-graph)
|
|
124
|
+
|
|
125
|
+
`roam_get_guidelines` reads a page **inside the graph** — `[[roam/agent guidelines]]` by default — holding your own conventions: how you tag, how you namespace pages, what an agent should never do. Roam's official MCP server reads the same page title, so one page serves both.
|
|
126
|
+
|
|
127
|
+
This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
|
|
128
|
+
|
|
129
|
+
| | `CUSTOM_INSTRUCTIONS_PATH` | `[[roam/agent guidelines]]` |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| Lives in | a file on disk | a page in the graph |
|
|
132
|
+
| Scope | server-wide, all graphs | **per-graph** |
|
|
133
|
+
| To change it | edit the file, restart the server | edit the page |
|
|
134
|
+
| Answers | how to write Roam markdown | how *this user* wants *this graph* handled |
|
|
135
|
+
|
|
136
|
+
Configure per graph, or disable with `false`:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
ROAM_GRAPHS='{"personal": {"token": "...", "graph": "...", "guidelinesPage": "roam/agent guidelines"}, "work": {"token": "...", "graph": "...", "guidelinesPage": false}}'
|
|
140
|
+
ROAM_GUIDELINES_PAGE='roam/agent guidelines' # fallback when a graph sets none
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If no such page exists the tool returns `exists: false` rather than failing, so it is safe to call unconditionally. Results are cached for 30 seconds — an edit to the page takes effect without a restart. A starter template lives at [`.roam/agent-guidelines.template.md`](.roam/agent-guidelines.template.md).
|
|
144
|
+
|
|
145
|
+
Note that guidelines are read through the normal page path, so blocks tagged `#.rm-hide` / `#.rm-private` are withheld from them too — see below.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Hiding content from the AI
|
|
150
|
+
|
|
151
|
+
Blocks tagged `#.rm-hide` or `#.rm-private` — and everything nested under them — are omitted from the content these tools return. Both the hashtag (`#.rm-hide`, `#[[.rm-hide]]`) and link (`[[.rm-hide]]`) forms work. `.rm-private` is Roam's existing "hidden from other users" tag; `.rm-hide` hides from the AI specifically.
|
|
152
|
+
|
|
153
|
+
This follows the same convention as Roam's official MCP server, so a block tagged for one is hidden from the other.
|
|
154
|
+
|
|
155
|
+
Applied to: `roam_fetch_page_by_title`, `roam_fetch_block`, `roam_fetch_page_full_view`, `roam_get_subpages`, `roam_search_by_text`, `roam_search_for_tag`, `roam_search_by_status`, `roam_search_block_refs`, `roam_search_hierarchy`, `roam_search_by_date`.
|
|
156
|
+
|
|
157
|
+
**This is a convenience filter, not a security guarantee.** `roam_datomic_query` reads the database directly and deliberately does **not** apply it, so a capable agent can still surface hidden blocks through raw Datalog. Treat these tags as "keep it out of the AI's way," not "keep it secret."
|
|
158
|
+
|
|
159
|
+
Tag matching is case-insensitive, and only exact tags match — `#.rm-hidden` and `#.rm-highlight` are left alone. The set of hidden UIDs is cached for 30 seconds, so a block tagged just now may remain visible for up to that long.
|
|
119
160
|
|
|
120
161
|
---
|
|
121
162
|
|
|
@@ -155,35 +196,114 @@ ROAM_SYSTEM_WRITE_KEY=your-secret-key
|
|
|
155
196
|
| `protected` | No | If `true`, writes require `ROAM_SYSTEM_WRITE_KEY` confirmation |
|
|
156
197
|
| `memoriesTag` | No | Tag for `roam_remember`/`roam_recall` (overrides global default) |
|
|
157
198
|
|
|
158
|
-
**
|
|
159
|
-
|
|
199
|
+
**Two kinds of access control (and how they differ)**
|
|
200
|
+
|
|
201
|
+
The server has two independent locks. They're easy to mix up because both are "keys" — here's the plain version (both are **optional and off by default**):
|
|
202
|
+
|
|
203
|
+
| | **Bearer token** — `HTTP_AUTH_TOKEN` | **Write key** — `ROAM_SYSTEM_WRITE_KEY` |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| In a phrase | The key to the **front door** | The latch on a **safe inside** |
|
|
206
|
+
| Controls | *Who can reach the server at all* | *Whether a write to a `protected` graph is allowed* |
|
|
207
|
+
| Covers | Everything — reads **and** writes, all graphs | Only **writes**, and only to graphs marked `protected` |
|
|
208
|
+
| Protects reading? | **Yes** | **No** |
|
|
209
|
+
| When you need it | Only if the server is reachable beyond your own machine (e.g. `-H 0.0.0.0`) | Whenever you want a guard against accidental edits to important graphs |
|
|
210
|
+
| How it's sent | HTTP header: `Authorization: Bearer <token>` | A `write_key` argument on write tools / CLI commands |
|
|
211
|
+
|
|
212
|
+
Think of a house: the **bearer token locks the front door** (keeps strangers out entirely), and the **write key locks a safe inside** (even someone already in the house needs it to change what's in the safe). On your own machine bound to `127.0.0.1`, the front door faces a wall — you don't need the bearer token there. The write key is still handy locally as an "are you sure?" guard, because **Roam has no undo**.
|
|
213
|
+
|
|
214
|
+
So: to mark a graph as needing the write key, set `protected: true` on it and configure `ROAM_SYSTEM_WRITE_KEY`; callers then pass a matching `write_key` for any write to that graph.
|
|
160
215
|
|
|
161
216
|
*Optional:*
|
|
162
217
|
- `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
|
|
163
|
-
- `HTTP_STREAM_PORT`:
|
|
218
|
+
- `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088).
|
|
219
|
+
- `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.
|
|
220
|
+
- `HTTP_AUTH_TOKEN`: Optional bearer token that locks the **whole** HTTP endpoint. Unset = open (fine for loopback). When set, every MCP request must send `Authorization: Bearer <token>` (`GET /health` stays open). Use it whenever you bind beyond `127.0.0.1`. Different from `ROAM_SYSTEM_WRITE_KEY` — see [Two kinds of access control](#two-kinds-of-access-control-and-how-they-differ).
|
|
164
221
|
|
|
165
222
|
### Running the Server
|
|
166
223
|
|
|
167
|
-
**1.
|
|
168
|
-
Best for local integration (e.g., Claude Desktop, IDE extensions).
|
|
224
|
+
**1. Default Mode (stdio + HTTP)**
|
|
225
|
+
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
226
|
|
|
170
227
|
```bash
|
|
171
228
|
npx roam-research-mcp
|
|
172
229
|
```
|
|
173
230
|
|
|
174
|
-
|
|
231
|
+
**2. Shared Server Mode (`--server`)**
|
|
232
|
+
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.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
HTTP_STREAM_PORT=8088 npx roam-research-mcp --server
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Or manage it through the `roam` CLI, which adds start/stop/status/logs:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
roam server start # start the shared daemon in the background
|
|
242
|
+
roam server start -H 0.0.0.0 # expose on the LAN (no transport auth!)
|
|
243
|
+
roam server status # is it up? version, graphs, active sessions
|
|
244
|
+
roam server logs -f # follow the log
|
|
245
|
+
roam server stop # stop a CLI-started daemon
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`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`).
|
|
249
|
+
|
|
250
|
+
In `--server` mode the server:
|
|
251
|
+
- runs **HTTP-only** (no stdio transport),
|
|
252
|
+
- 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),
|
|
253
|
+
- exposes `GET /health` → `{"status":"ok", ...}` for liveness checks.
|
|
254
|
+
|
|
255
|
+
Point MCP clients at it with an HTTP transport config:
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"mcpServers": {
|
|
260
|
+
"roam-research-mcp": {
|
|
261
|
+
"type": "http",
|
|
262
|
+
"url": "http://127.0.0.1:8088/mcp"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Env vars (tokens, graphs) live with the **server** process, not the client config.
|
|
269
|
+
|
|
270
|
+
**Securing an exposed server (two layers):**
|
|
271
|
+
If you bind beyond loopback (`-H 0.0.0.0`), add the perimeter lock:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
HTTP_AUTH_TOKEN=$(openssl rand -hex 32) roam server start -H 0.0.0.0
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Clients then send the token as a header:
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{
|
|
281
|
+
"mcpServers": {
|
|
282
|
+
"roam-research-mcp": {
|
|
283
|
+
"type": "http",
|
|
284
|
+
"url": "http://<host>:8088/mcp",
|
|
285
|
+
"headers": { "Authorization": "Bearer <token>" }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Keep **both** — they do different jobs (see [Two kinds of access control](#two-kinds-of-access-control-and-how-they-differ) above): the bearer token controls **who can connect**, the write key only guards **writes to protected graphs**.
|
|
292
|
+
|
|
293
|
+
> ⚠️ The write key is **not** a substitute for the bearer token. On an exposed server without `HTTP_AUTH_TOKEN`, anyone on the network can still **read every graph** (and write non-protected ones). For anything beyond loopback, set `HTTP_AUTH_TOKEN`.
|
|
175
294
|
|
|
176
|
-
**
|
|
177
|
-
|
|
295
|
+
**Keeping it running (macOS LaunchAgent):**
|
|
296
|
+
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
297
|
|
|
179
298
|
```bash
|
|
180
|
-
|
|
299
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.roam-mcp.plist
|
|
300
|
+
curl -s http://127.0.0.1:8088/health # verify
|
|
181
301
|
```
|
|
182
302
|
|
|
183
303
|
**3. Docker**
|
|
184
304
|
|
|
185
305
|
```bash
|
|
186
|
-
docker run -p 8088:8088 --env-file .env roam-research-mcp
|
|
306
|
+
docker run -p 8088:8088 --env-file .env roam-research-mcp --server
|
|
187
307
|
```
|
|
188
308
|
|
|
189
309
|
### Configuring in LLMs
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
⚠️ Never concatenate: `#knowledgemanagement` ≠ `#[[knowledge management]]`
|
|
25
25
|
⚠️ `#` always creates tags — write `Step 1` not `#1`
|
|
26
|
+
⚠️ Because `#` creates tags, a bare `#N` silently creates a numbered page. When you must show the literal form, put it in quotes: `"#1"`, `"#2"` — otherwise rephrase (`Step 1`, `No. 1`, `item 1`)
|
|
26
27
|
|
|
27
28
|
### Dates
|
|
28
29
|
Always ordinal format: `[[January 1st, 2025]]`, `[[December 23rd, 2024]]`
|
|
@@ -161,7 +162,7 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
|
|
|
161
162
|
| ❌ Wrong | ✅ Correct |
|
|
162
163
|
|----------|-----------|
|
|
163
164
|
| `#multiplewords` | `#[[multiple words]]` |
|
|
164
|
-
| `#1`, `#2` | `Step 1`, `No. 1` |
|
|
165
|
+
| `#1`, `#2` | `Step 1`, `No. 1`, or quoted: `"#1"` |
|
|
165
166
|
| `[[january 1, 2025]]` | `[[January 1st, 2025]]` |
|
|
166
167
|
| `[text](((uid)))` | `[text](<((uid))>)` |
|
|
167
168
|
| `{{embed: ((uid))}}` | `{{[[embed]]: ((uid))}}` |
|
|
@@ -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();
|
package/build/cli/utils/graph.js
CHANGED
|
@@ -33,8 +33,10 @@ export function resolveGraph(options, isWriteOp = false) {
|
|
|
33
33
|
if (!systemWriteKey) {
|
|
34
34
|
throw new Error(`Write to protected graph "${graphKey}" failed: ROAM_SYSTEM_WRITE_KEY not configured.`);
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// Never echo the key itself — CLI output lands in scrollback, logs and
|
|
37
|
+
// transcripts. Name the variable so the shell can expand it instead.
|
|
38
|
+
throw new Error(`Write to protected graph "${graphKey}" requires --write-key confirmation.\n` +
|
|
39
|
+
`Use: --write-key "$ROAM_SYSTEM_WRITE_KEY"`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
@@ -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 };
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Lazy graph initialization (connects only when first accessed)
|
|
9
9
|
*/
|
|
10
10
|
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
11
|
-
import {
|
|
11
|
+
import { RoamError } from '../shared/errors.js';
|
|
12
12
|
/** List of tool names that perform write operations */
|
|
13
13
|
export const WRITE_OPERATIONS = [
|
|
14
14
|
'roam_create_page',
|
|
@@ -69,6 +69,18 @@ export class GraphRegistry {
|
|
|
69
69
|
// Priority: per-graph config > env var > default
|
|
70
70
|
return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Page holding a graph's agent conventions, or null when disabled.
|
|
74
|
+
* Same precedence as memoriesTag: per-graph config > env var > default.
|
|
75
|
+
*/
|
|
76
|
+
getGuidelinesPage(key) {
|
|
77
|
+
const resolvedKey = key ?? this.defaultKey;
|
|
78
|
+
const config = this.configs.get(resolvedKey);
|
|
79
|
+
if (config?.guidelinesPage === false) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? 'roam/agent guidelines';
|
|
83
|
+
}
|
|
72
84
|
/**
|
|
73
85
|
* Get an initialized Graph instance, creating it lazily if needed
|
|
74
86
|
* @param key - Graph key from config. Defaults to defaultKey if not specified.
|
|
@@ -83,7 +95,7 @@ export class GraphRegistry {
|
|
|
83
95
|
// Get config
|
|
84
96
|
const config = this.configs.get(resolvedKey);
|
|
85
97
|
if (!config) {
|
|
86
|
-
throw new
|
|
98
|
+
throw new RoamError(`Unknown graph: "${resolvedKey}".`, 'UNKNOWN_GRAPH', { requested_graph: resolvedKey, available_graphs: this.getAvailableGraphs() });
|
|
87
99
|
}
|
|
88
100
|
// Initialize the graph
|
|
89
101
|
const graph = initializeGraph({
|
|
@@ -131,15 +143,19 @@ export class GraphRegistry {
|
|
|
131
143
|
if (!this.isWriteAllowed(resolvedKey, providedWriteKey)) {
|
|
132
144
|
const config = this.configs.get(resolvedKey);
|
|
133
145
|
if (!config) {
|
|
134
|
-
throw new
|
|
146
|
+
throw new RoamError(`Unknown graph: "${resolvedKey}".`, 'UNKNOWN_GRAPH', { requested_graph: resolvedKey, available_graphs: this.getAvailableGraphs() });
|
|
135
147
|
}
|
|
136
148
|
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
137
149
|
if (!systemWriteKey) {
|
|
138
|
-
throw new
|
|
150
|
+
throw new RoamError(`Write to protected graph "${resolvedKey}" failed: ROAM_SYSTEM_WRITE_KEY is not configured on the server.`, 'WRITE_KEY_NOT_CONFIGURED', { graph: resolvedKey });
|
|
139
151
|
}
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
// Say what is required, never what the value is. Echoing the key here
|
|
153
|
+
// hands the caller the means to retry and get through, which makes the
|
|
154
|
+
// whole gate decorative — including for a caller that simply guessed
|
|
155
|
+
// wrong. A legitimate operator can read ROAM_SYSTEM_WRITE_KEY from their
|
|
156
|
+
// own environment; an agent that cannot is exactly who this stops.
|
|
157
|
+
throw new RoamError(`Write to protected graph "${resolvedKey}" requires write_key confirmation. ` +
|
|
158
|
+
`Pass the value of the ROAM_SYSTEM_WRITE_KEY environment variable as the write_key parameter.`, 'WRITE_KEY_REQUIRED', { graph: resolvedKey, required_parameter: 'write_key' });
|
|
143
159
|
}
|
|
144
160
|
}
|
|
145
161
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { GraphRegistry } from './graph-registry.js';
|
|
3
3
|
describe('GraphRegistry', () => {
|
|
4
4
|
describe('getMemoriesTag', () => {
|
|
@@ -65,3 +65,94 @@ describe('GraphRegistry', () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
+
describe('getGuidelinesPage', () => {
|
|
69
|
+
const make = (configs, def = 'personal') => new GraphRegistry(configs, def);
|
|
70
|
+
it('defaults to the shared roam/agent guidelines convention', () => {
|
|
71
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
72
|
+
const r = make({ personal: { token: 't', graph: 'g' } });
|
|
73
|
+
expect(r.getGuidelinesPage('personal')).toBe('roam/agent guidelines');
|
|
74
|
+
});
|
|
75
|
+
it('prefers per-graph config over the env var', () => {
|
|
76
|
+
process.env.ROAM_GUIDELINES_PAGE = 'env/page';
|
|
77
|
+
const r = make({ work: { token: 't', graph: 'g', guidelinesPage: 'work/rules' } }, 'work');
|
|
78
|
+
expect(r.getGuidelinesPage('work')).toBe('work/rules');
|
|
79
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
80
|
+
});
|
|
81
|
+
it('falls back to the env var when a graph sets nothing', () => {
|
|
82
|
+
process.env.ROAM_GUIDELINES_PAGE = 'env/page';
|
|
83
|
+
const r = make({ personal: { token: 't', graph: 'g' } });
|
|
84
|
+
expect(r.getGuidelinesPage('personal')).toBe('env/page');
|
|
85
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
86
|
+
});
|
|
87
|
+
it('returns null when a graph disables guidelines', () => {
|
|
88
|
+
const r = make({ personal: { token: 't', graph: 'g', guidelinesPage: false } });
|
|
89
|
+
expect(r.getGuidelinesPage('personal')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
it('resolves the default graph when no key is given', () => {
|
|
92
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
93
|
+
const r = make({ personal: { token: 't', graph: 'g', guidelinesPage: 'p/rules' } });
|
|
94
|
+
expect(r.getGuidelinesPage()).toBe('p/rules');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('write-key denial does not disclose the key', () => {
|
|
98
|
+
/**
|
|
99
|
+
* The write key is the whole gate on protected graphs. An error that tells
|
|
100
|
+
* the caller what the key is hands the agent the means to retry and get
|
|
101
|
+
* through — the protection becomes decorative.
|
|
102
|
+
*/
|
|
103
|
+
const SECRET = 'super-secret-write-key';
|
|
104
|
+
const original = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
process.env.ROAM_SYSTEM_WRITE_KEY = SECRET;
|
|
107
|
+
});
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
if (original !== undefined)
|
|
110
|
+
process.env.ROAM_SYSTEM_WRITE_KEY = original;
|
|
111
|
+
else
|
|
112
|
+
delete process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
113
|
+
});
|
|
114
|
+
// 'work' must be NON-default: writes to the default graph bypass protection
|
|
115
|
+
// by design, so a protected default graph never reaches the denial path.
|
|
116
|
+
const protectedRegistry = () => new GraphRegistry({
|
|
117
|
+
personal: { token: 't', graph: 'p' },
|
|
118
|
+
work: { token: 't', graph: 'g', protected: true },
|
|
119
|
+
}, 'personal');
|
|
120
|
+
it('never puts the key in the denial message', () => {
|
|
121
|
+
let message = '';
|
|
122
|
+
try {
|
|
123
|
+
protectedRegistry().validateWriteAccess('roam_create_page', 'work', undefined);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
message = e.message;
|
|
127
|
+
}
|
|
128
|
+
expect(message, 'denial message must not be empty').not.toBe('');
|
|
129
|
+
expect(message).not.toContain(SECRET);
|
|
130
|
+
});
|
|
131
|
+
it('never discloses the key when a wrong one is supplied', () => {
|
|
132
|
+
let message = '';
|
|
133
|
+
try {
|
|
134
|
+
protectedRegistry().validateWriteAccess('roam_create_page', 'work', 'wrong-guess');
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
message = e.message;
|
|
138
|
+
}
|
|
139
|
+
expect(message).not.toContain(SECRET);
|
|
140
|
+
});
|
|
141
|
+
it('still explains what is required, so a legitimate caller can proceed', () => {
|
|
142
|
+
let message = '';
|
|
143
|
+
try {
|
|
144
|
+
protectedRegistry().validateWriteAccess('roam_create_page', 'work', undefined);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
message = e.message;
|
|
148
|
+
}
|
|
149
|
+
expect(message).toMatch(/write_key/);
|
|
150
|
+
expect(message).toMatch(/work/);
|
|
151
|
+
});
|
|
152
|
+
it('lets a correct key through', () => {
|
|
153
|
+
expect(() => protectedRegistry().validateWriteAccess('roam_create_page', 'work', SECRET)).not.toThrow();
|
|
154
|
+
});
|
|
155
|
+
it('does not gate reads on protected graphs', () => {
|
|
156
|
+
expect(() => protectedRegistry().validateWriteAccess('roam_search_by_text', 'work', undefined)).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|