This is page 1 of 2. Use http://codebase.md/2b3pro/roam-research-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .dockerignore ├── .gitignore ├── .roam │ └── custom-instructions.md ├── CHANGELOG.md ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── Roam Import JSON Schema.md ├── Roam_Markdown_Cheatsheet.md ├── Roam_Research_Datalog_Cheatsheet.md ├── roam-research-mcp-image.jpeg ├── src │ ├── config │ │ └── environment.ts │ ├── index.ts │ ├── markdown-utils.ts │ ├── search │ │ ├── block-ref-search.ts │ │ ├── datomic-search.ts │ │ ├── hierarchy-search.ts │ │ ├── index.ts │ │ ├── status-search.ts │ │ ├── tag-search.ts │ │ ├── text-search.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── server │ │ └── roam-server.ts │ ├── tools │ │ ├── helpers │ │ │ ├── refs.ts │ │ │ └── text.ts │ │ ├── operations │ │ │ ├── batch.ts │ │ │ ├── block-retrieval.ts │ │ │ ├── blocks.ts │ │ │ ├── memory.ts │ │ │ ├── outline.ts │ │ │ ├── pages.ts │ │ │ ├── search │ │ │ │ ├── handlers.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── todos.ts │ │ ├── schemas.ts │ │ ├── tool-handlers.ts │ │ └── types │ │ └── index.ts │ ├── types │ │ └── roam.ts │ ├── types.d.ts │ └── utils │ ├── helpers.ts │ └── net.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` node_modules/ build/ *.log .env* typescript/* src/test-queries.ts test-* src/.DS_Store .DS_Store .clinerules* tests/test_read.sh .roam/2b3-custom-instructions.md .roam/ias-custom-instructions.md .cline* ``` -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` # Ignore node_modules, as they will be installed in the container node_modules/ # Ignore build directory, as it's created inside the container build/ # Ignore log files *.log # Ignore environment files .env* # Ignore TypeScript cache typescript/ # Ignore test files src/test-queries.ts test-* tests/test_read.sh # Ignore OS-specific files src/.DS_Store .DS_Store # Ignore Git directory .git # Ignore Docker related files Dockerfile docker-compose.yml .dockerignore # Ignore the project's license and documentation LICENSE README.md CHANGELOG.md Roam Import JSON Schema.md Roam_Research_Datalog_Cheatsheet.md ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown  # Roam Research MCP Server [](https://badge.fury.io/js/roam-research-mcp) [](https://www.repostatus.org/#wip) [](https://opensource.org/licenses/MIT) [](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE) A Model Context Protocol (MCP) server that provides comprehensive access to Roam Research's API functionality. This server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface. It supports standard input/output (stdio), HTTP Stream, and Server-Sent Events (SSE) communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research) <a href="https://glama.ai/mcp/servers/fzfznyaflu"><img width="380" height="200" src="https://glama.ai/mcp/servers/fzfznyaflu/badge" alt="Roam Research MCP server" /></a> <a href="https://mseep.ai/app/2b3pro-roam-research-mcp"><img width="380" height="200" src="https://mseep.net/pr/2b3pro-roam-research-mcp-badge.png" alt="MseeP.ai Security Assessment Badge" /></a> ## Installation and Usage This MCP server supports three primary communication methods: 1. **Stdio (Standard Input/Output):** Ideal for local inter-process communication, command-line tools, and direct integration with applications running on the same machine. This is the default communication method when running the server directly. 2. **HTTP Stream:** Provides network-based communication, suitable for web-based clients, remote applications, or scenarios requiring real-time updates over HTTP. The HTTP Stream endpoint runs on port `8088` by default. 3. **SSE (Server-Sent Events):** A transport for legacy clients that require SSE. The SSE endpoint runs on port `8087` by default. (NOTE: ⚠️ DEPRECATED: The SSE Transport has been deprecated as of MCP specification version 2025-03-26. HTTP Stream Transport preferred.) ### Running with Stdio You can install the package globally and run it: ```bash npm install -g roam-research-mcp roam-research-mcp ``` Or clone the repository and build from source: ```bash git clone https://github.com/2b3pro/roam-research-mcp.git cd roam-research-mcp npm install npm run build npm start ``` ### Running with HTTP Stream To run the server with HTTP Stream or SSE support, you can either: 1. **Use the default ports:** Run `npm start` after building (as shown above). The server will automatically listen on port `8088` for HTTP Stream and `8087` for SSE. 2. **Specify custom ports:** Set the `HTTP_STREAM_PORT` and/or `SSE_PORT` environment variables before starting the server. ```bash HTTP_STREAM_PORT=9000 SSE_PORT=9001 npm start ``` Or, if using a `.env` file, add `HTTP_STREAM_PORT=9000` and/or `SSE_PORT=9001` to it. ## Docker This project can be easily containerized using Docker. A `Dockerfile` is provided at the root of the repository. ### Build the Docker Image To build the Docker image, navigate to the project root and run: ```bash docker build -t roam-research-mcp . ``` ### Run the Docker Container To run the Docker container and map the necessary ports, you must also provide the required environment variables. Use the `-e` flag to pass `ROAM_API_TOKEN`, `ROAM_GRAPH_NAME`, and optionally `MEMORIES_TAG`, `HTTP_STREAM_PORT`, and `SSE_PORT`: ```bash docker run -p 3000:3000 -p 8088:8088 -p 8087:8087 \ -e ROAM_API_TOKEN="your-api-token" \ -e ROAM_GRAPH_NAME="your-graph-name" \ -e MEMORIES_TAG="#[[LLM/Memories]]" \ -e CUSTOM_INSTRUCTIONS_PATH="/path/to/your/custom_instructions_file.md" \ -e HTTP_STREAM_PORT="8088" \ -e SSE_PORT="8087" \ roam-research-mcp ``` Alternatively, if you have a `.env` file in the project root (which is copied into the Docker image during build), you can use the `--env-file` flag: ```bash docker run -p 3000:3000 -p 8088:8088 --env-file .env roam-research-mcp ``` ## To Test Run [MCP Inspector](https://github.com/modelcontextprotocol/inspector) after build using the provided npm script: ```bash npm run inspector ``` ## Features The server provides powerful tools for interacting with Roam Research: - Environment variable handling with .env support - Comprehensive input validation - Case-insensitive page title matching - Recursive block reference resolution - Markdown parsing and conversion - Daily page integration - Detailed debug logging - Efficient batch operations - Hierarchical outline creation - Enhanced documentation for Roam Tables in `Roam_Markdown_Cheatsheet.md` for clearer guidance on nesting. - Custom instruction appended to the cheat sheet about your specific Roam notes. 1. `roam_fetch_page_by_title`: Fetch page content by title. Returns content in the specified format. 2. `roam_fetch_block_with_children`: Fetch a block by its UID along with its hierarchical children down to a specified depth. Automatically handles `((UID))` formatting. 3. `roam_create_page`: Create new pages with optional content and headings. Now creates a block on the daily page linking to the newly created page. 4. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.) 5. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.) 6. `roam_create_outline`: Add a structured outline to an existing page or block, with support for `children_view_type`. Best for simpler, sequential outlines. For complex nesting (e.g., tables), consider `roam_process_batch_actions`. If `page_title_uid` and `block_text_uid` are both blank, content defaults to the daily page. (Internally uses `roam_process_batch_actions`.) 7. `roam_search_block_refs`: Search for block references within a page or across the entire graph. 8. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy. 9. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight), with pagination and sorting options. 10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page. This tool supports pagination via the `limit` and `offset` parameters. 11. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page. 12. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates. 13. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag. This tool supports pagination via the `limit` and `offset` parameters. 14. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.) 15. `roam_recall`: Retrieve all stored memories. 16. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. Now supports client-side regex filtering for enhanced post-query processing. Optimal for complex filtering (including regex), highly complex boolean logic, arbitrary sorting criteria, and proximity search. 17. `roam_markdown_cheatsheet`: Provides the content of the Roam Markdown Cheatsheet resource, optionally concatenated with custom instructions if `CUSTOM_INSTRUCTIONS_PATH` environment variable is set. 18. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.) **Deprecated Tools**: The following tools have been deprecated as of `v0.36.2` in favor of the more powerful and flexible `roam_process_batch_actions`: - `roam_create_block`: Use `roam_process_batch_actions` with the `create-block` action. - `roam_update_block`: Use `roam_process_batch_actions` with the `update-block` action. - `roam_update_multiple_blocks`: Use `roam_process_batch_actions` with multiple `update-block` actions. --- ### Tool Usage Guidelines and Best Practices **Pre-computation and Context Loading:** ✅ Before attempting any Roam operations, **it is highly recommended** to load the `Roam Markdown Cheatsheet` resource into your context. This ensures you have immediate access to the correct Roam-flavored Markdown syntax, including details for tables, block references, and other special formatting. Example prompt: "Read the Roam cheatsheet first. Then, … <rest of your instructions>" - **Specific notes and preferences** concerning my Roam Research graph. Users can add their own specific notes and preferences for working with their own graph in the Cheatsheet. **Identifying Pages and Blocks for Manipulation:** To ensure accurate operations, always strive to identify target pages and blocks using their Unique Identifiers (UIDs) whenever possible. While some tools accept case-sensitive text titles or content, UIDs provide unambiguous references, reducing the risk of errors due to ambiguity or changes in text. - **For Pages:** Use `roam_fetch_page_by_title` to retrieve a page's UID if you only have its title. Example: "Read the page titled 'Trip to Las Vegas'" - **For Blocks:** If you need to manipulate an existing block, first use search tools like `roam_search_by_text`, `roam_search_for_tag`, or `roam_fetch_page_by_title` (with raw format) to find the block and obtain its UID. If the block exists on a page that has already been read, then a search isn't necessary. **Case-Sensitivity:** Be aware that text-based inputs (e.g., page titles, block content for search) are generally case-sensitive in Roam. Always match the exact casing of the text as it appears in your graph. **Iterative Refinement and Verification:** For complex operations, especially those involving nested structures or multiple changes, it is often beneficial to break down the task into smaller, verifiable steps. After each significant tool call, consider fetching the affected content to verify the changes before proceeding. **Understanding Tool Nuances:** Familiarize yourself with the specific behaviors and limitations of each tool. For instance, `roam_create_outline` is best for sequential outlines, while `roam_process_batch_actions` offers granular control for complex structures like tables. Refer to the individual tool descriptions for detailed usage notes. When making changes to your Roam graph, precision in your requests is crucial for achieving desired outcomes. **Specificity in Requests:** Some tools allow for identifying blocks or pages by their text content (e.g., `parent_string`, `title`). While convenient, using **Unique Identifiers (UIDs)** is always preferred for accuracy and reliability. Text-based matching can be prone to errors if there are multiple blocks with similar content or if the content changes. Tools are designed to work best when provided with explicit UIDs where available. **Example of Specificity:** Instead of: `"parent_string": "My project notes"` Prefer: `"parent_uid": "((some-unique-uid))"` **Caveat Regarding Heading Formatting:** Please note that while the `roam_process_batch_actions` tool can set block headings (H1, H2, H3), directly **removing** an existing heading (i.e., reverting a heading block to a plain text block) through this tool is not currently supported by the Roam API. The `heading` attribute persists its value once set, and attempting to remove it by setting `heading` to `0`, `null`, or omitting the property will not unset the heading. --- ## Example Prompts Here are some examples of how to creatively use the Roam tool in an LLM to interact with your Roam graph, particularly leveraging `roam_process_batch_actions` for complex operations. ### Example 1: Creating a Project Outline This prompt demonstrates creating a new page and populating it with a structured outline using a single `roam_process_batch_actions` call. ``` "Create a new Roam page titled 'Project Alpha Planning' and add the following outline: - Overview - Goals - Scope - Team Members - John Doe - Jane Smith - Tasks - Task 1 - Subtask 1.1 - Subtask 1.2 - Task 2 - Deadlines" ``` ### Example 2: Updating Multiple To-Dos and Adding a New One This example shows how to mark existing to-do items as `DONE` and add a new one, all within a single batch. ``` "Mark 'Finish report' and 'Review presentation' as done on today's daily page, and add a new todo 'Prepare for meeting'." ``` ### Example 3: Moving and Updating a Block This demonstrates moving a block from one location to another and simultaneously updating its content. ``` "Move the block 'Important note about client feedback' (from page 'Meeting Notes 2025-06-30') under the 'Action Items' section on the 'Project Alpha Planning' page, and change its content to 'Client feedback reviewed and incorporated'." ``` ### Example 4: Making a Table This demonstrates moving a block from one location to another and simultaneously updating its content. ``` "In Roam, add a new table on the page "Fruity Tables" that compares four types of fruits: apples, oranges, grapes, and dates. Choose randomly four areas to compare." ``` --- ## Setup 1. Create a [Roam Research API token](https://x.com/RoamResearch/status/1789358175474327881): - Go to your graph settings - Navigate to the "API tokens" section (Settings > "Graph" tab > "API Tokens" section and click on the "+ New API Token" button) - Create a new token 2. Configure the environment variables: You have two options for configuring the required environment variables: Option 1: Using a .env file (Recommended for development) Create a `.env` file in the roam-research directory: ``` ROAM_API_TOKEN=your-api-token ROAM_GRAPH_NAME=your-graph-name MEMORIES_TAG='#[[LLM/Memories]]' CUSTOM_INSTRUCTIONS_PATH='/path/to/your/custom_instructions_file.md' HTTP_STREAM_PORT=8088 # Or your desired port for HTTP Stream communication SSE_PORT=8087 # Or your desired port for SSE communication ``` Option 2: Using MCP settings (Alternative method) Add the configuration to your MCP settings file. Note that you may need to update the `args` to `["/path/to/roam-research-mcp/build/index.js"]` if you are running the server directly. - For Cline (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`): - For Claude desktop app (`~/Library/Application Support/Claude/claude_desktop_config.json`): ```json { "mcpServers": { "roam-research": { "command": "node", "args": ["/path/to/roam-research-mcp/build/index.js"], "env": { "ROAM_API_TOKEN": "your-api-token", "ROAM_GRAPH_NAME": "your-graph-name", "MEMORIES_TAG": "#[[LLM/Memories]]", "CUSTOM_INSTRUCTIONS_PATH": "/path/to/your/custom_instructions_file.md", "HTTP_STREAM_PORT": "8088", "SSE_PORT": "8087" } } } } ``` Note: The server will first try to load from .env file, then fall back to environment variables from MCP settings. 3. Build the server (make sure you're in the root directory of the MCP): Note: Customize 'Roam_Markdown_Cheatsheet.md' with any notes and preferences specific to your graph BEFORE building. ```bash cd roam-research-mcp npm install npm run build ``` ## Error Handling The server provides comprehensive error handling for common scenarios: - Configuration errors: - Missing API token or graph name - Invalid environment variables - API errors: - Authentication failures - Invalid requests - Failed operations - Tool-specific errors: - Page not found (with case-insensitive search) - Block not found by string match - Invalid markdown format - Missing required parameters - Invalid outline structure or content Each error response includes: - Standard MCP error code - Detailed error message - Suggestions for resolution when applicable --- ## Development ### Building To build the server: ```bash npm install npm run build ``` This will: 1. Install all required dependencies 2. Compile TypeScript to JavaScript 3. Make the output file executable You can also use `npm run watch` during development to automatically recompile when files change. ### Testing with MCP Inspector The MCP Inspector is a tool that helps test and debug MCP servers. To test the server: ```bash # Inspect with npx: npx @modelcontextprotocol/inspector node build/index.js ``` This will: 1. Start the server in inspector mode 2. Provide an interactive interface to: - List available tools and resources - Execute tools with custom parameters - View tool responses and error handling ## License MIT License --- ## About the Author This project is maintained by [Ian Shen](https://github.com/2b3pro). ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { RoamServer } from './server/roam-server.js'; const server = new RoamServer(); server.run().catch(() => { /* handle error silently */ }); ``` -------------------------------------------------------------------------------- /src/search/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './types.js'; export * from './utils.js'; export * from './tag-search.js'; export * from './status-search.js'; export * from './block-ref-search.js'; export * from './hierarchy-search.js'; export * from './text-search.js'; export * from './datomic-search.js'; ``` -------------------------------------------------------------------------------- /src/types/roam.ts: -------------------------------------------------------------------------------- ```typescript // Interface for Roam block structure export interface RoamBlock { uid: string; string: string; order: number; heading?: number | null; children: RoamBlock[]; } export type RoamBatchAction = { action: 'create-block' | 'update-block' | 'move-block' | 'delete-block'; [key: string]: any; }; ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2021", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": [ "src/**/*", "tests/test-addMarkdownText.ts", "tests/test-queries.ts" ], "exclude": [ "node_modules" ] } ``` -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- ```typescript // Helper function to get ordinal suffix for numbers (1st, 2nd, 3rd, etc.) export function getOrdinalSuffix(n: number): string { const j = n % 10; const k = n % 100; if (j === 1 && k !== 11) return "st"; if (j === 2 && k !== 12) return "nd"; if (j === 3 && k !== 13) return "rd"; return "th"; } // Format date in Roam's preferred format (e.g., "January 1st, 2024") export function formatRoamDate(date: Date): string { const month = date.toLocaleDateString('en-US', { month: 'long' }); const day = date.getDate(); const year = date.getFullYear(); return `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; } ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- ```yaml version: '3.8' services: roam-mcp: image: roam-research-mcp build: . container_name: roam-mcp ports: - "3010:3000" - "8047:8087" - "8048:8088" env_file: - .env networks: - n8n-net # n8n: # image: n8nio/n8n:latest # container_name: n8n # ports: # - "5678:5678" # volumes: # - ~/.n8n:/home/node/.n8n # environment: # - N8N_BASIC_AUTH_ACTIVE=true # - N8N_BASIC_AUTH_USER=admin # - N8N_BASIC_AUTH_PASSWORD=hallelujah # depends_on: # - roam-mcp # networks: # - n8n-net networks: n8n-net: driver: bridge ``` -------------------------------------------------------------------------------- /src/tools/types/index.ts: -------------------------------------------------------------------------------- ```typescript import { Graph } from '@roam-research/roam-api-sdk'; import type { RoamBlock } from '../../types/roam.js'; export interface ToolHandlerDependencies { graph: Graph; } export interface SearchResult { block_uid: string; content: string; page_title?: string; } export interface BlockUpdateResult { block_uid: string; content: string; success: boolean; error?: string; } export interface BlockUpdate { block_uid: string; content?: string; transform?: { find: string; replace: string; global?: boolean; }; } export interface OutlineItem { text: string | undefined; level: number; heading?: number; children_view_type?: 'bullet' | 'document' | 'numbered'; } export interface NestedBlock { uid: string; text: string; level: number; order: number; children?: NestedBlock[]; } export { RoamBlock }; ``` -------------------------------------------------------------------------------- /src/search/types.ts: -------------------------------------------------------------------------------- ```typescript import type { Graph } from '@roam-research/roam-api-sdk'; export interface SearchResult { success: boolean; matches: Array<{ block_uid: string; content: string; page_title?: string; [key: string]: any; // Additional context-specific fields }>; message: string; total_count?: number; // Added for total count of matches } export interface SearchHandler { execute(): Promise<SearchResult>; } // Tag Search Types export interface TagSearchParams { primary_tag: string; page_title_uid?: string; near_tag?: string; exclude_tag?: string; case_sensitive?: boolean; limit?: number; offset?: number; } // Text Search Types export interface TextSearchParams { text: string; page_title_uid?: string; case_sensitive?: boolean; limit?: number; offset?: number; } // Base class for all search handlers export abstract class BaseSearchHandler implements SearchHandler { constructor(protected graph: Graph) { } abstract execute(): Promise<SearchResult>; } ``` -------------------------------------------------------------------------------- /src/tools/operations/search/types.ts: -------------------------------------------------------------------------------- ```typescript import type { SearchResult } from '../../types/index.js'; // Base search parameters export interface BaseSearchParams { page_title_uid?: string; } // Datomic search parameters export interface DatomicSearchParams { query: string; inputs?: unknown[]; } // Tag search parameters export interface TagSearchParams extends BaseSearchParams { primary_tag: string; near_tag?: string; } // Block reference search parameters export interface BlockRefSearchParams extends BaseSearchParams { block_uid?: string; } // Hierarchy search parameters export interface HierarchySearchParams extends BaseSearchParams { parent_uid?: string; child_uid?: string; max_depth?: number; } // Text search parameters export interface TextSearchParams extends BaseSearchParams { text: string; } // Status search parameters export interface StatusSearchParams extends BaseSearchParams { status: 'TODO' | 'DONE'; } // Common search result type export interface SearchHandlerResult { success: boolean; matches: SearchResult[]; message: string; } ``` -------------------------------------------------------------------------------- /src/utils/net.ts: -------------------------------------------------------------------------------- ```typescript import { createServer } from 'node:net'; /** * Checks if a given port is currently in use. * @param port The port to check. * @returns A promise that resolves to true if the port is in use, and false otherwise. */ export function isPortInUse(port: number): Promise<boolean> { return new Promise((resolve) => { const server = createServer(); server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { resolve(true); } else { // Handle other errors if necessary, but for this check, we assume other errors mean the port is available. resolve(false); } }); server.once('listening', () => { server.close(); resolve(false); }); server.listen(port); }); } /** * Finds an available port, starting from a given port and incrementing by a specified amount. * @param startPort The port to start checking from. * @param incrementBy The amount to increment the port by if it's in use. Defaults to 2. * @returns A promise that resolves to an available port number. */ export async function findAvailablePort(startPort: number, incrementBy = 2): Promise<number> { let port = startPort; while (await isPortInUse(port)) { port += incrementBy; } return port; } ``` -------------------------------------------------------------------------------- /src/tools/operations/batch.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, batchActions as roamBatchActions } from '@roam-research/roam-api-sdk'; import { RoamBatchAction } from '../../types/roam.js'; export class BatchOperations { constructor(private graph: Graph) {} async processBatch(actions: any[]): Promise<any> { const batchActions: RoamBatchAction[] = actions.map(action => { const { action: actionType, ...rest } = action; const roamAction: any = { action: actionType }; if (rest.location) { roamAction.location = { 'parent-uid': rest.location['parent-uid'], order: rest.location.order, }; } const block: any = {}; if (rest.string) block.string = rest.string; if (rest.uid) block.uid = rest.uid; if (rest.open !== undefined) block.open = rest.open; if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) { block.heading = rest.heading; } if (rest['text-align']) block['text-align'] = rest['text-align']; if (rest['children-view-type']) block['children-view-type'] = rest['children-view-type']; if (Object.keys(block).length > 0) { roamAction.block = block; } return roamAction; }); return await roamBatchActions(this.graph, {actions: batchActions}); } } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Use an official Node.js runtime as a parent image for building FROM node:lts-alpine AS builder # Set the working directory in the container WORKDIR /app # Copy package.json and package-lock.json to the working directory COPY package.json ./ COPY package-lock.json ./ # Install development and production dependencies RUN --mount=type=cache,target=/root/.npm npm install # Copy source code and TypeScript configuration COPY src /app/src COPY tsconfig.json /app/tsconfig.json COPY Roam_Markdown_Cheatsheet.md /app/Roam_Markdown_Cheatsheet.md # Build the TypeScript project RUN npm run build # Use a minimal Node.js runtime as the base for the release image FROM node:lts-alpine AS release # Set environment to production ENV NODE_ENV=production # Set the working directory WORKDIR /app # Copy only the built application (from /app/build) and production dependencies from the builder stage COPY --from=builder /app/build /app/build COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package-lock.json /app/package-lock.json # Install only production dependencies (based on package-lock.json) # This keeps the final image small and secure by omitting development dependencies RUN npm ci --ignore-scripts --omit-dev # Expose the ports the app runs on (3000 for standard, 8087 for SSE, 8088 for HTTP Stream) EXPOSE 3000 EXPOSE 8087 EXPOSE 8088 # Run the application ENTRYPOINT ["node", "build/index.js"] ``` -------------------------------------------------------------------------------- /src/tools/helpers/refs.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q } from '@roam-research/roam-api-sdk'; /** * Collects all referenced block UIDs from text */ export const collectRefs = (text: string, depth: number = 0, refs: Set<string> = new Set()): Set<string> => { if (depth >= 4) return refs; // Max recursion depth const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g; let match; while ((match = refRegex.exec(text)) !== null) { const [_, uid] = match; refs.add(uid); } return refs; }; /** * Resolves block references in text by replacing them with their content */ export const resolveRefs = async (graph: Graph, text: string, depth: number = 0): Promise<string> => { if (depth >= 4) return text; // Max recursion depth const refs = collectRefs(text, depth); if (refs.size === 0) return text; // Get referenced block contents const refQuery = `[:find ?uid ?string :in $ [?uid ...] :where [?b :block/uid ?uid] [?b :block/string ?string]]`; const refResults = await q(graph, refQuery, [Array.from(refs)]) as [string, string][]; // Create lookup map of uid -> string const refMap = new Map<string, string>(); refResults.forEach(([uid, string]) => { refMap.set(uid, string); }); // Replace references with their content let resolvedText = text; for (const uid of refs) { const refContent = refMap.get(uid); if (refContent) { // Recursively resolve nested references const resolvedContent = await resolveRefs(graph, refContent, depth + 1); resolvedText = resolvedText.replace( new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent ); } } return resolvedText; }; ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "roam-research-mcp", "version": "0.36.3", "description": "A Model Context Protocol (MCP) server for Roam Research API integration", "private": false, "repository": { "type": "git", "url": "git+https://github.com/2b3pro/roam-research-mcp.git" }, "keywords": [ "mcp", "roam-research", "api", "claude", "model-context-protocol" ], "author": "Ian Shen / 2B3 PRODUCTIONS LLC", "license": "MIT", "bugs": { "url": "https://github.com/2b3pro/roam-research-mcp/issues" }, "homepage": "https://github.com/2b3pro/roam-research-mcp#readme", "type": "module", "bin": { "roam-research-mcp": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "echo \"Using custom instructions: .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md\" && tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js", "clean": "rm -rf build", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js", "start": "node build/index.js", "prepublishOnly": "npm run clean && npm run build", "release:patch": "npm version patch && git push origin v$(node -p \"require('./package.json').version\")", "release:minor": "npm version minor && git push origin v$(node -p \"require('./package.json').version\")", "release:major": "npm version major && git push origin v$(node -p \"require('./package.json').version\")" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.13.2", "@roam-research/roam-api-sdk": "^0.10.0", "dotenv": "^16.4.7" }, "devDependencies": { "@types/node": "^20.11.24", "ts-node": "^10.9.2", "typescript": "^5.3.3" } } ``` -------------------------------------------------------------------------------- /src/tools/operations/todos.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q, createBlock, createPage, batchActions } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { formatRoamDate } from '../../utils/helpers.js'; export class TodoOperations { constructor(private graph: Graph) {} async addTodos(todos: string[]): Promise<{ success: boolean }> { if (!Array.isArray(todos) || todos.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'todos must be a non-empty array' ); } // Get today's date const today = new Date(); const dateStr = formatRoamDate(today); // Try to find today's page const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; let targetPageUid: string; if (findResults && findResults.length > 0) { targetPageUid = findResults[0][0]; } else { // Create today's page if it doesn't exist try { await createPage(this.graph, { action: 'create-page', page: { title: dateStr } }); // Get the new page's UID const results = await q(this.graph, findQuery, [dateStr]) as [string][]; if (!results || results.length === 0) { throw new Error('Could not find created today\'s page'); } targetPageUid = results[0][0]; } catch (error) { throw new Error('Failed to create today\'s page'); } } const todo_tag = "{{TODO}}"; const actions = todos.map((todo, index) => ({ action: 'create-block', location: { 'parent-uid': targetPageUid, order: index }, block: { string: `${todo_tag} ${todo}` } })); const result = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!result) { throw new Error('Failed to create todo blocks'); } return { success: true }; } } ``` -------------------------------------------------------------------------------- /src/config/environment.ts: -------------------------------------------------------------------------------- ```typescript import * as dotenv from 'dotenv'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { existsSync } from 'fs'; // Get the project root from the script path const scriptPath = process.argv[1]; // Full path to the running script const projectRoot = dirname(dirname(scriptPath)); // Go up two levels from build/index.js // Try to load .env from project root const envPath = join(projectRoot, '.env'); if (existsSync(envPath)) { dotenv.config({ path: envPath }); } // Required environment variables const API_TOKEN = process.env.ROAM_API_TOKEN as string; const GRAPH_NAME = process.env.ROAM_GRAPH_NAME as string; // Validate environment variables if (!API_TOKEN || !GRAPH_NAME) { const missingVars = []; if (!API_TOKEN) missingVars.push('ROAM_API_TOKEN'); if (!GRAPH_NAME) missingVars.push('ROAM_GRAPH_NAME'); throw new Error( `Missing required environment variables: ${missingVars.join(', ')}\n\n` + 'Please configure these variables either:\n' + '1. In your MCP settings file:\n' + ' - For Cline: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json\n' + ' - For Claude: ~/Library/Application Support/Claude/claude_desktop_config.json\n\n' + ' Example configuration:\n' + ' {\n' + ' "mcpServers": {\n' + ' "roam-research": {\n' + ' "command": "node",\n' + ' "args": ["/path/to/roam-research-mcp/build/index.js"],\n' + ' "env": {\n' + ' "ROAM_API_TOKEN": "your-api-token",\n' + ' "ROAM_GRAPH_NAME": "your-graph-name"\n' + ' }\n' + ' }\n' + ' }\n' + ' }\n\n' + '2. Or in a .env file in the roam-research directory:\n' + ' ROAM_API_TOKEN=your-api-token\n' + ' ROAM_GRAPH_NAME=your-graph-name' ); } const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8088 const SSE_PORT = process.env.SSE_PORT || '8087'; // Default to 8087 const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5678'; export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT, CORS_ORIGIN }; ``` -------------------------------------------------------------------------------- /src/search/status-search.ts: -------------------------------------------------------------------------------- ```typescript import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import { BaseSearchHandler, SearchResult } from './types.js'; import { SearchUtils } from './utils.js'; import { resolveRefs } from '../tools/helpers/refs.js'; export interface StatusSearchParams { status: 'TODO' | 'DONE'; page_title_uid?: string; } export class StatusSearchHandler extends BaseSearchHandler { constructor( graph: Graph, private params: StatusSearchParams ) { super(graph); } async execute(): Promise<SearchResult> { const { status, page_title_uid } = this.params; // Get target page UID if provided let targetPageUid: string | undefined; if (page_title_uid) { targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); } // Build query based on whether we're searching in a specific page let queryStr: string; let queryParams: any[]; if (targetPageUid) { queryStr = `[:find ?block-uid ?block-str :in $ ?status ?page-uid :where [?p :block/uid ?page-uid] [?b :block/page ?p] [?b :block/string ?block-str] [?b :block/uid ?block-uid] [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`; queryParams = [status, targetPageUid]; } else { queryStr = `[:find ?block-uid ?block-str ?page-title :in $ ?status :where [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`; queryParams = [status]; } const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?][]; // Resolve block references in content const resolvedResults = await Promise.all( rawResults.map(async ([uid, content, pageTitle]) => { const resolvedContent = await resolveRefs(this.graph, content); return [uid, resolvedContent, pageTitle] as [string, string, string?]; }) ); return SearchUtils.formatSearchResults(resolvedResults, `with status ${status}`, !targetPageUid); } } ``` -------------------------------------------------------------------------------- /src/tools/operations/search/handlers.ts: -------------------------------------------------------------------------------- ```typescript import { Graph } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler, DatomicSearchHandler, StatusSearchHandler } from '../../../search/index.js'; import type { TagSearchParams, BlockRefSearchParams, HierarchySearchParams, TextSearchParams, SearchHandlerResult, DatomicSearchParams, StatusSearchParams } from './types.js'; // Base class for all search handlers export abstract class BaseSearchHandler { constructor(protected graph: Graph) {} abstract execute(): Promise<SearchHandlerResult>; } // Tag search handler export class TagSearchHandlerImpl extends BaseSearchHandler { constructor(graph: Graph, private params: TagSearchParams) { super(graph); } async execute() { const handler = new TagSearchHandler(this.graph, this.params); return handler.execute(); } } // Block reference search handler export class BlockRefSearchHandlerImpl extends BaseSearchHandler { constructor(graph: Graph, private params: BlockRefSearchParams) { super(graph); } async execute() { const handler = new BlockRefSearchHandler(this.graph, this.params); return handler.execute(); } } // Hierarchy search handler export class HierarchySearchHandlerImpl extends BaseSearchHandler { constructor(graph: Graph, private params: HierarchySearchParams) { super(graph); } async execute() { const handler = new HierarchySearchHandler(this.graph, this.params); return handler.execute(); } } // Text search handler export class TextSearchHandlerImpl extends BaseSearchHandler { constructor(graph: Graph, private params: TextSearchParams) { super(graph); } async execute() { const handler = new TextSearchHandler(this.graph, this.params); return handler.execute(); } } // Status search handler export class StatusSearchHandlerImpl extends BaseSearchHandler { constructor(graph: Graph, private params: StatusSearchParams) { super(graph); } async execute() { const handler = new StatusSearchHandler(this.graph, this.params); return handler.execute(); } } // Datomic query handler export class DatomicSearchHandlerImpl extends BaseSearchHandler { constructor(graph: Graph, private params: DatomicSearchParams) { super(graph); } async execute() { const handler = new DatomicSearchHandler(this.graph, this.params); return handler.execute(); } } ``` -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- ```typescript declare module '@roam-research/roam-api-sdk' { interface Graph { token: string; graph: string; } interface RoamBlockLocation { 'parent-uid': string; order: number | string; } interface RoamBlock { string: string; uid?: string; open?: boolean; heading?: number; 'text-align'?: boolean; 'children-view-type'?: string; } interface RoamCreateBlock { action?: 'create-block'; location: RoamBlockLocation; block: RoamBlock; } export function initializeGraph(config: { token: string; graph: string }): Graph; export function q( graph: Graph, query: string, inputs: any[] ): Promise<any[]>; interface RoamCreatePage { action?: 'create-page'; page: { title: string; uid?: string; 'children-view-type'?: string; }; } export function createPage( graph: Graph, options: RoamCreatePage ): Promise<boolean>; export function createBlock( graph: Graph, options: RoamCreateBlock ): Promise<boolean>; interface RoamUpdateBlock { action?: 'update-block'; block: { string?: string; uid: string; open?: boolean; heading?: number; 'text-align'?: boolean; 'children-view-type'?: string; }; } export function updateBlock( graph: Graph, options: RoamUpdateBlock ): Promise<boolean>; export function deleteBlock( graph: Graph, options: { uid: string } ): Promise<void>; export function pull( graph: Graph, pattern: string, eid: string ): Promise<any>; export function pull_many( graph: Graph, pattern: string, eids: string ): Promise<any>; interface RoamMoveBlock { action?: 'move-block'; location: RoamBlockLocation; block: { uid: RoamBlock['uid']; }; } export function moveBlock( graph: Graph, options: RoamMoveBlock ): Promise<boolean>; interface RoamDeletePage { action?: 'delete-page'; page: { uid: string; }; } export function deletePage( graph: Graph, options: RoamDeletePage ): Promise<boolean>; interface RoamDeleteBlock { action?: 'delete-block'; block: { uid: string; }; } export function deleteBlock( graph: Graph, options: RoamDeleteBlock ): Promise<boolean>; interface RoamBatchActions { action?: 'batch-actions'; actions: Array< | RoamDeletePage | RoamUpdatePage | RoamCreatePage | RoamDeleteBlock | RoamUpdateBlock | RoamMoveBlock | RoamCreateBlock >; } export function batchActions( graph: Graph, options: RoamBatchActions ): Promise<any>; } ``` -------------------------------------------------------------------------------- /src/search/datomic-search.ts: -------------------------------------------------------------------------------- ```typescript import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import { BaseSearchHandler, SearchResult } from './types.js'; // import { resolveRefs } from '../helpers/refs.js'; export interface DatomicSearchParams { query: string; inputs?: unknown[]; regexFilter?: string; regexFlags?: string; regexTargetField?: string[]; } export class DatomicSearchHandler extends BaseSearchHandler { constructor( graph: Graph, private params: DatomicSearchParams ) { super(graph); } async execute(): Promise<SearchResult> { try { // Execute the datomic query using the Roam API let results = await q(this.graph, this.params.query, this.params.inputs || []) as unknown[]; if (this.params.regexFilter) { let regex: RegExp; try { regex = new RegExp(this.params.regexFilter, this.params.regexFlags); } catch (e) { return { success: false, matches: [], message: `Invalid regex filter provided: ${e instanceof Error ? e.message : String(e)}` }; } results = results.filter(result => { if (this.params.regexTargetField && this.params.regexTargetField.length > 0) { for (const field of this.params.regexTargetField) { // Access nested fields if path is provided (e.g., "prop.nested") const fieldPath = field.split('.'); let value: any = result; for (const part of fieldPath) { if (typeof value === 'object' && value !== null && part in value) { value = value[part]; } else { value = undefined; // Field not found break; } } if (typeof value === 'string' && regex.test(value)) { return true; } } return false; } else { // Default to stringifying the whole result if no target field is specified return regex.test(JSON.stringify(result)); } }); } return { success: true, matches: results.map(result => ({ content: JSON.stringify(result), block_uid: '', // Datomic queries may not always return block UIDs page_title: '' // Datomic queries may not always return page titles })), message: `Query executed successfully. Found ${results.length} results.` }; } catch (error) { return { success: false, matches: [], message: `Failed to execute query: ${error instanceof Error ? error.message : String(error)}` }; } } } ``` -------------------------------------------------------------------------------- /src/tools/helpers/text.ts: -------------------------------------------------------------------------------- ```typescript /** * Capitalizes each word in a string */ import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; /** * Capitalizes each word in a string */ export const capitalizeWords = (str: string): string => { return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1) ).join(' '); }; /** * Retrieves a block's UID based on its exact text content. * This function is intended for internal use by other MCP tools. * @param graph The Roam graph instance. * @param blockText The exact text content of the block to find. * @returns The UID of the block if found, otherwise null. */ export const getBlockUidByText = async (graph: Graph, blockText: string): Promise<string | null> => { const query = `[:find ?uid . :in $ ?blockString :where [?b :block/string ?blockString] [?b :block/uid ?uid]]`; const result = await q(graph, query, [blockText]) as [string][] | null; return result && result.length > 0 ? result[0][0] : null; }; /** * Retrieves all UIDs nested under a given block_uid or block_text (exact match). * This function is intended for internal use by other MCP tools. * @param graph The Roam graph instance. * @param rootIdentifier The UID or exact text content of the root block. * @returns An array of UIDs of all descendant blocks, including the root block's UID. */ export const getNestedUids = async (graph: Graph, rootIdentifier: string): Promise<string[]> => { let rootUid: string | null = rootIdentifier; // If the rootIdentifier is not a UID (simple check for 9 alphanumeric characters), try to resolve it as block text if (!rootIdentifier.match(/^[a-zA-Z0-9]{9}$/)) { rootUid = await getBlockUidByText(graph, rootIdentifier); } if (!rootUid) { return []; // No root block found } const query = `[:find ?child-uid :in $ ?root-uid :where [?root-block :block/uid ?root-uid] [?root-block :block/children ?child-block] [?child-block :block/uid ?child-uid]]`; const results = await q(graph, query, [rootUid]) as [string][]; return results.map(r => r[0]); }; /** * Retrieves all UIDs nested under a given block_text (exact match). * This function is intended for internal use by other MCP tools. * It strictly requires an exact text match for the root block. * @param graph The Roam graph instance. * @param blockText The exact text content of the root block. * @returns An array of UIDs of all descendant blocks, including the root block's UID. */ export const getNestedUidsByText = async (graph: Graph, blockText: string): Promise<string[]> => { const rootUid = await getBlockUidByText(graph, blockText); if (!rootUid) { return []; // No root block found with exact text match } return getNestedUids(graph, rootUid); }; ``` -------------------------------------------------------------------------------- /Roam Import JSON Schema.md: -------------------------------------------------------------------------------- ```markdown - **Description** - A schema for JSON import into Roam. A file being uploaded must be an array of pages. Keys not defined in the schema will be ignored. - **Objects** - Page - description:: An object representing a page. The only required key is the title - keys:: - title - children - create-time - edit-time - edit-user - Block - description:: An object representing a block. The only required key is the string - keys:: - string - uid - children - create-time - edit-time - edit-user - heading - text-align - **Keys** - title - description:: The title of a page. The string is unique across a user's database. If importing a title that is already used, it will merge with the already existing content. - type:: string - string - description:: The string of a block - type:: string - uid - description:: The unique identifier of a block. Use this if you want to preserve block references. If the unique identifier conflicts with other uid's in the database or the import, the import will fail. Be careful using this attribute. Standard Roam uid's are 9 characters, but can be any length. Roam uses naniod.js to generate these - type:: string - children - description:: An array of blocks, the order is implicit from the order of the array - type:: array of Blocks - create-time - description:: The time the object was created, measured in ms since unix epoch. If not supplied, the create-time of the object will be filled in by either the edit-time, or now. - type:: integer - Epoch time in milliseconds (13-digit numbers) - edit-time - description:: The time the object was last edited, measured in ms since unix epoch. If not supplied, the edit-time of the object will be filled in by either the create-time, or now. - type:: integer - edit-user - description:: The user who last edited the object. - type:: json object of the format `{":user/uid" "ROAM-USER-UID"}` - heading - description:: Determines what heading tag to wrap the block in, default is no heading (0) - type:: integer, 0 | 1 | 2 | 3 - For level of heading, 0 being no heading (the default) 1 heading h1, etc - text-align - description:: The text-align style for a block - type:: string, "left" | "center" | "right" | "justify" - By default is left (as determined by the browser defaults) - **Example** - ```javascript [{:title "December 10th 2018" :create-email "[email protected]" :create-time 1576025237000 :children [{:string "[[Meeting]] with [[Tim]]" :children [{:string "Meeting went well"}]} {:string "[[Call]] with [[John]]"}]} {:title "December 11th 2018"}] ``` - More (better) examples can be found by exporting roam to json ``` -------------------------------------------------------------------------------- /src/tools/operations/block-retrieval.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { RoamBlock } from '../../types/roam.js'; export class BlockRetrievalOperations { constructor(private graph: Graph) { } async fetchBlockWithChildren(block_uid_raw: string, depth: number = 4): Promise<RoamBlock | null> { if (!block_uid_raw) { throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required.'); } const block_uid = block_uid_raw.replace(/^\(\((.*)\)\)$/, '$1'); const fetchChildren = async (parentUids: string[], currentDepth: number): Promise<Record<string, RoamBlock[]>> => { if (currentDepth >= depth || parentUids.length === 0) { return {}; } const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading :in $ [?parentUid ...] :where [?parent :block/uid ?parentUid] [?parent :block/children ?child] [?child :block/uid ?childUid] [?child :block/string ?childString] [?child :block/order ?childOrder] [(get-else $ ?child :block/heading 0) ?childHeading]]`; const childrenResults = await q(this.graph, childrenQuery, [parentUids]) as [string, string, string, number, number | null][]; const childrenByParent: Record<string, RoamBlock[]> = {}; const allChildUids: string[] = []; for (const [parentUid, childUid, childString, childOrder, childHeading] of childrenResults) { if (!childrenByParent[parentUid]) { childrenByParent[parentUid] = []; } childrenByParent[parentUid].push({ uid: childUid, string: childString, order: childOrder, heading: childHeading || undefined, children: [], }); allChildUids.push(childUid); } const grandChildren = await fetchChildren(allChildUids, currentDepth + 1); for (const parentUid in childrenByParent) { for (const child of childrenByParent[parentUid]) { child.children = grandChildren[child.uid] || []; } childrenByParent[parentUid].sort((a, b) => a.order - b.order); } return childrenByParent; }; try { const rootBlockQuery = `[:find ?string ?order ?heading :in $ ?blockUid :where [?b :block/uid ?blockUid] [?b :block/string ?string] [?b :block/order ?order] [(get-else $ ?b :block/heading 0) ?heading]]`; const rootBlockResult = await q(this.graph, rootBlockQuery, [block_uid]) as [string, number, number | null] | null; if (!rootBlockResult) { return null; } const [rootString, rootOrder, rootHeading] = rootBlockResult; const childrenMap = await fetchChildren([block_uid], 0); return { uid: block_uid, string: rootString, order: rootOrder, heading: rootHeading || undefined, children: childrenMap[block_uid] || [], }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to fetch block with children: ${error instanceof Error ? error.message : String(error)}` ); } } } ``` -------------------------------------------------------------------------------- /src/search/block-ref-search.ts: -------------------------------------------------------------------------------- ```typescript import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import { BaseSearchHandler, SearchResult } from './types.js'; import { SearchUtils } from './utils.js'; import { resolveRefs } from '../tools/helpers/refs.js'; export interface BlockRefSearchParams { block_uid?: string; page_title_uid?: string; } export class BlockRefSearchHandler extends BaseSearchHandler { constructor( graph: Graph, private params: BlockRefSearchParams ) { super(graph); } async execute(): Promise<SearchResult> { const { block_uid, page_title_uid } = this.params; // Get target page UID if provided let targetPageUid: string | undefined; if (page_title_uid) { targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); } // Build query based on whether we're searching for references to a specific block // or all block references within a page/graph let queryStr: string; let queryParams: any[]; if (block_uid) { // Search for references to a specific block if (targetPageUid) { queryStr = `[:find ?block-uid ?block-str :in $ ?ref-uid ?page-uid :where [?p :block/uid ?page-uid] [?b :block/page ?p] [?b :block/string ?block-str] [?b :block/uid ?block-uid] [(clojure.string/includes? ?block-str ?ref-uid)]]`; queryParams = [`((${block_uid}))`, targetPageUid]; } else { queryStr = `[:find ?block-uid ?block-str ?page-title :in $ ?ref-uid :where [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [(clojure.string/includes? ?block-str ?ref-uid)]]`; queryParams = [`((${block_uid}))`]; } } else { // Search for any block references if (targetPageUid) { queryStr = `[:find ?block-uid ?block-str :in $ ?page-uid :where [?p :block/uid ?page-uid] [?b :block/page ?p] [?b :block/string ?block-str] [?b :block/uid ?block-uid] [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`; queryParams = [targetPageUid]; } else { queryStr = `[:find ?block-uid ?block-str ?page-title :where [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`; queryParams = []; } } const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?][]; // Resolve block references in content const resolvedResults = await Promise.all( rawResults.map(async ([uid, content, pageTitle]) => { const resolvedContent = await resolveRefs(this.graph, content); return [uid, resolvedContent, pageTitle] as [string, string, string?]; }) ); const searchDescription = block_uid ? `referencing block ((${block_uid}))` : 'containing block references'; return SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid); } } ``` -------------------------------------------------------------------------------- /src/search/text-search.ts: -------------------------------------------------------------------------------- ```typescript import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import { BaseSearchHandler, SearchResult, TextSearchParams } from './types.js'; import { SearchUtils } from './utils.js'; import { resolveRefs } from '../tools/helpers/refs.js'; export class TextSearchHandler extends BaseSearchHandler { constructor( graph: Graph, private params: TextSearchParams ) { super(graph); } async execute(): Promise<SearchResult> { const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0 } = this.params; // Get target page UID if provided for scoped search let targetPageUid: string | undefined; if (page_title_uid) { targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); } const searchTerms: string[] = []; if (case_sensitive) { searchTerms.push(text); } else { searchTerms.push(text); // Add capitalized version (e.g., "Hypnosis") searchTerms.push(text.charAt(0).toUpperCase() + text.slice(1)); // Add all caps version (e.g., "HYPNOSIS") searchTerms.push(text.toUpperCase()); // Add all lowercase version (e.g., "hypnosis") searchTerms.push(text.toLowerCase()); } const whereClauses = searchTerms.map(term => `[(clojure.string/includes? ?block-str "${term}")]`).join(' '); let queryStr: string; let queryParams: (string | number)[] = []; let queryLimit = limit === -1 ? '' : `:limit ${limit}`; let queryOffset = offset === 0 ? '' : `:offset ${offset}`; let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID let baseQueryWhereClauses = ` [?b :block/string ?block-str] (or ${whereClauses}) [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting if (targetPageUid) { queryStr = `[:find ?block-uid ?block-str ?page-title :in $ ?page-uid ${queryLimit} ${queryOffset} ${queryOrder} :where ${baseQueryWhereClauses} [?p :block/uid ?page-uid]]`; queryParams = [targetPageUid]; } else { queryStr = `[:find ?block-uid ?block-str ?page-title :in $ ${queryLimit} ${queryOffset} ${queryOrder} :where ${baseQueryWhereClauses}]`; } const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?][]; // Query to get total count without limit const countQueryStr = `[:find (count ?b) :in $ :where ${baseQueryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query const totalCountResults = await q(this.graph, countQueryStr, queryParams) as number[][]; const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0; // Resolve block references in content const resolvedResults = await Promise.all( rawResults.map(async ([uid, content, pageTitle]) => { const resolvedContent = await resolveRefs(this.graph, content); return [uid, resolvedContent, pageTitle] as [string, string, string?]; }) ); const searchDescription = `containing "${text}"`; const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid); formattedResults.total_count = totalCount; return formattedResults; } } ``` -------------------------------------------------------------------------------- /src/search/utils.ts: -------------------------------------------------------------------------------- ```typescript import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import type { SearchResult } from './types.js'; export class SearchUtils { /** * Find a page by title or UID */ static async findPageByTitleOrUid(graph: Graph, titleOrUid: string): Promise<string> { // Try to find page by title const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(graph, findQuery, [titleOrUid]) as [string][]; if (findResults && findResults.length > 0) { return findResults[0][0]; } // Try as UID const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`; const uidResults = await q(graph, uidQuery, []) as [string][]; if (!uidResults || uidResults.length === 0) { throw new McpError( ErrorCode.InvalidRequest, `Page with title/UID "${titleOrUid}" not found` ); } return uidResults[0][0]; } /** * Format search results into a standard structure */ static formatSearchResults( results: [string, string, string?][], searchDescription: string, includePageTitle: boolean = true ): SearchResult { if (!results || results.length === 0) { return { success: true, matches: [], message: `No blocks found ${searchDescription}` }; } const matches = results.map(([uid, content, pageTitle]) => ({ block_uid: uid, content, ...(includePageTitle && pageTitle && { page_title: pageTitle }) })); return { success: true, matches, message: `Found ${matches.length} block(s) ${searchDescription}` }; } /** * Format a tag for searching, handling both # and [[]] formats * @param tag Tag without prefix * @returns Array of possible formats to search for */ static formatTag(tag: string): string[] { // Remove any existing prefixes const cleanTag = tag.replace(/^#|\[\[|\]\]$/g, ''); // Return both formats for comprehensive search return [`#${cleanTag}`, `[[${cleanTag}]]`]; } /** * Parse a date string into a Roam-formatted date */ static parseDate(dateStr: string): string { const date = new Date(dateStr); const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; // Adjust for timezone to ensure consistent date comparison const utcDate = new Date(date.getTime() + date.getTimezoneOffset() * 60000); return `${months[utcDate.getMonth()]} ${utcDate.getDate()}${this.getOrdinalSuffix(utcDate.getDate())}, ${utcDate.getFullYear()}`; } /** * Parse a date string into a Roam-formatted date range * Returns [startDate, endDate] with endDate being inclusive (end of day) */ static parseDateRange(startStr: string, endStr: string): [string, string] { const startDate = new Date(startStr); const endDate = new Date(endStr); endDate.setHours(23, 59, 59, 999); // Make end date inclusive const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; // Adjust for timezone const utcStart = new Date(startDate.getTime() + startDate.getTimezoneOffset() * 60000); const utcEnd = new Date(endDate.getTime() + endDate.getTimezoneOffset() * 60000); return [ `${months[utcStart.getMonth()]} ${utcStart.getDate()}${this.getOrdinalSuffix(utcStart.getDate())}, ${utcStart.getFullYear()}`, `${months[utcEnd.getMonth()]} ${utcEnd.getDate()}${this.getOrdinalSuffix(utcEnd.getDate())}, ${utcEnd.getFullYear()}` ]; } private static getOrdinalSuffix(day: number): string { if (day > 3 && day < 21) return 'th'; switch (day % 10) { case 1: return 'st'; case 2: return 'nd'; case 3: return 'rd'; default: return 'th'; } } } ``` -------------------------------------------------------------------------------- /Roam_Research_Datalog_Cheatsheet.md: -------------------------------------------------------------------------------- ```markdown # Roam Research Datalog Cheatsheet ([Gist](https://gist.github.com/2b3pro/231e4f230ed41e3f52e8a89ebf49848b)) ## Basic Structure - Roam uses Datascript (JavaScript/ClojureScript Datalog implementation) - Each fact is a datom: `[entity-id attribute value transaction-id]` ## Core Components ### Entity IDs - Hidden ID: Internal database entity-id - Public ID: Block reference (e.g., `((GGv3cyL6Y))`) or page title (`[[Page Title]]`) ### Common Block Attributes ```clojure :block/uid # Nine-character block reference :create/email # Creator's email :create/time # Creation timestamp :edit/email # Editor's email :edit/time # Last edit timestamp ``` ### Page-Specific Attributes ```clojure :node/title # Page title (pages only) ``` ### Block Attributes ```clojure :block/page # Reference to page entity-id :block/order # Sequence within parent :block/string # Block content :block/parents # List of ancestor blocks ``` ### Optional Block Attributes ```clojure :children/view-type # 'bullet', 'document', 'numbered' :block/heading # 1, 2, 3 for H1-H3 :block/props # Image/iframe sizing, slider position :block/text-align # 'left', 'center', 'right', 'justify' ``` ## Query Examples ### Graph Statistics #### Count Pages ```clojure [:find (count ?title) :where [_ :node/title ?title]] ``` #### Count Blocks ```clojure [:find (count ?string) :where [_ :block/string ?string]] ``` #### Find Blocks with Most Descendants ```clojure [:find ?ancestor (count ?block) :in $ % :where [?ancestor :block/string] [?block :block/string] (ancestor ?block ?ancestor)] ``` ### Page Queries #### List Pages in Namespace ```clojure [:find ?title:name ?title:uid ?time:date :where [?page :node/title ?title:name] [?page :block/uid ?title:uid] [?page :edit/time ?time:date] [(clojure.string/starts-with? ?title:name "roam/")]] ``` #### Find Pages Modified Today ```clojure [:find ?page_title:name ?page_title:uid :in $ ?start_of_day % :where [?page :node/title ?page_title:name] [?page :block/uid ?page_title:uid] (ancestor ?block ?page) [?block :edit/time ?time] [(> ?time ?start_of_day)]] ``` ### Block Queries #### Find Direct Children ```clojure [:find ?block_string :where [?p :node/title "Page Title"] [?p :block/children ?c] [?c :block/string ?block_string]] ``` #### Find with Pull Pattern ```clojure [:find (pull ?e [*{:block/children [*]}]) :where [?e :node/title "Page Title"]] ``` ### Advanced Queries #### Search with Case-Insensitive Pattern ```javascript let fragment = "search_term"; let query = `[:find ?title:name ?title:uid ?time:date :where [?page :node/title ?title:name] [?page :block/uid ?title:uid] [?page :edit/time ?time:date]]`; let results = window.roamAlphaAPI .q(query) .filter((item, index) => item[0].toLowerCase().indexOf(fragment) > 0) .sort((a, b) => a[0].localeCompare(b[0])); ``` #### List Namespace Attributes ```clojure [:find ?namespace ?attribute :where [_ ?attribute] [(namespace ?attribute) ?namespace]] ``` ## Tips - Use `:block/parents` for ancestors (includes all levels) - Use `:block/children` for immediate descendants only - Combine `clojure.string` functions for complex text matching - Use `distinct` to avoid duplicate results - Use Pull patterns for hierarchical data retrieval - Handle case sensitivity in string operations carefully - Chain ancestry rules for multi-level traversal ## Common Predicates Available functions: - clojure.string/includes? - clojure.string/starts-with? - clojure.string/ends-with? - count - <, >, <=, >=, =, not=, != ## Aggregates Available functions: - sum - max - min - avg - count - distinct # Sources/References: - [Deep Dive Into Roam's Data Structure - Why Roam is Much More Than a Note Taking App](https://www.zsolt.blog/2021/01/Roam-Data-Structure-Query.html) - [Query Reference | Datomic](https://docs.datomic.com/query/query-data-reference.html) - [Datalog Queries for Roam Research | David Bieber](https://davidbieber.com/snippets/2020-12-22-datalog-queries-for-roam-research/) ``` -------------------------------------------------------------------------------- /Roam_Markdown_Cheatsheet.md: -------------------------------------------------------------------------------- ```markdown !!!! IMPORTANT: Always consult this cheatsheet for correct Roam-flavored markdown syntax BEFORE making any Roam tool calls. # Roam Markdown Cheatsheet ⭐️📋 > > > START 📋⭐️ ## Markdown Styles in Roam: - **Bold Text here** - **Italics Text here** - External Link: `[Link text](URL)` - Image Embed: `` - ^^Highlighted Text here^^ - Bullet points: - or \* followed by a space and the text - {{[[TODO]]}} todo text - {{[[DONE]]}} todo text - LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$` - Bullet points use dashes not asterisks. ## Roam-specific Markdown: - Dates are in ordinal format: `[[January 1st, 2025]]` - Block references: `((block-id))` This inserts a reference to the content of a specific block. - Page references: `[[Page name]]` This creates a link to another page within your Roam graph. - Link to blocks: `[Link Text](<((block-id))>)` This will link to the block. - Embed block in a block: `{{[[embed]]: ((block-id))}}` - To-do items: `{{[[TODO]]}} todo text` or `{{[[DONE]]}} todo text` - Syntax highlighting for fenced code blocks (add language next to backticks before fenced code block - all in one block) - Example: ```javascript const foo(bar) => { return bar; } ``` - Tags: - one-word: `#word` - multiple words: `#[[two or more words]]` - hyphenated words: `#self-esteem` ## Roam Tables Roam tables are created by nesting blocks under a `{{[[table]]}}` parent block. The key to correct table rendering is to ensure proper indentation levels for headers and data cells. Each subsequent header or data cell within a row must be nested one level deeper than the previous one. - The `{{[[table]]}}` block acts as the container for the entire table. - The first header block should be at level 2 (one level deeper than `{{[[table]]}}`). - Subsequent header blocks must increase their level by one. - Each row starts at level 2. - The first data cell in a row is at level 3 (one level deeper than the row block). - Subsequent data cells within the same row must increase their level by one. Example of a 4x4 table structure: ``` {{[[table]]}} - Header 1 - Header 2 - Header 3 - Header 4 - Row 1 - Data 1.1 - Data 1.2 - Data 1.3 - Data 1.4 - Row 2 - Data 2.1 - Data 2.2 - Data 2.3 - Data 2.4 - Row 3 - Data 3.1 - Data 3.2 - Data 3.3 - Data 3.4 - Row 4 - Data 4.1 - Data 4.2 - Data 4.3 - Data 4.4 ``` ## Roam Mermaid This markdown structure represents a Roam Research Mermaid diagram. It begins with a `{{[[mermaid]]}}` block, which serves as the primary container for the diagram definition. Nested underneath this block, using bullet points, is the actual Mermaid syntax. Each bullet point corresponds to a line of the Mermaid graph definition, allowing Roam to render a visual diagram based on the provided code. For example, `graph TD` specifies a top-down directed graph, and subsequent bullet points define nodes and their connections. ``` - {{[[mermaid]]}} - graph TD - A[Start] --> B{Decision Point} - B -->|Yes| C[Process A] - B -->|No| D[Process B] - C --> E[Merge Results] - D --> E - E --> F[End] ``` ## Roam Kanban Boards The provided markdown structure represents a Roam Research Kanban board. It starts with a `{{[[kanban]]}}` block, under which nested bullet points define the Kanban cards. Each top-level bullet point directly under `{{[[kanban]]}}` serves as a card title, and any further nested bullet points under a card title act as details or sub-items for that specific card. ``` - {{[[kanban]]}} - card title 1 - bullet point 1.1 - bullet point 1.2 - card title 2 - bullet point 2.1 - bullet point 2.2 ``` --- ## Roam Hiccup This markdown structure allows embedding custom HTML or other content using Hiccup syntax. The `:hiccup` keyword is followed by a Clojure-like vector defining the HTML elements and their attributes in one block. This provides a powerful way to inject dynamic or custom components into your Roam graph. Example: `:hiccup [:iframe {:width "600" :height "400" :src "https://www.example.com"}]` ## Specific notes and preferences concerning my Roam Research graph ``` -------------------------------------------------------------------------------- /src/tools/operations/search/index.ts: -------------------------------------------------------------------------------- ```typescript import { Graph } from '@roam-research/roam-api-sdk'; import type { SearchResult } from '../../types/index.js'; import type { TagSearchParams, BlockRefSearchParams, HierarchySearchParams, TextSearchParams, SearchHandlerResult } from './types.js'; import { TagSearchHandlerImpl, BlockRefSearchHandlerImpl, HierarchySearchHandlerImpl, TextSearchHandlerImpl, StatusSearchHandlerImpl } from './handlers.js'; export class SearchOperations { constructor(private graph: Graph) {} async searchByStatus( status: 'TODO' | 'DONE', page_title_uid?: string, include?: string, exclude?: string ): Promise<SearchHandlerResult> { const handler = new StatusSearchHandlerImpl(this.graph, { status, page_title_uid, }); const result = await handler.execute(); // Post-process results with include/exclude filters let matches = result.matches; if (include) { const includeTerms = include.split(',').map(term => term.trim()); matches = matches.filter((match: SearchResult) => { const matchContent = match.content; const matchTitle = match.page_title; const terms = includeTerms; return terms.some(term => matchContent.includes(term) || (matchTitle && matchTitle.includes(term)) ); }); } if (exclude) { const excludeTerms = exclude.split(',').map(term => term.trim()); matches = matches.filter((match: SearchResult) => { const matchContent = match.content; const matchTitle = match.page_title; const terms = excludeTerms; return !terms.some(term => matchContent.includes(term) || (matchTitle && matchTitle.includes(term)) ); }); } return { success: true, matches, message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}` }; } async searchForTag( primary_tag: string, page_title_uid?: string, near_tag?: string ): Promise<SearchHandlerResult> { const handler = new TagSearchHandlerImpl(this.graph, { primary_tag, page_title_uid, near_tag, }); return handler.execute(); } async searchBlockRefs(params: BlockRefSearchParams): Promise<SearchHandlerResult> { const handler = new BlockRefSearchHandlerImpl(this.graph, params); return handler.execute(); } async searchHierarchy(params: HierarchySearchParams): Promise<SearchHandlerResult> { const handler = new HierarchySearchHandlerImpl(this.graph, params); return handler.execute(); } async searchByText(params: TextSearchParams): Promise<SearchHandlerResult> { const handler = new TextSearchHandlerImpl(this.graph, params); return handler.execute(); } async searchByDate(params: { start_date: string; end_date?: string; type: 'created' | 'modified' | 'both'; scope: 'blocks' | 'pages' | 'both'; include_content: boolean; }): Promise<{ success: boolean; matches: Array<{ uid: string; type: string; time: number; content?: string; page_title?: string }>; message: string }> { // Convert dates to timestamps const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime(); const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined; // Use text search handler for content-based filtering const handler = new TextSearchHandlerImpl(this.graph, { text: '', // Empty text to match all blocks }); const result = await handler.execute(); // Filter results by date const matches = result.matches .filter(match => { const time = params.type === 'created' ? new Date(match.content || '').getTime() : // Use content date for creation time Date.now(); // Use current time for modification time (simplified) return time >= startTimestamp && (!endTimestamp || time <= endTimestamp); }) .map(match => ({ uid: match.block_uid, type: 'block', time: params.type === 'created' ? new Date(match.content || '').getTime() : Date.now(), ...(params.include_content && { content: match.content }), page_title: match.page_title })); // Sort by time const sortedMatches = matches.sort((a, b) => b.time - a.time); return { success: true, matches: sortedMatches, message: `Found ${sortedMatches.length} matches for the given date range and criteria` }; } } ``` -------------------------------------------------------------------------------- /src/search/hierarchy-search.ts: -------------------------------------------------------------------------------- ```typescript import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import { BaseSearchHandler, SearchResult } from './types.js'; import { SearchUtils } from './utils.js'; import { resolveRefs } from '../tools/helpers/refs.js'; export interface HierarchySearchParams { parent_uid?: string; // Search for children of this block child_uid?: string; // Search for parents of this block page_title_uid?: string; max_depth?: number; // How many levels deep to search (default: 1) } export class HierarchySearchHandler extends BaseSearchHandler { constructor( graph: Graph, private params: HierarchySearchParams ) { super(graph); } async execute(): Promise<SearchResult> { const { parent_uid, child_uid, page_title_uid, max_depth = 1 } = this.params; if (!parent_uid && !child_uid) { return { success: false, matches: [], message: 'Either parent_uid or child_uid must be provided' }; } // Get target page UID if provided let targetPageUid: string | undefined; if (page_title_uid) { targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); } // Define ancestor rule for recursive traversal const ancestorRule = `[ [ (ancestor ?child ?parent) [?parent :block/children ?child] ] [ (ancestor ?child ?a) [?parent :block/children ?child] (ancestor ?parent ?a) ] ]`; let queryStr: string; let queryParams: any[]; if (parent_uid) { // Search for all descendants using ancestor rule if (targetPageUid) { queryStr = `[:find ?block-uid ?block-str ?depth :in $ % ?parent-uid ?page-uid :where [?p :block/uid ?page-uid] [?parent :block/uid ?parent-uid] (ancestor ?b ?parent) [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [(get-else $ ?b :block/path-length 1) ?depth]]`; queryParams = [ancestorRule, parent_uid, targetPageUid]; } else { queryStr = `[:find ?block-uid ?block-str ?page-title ?depth :in $ % ?parent-uid :where [?parent :block/uid ?parent-uid] (ancestor ?b ?parent) [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [(get-else $ ?b :block/path-length 1) ?depth]]`; queryParams = [ancestorRule, parent_uid]; } } else { // Search for ancestors using the same rule if (targetPageUid) { queryStr = `[:find ?block-uid ?block-str ?depth :in $ % ?child-uid ?page-uid :where [?p :block/uid ?page-uid] [?child :block/uid ?child-uid] (ancestor ?child ?b) [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [(get-else $ ?b :block/path-length 1) ?depth]]`; queryParams = [ancestorRule, child_uid, targetPageUid]; } else { queryStr = `[:find ?block-uid ?block-str ?page-title ?depth :in $ % ?child-uid :where [?child :block/uid ?child-uid] (ancestor ?child ?b) [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [(get-else $ ?b :block/path-length 1) ?depth]]`; queryParams = [ancestorRule, child_uid]; } } const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?, number?][]; // Resolve block references and format results to include depth information const matches = await Promise.all(rawResults.map(async ([uid, content, pageTitle, depth]) => { const resolvedContent = await resolveRefs(this.graph, content); return { block_uid: uid, content: resolvedContent, depth: depth || 1, ...(pageTitle && { page_title: pageTitle }) }; })); const searchDescription = parent_uid ? `descendants of block ${parent_uid}` : `ancestors of block ${child_uid}`; return { success: true, matches, message: `Found ${matches.length} block(s) as ${searchDescription}` }; } } ``` -------------------------------------------------------------------------------- /src/search/tag-search.ts: -------------------------------------------------------------------------------- ```typescript import { q } from '@roam-research/roam-api-sdk'; import type { Graph } from '@roam-research/roam-api-sdk'; import { BaseSearchHandler, TagSearchParams, SearchResult } from './types.js'; import { SearchUtils } from './utils.js'; import { resolveRefs } from '../tools/helpers/refs.js'; export class TagSearchHandler extends BaseSearchHandler { constructor( graph: Graph, private params: TagSearchParams ) { super(graph); } async execute(): Promise<SearchResult> { const { primary_tag, page_title_uid, near_tag, exclude_tag, case_sensitive = false, limit = -1, offset = 0 } = this.params; let nearTagUid: string | undefined; if (near_tag) { nearTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, near_tag); if (!nearTagUid) { return { success: false, matches: [], message: `Near tag "${near_tag}" not found.`, total_count: 0 }; } } let excludeTagUid: string | undefined; if (exclude_tag) { excludeTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, exclude_tag); if (!excludeTagUid) { return { success: false, matches: [], message: `Exclude tag "${exclude_tag}" not found.`, total_count: 0 }; } } // Get target page UID if provided for scoped search let targetPageUid: string | undefined; if (page_title_uid) { targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); } const searchTags: string[] = []; if (case_sensitive) { searchTags.push(primary_tag); } else { searchTags.push(primary_tag); searchTags.push(primary_tag.charAt(0).toUpperCase() + primary_tag.slice(1)); searchTags.push(primary_tag.toUpperCase()); searchTags.push(primary_tag.toLowerCase()); } const tagWhereClauses = searchTags.map(tag => { // Roam tags can be [[tag name]] or #tag-name or #[[tag name]] // The :node/title for a tag page is just the tag name without any # or [[ ]] return `[?ref-page :node/title "${tag}"]`; }).join(' '); let inClause = `:in $`; let queryLimit = limit === -1 ? '' : `:limit ${limit}`; let queryOffset = offset === 0 ? '' : `:offset ${offset}`; let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID let queryWhereClauses = ` (or ${tagWhereClauses}) [?b :block/refs ?ref-page] [?b :block/string ?block-str] [?b :block/uid ?block-uid] [?b :block/page ?p] [?p :node/title ?page-title] [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting if (nearTagUid) { queryWhereClauses += ` [?b :block/refs ?near-tag-page] [?near-tag-page :block/uid "${nearTagUid}"]`; } if (excludeTagUid) { queryWhereClauses += ` (not [?b :block/refs ?exclude-tag-page]) [?exclude-tag-page :block/uid "${excludeTagUid}"]`; } if (targetPageUid) { inClause += ` ?target-page-uid`; queryWhereClauses += ` [?p :block/uid ?target-page-uid]`; } const queryStr = `[:find ?block-uid ?block-str ?page-title ${inClause} ${queryLimit} ${queryOffset} ${queryOrder} :where ${queryWhereClauses}]`; const queryArgs: (string | number)[] = []; if (targetPageUid) { queryArgs.push(targetPageUid); } const rawResults = await q(this.graph, queryStr, queryArgs) as [string, string, string?][]; // Query to get total count without limit const countQueryStr = `[:find (count ?b) ${inClause} :where ${queryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query const totalCountResults = await q(this.graph, countQueryStr, queryArgs) as number[][]; const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0; // Resolve block references in content const resolvedResults = await Promise.all( rawResults.map(async ([uid, content, pageTitle]) => { const resolvedContent = await resolveRefs(this.graph, content); return [uid, resolvedContent, pageTitle] as [string, string, string?]; }) ); const searchDescription = `referencing "${primary_tag}"`; const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid); formattedResults.total_count = totalCount; return formattedResults; } } ``` -------------------------------------------------------------------------------- /src/tools/tool-handlers.ts: -------------------------------------------------------------------------------- ```typescript import { Graph } from '@roam-research/roam-api-sdk'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { PageOperations } from './operations/pages.js'; import { BlockOperations } from './operations/blocks.js'; import { BlockRetrievalOperations } from './operations/block-retrieval.js'; // New import import { SearchOperations } from './operations/search/index.js'; import { MemoryOperations } from './operations/memory.js'; import { TodoOperations } from './operations/todos.js'; import { OutlineOperations } from './operations/outline.js'; import { BatchOperations } from './operations/batch.js'; import { DatomicSearchHandlerImpl } from './operations/search/handlers.js'; export class ToolHandlers { private pageOps: PageOperations; private blockOps: BlockOperations; private blockRetrievalOps: BlockRetrievalOperations; // New instance private searchOps: SearchOperations; private memoryOps: MemoryOperations; private todoOps: TodoOperations; private outlineOps: OutlineOperations; private batchOps: BatchOperations; constructor(private graph: Graph) { this.pageOps = new PageOperations(graph); this.blockOps = new BlockOperations(graph); this.blockRetrievalOps = new BlockRetrievalOperations(graph); // Initialize new instance this.searchOps = new SearchOperations(graph); this.memoryOps = new MemoryOperations(graph); this.todoOps = new TodoOperations(graph); this.outlineOps = new OutlineOperations(graph); this.batchOps = new BatchOperations(graph); } // Page Operations async findPagesModifiedToday(limit: number = 50, offset: number = 0, sort_order: 'asc' | 'desc' = 'desc') { return this.pageOps.findPagesModifiedToday(limit, offset, sort_order); } async createPage(title: string, content?: Array<{ text: string; level: number; heading?: number }>) { return this.pageOps.createPage(title, content); } async fetchPageByTitle(title: string, format?: 'markdown' | 'raw') { return this.pageOps.fetchPageByTitle(title, format); } // Block Operations async fetchBlockWithChildren(block_uid: string, depth?: number) { return this.blockRetrievalOps.fetchBlockWithChildren(block_uid, depth); } // Search Operations async searchByStatus( status: 'TODO' | 'DONE', page_title_uid?: string, include?: string, exclude?: string ) { return this.searchOps.searchByStatus(status, page_title_uid, include, exclude); } async searchForTag( primary_tag: string, page_title_uid?: string, near_tag?: string ) { return this.searchOps.searchForTag(primary_tag, page_title_uid, near_tag); } async searchBlockRefs(params: { block_uid?: string; page_title_uid?: string }) { return this.searchOps.searchBlockRefs(params); } async searchHierarchy(params: { parent_uid?: string; child_uid?: string; page_title_uid?: string; max_depth?: number; }) { return this.searchOps.searchHierarchy(params); } async searchByText(params: { text: string; page_title_uid?: string; }) { return this.searchOps.searchByText(params); } async searchByDate(params: { start_date: string; end_date?: string; type: 'created' | 'modified' | 'both'; scope: 'blocks' | 'pages' | 'both'; include_content: boolean; }) { return this.searchOps.searchByDate(params); } // Datomic query async executeDatomicQuery(params: { query: string; inputs?: unknown[] }) { const handler = new DatomicSearchHandlerImpl(this.graph, params); return handler.execute(); } // Memory Operations async remember(memory: string, categories?: string[]) { return this.memoryOps.remember(memory, categories); } async recall(sort_by: 'newest' | 'oldest' = 'newest', filter_tag?: string) { return this.memoryOps.recall(sort_by, filter_tag); } // Todo Operations async addTodos(todos: string[]) { return this.todoOps.addTodos(todos); } // Outline Operations async createOutline(outline: Array<{ text: string | undefined; level: number }>, page_title_uid?: string, block_text_uid?: string) { return this.outlineOps.createOutline(outline, page_title_uid, block_text_uid); } async importMarkdown( content: string, page_uid?: string, page_title?: string, parent_uid?: string, parent_string?: string, order: 'first' | 'last' = 'first' ) { return this.outlineOps.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order); } // Batch Operations async processBatch(actions: any[]) { return this.batchOps.processBatch(actions); } async getRoamMarkdownCheatsheet() { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const cheatsheetPath = path.join(__dirname, '../../Roam_Markdown_Cheatsheet.md'); let cheatsheetContent = fs.readFileSync(cheatsheetPath, 'utf-8'); const customInstructionsPath = process.env.CUSTOM_INSTRUCTIONS_PATH; if (customInstructionsPath && fs.existsSync(customInstructionsPath)) { try { const customInstructionsContent = fs.readFileSync(customInstructionsPath, 'utf-8'); cheatsheetContent += `\n\n${customInstructionsContent}`; } catch (error) { console.warn(`Could not read custom instructions file at ${customInstructionsPath}: ${error}`); } } return cheatsheetContent; } } ``` -------------------------------------------------------------------------------- /src/tools/operations/memory.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q, createPage, batchActions } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { formatRoamDate } from '../../utils/helpers.js'; import { resolveRefs } from '../helpers/refs.js'; import { SearchOperations } from './search/index.js'; import type { SearchResult } from '../types/index.js'; export class MemoryOperations { private searchOps: SearchOperations; constructor(private graph: Graph) { this.searchOps = new SearchOperations(graph); } async remember(memory: string, categories?: string[]): Promise<{ success: boolean }> { // Get today's date const today = new Date(); const dateStr = formatRoamDate(today); // Try to find today's page const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; let pageUid: string; if (findResults && findResults.length > 0) { pageUid = findResults[0][0]; } else { // Create today's page if it doesn't exist try { await createPage(this.graph, { action: 'create-page', page: { title: dateStr } }); // Get the new page's UID const results = await q(this.graph, findQuery, [dateStr]) as [string][]; if (!results || results.length === 0) { throw new McpError( ErrorCode.InternalError, 'Could not find created today\'s page' ); } pageUid = results[0][0]; } catch (error) { throw new McpError( ErrorCode.InternalError, 'Failed to create today\'s page' ); } } // Get memories tag from environment const memoriesTag = process.env.MEMORIES_TAG; if (!memoriesTag) { throw new McpError( ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set' ); } // Format categories as Roam tags if provided const categoryTags = categories?.map(cat => { // Handle multi-word categories return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`; }).join(' ') || ''; // Create block with memory, memories tag, and optional categories const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim(); const actions = [{ action: 'create-block', location: { 'parent-uid': pageUid, order: 'last' }, block: { string: blockContent } }]; try { const result = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!result) { throw new McpError( ErrorCode.InternalError, 'Failed to create memory block via batch action' ); } } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}` ); } return { success: true }; } async recall(sort_by: 'newest' | 'oldest' = 'newest', filter_tag?: string): Promise<{ success: boolean; memories: string[] }> { // Get memories tag from environment var memoriesTag = process.env.MEMORIES_TAG; if (!memoriesTag) { memoriesTag = "Memories" } // Extract the tag text, removing any formatting const tagText = memoriesTag .replace(/^#/, '') // Remove leading # .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]] try { // Get page blocks using query to access actual block content const ancestorRule = `[ [ (ancestor ?b ?a) [?a :block/children ?b] ] [ (ancestor ?b ?a) [?parent :block/children ?b] (ancestor ?parent ?a) ] ]`; // Query to find all blocks on the page const pageQuery = `[:find ?string ?time :in $ % ?title :where [?page :node/title ?title] [?block :block/string ?string] [?block :create/time ?time] (ancestor ?block ?page)]`; // Execute query const pageResults = await q(this.graph, pageQuery, [ancestorRule, tagText]) as [string, number][]; // Process page blocks with sorting let pageMemories = pageResults .sort(([_, aTime], [__, bTime]) => sort_by === 'newest' ? bTime - aTime : aTime - bTime ) .map(([content]) => content); // Get tagged blocks from across the graph const tagResults = await this.searchOps.searchForTag(tagText); // Process tagged blocks with sorting let taggedMemories = tagResults.matches .sort((a: SearchResult, b: SearchResult) => { const aTime = a.block_uid ? parseInt(a.block_uid.split('-')[0], 16) : 0; const bTime = b.block_uid ? parseInt(b.block_uid.split('-')[0], 16) : 0; return sort_by === 'newest' ? bTime - aTime : aTime - bTime; }) .map(match => match.content); // Resolve any block references in both sets const resolvedPageMemories = await Promise.all( pageMemories.map(async (content: string) => resolveRefs(this.graph, content)) ); const resolvedTaggedMemories = await Promise.all( taggedMemories.map(async (content: string) => resolveRefs(this.graph, content)) ); // Combine both sets and remove duplicates while preserving order let uniqueMemories = [ ...resolvedPageMemories, ...resolvedTaggedMemories ].filter((memory, index, self) => self.indexOf(memory) === index ); // Format filter tag with exact Roam tag syntax const filterTagFormatted = filter_tag ? (filter_tag.includes(' ') ? `#[[${filter_tag}]]` : `#${filter_tag}`) : null; // Filter by exact tag match if provided if (filterTagFormatted) { uniqueMemories = uniqueMemories.filter(memory => memory.includes(filterTagFormatted)); } // Format memories tag for removal and clean up memories tag const memoriesTagFormatted = tagText.includes(' ') || tagText.includes('/') ? `#[[${tagText}]]` : `#${tagText}`; uniqueMemories = uniqueMemories.map(memory => memory.replace(memoriesTagFormatted, '').trim()); // return { // success: true, // memories: [ // `memoriesTag = ${memoriesTag}`, // `filter_tag = ${filter_tag}`, // `filterTagFormatted = ${filterTagFormatted}`, // `memoriesTagFormatted = ${memoriesTagFormatted}`, // ] // } return { success: true, memories: uniqueMemories }; } catch (error: any) { throw new McpError( ErrorCode.InternalError, `Failed to recall memories: ${error.message}` ); } } } ``` -------------------------------------------------------------------------------- /src/markdown-utils.ts: -------------------------------------------------------------------------------- ```typescript import type { RoamCreateBlock, RoamCreatePage, RoamUpdateBlock, RoamDeleteBlock, RoamDeletePage, RoamMoveBlock } from '@roam-research/roam-api-sdk'; export type BatchAction = | RoamCreateBlock | RoamCreatePage | RoamUpdateBlock | RoamDeleteBlock | RoamDeletePage | RoamMoveBlock; interface MarkdownNode { content: string; level: number; heading_level?: number; // Optional heading level (1-3) for heading nodes children_view_type?: 'bullet' | 'document' | 'numbered'; // Optional view type for children children: MarkdownNode[]; } /** * Check if text has a traditional markdown table */ function hasMarkdownTable(text: string): boolean { return /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+$/.test(text); } /** * Converts a markdown table to Roam format */ function convertTableToRoamFormat(text: string) { const lines = text.split('\n') .map(line => line.trim()) .filter(line => line.length > 0); const tableRegex = /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+/m; if (!tableRegex.test(text)) { return text; } const rows = lines .filter((_, index) => index !== 1) .map(line => line.trim() .replace(/^\||\|$/g, '') .split('|') .map(cell => cell.trim()) ); let roamTable = '{{[[table]]}}\n'; // First row becomes column headers const headers = rows[0]; for (let i = 0; i < headers.length; i++) { roamTable += `${' '.repeat(i + 1)}- ${headers[i]}\n`; } // Remaining rows become nested under each column for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) { const row = rows[rowIndex]; for (let colIndex = 0; colIndex < row.length; colIndex++) { roamTable += `${' '.repeat(colIndex + 1)}- ${row[colIndex]}\n`; } } return roamTable.trim(); } function convertAllTables(text: string) { return text.replaceAll( /(^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+)/gm, (match) => { return '\n' + convertTableToRoamFormat(match) + '\n'; } ); } /** * Parse markdown heading syntax (e.g. "### Heading") and return the heading level (1-3) and content. * Heading level is determined by the number of # characters (e.g. # = h1, ## = h2, ### = h3). * Returns heading_level: 0 for non-heading content. */ function parseMarkdownHeadingLevel(text: string): { heading_level: number; content: string } { const match = text.match(/^(#{1,3})\s+(.+)$/); if (match) { return { heading_level: match[1].length, // Number of # characters determines heading level content: match[2].trim() }; } return { heading_level: 0, // Not a heading content: text.trim() }; } function convertToRoamMarkdown(text: string): string { // Handle double asterisks/underscores (bold) text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks // Handle single asterisks/underscores (italic) text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore // Handle highlights text = text.replace(/==(.+?)==/g, '^^$1^^'); // Convert tasks text = text.replace(/- \[ \]/g, '- {{[[TODO]]}}'); text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}'); // Convert tables text = convertAllTables(text); return text; } function parseMarkdown(markdown: string): MarkdownNode[] { markdown = convertToRoamMarkdown(markdown); const originalLines = markdown.split('\n'); const processedLines: string[] = []; // Pre-process lines to handle mid-line code blocks without splice for (const line of originalLines) { const trimmedLine = line.trimEnd(); const codeStartIndex = trimmedLine.indexOf('```'); if (codeStartIndex > 0) { const indentationWhitespace = line.match(/^\s*/)?.[0] ?? ''; processedLines.push(indentationWhitespace + trimmedLine.substring(0, codeStartIndex)); processedLines.push(indentationWhitespace + trimmedLine.substring(codeStartIndex)); } else { processedLines.push(line); } } const rootNodes: MarkdownNode[] = []; const stack: MarkdownNode[] = []; let inCodeBlock = false; let codeBlockContent = ''; let codeBlockIndentation = 0; let codeBlockParentLevel = 0; for (let i = 0; i < processedLines.length; i++) { const line = processedLines[i]; const trimmedLine = line.trimEnd(); if (trimmedLine.match(/^(\s*)```/)) { if (!inCodeBlock) { inCodeBlock = true; codeBlockContent = trimmedLine.trimStart() + '\n'; codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0; codeBlockParentLevel = stack.length; } else { inCodeBlock = false; codeBlockContent += trimmedLine.trimStart(); const linesInCodeBlock = codeBlockContent.split('\n'); let baseIndentation = ''; for (let j = 1; j < linesInCodeBlock.length - 1; j++) { const codeLine = linesInCodeBlock[j]; if (codeLine.trim().length > 0) { const indentMatch = codeLine.match(/^[\t ]*/); if (indentMatch) { baseIndentation = indentMatch[0]; break; } } } const processedCodeLines = linesInCodeBlock.map((codeLine, index) => { if (index === 0 || index === linesInCodeBlock.length - 1) return codeLine.trimStart(); if (codeLine.trim().length === 0) return ''; if (codeLine.startsWith(baseIndentation)) { return codeLine.slice(baseIndentation.length); } return codeLine.trimStart(); }); const level = Math.floor(codeBlockIndentation / 2); const node: MarkdownNode = { content: processedCodeLines.join('\n'), level, children: [] }; while (stack.length > codeBlockParentLevel) { stack.pop(); } if (level === 0) { rootNodes.push(node); stack[0] = node; } else { while (stack.length > level) { stack.pop(); } if (stack[level - 1]) { stack[level - 1].children.push(node); } else { rootNodes.push(node); } stack[level] = node; } codeBlockContent = ''; } continue; } if (inCodeBlock) { codeBlockContent += line + '\n'; continue; } if (trimmedLine === '') { continue; } const indentation = line.match(/^\s*/)?.[0].length ?? 0; let level = Math.floor(indentation / 2); let contentToParse: string; const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/); if (bulletMatch) { level = Math.floor(bulletMatch[1].length / 2); contentToParse = trimmedLine.substring(bulletMatch[0].length); } else { contentToParse = trimmedLine; } const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse); const node: MarkdownNode = { content: finalContent, level, ...(heading_level > 0 && { heading_level }), children: [] }; while (stack.length > level) { stack.pop(); } if (level === 0 || !stack[level - 1]) { rootNodes.push(node); stack[0] = node; } else { stack[level - 1].children.push(node); } stack[level] = node; } return rootNodes; } function parseTableRows(lines: string[]): MarkdownNode[] { const tableNodes: MarkdownNode[] = []; let currentLevel = -1; for (const line of lines) { const trimmedLine = line.trimEnd(); if (!trimmedLine) continue; // Calculate indentation level const indentation = line.match(/^\s*/)?.[0].length ?? 0; const level = Math.floor(indentation / 2); // Extract content after bullet point const content = trimmedLine.replace(/^\s*[-*+]\s*/, ''); // Create node for this cell const node: MarkdownNode = { content, level, children: [] }; // Track the first level we see to maintain relative nesting if (currentLevel === -1) { currentLevel = level; } // Add node to appropriate parent based on level if (level === currentLevel) { tableNodes.push(node); } else { // Find parent by walking back through nodes let parent = tableNodes[tableNodes.length - 1]; while (parent && parent.level < level - 1) { parent = parent.children[parent.children.length - 1]; } if (parent) { parent.children.push(node); } } } return tableNodes; } function generateBlockUid(): string { // Generate a random string of 9 characters (Roam's format) const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'; let uid = ''; for (let i = 0; i < 9; i++) { uid += chars.charAt(Math.floor(Math.random() * chars.length)); } return uid; } interface BlockInfo { uid: string; content: string; heading_level?: number; // Optional heading level (1-3) for heading nodes children_view_type?: 'bullet' | 'document' | 'numbered'; // Optional view type for children children: BlockInfo[]; } function convertNodesToBlocks(nodes: MarkdownNode[]): BlockInfo[] { return nodes.map(node => ({ uid: generateBlockUid(), content: node.content, ...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present children: convertNodesToBlocks(node.children) })); } function convertToRoamActions( nodes: MarkdownNode[], parentUid: string, order: 'first' | 'last' | number = 'last' ): BatchAction[] { // First convert nodes to blocks with UIDs const blocks = convertNodesToBlocks(nodes); const actions: BatchAction[] = []; // Helper function to recursively create actions function createBlockActions(blocks: BlockInfo[], parentUid: string, order: 'first' | 'last' | number): void { for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; // Create the current block const action: RoamCreateBlock = { action: 'create-block', location: { 'parent-uid': parentUid, order: typeof order === 'number' ? order + i : i }, block: { uid: block.uid, string: block.content, ...(block.heading_level && { heading: block.heading_level }), ...(block.children_view_type && { 'children-view-type': block.children_view_type }) } }; actions.push(action); // Create child blocks if any if (block.children.length > 0) { createBlockActions(block.children, block.uid, 'last'); } } } // Create all block actions createBlockActions(blocks, parentUid, order); return actions; } // Export public functions and types export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel }; ``` -------------------------------------------------------------------------------- /src/tools/operations/blocks.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { formatRoamDate } from '../../utils/helpers.js'; import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable, type BatchAction } from '../../markdown-utils.js'; import type { BlockUpdate, BlockUpdateResult } from '../types/index.js'; export class BlockOperations { constructor(private graph: Graph) {} async createBlock(content: string, page_uid?: string, title?: string, heading?: number): Promise<{ success: boolean; block_uid?: string; parent_uid: string }> { // If page_uid provided, use it directly let targetPageUid = page_uid; // If no page_uid but title provided, search for page by title if (!targetPageUid && title) { const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [title]) as [string][]; if (findResults && findResults.length > 0) { targetPageUid = findResults[0][0]; } else { // Create page with provided title if it doesn't exist try { await createPage(this.graph, { action: 'create-page', page: { title } }); // Get the new page's UID const results = await q(this.graph, findQuery, [title]) as [string][]; if (!results || results.length === 0) { throw new Error('Could not find created page'); } targetPageUid = results[0][0]; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}` ); } } } // If neither page_uid nor title provided, use today's date page if (!targetPageUid) { const today = new Date(); const dateStr = formatRoamDate(today); // Try to find today's page const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; if (findResults && findResults.length > 0) { targetPageUid = findResults[0][0]; } else { // Create today's page if it doesn't exist try { await createPage(this.graph, { action: 'create-page', page: { title: dateStr } }); // Get the new page's UID const results = await q(this.graph, findQuery, [dateStr]) as [string][]; if (!results || results.length === 0) { throw new Error('Could not find created today\'s page'); } targetPageUid = results[0][0]; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}` ); } } } try { // If the content has multiple lines or is a table, use nested import if (content.includes('\n')) { let nodes; // If heading parameter is provided, manually construct nodes to preserve heading if (heading) { const lines = content.split('\n'); const firstLine = lines[0].trim(); const remainingLines = lines.slice(1); // Create the first node with heading formatting const firstNode = { content: firstLine, level: 0, heading_level: heading, children: [] }; // If there are remaining lines, parse them as children or siblings if (remainingLines.length > 0 && remainingLines.some(line => line.trim())) { const remainingContent = remainingLines.join('\n'); const convertedRemainingContent = convertToRoamMarkdown(remainingContent); const remainingNodes = parseMarkdown(convertedRemainingContent); // Add remaining nodes as siblings to the first node nodes = [firstNode, ...remainingNodes]; } else { nodes = [firstNode]; } } else { // No heading parameter, use original parsing logic const convertedContent = convertToRoamMarkdown(content); nodes = parseMarkdown(convertedContent); } const actions = convertToRoamActions(nodes, targetPageUid, 'last'); // Execute batch actions to create the nested structure const result = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!result) { throw new Error('Failed to create nested blocks'); } const blockUid = result.created_uids?.[0]; return { success: true, block_uid: blockUid, parent_uid: targetPageUid! }; } else { // For single block content, use the same convertToRoamActions approach that works in roam_create_page const nodes = [{ content: content, level: 0, ...(heading && typeof heading === 'number' && heading > 0 && { heading_level: heading }), children: [] }]; if (!targetPageUid) { throw new McpError(ErrorCode.InternalError, 'targetPageUid is undefined'); } const actions = convertToRoamActions(nodes, targetPageUid, 'last'); // Execute batch actions to create the block const result = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!result) { throw new Error('Failed to create block'); } const blockUid = result.created_uids?.[0]; return { success: true, block_uid: blockUid, parent_uid: targetPageUid! }; } } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create block: ${error instanceof Error ? error.message : String(error)}` ); } } async updateBlock(block_uid: string, content?: string, transform?: (currentContent: string) => string): Promise<{ success: boolean; content: string }> { if (!block_uid) { throw new McpError( ErrorCode.InvalidRequest, 'block_uid is required' ); } // Get current block content const blockQuery = `[:find ?string . :where [?b :block/uid "${block_uid}"] [?b :block/string ?string]]`; const result = await q(this.graph, blockQuery, []); if (result === null || result === undefined) { throw new McpError( ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found` ); } const currentContent = String(result); if (currentContent === null || currentContent === undefined) { throw new McpError( ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found` ); } // Determine new content let newContent: string; if (content) { newContent = content; } else if (transform) { newContent = transform(currentContent); } else { throw new McpError( ErrorCode.InvalidRequest, 'Either content or transform function must be provided' ); } try { await updateRoamBlock(this.graph, { action: 'update-block', block: { uid: block_uid, string: newContent } }); return { success: true, content: newContent }; } catch (error: any) { throw new McpError( ErrorCode.InternalError, `Failed to update block: ${error.message}` ); } } async updateBlocks(updates: BlockUpdate[]): Promise<{ success: boolean; results: BlockUpdateResult[] }> { if (!Array.isArray(updates) || updates.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'updates must be a non-empty array' ); } // Validate each update has required fields updates.forEach((update, index) => { if (!update.block_uid) { throw new McpError( ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid` ); } if (!update.content && !update.transform) { throw new McpError( ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform` ); } }); // Get current content for all blocks const blockUids = updates.map(u => u.block_uid); const blockQuery = `[:find ?uid ?string :in $ [?uid ...] :where [?b :block/uid ?uid] [?b :block/string ?string]]`; const blockResults = await q(this.graph, blockQuery, [blockUids]) as [string, string][]; // Create map of uid -> current content const contentMap = new Map<string, string>(); blockResults.forEach(([uid, string]) => { contentMap.set(uid, string); }); // Prepare batch actions const actions: BatchAction[] = []; const results: BlockUpdateResult[] = []; for (const update of updates) { try { const currentContent = contentMap.get(update.block_uid); if (!currentContent) { results.push({ block_uid: update.block_uid, content: '', success: false, error: `Block with UID "${update.block_uid}" not found` }); continue; } // Determine new content let newContent: string; if (update.content) { newContent = update.content; } else if (update.transform) { const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : ''); newContent = currentContent.replace(regex, update.transform.replace); } else { // This shouldn't happen due to earlier validation throw new Error('Invalid update configuration'); } // Add to batch actions actions.push({ action: 'update-block', block: { uid: update.block_uid, string: newContent } }); results.push({ block_uid: update.block_uid, content: newContent, success: true }); } catch (error: any) { results.push({ block_uid: update.block_uid, content: contentMap.get(update.block_uid) || '', success: false, error: error.message }); } } // Execute batch update if we have any valid actions if (actions.length > 0) { try { const batchResult = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!batchResult) { throw new Error('Batch update failed'); } } catch (error: any) { // Mark all previously successful results as failed results.forEach(result => { if (result.success) { result.success = false; result.error = `Batch update failed: ${error.message}`; } }); } } return { success: results.every(r => r.success), results }; } } ``` -------------------------------------------------------------------------------- /src/tools/operations/pages.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { capitalizeWords } from '../helpers/text.js'; import { resolveRefs } from '../helpers/refs.js'; import type { RoamBlock } from '../types/index.js'; import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable } from '../../markdown-utils.js'; // Helper to get ordinal suffix for dates function getOrdinalSuffix(day: number): string { if (day > 3 && day < 21) return 'th'; // Handles 11th, 12th, 13th switch (day % 10) { case 1: return 'st'; case 2: return 'nd'; case 3: return 'rd'; default: return 'th'; } } export class PageOperations { constructor(private graph: Graph) { } async findPagesModifiedToday(limit: number = 50, offset: number = 0, sort_order: 'asc' | 'desc' = 'desc') { // Define ancestor rule for traversing block hierarchy const ancestorRule = `[ [ (ancestor ?b ?a) [?a :block/children ?b] ] [ (ancestor ?b ?a) [?parent :block/children ?b] (ancestor ?parent ?a) ] ]`; // Get start of today const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); try { // Query for pages modified today, including modification time for sorting let query = `[:find ?title ?time :in $ ?start_of_day % :where [?page :node/title ?title] (ancestor ?block ?page) [?block :edit/time ?time] [(> ?time ?start_of_day)]]`; if (limit !== -1) { query += ` :limit ${limit}`; } if (offset > 0) { query += ` :offset ${offset}`; } const results = await q( this.graph, query, [startOfDay.getTime(), ancestorRule] ) as [string, number][]; if (!results || results.length === 0) { return { success: true, pages: [], message: 'No pages have been modified today' }; } // Sort results by modification time results.sort((a, b) => { if (sort_order === 'desc') { return b[1] - a[1]; // Newest first } else { return a[1] - b[1]; // Oldest first } }); // Extract unique page titles from sorted results const uniquePages = Array.from(new Set(results.map(([title]) => title))); return { success: true, pages: uniquePages, message: `Found ${uniquePages.length} page(s) modified today` }; } catch (error: any) { throw new McpError( ErrorCode.InternalError, `Failed to find modified pages: ${error.message}` ); } } async createPage(title: string, content?: Array<{ text: string; level: number; heading?: number }>): Promise<{ success: boolean; uid: string }> { // Ensure title is properly formatted const pageTitle = String(title).trim(); // First try to find if the page exists const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; type FindResult = [string]; const findResults = await q(this.graph, findQuery, [pageTitle]) as FindResult[]; let pageUid: string | undefined; if (findResults && findResults.length > 0) { // Page exists, use its UID pageUid = findResults[0][0]; } else { // Create new page try { await createRoamPage(this.graph, { action: 'create-page', page: { title: pageTitle } }); // Get the new page's UID const results = await q(this.graph, findQuery, [pageTitle]) as FindResult[]; if (!results || results.length === 0) { throw new Error('Could not find created page'); } pageUid = results[0][0]; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}` ); } } // If content is provided, create blocks using batch operations if (content && content.length > 0) { try { // Convert content array to MarkdownNode format expected by convertToRoamActions const nodes = content.map(block => ({ content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')), level: block.level, ...(block.heading && { heading_level: block.heading }), children: [] })); // Create hierarchical structure based on levels const rootNodes: any[] = []; const levelMap: { [level: number]: any } = {}; for (const node of nodes) { if (node.level === 1) { rootNodes.push(node); levelMap[1] = node; } else { const parentLevel = node.level - 1; const parent = levelMap[parentLevel]; if (!parent) { throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`); } parent.children.push(node); levelMap[node.level] = node; } } // Generate batch actions for all blocks const actions = convertToRoamActions(rootNodes, pageUid, 'last'); // Execute batch operation if (actions.length > 0) { const batchResult = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!batchResult) { throw new Error('Failed to create blocks'); } } } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}` ); } } // Add a link to the created page on today's daily page try { const today = new Date(); const day = today.getDate(); const month = today.toLocaleString('en-US', { month: 'long' }); const year = today.getFullYear(); const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; const dailyPageQuery = `[:find ?uid . :where [?e :node/title "${formattedTodayTitle}"] [?e :block/uid ?uid]]`; const dailyPageResult = await q(this.graph, dailyPageQuery, []); const dailyPageUid = dailyPageResult ? String(dailyPageResult) : null; if (dailyPageUid) { await createBlock(this.graph, { action: 'create-block', block: { string: `Created page: [[${pageTitle}]]` }, location: { 'parent-uid': dailyPageUid, order: 'last' } }); } else { console.warn(`Could not find daily page with title: ${formattedTodayTitle}. Link to created page not added.`); } } catch (error) { console.error(`Failed to add link to daily page: ${error instanceof Error ? error.message : String(error)}`); } return { success: true, uid: pageUid }; } async fetchPageByTitle( title: string, format: 'markdown' | 'raw' = 'raw' ): Promise<string | RoamBlock[]> { if (!title) { throw new McpError(ErrorCode.InvalidRequest, 'title is required'); } // Try different case variations const variations = [ title, // Original capitalizeWords(title), // Each word capitalized title.toLowerCase() // All lowercase ]; let uid: string | null = null; for (const variation of variations) { const searchQuery = `[:find ?uid . :where [?e :node/title "${variation}"] [?e :block/uid ?uid]]`; const result = await q(this.graph, searchQuery, []); uid = (result === null || result === undefined) ? null : String(result); if (uid) break; } if (!uid) { throw new McpError( ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)` ); } // Define ancestor rule for traversing block hierarchy const ancestorRule = `[ [ (ancestor ?b ?a) [?a :block/children ?b] ] [ (ancestor ?b ?a) [?parent :block/children ?b] (ancestor ?parent ?a) ] ]`; // Get all blocks under this page using ancestor rule const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid :in $ % ?page-title :where [?page :node/title ?page-title] [?block :block/string ?block-str] [?block :block/uid ?block-uid] [?block :block/order ?order] (ancestor ?block ?page) [?parent :block/children ?block] [?parent :block/uid ?parent-uid]]`; const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]); if (!blocks || blocks.length === 0) { if (format === 'raw') { return []; } return `${title} (no content found)`; } // Get heading information for blocks that have it const headingsQuery = `[:find ?block-uid ?heading :in $ % ?page-title :where [?page :node/title ?page-title] [?block :block/uid ?block-uid] [?block :block/heading ?heading] (ancestor ?block ?page)]`; const headings = await q(this.graph, headingsQuery, [ancestorRule, title]); // Create a map of block UIDs to heading levels const headingMap = new Map<string, number>(); if (headings) { for (const [blockUid, heading] of headings) { headingMap.set(blockUid, heading as number); } } // Create a map of all blocks const blockMap = new Map<string, RoamBlock>(); const rootBlocks: RoamBlock[] = []; // First pass: Create all block objects for (const [blockUid, blockStr, order, parentUid] of blocks) { const resolvedString = await resolveRefs(this.graph, blockStr); const block = { uid: blockUid, string: resolvedString, order: order as number, heading: headingMap.get(blockUid) || null, children: [] }; blockMap.set(blockUid, block); // If no parent or parent is the page itself, it's a root block if (!parentUid || parentUid === uid) { rootBlocks.push(block); } } // Second pass: Build parent-child relationships for (const [blockUid, _, __, parentUid] of blocks) { if (parentUid && parentUid !== uid) { const child = blockMap.get(blockUid); const parent = blockMap.get(parentUid); if (child && parent && !parent.children.includes(child)) { parent.children.push(child); } } } // Sort blocks recursively const sortBlocks = (blocks: RoamBlock[]) => { blocks.sort((a, b) => a.order - b.order); blocks.forEach(block => { if (block.children.length > 0) { sortBlocks(block.children); } }); }; sortBlocks(rootBlocks); if (format === 'raw') { return JSON.stringify(rootBlocks); } // Convert to markdown with proper nesting const toMarkdown = (blocks: RoamBlock[], level: number = 0): string => { return blocks .map(block => { const indent = ' '.repeat(level); let md: string; // Check block heading level and format accordingly if (block.heading && block.heading > 0) { // Format as heading with appropriate number of hashtags const hashtags = '#'.repeat(block.heading); md = `${indent}${hashtags} ${block.string}`; } else { // No heading, use bullet point (current behavior) md = `${indent}- ${block.string}`; } if (block.children.length > 0) { md += '\n' + toMarkdown(block.children, level + 1); } return md; }) .join('\n'); }; return `# ${title}\n\n${toMarkdown(rootBlocks)}`; } } ``` -------------------------------------------------------------------------------- /src/server/roam-server.ts: -------------------------------------------------------------------------------- ```typescript 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}`); } } } ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown # Changelog v0.36.3 - 2025-08-30 - FEATURE: Implemented `prompts/list` method for MCP server, returning an empty array of prompts. - FIXED: Removed `roam-markdown-cheatsheet.md` from advertised resources in MCP server capabilities to align with its tool-only access. v0.36.2 - 2025-08-28 - ENHANCED: `roam_datomic_query` tool - Added `regexFilter`, `regexFlags`, and `regexTargetField` parameters for client-side regex filtering of results. - Updated description to reflect enhanced filtering capabilities. v0.36.1 - 2025-08-28 - ENHANCED: `roam_find_pages_modified_today` tool - Added `limit`, `offset`, and `sort_order` parameters for pagination and sorting. v1.36.0 - 2025-08-28 - ENHANCED: `roam_search_for_tag` and `roam_search_by_text` tools - Added `offset` parameter for pagination support. - ENHANCED: `roam_search_for_tag` tool - Implemented `near_tag` and `exclude_tag` parameters for more precise tag-based filtering. - ENHANCED: `roam_datomic_query` tool - Updated description to clarify optimal use cases (Regex, Complex Boolean Logic, Arbitrary Sorting, Proximity Search). v.0.35.1 - 2025-08-23 9:33 - ENHANCED: `roam_create_page` and `roam_create_outline` tool descriptions in `src/tools/schemas.ts` for improved clarity and to guide users toward the most efficient workflow. v.0.35.0 - 2025-08-23 - ENHANCED: `roam_import_markdown` tool - Now returns a nested object structure for `created_uids`, reflecting the hierarchy of the imported content, including `uid`, `text`, `order`, and `children`. - If a `parent_string` is provided and the block does not exist, it will be created automatically. - FIXED: Block ordering issue in `roam_import_markdown` and `roam_create_outline`. Nested outlines are now created in the correct order. - FIXED: Duplication issue in the response of `roam_fetch_block_with_children`. v.0.32.4 - FIXED: Memory allocation issue (`FATAL ERROR: invalid array length Allocation failed - JavaScript heap out of memory`) - Removed `console.log` statements from `src/tools/operations/outline.ts` to adhere to MCP server stdio communication rules. - Optimized `parseMarkdown` function in `src/markdown-utils.ts` to avoid inefficient `lines.splice()` operations when handling mid-line code blocks, improving memory usage and performance. - ENHANCED: `roam_create_outline` tool - Successfully created outlines with nested code blocks, confirming the fix for memory allocation issues. v.0.32.1 - ENHANCED: `roam_create_outline` tool - The tool now returns a nested structure of UIDs (`NestedBlock[]`) for all created blocks, including children, accurately reflecting the outline hierarchy. - Implemented a recursive fetching mechanism (`fetchBlockWithChildren` helper) to retrieve all nested block UIDs and their content after creation. - Fixed an issue where the `created_uids` array was only returning top-level block UIDs. - Corrected the Datomic query used for fetching children to ensure only direct children are retrieved, resolving previous duplication and incorrect nesting issues. - Removed `console.log` and `console.warn` statements from `src/tools/operations/outline.ts` to adhere to MCP server stdio communication rules. - ADDED: `NestedBlock` interface in `src/tools/types/index.ts` to represent the hierarchical structure of created blocks. v.0.32.3 - ENHANCED: `roam_create_page` tool - Now creates a block on the daily page linking to the newly created page, formatted as `Create [[Page Title]]`. v.0.32.2 - FIXED: `roam_create_outline` now correctly respects the order of top-level blocks. - Changed the default insertion order for batch actions from 'first' to 'last' in `src/tools/operations/outline.ts` to ensure blocks are added in the intended sequence. v.0.30.10 - ENHANCED: `roam_markdown_cheatsheet` tool - The tool now reads the `Roam_Markdown_Cheatsheet.md` and concatenates it with custom instructions from the path specified by the `CUSTOM_INSTRUCTIONS_PATH` environment variable, if the file exists. If the custom instructions file is not found, only the cheatsheet content is returned. - UPDATED: The description of `roam_markdown_cheatsheet` in `src/tools/schemas.ts` to reflect the new functionality. v.0.30.9 - FIXED: `roam_fetch_block_with_children` tool to use a more efficient batched recursive approach, avoiding "Too many requests" and other API errors. - The tool now fetches all children of a block in a single query per level of depth, significantly reducing the number of API calls. v.0.30.8 - ADDED: `roam_fetch_block_with_children` tool - Fetches a block by its UID along with its hierarchical children down to a specified depth. - Automatically handles Roam's `((UID))` formatting, extracting the raw UID for lookup. - This tool provides a direct and structured way to retrieve specific block content and its nested hierarchy. v.0.30.7 - FIXED: `roam_create_outline` now prevents errors from invalid outline structures by enforcing that outlines must start at level 1 and that subsequent levels cannot increase by more than 1 at a time. - Updated the tool's schema in `src/tools/schemas.ts` with more explicit instructions to guide the LLM in generating valid hierarchical structures. - Added stricter validation in `src/tools/operations/outline.ts` to reject outlines that do not start at level 1, providing a clearer error message. - Optimized page creation v.0.30.6 - FIXED: `roam_create_page` now correctly strips heading markers (`#`) from block content before creation. - FIXED: Block creation order is now correct. Removed the incorrect `.reverse()` call in `convertToRoamActions` and the corresponding workaround in `createBlock`. - UPDATED: the cheat sheet for ordinal dates. v.0.30.5 - FIXED: `roam_search_for_tag` now correctly scopes searches to a specific page when `page_title_uid` is provided. - The Datalog query in `src/search/tag-search.ts` was updated to include the `targetPageUid` in the `where` clause. v.0.30.4 - FIXED: Tools not loading properly in Gemini CLI - Clarified outline description - FIXED: `roam_process_batch_actions` `heading` enum type in `schemas.ts` for Gemini CLI compatibility. v.0.30.3 - ADDED: `roam_markdown_cheatsheet` tool - Provides the content of the Roam Markdown Cheatsheet directly via a tool call. - The content is now read dynamically from `Roam_Markdown_Cheatsheet.md` on the filesystem. - **Reason for Tool Creation:** While Cline can access local resources provided by an MCP server, other AI models (suchs as Claude AI) may not have this capability. By exposing the cheatsheet as a tool, it ensures broader accessibility and utility for all connected AI models, allowing them to programmatically request and receive the cheatsheet content when needed. - REMOVED: Roam Markdown Cheatsheet as a direct resource - The cheatsheet is no longer exposed as a static resource; it is now accessed programmatically through the new `roam_markdown_cheatsheet` tool. - ADDED: package.json new utilty scripts v.0.30.2 - ADDED: 4x4 table creation example - Created a 4x4 table with random data on the "Testing Tables" page, demonstrating proper Roam table structure. - ENHANCED: `Roam_Markdown_Cheatsheet.md` - Updated the "Roam Tables" section with a more detailed explanation of table structure, including proper indentation levels for headers and data cells. - ENHANCED: `src/tools/schemas.ts` - Clarified the distinction between `roam_create_outline` and `roam_process_batch_actions` in their respective descriptions, providing guidance on their best use cases. v.0.30.1 - ENHANCED: `roam_process_batch_actions` tool description - Clarified that Roam-flavored markdown, including block embedding with `((UID))` syntax, is supported within the `string` property for `create-block` and `update-block` actions. - Added a note advising users to obtain valid page or block UIDs using `roam_fetch_page_by_title` or other search tools for actions on existing blocks or within a specific page context. - Clarified the `block_text_uid` description for `roam_create_outline` to explicitly mention defaulting to the daily page. - Simplified the top-level description for `roam_datomic_query`. - Refined the introductory sentence for `roam_datomic_query`. - ADDED: "Example Prompts" section in `README.md` - Provided 2-3 examples demonstrating how to prompt an LLM to use the Roam tool, specifically leveraging `roam_process_batch_actions` for creative use cases. v.0.30.0 - DEPRECATED: **Generic Block Manipulation Tools**: - `roam_create_block`: Deprecated in favor of `roam_process_batch_actions` (action: `create-block`). - `roam_update_block`: Deprecated in favor of `roam_process_batch_actions` (action: `update-block`). - `roam_update_multiple_blocks`: Deprecated in favor of `roam_process_batch_actions` for batch updates. Users are encouraged to use `roam_process_batch_actions` for all direct, generic block manipulations due to its enhanced flexibility and batch processing capabilities. - REFACTORED: `roam_add_todo` to internally use `roam_process_batch_actions` for all block creations, enhancing efficiency and consistency. - REFACTORED: `roam_remember` to internally use `roam_process_batch_actions` for all block creations, enhancing efficiency and consistency. - ENHANCED: `roam_create_outline` - Refactored to internally use `roam_process_batch_actions` for all block creations, including parent blocks. - Added support for `children_view_type` in outline items, allowing users to specify the display format (bullet, document, numbered) for nested blocks. - REFACTORED: `roam_import_markdown` to internally use `roam_process_batch_actions` for all content imports, enhancing efficiency and consistency. v.0.29.0 - ADDED: **Batch Processing Tool**: Introduced `roam_process_batch_actions`, a powerful new tool for executing a sequence of low-level block actions (create, update, move, delete) in a single API call. This enables complex, multi-step workflows, programmatic content reorganization, and high-performance data imports. - ENHANCED: **Schema Clarity**: Updated the descriptions for multiple tool parameters in `src/tools/schemas.ts` to explicitly state that using a block or page UID is preferred over text-based identifiers for improved accuracy and reliability. - NOTE: **Heading Removal Limitation**: Discovered that directly removing heading formatting (e.g., setting `heading` to `0` or `null`) via `update-block` action in `roam_process_batch_actions` is not supported by the Roam API. The `heading` attribute persists its value. v.0.28.0 - ADDED: **Configurable HTTP and SSE Ports**: The HTTP and SSE server ports can now be configured via environment variables (`HTTP_STREAM_PORT` and `SSE_PORT`). - ADDED: **Automatic Port Conflict Resolution**: The server now automatically checks if the desired ports are in use and finds the next available ports, preventing startup errors due to port conflicts. v.0.27.0 - ADDED: SSE (Server-Sent Events) transport support for legacy clients. - REFACTORED: `src/server/roam-server.ts` to use separate MCP `Server` instances for each transport (Stdio, HTTP Stream, and SSE) to ensure they can run concurrently without conflicts. - ENHANCED: Each transport now runs on its own isolated `Server` instance, improving stability and preventing cross-transport interference. - UPDATED: `src/config/environment.ts` to include `SSE_PORT` for configurable SSE endpoint (defaults to `8087`). v.0.26.0 - ENHANCED: Added HTTP Stream Transport support - Implemented dual transport support for Stdio and HTTP Stream, allowing communication via both local processes and network connections. - Updated `src/config/environment.ts` to include `HTTP_STREAM_PORT` for configurable HTTP Stream endpoint. - Modified `src/server/roam-server.ts` to initialize and connect `StreamableHTTPServerTransport` alongside `StdioServerTransport`. - Configured HTTP server to listen on `HTTP_STREAM_PORT` and handle requests via `StreamableHTTPServerTransport`. v.0.25.7 - FIXED: `roam_fetch_page_by_title` schema definition - Corrected missing `name` property and proper nesting of `inputSchema` in `src/tools/schemas.ts`. - ENHANCED: Dynamic tool loading and error reporting - Implemented dynamic loading of tool capabilities from `toolSchemas` in `src/server/roam-server.ts` to ensure consistency. - Added robust error handling during server initialization (graph, tool handlers) and connection attempts in `src/server/roam-server.ts` to provide more specific feedback on startup issues. - CENTRALIZED: Versioning in `src/server/roam-server.ts` - Modified `src/server/roam-server.ts` to dynamically read the version from `package.json`, ensuring a single source of truth for the project version. v.0.25.6 - ADDED: Docker support - Created a `Dockerfile` for containerization. - Added an `npm start` script to `package.json` for running the application within the Docker container. v.0.25.5 - ENHANCED: `roam_create_outline` tool for better heading and nesting support - Reverted previous change in `src/tools/operations/outline.ts` to preserve original indentation for outline items. - Refined `parseMarkdown` in `src/markdown-utils.ts` to correctly parse markdown heading syntax (`#`, `##`, `###`) while maintaining the block's hierarchical level based on indentation. - Updated `block_text_uid` description in `roam_create_outline` schema (`src/tools/schemas.ts`) to clarify its use for specifying a parent block by text or UID. - Clarified that `roam_create_block` creates blocks directly on a page and does not support nesting under existing blocks. `roam_create_outline` should be used for this purpose. v.0.25.4 - ADDED: `format` parameter to `roam_fetch_page_by_title` tool - Allows fetching page content as raw JSON data (blocks with UIDs) or markdown. - Updated `fetchPageByTitle` in `src/tools/operations/pages.ts` to return stringified JSON for raw format. - Updated `roam_fetch_page_by_title` schema in `src/tools/schemas.ts` to include `format` parameter with 'raw' as default. - Updated `fetchPageByTitle` handler in `src/tools/tool-handlers.ts` to pass `format` parameter. - Updated `roam_fetch_page_by_title` case in `src/server/roam-server.ts` to extract and pass `format` parameter. v.0.25.3 - FIXED: roam_create_block multiline content ordering issue - Root cause: Simple newline-separated content was being created in reverse order - Solution: Added logic to detect simple newline-separated content and reverse the nodes array to maintain original order - Fix is specific to simple multiline content without markdown formatting, preserving existing behavior for complex markdown v.0.25.2 - FIXED: roam_create_block heading formatting issue - Root cause: Missing heading parameter extraction in server request handler - Solution: Added heading parameter to roam_create_block handler in roam-server.ts - Also removed problematic default: 0 from heading schema definition - Heading formatting now works correctly for both single and multi-line blocks - roam_create_block now properly applies H1, H2, and H3 formatting when heading parameter is provided v.0.25.1 - Investigated heading formatting issue in roam_create_block tool - Attempted multiple fixes: direct createBlock API → batchActions → convertToRoamActions → direct batch action creation - Confirmed roam_create_page works correctly for heading formatting - Identified that heading formatting fails specifically for single block creation via roam_create_block - Issue remains unresolved despite extensive troubleshooting and multiple implementation approaches - Current status: roam_create_block does not apply heading formatting, investigation ongoing v.0.25.0 - Updated roam_create_page to use batchActions v.0.24.6 - Updated roam_create_page to use explicit levels v.0.24.5 - Enhanced createOutline to properly handle block_text_uid as either a 9-character UID or string title - Added proper detection and use of existing blocks when given a valid block UID - Improved error messages to be more specific about block operations v.0.24.4 - Clarified roam_search_by_date and roam_fetch_page_by_title when it comes to searching for daily pages vs. blocks by date v.0.24.3 - Clarified roam_update_multiple_blocks - Added a variable to roam_find_pages_modified_today v.0.24.2 - Added sort_by and filter_tag to roam_recall v.0.24.1 - Fixed searchByStatus for TODO checks - Added resolution of references to various tools v.0.23.2 - Fixed create_page tool as first-level blocks were created in reversed order v.0.23.1 - Fixed roam_outline tool not writing properly v.0.23.0 - Added advanced, more flexible datomic query v.0.22.1 - Important description change in roam_remember v0.22.0 - Restructured search functionality into dedicated directory with proper TypeScript support - Fixed TypeScript errors and import paths throughout the codebase - Improved outline creation to maintain exact input array order - Enhanced recall() method to fetch memories from both tag searches and dedicated memories page - Maintained backward compatibility while improving code organization v0.21.0 - Added roam_recall tool to recall memories from all tags and the page itself. v0.20.0 - Added roam_remember tool to remember specific memories as created on the daily page. Can be used throughout the graph. Tag set in environmental vars in config. v0.19.0 - Changed default case-sensitivity behavior in search tools to match Roam's native behavior (now defaults to true) - Updated case-sensitivity handling in findBlockWithRetry, searchByStatus, searchForTag, and searchByDate tools v0.18.0 - Added roam_search_by_date tool to search for blocks and pages based on creation or modification dates - Added support for date range filtering and content inclusion options v0.17.0 - Enhanced roam_update_block tool with transform pattern support, allowing regex-based content transformations - Added ability to update blocks with either direct content or pattern-based transformations v0.16.0 - Added roam_search_by_text tool to search for blocks containing specific text, with optional page scope and case sensitivity - Fixed roam_search_by_tag v.0.15.0 - Added roam_find_pages_modified_today tool to search for pages modified since midnight today v.0.14 ``` -------------------------------------------------------------------------------- /src/tools/operations/outline.ts: -------------------------------------------------------------------------------- ```typescript import { Graph, q, createPage, createBlock, batchActions } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { formatRoamDate } from '../../utils/helpers.js'; import { capitalizeWords, getNestedUids, getNestedUidsByText } from '../helpers/text.js'; import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable, type BatchAction } from '../../markdown-utils.js'; import type { OutlineItem, NestedBlock } from '../types/index.js'; export class OutlineOperations { constructor(private graph: Graph) { } /** * Helper function to find block with improved relationship checks */ private async findBlockWithRetry(pageUid: string, blockString: string, maxRetries = 5, initialDelay = 1000): Promise<string> { // Try multiple query strategies const queries = [ // Strategy 1: Direct page and string match `[:find ?b-uid ?order :where [?p :block/uid "${pageUid}"] [?b :block/page ?p] [?b :block/string "${blockString}"] [?b :block/order ?order] [?b :block/uid ?b-uid]]`, // Strategy 2: Parent-child relationship `[:find ?b-uid ?order :where [?p :block/uid "${pageUid}"] [?b :block/parents ?p] [?b :block/string "${blockString}"] [?b :block/order ?order] [?b :block/uid ?b-uid]]`, // Strategy 3: Broader page relationship `[:find ?b-uid ?order :where [?p :block/uid "${pageUid}"] [?b :block/page ?page] [?p :block/page ?page] [?b :block/string "${blockString}"] [?b :block/order ?order] [?b :block/uid ?b-uid]]` ]; for (let retry = 0; retry < maxRetries; retry++) { // Try each query strategy for (const queryStr of queries) { const blockResults = await q(this.graph, queryStr, []) as [string, number][]; if (blockResults && blockResults.length > 0) { // Use the most recently created block const sorted = blockResults.sort((a, b) => b[1] - a[1]); return sorted[0][0]; } } // Exponential backoff const delay = initialDelay * Math.pow(2, retry); await new Promise(resolve => setTimeout(resolve, delay)); } throw new McpError( ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies` ); }; /** * Helper function to create and verify block with improved error handling */ private async createAndVerifyBlock( content: string, parentUid: string, maxRetries = 5, initialDelay = 1000, isRetry = false ): Promise<string> { try { // Initial delay before any operations if (!isRetry) { await new Promise(resolve => setTimeout(resolve, initialDelay)); } for (let retry = 0; retry < maxRetries; retry++) { console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`); // Create block using batchActions const batchResult = await batchActions(this.graph, { action: 'batch-actions', actions: [{ action: 'create-block', location: { 'parent-uid': parentUid, order: 'last' }, block: { string: content } }] }); if (!batchResult) { throw new McpError( ErrorCode.InternalError, `Failed to create block "${content}" via batch action` ); } // Wait with exponential backoff const delay = initialDelay * Math.pow(2, retry); await new Promise(resolve => setTimeout(resolve, delay)); try { // Try to find the block using our improved findBlockWithRetry return await this.findBlockWithRetry(parentUid, content); } catch (error: any) { const errorMessage = error instanceof Error ? error.message : String(error); // console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log if (retry === maxRetries - 1) throw error; } } throw new McpError( ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts` ); } catch (error) { // If this is already a retry, throw the error if (isRetry) throw error; // Otherwise, try one more time with a clean slate // console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log await new Promise(resolve => setTimeout(resolve, initialDelay * 2)); return this.createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true); } }; /** * Helper function to check if string is a valid Roam UID (9 characters) */ private isValidUid = (str: string): boolean => { return typeof str === 'string' && str.length === 9; }; /** * Helper function to fetch a block and its children recursively */ private async fetchBlockWithChildren(blockUid: string, level: number = 1): Promise<NestedBlock | null> { const query = ` [:find ?childUid ?childString ?childOrder :in $ ?parentUid :where [?parentEntity :block/uid ?parentUid] [?parentEntity :block/children ?childEntity] ; This ensures direct children [?childEntity :block/uid ?childUid] [?childEntity :block/string ?childString] [?childEntity :block/order ?childOrder]] `; const blockQuery = ` [:find ?string :in $ ?uid :where [?e :block/uid ?uid] [?e :block/string ?string]] `; try { const blockStringResult = await q(this.graph, blockQuery, [blockUid]) as [string][]; if (!blockStringResult || blockStringResult.length === 0) { return null; } const text = blockStringResult[0][0]; const childrenResults = await q(this.graph, query, [blockUid]) as [string, string, number][]; const children: NestedBlock[] = []; if (childrenResults && childrenResults.length > 0) { // Sort children by order const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]); for (const childResult of sortedChildren) { const childUid = childResult[0]; const nestedChild = await this.fetchBlockWithChildren(childUid, level + 1); if (nestedChild) { children.push(nestedChild); } } } // The order of the root block is not available from this query, so we set it to 0 return { uid: blockUid, text, level, order: 0, children: children.length > 0 ? children : undefined }; } catch (error: any) { throw new McpError( ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}` ); } }; /** * Recursively fetches a nested structure of blocks under a given root block UID. */ private async fetchNestedStructure(rootUid: string): Promise<NestedBlock[]> { const query = `[:find ?child-uid ?child-string ?child-order :in $ ?parent-uid :where [?parent :block/uid ?parent-uid] [?parent :block/children ?child] [?child :block/uid ?child-uid] [?child :block/string ?child-string] [?child :block/order ?child-order]]`; const directChildrenResult = await q(this.graph, query, [rootUid]) as [string, string, number][]; if (directChildrenResult.length === 0) { return []; } const nestedBlocks: NestedBlock[] = []; for (const [childUid, childString, childOrder] of directChildrenResult) { const children = await this.fetchNestedStructure(childUid); nestedBlocks.push({ uid: childUid, text: childString, level: 0, // Level is not easily determined here, so we set it to 0 children: children, order: childOrder }); } return nestedBlocks.sort((a, b) => a.order - b.order); } /** * Creates an outline structure on a Roam Research page, optionally under a specific block. * * @param outline - An array of OutlineItem objects, each containing text and a level. * Markdown heading syntax (#, ##, ###) in the text will be recognized * and converted to Roam headings while preserving the outline's hierarchical * structure based on indentation. * @param page_title_uid - The title or UID of the page where the outline should be created. * If not provided, today's daily page will be used. * @param block_text_uid - Optional. The text content or UID of an existing block under which * the outline should be inserted. If a text string is provided and * no matching block is found, a new block with that text will be created * on the page to serve as the parent. If a UID is provided and the block * is not found, an error will be thrown. * @returns An object containing success status, page UID, parent UID, and a nested array of created block UIDs. */ async createOutline( outline: Array<OutlineItem>, page_title_uid?: string, block_text_uid?: string ): Promise<{ success: boolean; page_uid: string; parent_uid: string; created_uids: NestedBlock[] }> { // Validate input if (!Array.isArray(outline) || outline.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'outline must be a non-empty array' ); } // Filter out items with undefined text const validOutline = outline.filter(item => item.text !== undefined); if (validOutline.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'outline must contain at least one item with text' ); } // Validate outline structure const invalidItems = validOutline.filter(item => typeof item.level !== 'number' || item.level < 1 || item.level > 10 || typeof item.text !== 'string' || item.text.trim().length === 0 ); if (invalidItems.length > 0) { throw new McpError( ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text' ); } // Helper function to find or create page with retries const findOrCreatePage = async (titleOrUid: string, maxRetries = 3, delayMs = 500): Promise<string> => { // First try to find by title const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const variations = [ titleOrUid, // Original capitalizeWords(titleOrUid), // Each word capitalized titleOrUid.toLowerCase() // All lowercase ]; for (let retry = 0; retry < maxRetries; retry++) { // Try each case variation for (const variation of variations) { const findResults = await q(this.graph, titleQuery, [variation]) as [string][]; if (findResults && findResults.length > 0) { return findResults[0][0]; } } // If not found as title, try as UID const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`; const uidResult = await q(this.graph, uidQuery, []); if (uidResult && uidResult.length > 0) { return uidResult[0][0]; } // If still not found and this is the first retry, try to create the page if (retry === 0) { const success = await createPage(this.graph, { action: 'create-page', page: { title: titleOrUid } }); // Even if createPage returns false, the page might still have been created // Wait a bit and continue to next retry await new Promise(resolve => setTimeout(resolve, delayMs)); continue; } if (retry < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delayMs)); } } throw new McpError( ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts` ); }; // Get or create the target page const targetPageUid = await findOrCreatePage( page_title_uid || formatRoamDate(new Date()) ); // Get or create the parent block let targetParentUid: string; if (!block_text_uid) { targetParentUid = targetPageUid; } else { try { if (this.isValidUid(block_text_uid)) { // First try to find block by UID const uidQuery = `[:find ?uid :where [?e :block/uid "${block_text_uid}"] [?e :block/uid ?uid]]`; const uidResult = await q(this.graph, uidQuery, []) as [string][]; if (uidResult && uidResult.length > 0) { // Use existing block if found targetParentUid = uidResult[0][0]; } else { throw new McpError( ErrorCode.InvalidRequest, `Block with UID "${block_text_uid}" not found` ); } } else { // Create header block and get its UID if not a valid UID targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid); } } catch (error: any) { const errorMessage = error instanceof Error ? error.message : String(error); throw new McpError( ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}` ); } } // Initialize result variable let result; try { // Validate level sequence if (validOutline.length > 0 && validOutline[0].level !== 1) { throw new McpError( ErrorCode.InvalidRequest, 'Invalid outline structure - the first item must be at level 1' ); } let prevLevel = 0; for (const item of validOutline) { // Level should not increase by more than 1 at a time if (item.level > prevLevel + 1) { throw new McpError( ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}` ); } prevLevel = item.level; } // Convert outline items to markdown-like structure const markdownContent = validOutline .map(item => { const indent = ' '.repeat(item.level - 1); // If the item text starts with a markdown heading (e.g., #, ##, ###), // treat it as a direct heading without adding a bullet or outline indentation. // NEW CHANGE: Handle standalone code blocks - do not prepend bullet const isCodeBlock = item.text?.startsWith('```') && item.text.endsWith('```') && item.text.includes('\n'); return isCodeBlock ? `${indent}${item.text?.trim()}` : `${indent}- ${item.text?.trim()}`; }) .join('\n'); // Convert to Roam markdown format const convertedContent = convertToRoamMarkdown(markdownContent); // Parse markdown into hierarchical structure // We pass the original OutlineItem properties (heading, children_view_type) // along with the parsed content to the nodes. const nodes = parseMarkdown(convertedContent).map((node, index) => { const outlineItem = validOutline[index]; return { ...node, ...(outlineItem?.heading && { heading_level: outlineItem.heading }), ...(outlineItem?.children_view_type && { children_view_type: outlineItem.children_view_type }) }; }); // Convert nodes to batch actions const actions = convertToRoamActions(nodes, targetParentUid, 'last'); if (actions.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'No valid actions generated from outline' ); } // Execute batch actions to create the outline result = await batchActions(this.graph, { action: 'batch-actions', actions }).catch(error => { throw new McpError( ErrorCode.InternalError, `Failed to create outline blocks: ${error.message}` ); }); if (!result) { throw new McpError( ErrorCode.InternalError, 'Failed to create outline blocks - no result returned' ); } } catch (error: any) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InternalError, `Failed to create outline: ${error.message}` ); } // Post-creation verification to get actual UIDs for top-level blocks and their children const createdBlocks: NestedBlock[] = []; // Only query for top-level blocks (level 1) based on the original outline input const topLevelOutlineItems = validOutline.filter(item => item.level === 1); for (const item of topLevelOutlineItems) { try { // Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty const foundUid = await this.findBlockWithRetry(targetParentUid, item.text!); if (foundUid) { const nestedBlock = await this.fetchBlockWithChildren(foundUid); if (nestedBlock) { createdBlocks.push(nestedBlock); } } } catch (error: any) { // This is a warning because even if one block fails to fetch, others might succeed. // The error will be logged but not re-thrown to allow partial success reporting. // console.warn(`Could not fetch nested block for "${item.text}": ${error.message}`); } } return { success: true, page_uid: targetPageUid, parent_uid: targetParentUid, created_uids: createdBlocks }; } async importMarkdown( content: string, page_uid?: string, page_title?: string, parent_uid?: string, parent_string?: string, order: 'first' | 'last' = 'last' ): Promise<{ success: boolean; page_uid: string; parent_uid: string; created_uids: NestedBlock[] }> { // First get the page UID let targetPageUid = page_uid; if (!targetPageUid && page_title) { const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [page_title]) as [string][]; if (findResults && findResults.length > 0) { targetPageUid = findResults[0][0]; } else { throw new McpError( ErrorCode.InvalidRequest, `Page with title "${page_title}" not found` ); } } // If no page specified, use today's date page if (!targetPageUid) { const today = new Date(); const dateStr = formatRoamDate(today); const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; if (findResults && findResults.length > 0) { targetPageUid = findResults[0][0]; } else { // Create today's page try { await createPage(this.graph, { action: 'create-page', page: { title: dateStr } }); const results = await q(this.graph, findQuery, [dateStr]) as [string][]; if (!results || results.length === 0) { throw new McpError( ErrorCode.InternalError, 'Could not find created today\'s page' ); } targetPageUid = results[0][0]; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}` ); } } } // Now get the parent block UID let targetParentUid = parent_uid; if (!targetParentUid && parent_string) { if (!targetPageUid) { throw new McpError( ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string' ); } // Find block by exact string match within the page const findBlockQuery = `[:find ?b-uid :in $ ?page-uid ?block-string :where [?p :block/uid ?page-uid] [?b :block/page ?p] [?b :block/string ?block-string] [?b :block/uid ?b-uid]]`; const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, parent_string]) as [string][]; if (blockResults && blockResults.length > 0) { targetParentUid = blockResults[0][0]; } else { // If parent_string block doesn't exist, create it targetParentUid = await this.createAndVerifyBlock(parent_string, targetPageUid); } } // If no parent specified, use page as parent if (!targetParentUid) { targetParentUid = targetPageUid; } // Always use parseMarkdown for content with multiple lines or any markdown formatting const isMultilined = content.includes('\n'); if (isMultilined) { // Parse markdown into hierarchical structure const convertedContent = convertToRoamMarkdown(content); const nodes = parseMarkdown(convertedContent); // Convert markdown nodes to batch actions const actions = convertToRoamActions(nodes, targetParentUid, order); // Execute batch actions to add content const result = await batchActions(this.graph, { action: 'batch-actions', actions }); if (!result) { throw new McpError( ErrorCode.InternalError, 'Failed to import nested markdown content' ); } // After successful batch action, get all nested UIDs under the parent const createdUids = await this.fetchNestedStructure(targetParentUid); return { success: true, page_uid: targetPageUid, parent_uid: targetParentUid, created_uids: createdUids }; } else { // Create a simple block for non-nested content using batchActions const actions = [{ action: 'create-block', location: { "parent-uid": targetParentUid, "order": order }, block: { string: content } }]; try { await batchActions(this.graph, { action: 'batch-actions', actions }); } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}` ); } // For single-line content, we still need to fetch the UID and construct a NestedBlock const createdUids: NestedBlock[] = []; try { const foundUid = await this.findBlockWithRetry(targetParentUid, content); if (foundUid) { createdUids.push({ uid: foundUid, text: content, level: 0, order: 0, children: [] }); } } catch (error: any) { // Log warning but don't re-throw, as the block might be created, just not immediately verifiable // console.warn(`Could not verify single block creation for "${content}": ${error.message}`); } return { success: true, page_uid: targetPageUid, parent_uid: targetParentUid, created_uids: createdUids }; } } } ```