Skip to main content
Glama

Roam Research

by 2b3pro
roam-server.ts16.2 kB
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 { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, Resource, ListToolsRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { initializeGraph, type Graph } from '@roam-research/roam-api-sdk'; import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT } from '../config/environment.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, IncomingMessage, ServerResponse } from 'node:http'; import { fileURLToPath } from 'node:url'; import { findAvailablePort } from '../utils/net.js'; import { CORS_ORIGIN } 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; export class RoamServer { private toolHandlers: ToolHandlers; private graph: Graph; constructor() { // console.log('RoamServer: Constructor started.'); try { this.graph = initializeGraph({ token: API_TOKEN, graph: GRAPH_NAME, }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); throw new McpError(ErrorCode.InternalError, `Failed to initialize Roam graph: ${errorMessage}`); } try { this.toolHandlers = new ToolHandlers(this.graph); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); throw new McpError(ErrorCode.InternalError, `Failed to initialize tool handlers: ${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'); } // console.log('RoamServer: Constructor finished.'); } // Refactored to accept a Server instance private setupRequestHandlers(mcpServer: Server) { // List available tools mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: Object.values(toolSchemas), })); // List available resources mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => { const resources: Resource[] = []; // 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 { switch (request.params.name) { case 'roam_markdown_cheatsheet': { const content = await this.toolHandlers.getRoamMarkdownCheatsheet(); return { content: [{ type: 'text', text: content }], }; } case 'roam_remember': { const { memory, categories } = request.params.arguments as { memory: string; categories?: string[]; }; const result = await this.toolHandlers.remember(memory, categories); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_fetch_page_by_title': { const { title, format } = request.params.arguments as { title: string; format?: 'markdown' | 'raw'; }; const content = await this.toolHandlers.fetchPageByTitle(title, format); return { content: [{ type: 'text', text: content }], }; } case 'roam_create_page': { const { title, content } = request.params.arguments as { title: string; content?: Array<{ text: string; level: number; }>; }; const result = await this.toolHandlers.createPage(title, content); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_import_markdown': { const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = request.params.arguments as { content: string; page_uid?: string; page_title?: string; parent_uid?: string; parent_string?: string; order?: 'first' | 'last'; }; const result = await this.toolHandlers.importMarkdown( content, page_uid, page_title, parent_uid, parent_string, order ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_add_todo': { const { todos } = request.params.arguments as { todos: string[] }; const result = await this.toolHandlers.addTodos(todos); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_create_outline': { const { outline, page_title_uid, block_text_uid } = request.params.arguments as { outline: Array<{ text: string | undefined; level: number }>; page_title_uid?: string; block_text_uid?: string; }; const result = await this.toolHandlers.createOutline( outline, page_title_uid, block_text_uid ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_search_for_tag': { const { primary_tag, page_title_uid, near_tag } = request.params.arguments as { primary_tag: string; page_title_uid?: string; near_tag?: string; }; const result = await this.toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_search_by_status': { const { status, page_title_uid, include, exclude } = request.params.arguments as { status: 'TODO' | 'DONE'; page_title_uid?: string; include?: string; exclude?: string; }; const result = await this.toolHandlers.searchByStatus(status, page_title_uid, include, exclude); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_search_block_refs': { const params = request.params.arguments as { block_uid?: string; page_title_uid?: string; }; const result = await this.toolHandlers.searchBlockRefs(params); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_search_hierarchy': { const params = request.params.arguments as { parent_uid?: string; child_uid?: string; page_title_uid?: string; max_depth?: number; }; // 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 this.toolHandlers.searchHierarchy(params); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_find_pages_modified_today': { const { max_num_pages } = request.params.arguments as { max_num_pages?: number; }; const result = await this.toolHandlers.findPagesModifiedToday(max_num_pages || 50); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_search_by_text': { const params = request.params.arguments as { text: string; page_title_uid?: string; }; const result = await this.toolHandlers.searchByText(params); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_search_by_date': { const params = request.params.arguments as { start_date: string; end_date?: string; type: 'created' | 'modified' | 'both'; scope: 'blocks' | 'pages' | 'both'; include_content: boolean; }; const result = await this.toolHandlers.searchByDate(params); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_recall': { const { sort_by = 'newest', filter_tag } = request.params.arguments as { sort_by?: 'newest' | 'oldest'; filter_tag?: string; }; const result = await this.toolHandlers.recall(sort_by, filter_tag); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_datomic_query': { const { query, inputs } = request.params.arguments as { query: string; inputs?: unknown[]; }; const result = await this.toolHandlers.executeDatomicQuery({ query, inputs }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_process_batch_actions': { const { actions } = request.params.arguments as { actions: any[]; }; const result = await this.toolHandlers.processBatch(actions); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'roam_fetch_block_with_children': { const { block_uid, depth } = request.params.arguments as { block_uid: string; depth?: number; }; const result = await this.toolHandlers.fetchBlockWithChildren(block_uid, depth); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error: unknown) { 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() { // console.log('RoamServer: run() method started.'); try { // console.log('RoamServer: Attempting to create stdioMcpServer...'); const stdioMcpServer = new Server( { name: 'roam-research', version: serverVersion, }, { capabilities: { tools: { ...Object.fromEntries( (Object.keys(toolSchemas) as Array<keyof typeof toolSchemas>).map((toolName) => [toolName, toolSchemas[toolName].inputSchema]) ), }, resources: {}, // No resources exposed via capabilities prompts: {}, // No prompts exposed via capabilities }, } ); // console.log('RoamServer: stdioMcpServer created. Setting up request handlers...'); this.setupRequestHandlers(stdioMcpServer); // console.log('RoamServer: stdioMcpServer handlers setup complete. Connecting transport...'); const stdioTransport = new StdioServerTransport(); await stdioMcpServer.connect(stdioTransport); // console.log('RoamServer: stdioTransport connected. Attempting to create httpMcpServer...'); const httpMcpServer = new Server( { name: 'roam-research-http', // A distinct name for the HTTP server version: serverVersion, }, { capabilities: { tools: { ...Object.fromEntries( (Object.keys(toolSchemas) as Array<keyof typeof toolSchemas>).map((toolName) => [toolName, toolSchemas[toolName].inputSchema]) ), }, resources: { // No resources exposed via capabilities }, prompts: {}, // No prompts exposed via capabilities }, } ); // console.log('RoamServer: httpMcpServer created. Setting up request handlers...'); this.setupRequestHandlers(httpMcpServer); // console.log('RoamServer: httpMcpServer handlers setup complete. Connecting transport...'); const httpStreamTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), }); await httpMcpServer.connect(httpStreamTransport); // console.log('RoamServer: httpStreamTransport connected.'); const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { // Set CORS headers res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // Handle preflight OPTIONS requests if (req.method === 'OPTIONS') { res.writeHead(204); // No Content res.end(); return; } try { await httpStreamTransport.handleRequest(req, res); } catch (error) { // // console.error('HTTP Stream Server error:', error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Internal Server Error' })); } } }); const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT)); httpServer.listen(availableHttpPort, () => { // // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`); }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); throw new McpError(ErrorCode.InternalError, `Failed to connect MCP server: ${errorMessage}`); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/2b3pro/roam-research-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server