roam-research-mcp
Version:
MCP server and CLI for Roam Research
467 lines (466 loc) • 25.8 kB
JavaScript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
import { HTTP_STREAM_PORT, HTTP_STREAM_HOST, HTTP_AUTH_TOKEN, validateEnvironment } from '../config/environment.js';
import { isBearerAuthorized } from '../utils/auth.js';
import { RoamError, toErrorResult } from '../shared/errors.js';
import { createRegistryFromEnv } from '../config/graph-registry.js';
import { toolSchemas } from '../tools/schemas.js';
import { ToolHandlers } from '../tools/tool-handlers.js';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { isPortInUse } from '../utils/net.js';
import { CORS_ORIGINS } from '../config/environment.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Read package.json to get the version
const packageJsonPath = join(__dirname, '../../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const serverVersion = packageJson.version;
/**
* The single place a tool result is built, and the only place
* `structuredContent` may be attached.
*
* The wire invariant is **`structuredContent` is present iff the tool declares
* an `outputSchema`**. A schema-bearing tool that returns none, or a
* schema-less tool that returns some, is a protocol violation a strict client
* will reject. We use the low-level `Server` rather than `McpServer`, so the
* SDK does not enforce this for us — routing every result through here is what
* enforces it. Adding a schema in schemas.ts is therefore sufficient; no switch
* case needs touching.
*
* The text channel is unchanged either way, so clients that never look at
* structuredContent see exactly what they saw before.
*/
function toolResult(toolName, result) {
const declaresSchema = Boolean(toolSchemas[toolName]?.outputSchema);
const canStructure = declaresSchema && result !== null && typeof result === 'object';
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
...(canStructure ? { structuredContent: result } : {}),
};
}
export class RoamServer {
constructor() {
this.toolHandlersCache = new Map();
// Validate environment first
validateEnvironment();
try {
this.registry = createRegistryFromEnv();
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.InternalError, `Failed to initialize graph registry: ${errorMessage}`);
}
// Ensure toolSchemas is not empty before proceeding
if (Object.keys(toolSchemas).length === 0) {
throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts');
}
}
/**
* Get or create a ToolHandlers instance for a specific graph
* Handlers are cached per-graph for efficiency
*/
getToolHandlers(graph, graphKey) {
const cached = this.toolHandlersCache.get(graphKey);
if (cached) {
return cached;
}
const memoriesTag = this.registry.getMemoriesTag(graphKey);
const guidelinesPage = this.registry.getGuidelinesPage(graphKey);
const handlers = new ToolHandlers(graph, memoriesTag, guidelinesPage);
this.toolHandlersCache.set(graphKey, handlers);
return handlers;
}
// Helper to create and configure MCP server instance
createMcpServer(nameSuffix = '') {
const server = new Server({
name: `roam-research${nameSuffix}`,
version: serverVersion,
}, {
capabilities: {
tools: {
...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, toolSchemas[toolName].inputSchema])),
},
resources: {}, // No resources exposed via capabilities
prompts: {}, // No prompts exposed via capabilities
},
});
this.setupRequestHandlers(server);
return server;
}
/**
* Extract graph and write_key from tool arguments
*/
extractGraphParams(args) {
const { graph, write_key, ...cleanedArgs } = args;
return {
graphKey: graph,
writeKey: write_key,
cleanedArgs,
};
}
/**
* Resolve graph for a tool call with validation
*/
resolveGraph(toolName, graphKey, writeKey) {
const resolvedKey = graphKey ?? this.registry.defaultKey;
const graph = this.registry.resolveGraphForTool(toolName, graphKey, writeKey);
return { graph, resolvedKey };
}
// Refactored to accept a Server instance
setupRequestHandlers(mcpServer) {
// List available tools
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: Object.values(toolSchemas),
}));
// List available resources
mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources = []; // No resources, as cheatsheet is now a tool
return { resources };
});
// Access resource - no resources handled directly here anymore
mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
throw new McpError(ErrorCode.InternalError, `Resource not found: ${request.params.uri}`);
});
// List available prompts
mcpServer.setRequestHandler(ListPromptsRequestSchema, async () => {
return { prompts: [] };
});
// Handle tool calls
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const args = (request.params.arguments ?? {});
const { graphKey, writeKey, cleanedArgs } = this.extractGraphParams(args);
const { graph, resolvedKey } = this.resolveGraph(request.params.name, graphKey, writeKey);
const toolHandlers = this.getToolHandlers(graph, resolvedKey);
switch (request.params.name) {
case 'roam_markdown_cheatsheet': {
const graphInfo = this.registry.getGraphInfoMarkdown();
const cheatsheet = await toolHandlers.getRoamMarkdownCheatsheet();
const content = graphInfo + cheatsheet;
return {
content: [{ type: 'text', text: content }],
};
}
case 'roam_remember': {
const { memory, categories, heading, parent_uid, include_memories_tag } = cleanedArgs;
const result = await toolHandlers.remember(memory, categories, heading, parent_uid, include_memories_tag);
return toolResult(request.params.name, result);
}
case 'roam_fetch_page_full_view': {
const { title, children_depth, max_references } = cleanedArgs;
const content = await toolHandlers.fetchPageFullView(title, children_depth, max_references);
return {
content: [{ type: 'text', text: content }],
};
}
case 'roam_get_subpages': {
const { prefix, filter_tag, include_content } = cleanedArgs;
const content = await toolHandlers.getSubPages(prefix, filter_tag, include_content);
return {
content: [{ type: 'text', text: content }],
};
}
case 'roam_get_guidelines': {
const result = await toolHandlers.getGuidelines();
return toolResult(request.params.name, result);
}
case 'roam_fetch_page_by_title': {
const { title, format } = cleanedArgs;
const content = await toolHandlers.fetchPageByTitle(title, format);
return {
content: [{ type: 'text', text: content }],
};
}
case 'roam_create_page': {
const { title, content } = cleanedArgs;
const result = await toolHandlers.createPage(title, content);
return toolResult(request.params.name, result);
}
case 'roam_import_markdown': {
const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = cleanedArgs;
const result = await toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
return toolResult(request.params.name, result);
}
case 'roam_add_todo': {
const { todos } = cleanedArgs;
const result = await toolHandlers.addTodos(todos);
return toolResult(request.params.name, result);
}
case 'roam_create_outline': {
const { outline, page_title_uid, block_text_uid, order } = cleanedArgs;
const result = await toolHandlers.createOutline(outline, page_title_uid, block_text_uid, order);
return toolResult(request.params.name, result);
}
case 'roam_search_for_tag': {
const { primary_tag, page_title_uid, near_tag } = cleanedArgs;
if (!primary_tag) {
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: primary_tag (the tag to search for). Use page_title_uid to limit search to a specific page.');
}
const result = await toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
return toolResult(request.params.name, result);
}
case 'roam_search_by_status': {
const { status, page_title_uid, include, exclude } = cleanedArgs;
const result = await toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
return toolResult(request.params.name, result);
}
case 'roam_search_block_refs': {
const params = cleanedArgs;
const result = await toolHandlers.searchBlockRefs(params);
return toolResult(request.params.name, result);
}
case 'roam_search_hierarchy': {
const params = cleanedArgs;
// Validate that either parent_uid or child_uid is provided, but not both
if ((!params.parent_uid && !params.child_uid) || (params.parent_uid && params.child_uid)) {
throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
}
const result = await toolHandlers.searchHierarchy(params);
return toolResult(request.params.name, result);
}
case 'roam_find_pages_modified_today': {
const { max_num_pages } = cleanedArgs;
const result = await toolHandlers.findPagesModifiedToday(max_num_pages || 50);
return toolResult(request.params.name, result);
}
case 'roam_search_by_text': {
const params = cleanedArgs;
const result = await toolHandlers.searchByText(params);
return toolResult(request.params.name, result);
}
case 'roam_search_by_date': {
const params = cleanedArgs;
const result = await toolHandlers.searchByDate(params);
return toolResult(request.params.name, result);
}
case 'roam_recall': {
const { sort_by = 'newest', filter_tag } = cleanedArgs;
const result = await toolHandlers.recall(sort_by, filter_tag);
return toolResult(request.params.name, result);
}
case 'roam_datomic_query': {
const { query, inputs } = cleanedArgs;
const result = await toolHandlers.executeDatomicQuery({ query, inputs });
return toolResult(request.params.name, result);
}
case 'roam_process_batch_actions': {
const { actions } = cleanedArgs;
const result = await toolHandlers.processBatch(actions);
return toolResult(request.params.name, result);
}
case 'roam_fetch_block': {
const { block_uid, depth, include_ancestors } = cleanedArgs;
const result = await toolHandlers.fetchBlock(block_uid, depth, include_ancestors);
return toolResult(request.params.name, result);
}
case 'roam_create_table': {
const { parent_uid, order, headers, rows } = cleanedArgs;
const result = await toolHandlers.createTable({
parent_uid,
order,
headers,
rows
});
return toolResult(request.params.name, result);
}
case 'roam_move_block': {
const { block_uid, parent_uid, order = 'last' } = cleanedArgs;
const result = await toolHandlers.moveBlock(block_uid, parent_uid, order);
return toolResult(request.params.name, result);
}
case 'roam_update_page_markdown': {
const { title, markdown, dry_run = false } = cleanedArgs;
const result = await toolHandlers.updatePageMarkdown(title, markdown, dry_run);
return toolResult(request.params.name, result);
}
case 'roam_rename_page': {
const { old_title, uid, new_title } = cleanedArgs;
const result = await toolHandlers.renamePage({ old_title, uid, new_title });
return toolResult(request.params.name, result);
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
}
catch (error) {
// A RoamError is a TOOL failure, not a protocol failure: return it as
// content with isError so the model can read the code and the recovery
// context (available_graphs, and so on) and act on it. Throwing would
// collapse all of that into a JSON-RPC error string.
if (error instanceof RoamError) {
return toErrorResult(error);
}
if (error instanceof McpError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.InternalError, `Roam API error: ${errorMessage}`);
}
});
}
async run(options = {}) {
const { serverMode = false } = options;
try {
// The two transports are mutually exclusive, and each mode opens exactly
// one. Stdio mode talks to the client that spawned it over stdin/stdout
// and returns here — it opens no socket at all. Nothing about MCP over
// stdio needs one, and a listener nobody asked for is pure attack
// surface: until 3.1.0 stdio mode also bound an HTTP port, which shipped
// a token-free MCP endpoint per spawned instance. Run `--server` when
// you want HTTP; that is what it is for.
if (!serverMode) {
const stdioMcpServer = this.createMcpServer();
const stdioTransport = new StdioServerTransport();
await stdioMcpServer.connect(stdioTransport);
return;
}
// Track active transports by session ID for proper session management
const activeSessions = new Map();
const httpServer = createServer(async (req, res) => {
// Set CORS headers dynamically based on request origin
const requestOrigin = req.headers.origin;
if (requestOrigin && CORS_ORIGINS.includes(requestOrigin)) {
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
}
else if (CORS_ORIGINS.includes('*')) {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID');
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Handle preflight OPTIONS requests
if (req.method === 'OPTIONS') {
res.writeHead(204); // No Content
res.end();
return;
}
// Liveness probe — cheap, no MCP handshake required. Useful for the
// LaunchAgent/health checks (a bare GET on the MCP endpoint returns 406).
const requestPath = (req.url || '/').split('?')[0];
if (req.method === 'GET' && requestPath === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
name: 'roam-research-mcp',
version: serverVersion,
// Always 'server' since 3.1.0: this handler is only reachable in
// --server mode. Kept as a field because clients read it, and the
// retired 'stdio+http' value is how they can tell they are talking
// to an older build that still had the stdio-mode listener.
mode: 'server',
auth: HTTP_AUTH_TOKEN ? 'required' : 'none',
graphs: this.registry.getAvailableGraphs(),
defaultGraph: this.registry.defaultKey,
activeSessions: activeSessions.size,
}));
return;
}
// Transport authentication (perimeter): when HTTP_AUTH_TOKEN is set,
// every MCP request must carry `Authorization: Bearer <token>`. /health
// and OPTIONS are intentionally exempt (handled above). Unset = open.
if (HTTP_AUTH_TOKEN && !isBearerAuthorized(req.headers['authorization'], HTTP_AUTH_TOKEN)) {
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer',
});
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized: missing or invalid bearer token' },
id: null,
}));
return;
}
// Check for existing session ID in header
const sessionId = req.headers['mcp-session-id'];
// Handle session termination (DELETE request)
if (req.method === 'DELETE' && sessionId) {
const transport = activeSessions.get(sessionId);
if (transport) {
await transport.close();
activeSessions.delete(sessionId);
res.writeHead(200);
res.end();
}
else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
}
return;
}
try {
// If we have an existing session, use that transport
if (sessionId && activeSessions.has(sessionId)) {
const transport = activeSessions.get(sessionId);
await transport.handleRequest(req, res);
return;
}
// A request that carries a session ID we don't know (the daemon
// restarted, or the session was terminated) is a dead session. Per
// the MCP streamable-HTTP spec the server MUST respond 404 so the
// client starts a new session with a fresh InitializeRequest.
// Falling through to a new transport instead makes the SDK answer
// HTTP 400 "Server not initialized", which clients do not treat as
// a re-initialize signal — they keep retrying the dead session and
// every tool call times out with no useful error.
if (sessionId) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32001, message: 'Session not found' },
id: null,
}));
return;
}
// Create new transport and server for new sessions
const httpMcpServer = this.createMcpServer('-http');
const httpStreamTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
onsessioninitialized: (newSessionId) => {
activeSessions.set(newSessionId, httpStreamTransport);
}
});
// Clean up session when transport closes
httpStreamTransport.onclose = () => {
const entries = activeSessions.entries();
for (const [key, value] of entries) {
if (value === httpStreamTransport) {
activeSessions.delete(key);
break;
}
}
};
await httpMcpServer.connect(httpStreamTransport);
await httpStreamTransport.handleRequest(req, res);
}
catch (error) {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal Server Error' }));
}
}
});
const desiredPort = parseInt(HTTP_STREAM_PORT);
// A shared daemon must own a stable URL — never silently drift to another
// port. Fail loudly if the configured port is already taken on our bind
// host (a listener on a different interface is not our conflict).
if (await isPortInUse(desiredPort, HTTP_STREAM_HOST)) {
throw new McpError(ErrorCode.InternalError, `--server: port ${desiredPort} (HTTP_STREAM_PORT) is already in use. ` +
`Stop the process using it, or set HTTP_STREAM_PORT to a free port.`);
}
httpServer.listen(desiredPort, HTTP_STREAM_HOST, () => {
console.error(`roam-research-mcp v${serverVersion} (--server) listening on ` +
`http://${HTTP_STREAM_HOST}:${desiredPort}/ (health: /health)`);
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.InternalError, `Failed to connect MCP server: ${errorMessage}`);
}
}
}