roam-research-mcp 1.6.0 → 2.4.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.
@@ -9,40 +9,76 @@ const envPath = join(projectRoot, '.env');
9
9
  if (existsSync(envPath)) {
10
10
  dotenv.config({ path: envPath });
11
11
  }
12
- // Required environment variables
13
- const API_TOKEN = process.env.ROAM_API_TOKEN;
14
- const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
15
- // Validate environment variables
16
- if (!API_TOKEN || !GRAPH_NAME) {
17
- const missingVars = [];
18
- if (!API_TOKEN)
19
- missingVars.push('ROAM_API_TOKEN');
20
- if (!GRAPH_NAME)
21
- missingVars.push('ROAM_GRAPH_NAME');
22
- throw new Error(`Missing required environment variables: ${missingVars.join(', ')}\n\n` +
23
- 'Please configure these variables either:\n' +
24
- '1. In your MCP settings file:\n' +
25
- ' - For Cline: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json\n' +
26
- ' - For Claude: ~/Library/Application Support/Claude/claude_desktop_config.json\n\n' +
27
- ' Example configuration:\n' +
28
- ' {\n' +
29
- ' "mcpServers": {\n' +
30
- ' "roam-research": {\n' +
31
- ' "command": "node",\n' +
32
- ' "args": ["/path/to/roam-research-mcp/build/index.js"],\n' +
33
- ' "env": {\n' +
34
- ' "ROAM_API_TOKEN": "your-api-token",\n' +
35
- ' "ROAM_GRAPH_NAME": "your-graph-name"\n' +
36
- ' }\n' +
37
- ' }\n' +
38
- ' }\n' +
39
- ' }\n\n' +
40
- '2. Or in a .env file in the roam-research directory:\n' +
41
- ' ROAM_API_TOKEN=your-api-token\n' +
42
- ' ROAM_GRAPH_NAME=your-graph-name');
43
- }
44
- const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8088
12
+ // HTTP server configuration
13
+ const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088';
45
14
  const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5678,https://roamresearch.com')
46
15
  .split(',')
47
16
  .map(origin => origin.trim());
48
- export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, CORS_ORIGINS };
17
+ // Re-export for backwards compatibility with single-graph mode
18
+ // These are still used by CLI commands and for validation
19
+ const API_TOKEN = process.env.ROAM_API_TOKEN;
20
+ const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
21
+ // Multi-graph mode configuration
22
+ const ROAM_GRAPHS = process.env.ROAM_GRAPHS;
23
+ const ROAM_DEFAULT_GRAPH = process.env.ROAM_DEFAULT_GRAPH;
24
+ /**
25
+ * Check if we're in multi-graph mode
26
+ */
27
+ export function isMultiGraphMode() {
28
+ return !!ROAM_GRAPHS;
29
+ }
30
+ /**
31
+ * Validate that either multi-graph or single-graph configuration is provided
32
+ * Called during server/CLI initialization
33
+ */
34
+ export function validateEnvironment() {
35
+ if (ROAM_GRAPHS) {
36
+ // Multi-graph mode
37
+ if (!ROAM_DEFAULT_GRAPH) {
38
+ throw new Error('ROAM_DEFAULT_GRAPH is required when using ROAM_GRAPHS.\n' +
39
+ 'Set it to the key of the graph to use by default.');
40
+ }
41
+ // Validate JSON
42
+ try {
43
+ JSON.parse(ROAM_GRAPHS);
44
+ }
45
+ catch (e) {
46
+ throw new Error(`Invalid JSON in ROAM_GRAPHS: ${e.message}`);
47
+ }
48
+ }
49
+ else {
50
+ // Single-graph mode - require legacy env vars
51
+ if (!API_TOKEN || !GRAPH_NAME) {
52
+ const missingVars = [];
53
+ if (!API_TOKEN)
54
+ missingVars.push('ROAM_API_TOKEN');
55
+ if (!GRAPH_NAME)
56
+ missingVars.push('ROAM_GRAPH_NAME');
57
+ throw new Error(`Missing required environment variables: ${missingVars.join(', ')}\n\n` +
58
+ 'Please configure these variables either:\n' +
59
+ '1. In your MCP settings file:\n' +
60
+ ' - For Cline: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json\n' +
61
+ ' - For Claude: ~/Library/Application Support/Claude/claude_desktop_config.json\n\n' +
62
+ ' Example configuration:\n' +
63
+ ' {\n' +
64
+ ' "mcpServers": {\n' +
65
+ ' "roam-research": {\n' +
66
+ ' "command": "node",\n' +
67
+ ' "args": ["/path/to/roam-research-mcp/build/index.js"],\n' +
68
+ ' "env": {\n' +
69
+ ' "ROAM_API_TOKEN": "your-api-token",\n' +
70
+ ' "ROAM_GRAPH_NAME": "your-graph-name"\n' +
71
+ ' }\n' +
72
+ ' }\n' +
73
+ ' }\n' +
74
+ ' }\n\n' +
75
+ '2. Or in a .env file in the roam-research directory:\n' +
76
+ ' ROAM_API_TOKEN=your-api-token\n' +
77
+ ' ROAM_GRAPH_NAME=your-graph-name\n\n' +
78
+ '3. Or use multi-graph mode:\n' +
79
+ ' ROAM_GRAPHS=\'{"personal": {"token": "...", "graph": "..."}}\'\n' +
80
+ ' ROAM_DEFAULT_GRAPH=personal');
81
+ }
82
+ }
83
+ }
84
+ export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, CORS_ORIGINS, ROAM_GRAPHS, ROAM_DEFAULT_GRAPH };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * GraphRegistry - Manages multiple Roam graph connections with safety guardrails
3
+ *
4
+ * Supports:
5
+ * - Multiple graph configurations via ROAM_GRAPHS env var
6
+ * - Backwards compatibility with single graph via ROAM_API_TOKEN/ROAM_GRAPH_NAME
7
+ * - Write protection for non-default graphs via write_key confirmation
8
+ * - Lazy graph initialization (connects only when first accessed)
9
+ */
10
+ import { initializeGraph } from '@roam-research/roam-api-sdk';
11
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
12
+ /** List of tool names that perform write operations */
13
+ export const WRITE_OPERATIONS = [
14
+ 'roam_create_page',
15
+ 'roam_create_outline',
16
+ 'roam_import_markdown',
17
+ 'roam_process_batch_actions',
18
+ 'roam_add_todo',
19
+ 'roam_remember',
20
+ 'roam_create_table',
21
+ 'roam_move_block',
22
+ 'roam_update_page_markdown',
23
+ 'roam_rename_page',
24
+ ];
25
+ /**
26
+ * Check if a tool name is a write operation
27
+ */
28
+ export function isWriteOperation(toolName) {
29
+ return WRITE_OPERATIONS.includes(toolName);
30
+ }
31
+ /**
32
+ * GraphRegistry - Central manager for multiple Roam graph connections
33
+ */
34
+ export class GraphRegistry {
35
+ constructor(configs, defaultKey) {
36
+ this.configs = new Map(Object.entries(configs));
37
+ this.initialized = new Map();
38
+ this.defaultKey = defaultKey;
39
+ this.isMultiGraph = this.configs.size > 1;
40
+ // Validate default key exists
41
+ if (!this.configs.has(defaultKey)) {
42
+ throw new Error(`Default graph key "${defaultKey}" not found in configuration`);
43
+ }
44
+ }
45
+ /**
46
+ * Get configuration for a graph by key
47
+ */
48
+ getConfig(key) {
49
+ return this.configs.get(key);
50
+ }
51
+ /**
52
+ * Get all available graph keys
53
+ */
54
+ getAvailableGraphs() {
55
+ return Array.from(this.configs.keys());
56
+ }
57
+ /**
58
+ * Get an initialized Graph instance, creating it lazily if needed
59
+ * @param key - Graph key from config. Defaults to defaultKey if not specified.
60
+ */
61
+ getGraph(key) {
62
+ const resolvedKey = key ?? this.defaultKey;
63
+ // Check if already initialized
64
+ const existing = this.initialized.get(resolvedKey);
65
+ if (existing) {
66
+ return existing;
67
+ }
68
+ // Get config
69
+ const config = this.configs.get(resolvedKey);
70
+ if (!config) {
71
+ throw new McpError(ErrorCode.InvalidParams, `Unknown graph: "${resolvedKey}". Available graphs: ${this.getAvailableGraphs().join(', ')}`);
72
+ }
73
+ // Initialize the graph
74
+ const graph = initializeGraph({
75
+ token: config.token,
76
+ graph: config.graph,
77
+ });
78
+ this.initialized.set(resolvedKey, graph);
79
+ return graph;
80
+ }
81
+ /**
82
+ * Check if a write operation is allowed for a given graph
83
+ *
84
+ * Rules:
85
+ * - Writes to default graph are always allowed
86
+ * - Writes to non-default graphs require:
87
+ * - If write_key is configured: must provide matching write_key
88
+ * - If no write_key configured: writes are allowed
89
+ */
90
+ isWriteAllowed(graphKey, providedWriteKey) {
91
+ const resolvedKey = graphKey ?? this.defaultKey;
92
+ // Writes to default graph are always allowed
93
+ if (resolvedKey === this.defaultKey) {
94
+ return true;
95
+ }
96
+ const config = this.configs.get(resolvedKey);
97
+ if (!config) {
98
+ return false; // Unknown graph
99
+ }
100
+ // If no write_key is configured, allow writes
101
+ if (!config.write_key) {
102
+ return true;
103
+ }
104
+ // Check if provided key matches
105
+ return providedWriteKey === config.write_key;
106
+ }
107
+ /**
108
+ * Validate write access and return an informative error if denied
109
+ */
110
+ validateWriteAccess(toolName, graphKey, providedWriteKey) {
111
+ if (!isWriteOperation(toolName)) {
112
+ return; // Not a write operation, no validation needed
113
+ }
114
+ const resolvedKey = graphKey ?? this.defaultKey;
115
+ if (!this.isWriteAllowed(resolvedKey, providedWriteKey)) {
116
+ const config = this.configs.get(resolvedKey);
117
+ if (!config) {
118
+ throw new McpError(ErrorCode.InvalidParams, `Unknown graph: "${resolvedKey}". Available graphs: ${this.getAvailableGraphs().join(', ')}`);
119
+ }
120
+ // Provide informative error with the required key
121
+ throw new McpError(ErrorCode.InvalidParams, `Write to "${resolvedKey}" graph requires write_key confirmation.\n` +
122
+ `Provide write_key: "${config.write_key}" to proceed.`);
123
+ }
124
+ }
125
+ /**
126
+ * Resolve graph key and validate access for a tool call
127
+ * Returns the Graph instance ready to use
128
+ */
129
+ resolveGraphForTool(toolName, graphKey, writeKey) {
130
+ // Validate write access if this is a write operation
131
+ this.validateWriteAccess(toolName, graphKey, writeKey);
132
+ // Return the graph instance
133
+ return this.getGraph(graphKey);
134
+ }
135
+ /**
136
+ * Generate markdown documentation about available graphs and their configuration
137
+ * Used to inform AI models about graph access requirements
138
+ */
139
+ getGraphInfoMarkdown() {
140
+ const graphKeys = this.getAvailableGraphs();
141
+ // Single graph mode - minimal info
142
+ if (graphKeys.length === 1 && graphKeys[0] === 'default') {
143
+ return ''; // No need to show graph info in single-graph mode
144
+ }
145
+ const lines = [
146
+ '## Available Graphs',
147
+ '',
148
+ '| Graph | Default | Write Protected |',
149
+ '|-------|---------|-----------------|',
150
+ ];
151
+ for (const key of graphKeys) {
152
+ const config = this.configs.get(key);
153
+ const isDefault = key === this.defaultKey;
154
+ const isProtected = !!config.write_key;
155
+ const defaultCol = isDefault ? '✓' : '';
156
+ const protectedCol = isProtected
157
+ ? `Yes (requires \`write_key: "${config.write_key}"\`)`
158
+ : 'No';
159
+ lines.push(`| ${key} | ${defaultCol} | ${protectedCol} |`);
160
+ }
161
+ lines.push('');
162
+ lines.push('> **Note:** Write operations to protected graphs require the `write_key` parameter.');
163
+ lines.push('');
164
+ lines.push('---');
165
+ lines.push('');
166
+ return lines.join('\n');
167
+ }
168
+ }
169
+ /**
170
+ * Create a GraphRegistry from environment variables
171
+ *
172
+ * Supports two modes:
173
+ * 1. Multi-graph: ROAM_GRAPHS JSON + ROAM_DEFAULT_GRAPH
174
+ * 2. Single graph (backwards compat): ROAM_API_TOKEN + ROAM_GRAPH_NAME
175
+ */
176
+ export function createRegistryFromEnv() {
177
+ const roamGraphsJson = process.env.ROAM_GRAPHS;
178
+ if (roamGraphsJson) {
179
+ // Multi-graph mode
180
+ try {
181
+ const configs = JSON.parse(roamGraphsJson);
182
+ const defaultKey = process.env.ROAM_DEFAULT_GRAPH?.trim().replace(/,+$/, '');
183
+ if (!defaultKey) {
184
+ throw new Error('ROAM_DEFAULT_GRAPH is required when using ROAM_GRAPHS');
185
+ }
186
+ return new GraphRegistry(configs, defaultKey);
187
+ }
188
+ catch (error) {
189
+ if (error instanceof SyntaxError) {
190
+ throw new Error(`Invalid JSON in ROAM_GRAPHS: ${error.message}`);
191
+ }
192
+ throw error;
193
+ }
194
+ }
195
+ // Backwards compatibility: single graph mode
196
+ const token = process.env.ROAM_API_TOKEN;
197
+ const graphName = process.env.ROAM_GRAPH_NAME;
198
+ if (!token || !graphName) {
199
+ const missingVars = [];
200
+ if (!token)
201
+ missingVars.push('ROAM_API_TOKEN');
202
+ if (!graphName)
203
+ missingVars.push('ROAM_GRAPH_NAME');
204
+ throw new Error(`Missing required environment variables: ${missingVars.join(', ')}\n\n` +
205
+ 'Configure either:\n' +
206
+ '1. Multi-graph mode:\n' +
207
+ ' ROAM_GRAPHS=\'{"personal": {"token": "...", "graph": "..."}}\'\n' +
208
+ ' ROAM_DEFAULT_GRAPH=personal\n\n' +
209
+ '2. Single graph mode (backwards compatible):\n' +
210
+ ' ROAM_API_TOKEN=your-api-token\n' +
211
+ ' ROAM_GRAPH_NAME=your-graph-name');
212
+ }
213
+ // Create single-graph registry with "default" as the key
214
+ const configs = {
215
+ default: {
216
+ token,
217
+ graph: graphName,
218
+ }
219
+ };
220
+ return new GraphRegistry(configs, 'default');
221
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { GraphRegistry } from './graph-registry.js';
3
+ describe('GraphRegistry', () => {
4
+ describe('getGraphInfoMarkdown', () => {
5
+ it('returns empty string for single-graph mode with default key', () => {
6
+ const registry = new GraphRegistry({ default: { token: 'token', graph: 'graph' } }, 'default');
7
+ expect(registry.getGraphInfoMarkdown()).toBe('');
8
+ });
9
+ it('returns markdown table for multi-graph mode', () => {
10
+ const registry = new GraphRegistry({
11
+ personal: { token: 'token1', graph: 'personal-graph' },
12
+ work: { token: 'token2', graph: 'work-graph', write_key: 'confirm' },
13
+ }, 'personal');
14
+ const markdown = registry.getGraphInfoMarkdown();
15
+ expect(markdown).toContain('## Available Graphs');
16
+ expect(markdown).toContain('| personal | ✓ | No |');
17
+ expect(markdown).toContain('| work | | Yes (requires `write_key: "confirm"`) |');
18
+ expect(markdown).toContain('> **Note:** Write operations to protected graphs');
19
+ });
20
+ it('shows write protection for default graph if configured', () => {
21
+ const registry = new GraphRegistry({
22
+ main: { token: 'token1', graph: 'main-graph', write_key: 'secret' },
23
+ backup: { token: 'token2', graph: 'backup-graph' },
24
+ }, 'main');
25
+ const markdown = registry.getGraphInfoMarkdown();
26
+ expect(markdown).toContain('| main | ✓ | Yes (requires `write_key: "secret"`) |');
27
+ expect(markdown).toContain('| backup | | No |');
28
+ });
29
+ });
30
+ });
@@ -17,14 +17,15 @@ export class StatusSearchHandler extends BaseSearchHandler {
17
17
  // Build query based on whether we're searching in a specific page
18
18
  let queryStr;
19
19
  let queryParams;
20
+ // Search for "{{TODO" or "{{DONE" which matches both {{[[TODO]]}} and {{TODO}} formats
20
21
  if (targetPageUid) {
21
22
  queryStr = `[:find ?block-uid ?block-str
22
23
  :in $ ?status ?page-uid
23
24
  :where [?p :block/uid ?page-uid]
24
25
  [?b :block/page ?p]
25
- [?b :block/string ?block-str]
26
- [?b :block/uid ?block-uid]
27
- [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
26
+ [?b :block/string ?block-str]
27
+ [?b :block/uid ?block-uid]
28
+ [(clojure.string/includes? ?block-str (str "{{" ?status))]]`;
28
29
  queryParams = [status, targetPageUid];
29
30
  }
30
31
  else {
@@ -34,7 +35,7 @@ export class StatusSearchHandler extends BaseSearchHandler {
34
35
  [?b :block/uid ?block-uid]
35
36
  [?b :block/page ?p]
36
37
  [?p :node/title ?page-title]
37
- [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
38
+ [(clojure.string/includes? ?block-str (str "{{" ?status))]]`;
38
39
  queryParams = [status];
39
40
  }
40
41
  const rawResults = await q(this.graph, queryStr, queryParams);