roam-research-mcp
Version:
MCP server and CLI for Roam Research
270 lines (269 loc) • 11 kB
JavaScript
/**
* GraphRegistry - Manages multiple Roam graph connections with safety guardrails
*
* Supports:
* - Multiple graph configurations via ROAM_GRAPHS env var
* - Backwards compatibility with single graph via ROAM_API_TOKEN/ROAM_GRAPH_NAME
* - Write protection via protected: true flag + ROAM_SYSTEM_WRITE_KEY env var
* - Lazy graph initialization (connects only when first accessed)
*/
import { initializeGraph } from '@roam-research/roam-api-sdk';
import { RoamError } from '../shared/errors.js';
import { DEFAULT_GUIDELINES_PAGE } from '../tools/operations/guidelines.js';
/** List of tool names that perform write operations */
export const WRITE_OPERATIONS = [
'roam_create_page',
'roam_create_outline',
'roam_import_markdown',
'roam_process_batch_actions',
'roam_add_todo',
'roam_remember',
'roam_create_table',
'roam_move_block',
'roam_update_page_markdown',
'roam_rename_page',
];
/**
* Check if a tool name is a write operation
*/
export function isWriteOperation(toolName) {
return WRITE_OPERATIONS.includes(toolName);
}
/**
* GraphRegistry - Central manager for multiple Roam graph connections
*/
export class GraphRegistry {
constructor(configs, defaultKey) {
this.configs = new Map(Object.entries(configs));
this.initialized = new Map();
this.defaultKey = defaultKey;
this.isMultiGraph = this.configs.size > 1;
// Validate default key exists
if (!this.configs.has(defaultKey)) {
throw new Error(`Default graph key "${defaultKey}" not found in configuration`);
}
}
/**
* Get configuration for a graph by key
*/
getConfig(key) {
return this.configs.get(key);
}
/**
* Get all available graph keys
*/
getAvailableGraphs() {
return Array.from(this.configs.keys());
}
/**
* Get the memories tag for a graph
* Priority: graph config > ROAM_MEMORIES_TAG env var > "Memories"
* Returns null if explicitly disabled (memoriesTag: false)
*/
getMemoriesTag(key) {
const resolvedKey = key ?? this.defaultKey;
const config = this.configs.get(resolvedKey);
// If explicitly disabled, return null
if (config?.memoriesTag === false) {
return null;
}
// Priority: per-graph config > env var > default
return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
}
/**
* Page holding a graph's agent conventions, or null when explicitly disabled.
*
* Precedence: per-graph config > ROAM_GUIDELINES_PAGE env var >
* "roam/agent guidelines" (the shared convention).
*
* Reading the conventional title by default is deliberate. It is the same
* page Roam's own MCP server reads unconditionally, so writing it once makes
* both servers honour it — which is the entire point of a convention, and it
* stops working the moment it needs private configuration. Creating a page
* with that exact namespaced title IS the opt-in; nobody makes one by
* accident. And nothing here reads the graph unprompted: the lookup only
* happens when an agent explicitly calls roam_get_guidelines, so the tool
* call is already the consent.
*
* Set `guidelinesPage: false` to turn it off for a graph.
*/
getGuidelinesPage(key) {
const resolvedKey = key ?? this.defaultKey;
const config = this.configs.get(resolvedKey);
if (config?.guidelinesPage === false) {
return null;
}
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? DEFAULT_GUIDELINES_PAGE;
}
/**
* Get an initialized Graph instance, creating it lazily if needed
* @param key - Graph key from config. Defaults to defaultKey if not specified.
*/
getGraph(key) {
const resolvedKey = key ?? this.defaultKey;
// Check if already initialized
const existing = this.initialized.get(resolvedKey);
if (existing) {
return existing;
}
// Get config
const config = this.configs.get(resolvedKey);
if (!config) {
throw new RoamError(`Unknown graph: "${resolvedKey}".`, 'UNKNOWN_GRAPH', { requested_graph: resolvedKey, available_graphs: this.getAvailableGraphs() });
}
// Initialize the graph
const graph = initializeGraph({
token: config.token,
graph: config.graph,
});
this.initialized.set(resolvedKey, graph);
return graph;
}
/**
* Check if a write operation is allowed for a given graph
*
* Rules:
* - Writes to default graph are always allowed
* - Writes to non-default graphs require:
* - If protected: true, must provide matching ROAM_SYSTEM_WRITE_KEY
* - If not protected: writes are allowed
*/
isWriteAllowed(graphKey, providedWriteKey) {
const resolvedKey = graphKey ?? this.defaultKey;
// Writes to default graph are always allowed
if (resolvedKey === this.defaultKey) {
return true;
}
const config = this.configs.get(resolvedKey);
if (!config) {
return false; // Unknown graph
}
// If graph is not protected, allow writes
if (!config.protected) {
return true;
}
// Check if provided key matches ROAM_SYSTEM_WRITE_KEY
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
return !!systemWriteKey && providedWriteKey === systemWriteKey;
}
/**
* Validate write access and return an informative error if denied
*/
validateWriteAccess(toolName, graphKey, providedWriteKey) {
if (!isWriteOperation(toolName)) {
return; // Not a write operation, no validation needed
}
const resolvedKey = graphKey ?? this.defaultKey;
if (!this.isWriteAllowed(resolvedKey, providedWriteKey)) {
const config = this.configs.get(resolvedKey);
if (!config) {
throw new RoamError(`Unknown graph: "${resolvedKey}".`, 'UNKNOWN_GRAPH', { requested_graph: resolvedKey, available_graphs: this.getAvailableGraphs() });
}
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
if (!systemWriteKey) {
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 });
}
// Say what is required, never what the value is. Echoing the key here
// hands the caller the means to retry and get through, which makes the
// whole gate decorative — including for a caller that simply guessed
// wrong. A legitimate operator can read ROAM_SYSTEM_WRITE_KEY from their
// own environment; an agent that cannot is exactly who this stops.
throw new RoamError(`Write to protected graph "${resolvedKey}" requires write_key confirmation. ` +
`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' });
}
}
/**
* Resolve graph key and validate access for a tool call
* Returns the Graph instance ready to use
*/
resolveGraphForTool(toolName, graphKey, writeKey) {
// Validate write access if this is a write operation
this.validateWriteAccess(toolName, graphKey, writeKey);
// Return the graph instance
return this.getGraph(graphKey);
}
/**
* Generate markdown documentation about available graphs and their configuration
* Used to inform AI models about graph access requirements
*/
getGraphInfoMarkdown() {
const graphKeys = this.getAvailableGraphs();
// Single graph mode - minimal info
if (graphKeys.length === 1 && graphKeys[0] === 'default') {
return ''; // No need to show graph info in single-graph mode
}
const lines = [
'## Available Graphs',
'',
'| Graph | Default | Write Protected |',
'|-------|---------|-----------------|',
];
for (const key of graphKeys) {
const config = this.configs.get(key);
const isDefault = key === this.defaultKey;
const isProtected = !!config.protected;
const defaultCol = isDefault ? '✓' : '';
const protectedCol = isProtected ? 'Yes' : 'No';
lines.push(`| ${key} | ${defaultCol} | ${protectedCol} |`);
}
lines.push('');
lines.push('> **Note:** Write operations to protected graphs require the `write_key` parameter. The key will be shown in the error message if omitted.');
lines.push('');
lines.push('---');
lines.push('');
return lines.join('\n');
}
}
/**
* Create a GraphRegistry from environment variables
*
* Supports two modes:
* 1. Multi-graph: ROAM_GRAPHS JSON + ROAM_DEFAULT_GRAPH
* 2. Single graph (backwards compat): ROAM_API_TOKEN + ROAM_GRAPH_NAME
*/
export function createRegistryFromEnv() {
const roamGraphsJson = process.env.ROAM_GRAPHS;
if (roamGraphsJson) {
// Multi-graph mode
try {
const configs = JSON.parse(roamGraphsJson);
const defaultKey = process.env.ROAM_DEFAULT_GRAPH?.trim().replace(/,+$/, '');
if (!defaultKey) {
throw new Error('ROAM_DEFAULT_GRAPH is required when using ROAM_GRAPHS');
}
return new GraphRegistry(configs, defaultKey);
}
catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON in ROAM_GRAPHS: ${error.message}`);
}
throw error;
}
}
// Backwards compatibility: single graph mode
const token = process.env.ROAM_API_TOKEN;
const graphName = process.env.ROAM_GRAPH_NAME;
if (!token || !graphName) {
const missingVars = [];
if (!token)
missingVars.push('ROAM_API_TOKEN');
if (!graphName)
missingVars.push('ROAM_GRAPH_NAME');
throw new Error(`Missing required environment variables: ${missingVars.join(', ')}\n\n` +
'Configure either:\n' +
'1. Multi-graph mode:\n' +
' ROAM_GRAPHS=\'{"personal": {"token": "...", "graph": "..."}}\'\n' +
' ROAM_DEFAULT_GRAPH=personal\n\n' +
'2. Single graph mode (backwards compatible):\n' +
' ROAM_API_TOKEN=your-api-token\n' +
' ROAM_GRAPH_NAME=your-graph-name');
}
// Create single-graph registry with "default" as the key
const configs = {
default: {
token,
graph: graphName,
}
};
return new GraphRegistry(configs, 'default');
}