This is page 1 of 2. Use http://codebase.md/2b3pro/roam-research-mcp?lines=true&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: -------------------------------------------------------------------------------- ``` 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* 5 | typescript/* 6 | src/test-queries.ts 7 | test-* 8 | src/.DS_Store 9 | .DS_Store 10 | .clinerules* 11 | tests/test_read.sh 12 | .roam/2b3-custom-instructions.md 13 | .roam/ias-custom-instructions.md 14 | .cline* ``` -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` 1 | # Ignore node_modules, as they will be installed in the container 2 | node_modules/ 3 | 4 | # Ignore build directory, as it's created inside the container 5 | build/ 6 | 7 | # Ignore log files 8 | *.log 9 | 10 | # Ignore environment files 11 | .env* 12 | 13 | # Ignore TypeScript cache 14 | typescript/ 15 | 16 | # Ignore test files 17 | src/test-queries.ts 18 | test-* 19 | tests/test_read.sh 20 | 21 | # Ignore OS-specific files 22 | src/.DS_Store 23 | .DS_Store 24 | 25 | # Ignore Git directory 26 | .git 27 | 28 | # Ignore Docker related files 29 | Dockerfile 30 | docker-compose.yml 31 | .dockerignore 32 | 33 | # Ignore the project's license and documentation 34 | LICENSE 35 | README.md 36 | CHANGELOG.md 37 | Roam Import JSON Schema.md 38 | Roam_Research_Datalog_Cheatsheet.md 39 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 |  2 | 3 | # Roam Research MCP Server 4 | 5 | [](https://badge.fury.io/js/roam-research-mcp) 6 | [](https://www.repostatus.org/#wip) 7 | [](https://opensource.org/licenses/MIT) 8 | [](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE) 9 | 10 | 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) 11 | 12 | <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> 13 | <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> 14 | 15 | ## Installation and Usage 16 | 17 | This MCP server supports three primary communication methods: 18 | 19 | 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. 20 | 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. 21 | 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.) 22 | 23 | ### Running with Stdio 24 | 25 | You can install the package globally and run it: 26 | 27 | ```bash 28 | npm install -g roam-research-mcp 29 | roam-research-mcp 30 | ``` 31 | 32 | Or clone the repository and build from source: 33 | 34 | ```bash 35 | git clone https://github.com/2b3pro/roam-research-mcp.git 36 | cd roam-research-mcp 37 | npm install 38 | npm run build 39 | npm start 40 | ``` 41 | 42 | ### Running with HTTP Stream 43 | 44 | To run the server with HTTP Stream or SSE support, you can either: 45 | 46 | 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. 47 | 2. **Specify custom ports:** Set the `HTTP_STREAM_PORT` and/or `SSE_PORT` environment variables before starting the server. 48 | 49 | ```bash 50 | HTTP_STREAM_PORT=9000 SSE_PORT=9001 npm start 51 | ``` 52 | 53 | Or, if using a `.env` file, add `HTTP_STREAM_PORT=9000` and/or `SSE_PORT=9001` to it. 54 | 55 | ## Docker 56 | 57 | This project can be easily containerized using Docker. A `Dockerfile` is provided at the root of the repository. 58 | 59 | ### Build the Docker Image 60 | 61 | To build the Docker image, navigate to the project root and run: 62 | 63 | ```bash 64 | docker build -t roam-research-mcp . 65 | ``` 66 | 67 | ### Run the Docker Container 68 | 69 | 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`: 70 | 71 | ```bash 72 | docker run -p 3000:3000 -p 8088:8088 -p 8087:8087 \ 73 | -e ROAM_API_TOKEN="your-api-token" \ 74 | -e ROAM_GRAPH_NAME="your-graph-name" \ 75 | -e MEMORIES_TAG="#[[LLM/Memories]]" \ 76 | -e CUSTOM_INSTRUCTIONS_PATH="/path/to/your/custom_instructions_file.md" \ 77 | -e HTTP_STREAM_PORT="8088" \ 78 | -e SSE_PORT="8087" \ 79 | roam-research-mcp 80 | ``` 81 | 82 | 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: 83 | 84 | ```bash 85 | docker run -p 3000:3000 -p 8088:8088 --env-file .env roam-research-mcp 86 | ``` 87 | 88 | ## To Test 89 | 90 | Run [MCP Inspector](https://github.com/modelcontextprotocol/inspector) after build using the provided npm script: 91 | 92 | ```bash 93 | npm run inspector 94 | ``` 95 | 96 | ## Features 97 | 98 | The server provides powerful tools for interacting with Roam Research: 99 | 100 | - Environment variable handling with .env support 101 | - Comprehensive input validation 102 | - Case-insensitive page title matching 103 | - Recursive block reference resolution 104 | - Markdown parsing and conversion 105 | - Daily page integration 106 | - Detailed debug logging 107 | - Efficient batch operations 108 | - Hierarchical outline creation 109 | - Enhanced documentation for Roam Tables in `Roam_Markdown_Cheatsheet.md` for clearer guidance on nesting. 110 | - Custom instruction appended to the cheat sheet about your specific Roam notes. 111 | 112 | 1. `roam_fetch_page_by_title`: Fetch page content by title. Returns content in the specified format. 113 | 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. 114 | 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. 115 | 4. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.) 116 | 5. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.) 117 | 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`.) 118 | 7. `roam_search_block_refs`: Search for block references within a page or across the entire graph. 119 | 8. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy. 120 | 9. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight), with pagination and sorting options. 121 | 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. 122 | 11. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page. 123 | 12. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates. 124 | 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. 125 | 14. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.) 126 | 15. `roam_recall`: Retrieve all stored memories. 127 | 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. 128 | 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. 129 | 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`.) 130 | 131 | **Deprecated Tools**: 132 | The following tools have been deprecated as of `v0.36.2` in favor of the more powerful and flexible `roam_process_batch_actions`: 133 | 134 | - `roam_create_block`: Use `roam_process_batch_actions` with the `create-block` action. 135 | - `roam_update_block`: Use `roam_process_batch_actions` with the `update-block` action. 136 | - `roam_update_multiple_blocks`: Use `roam_process_batch_actions` with multiple `update-block` actions. 137 | 138 | --- 139 | 140 | ### Tool Usage Guidelines and Best Practices 141 | 142 | **Pre-computation and Context Loading:** 143 | ✅ 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>" 144 | 145 | - **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. 146 | 147 | **Identifying Pages and Blocks for Manipulation:** 148 | 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. 149 | 150 | - **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'" 151 | - **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. 152 | 153 | **Case-Sensitivity:** 154 | 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. 155 | 156 | **Iterative Refinement and Verification:** 157 | 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. 158 | 159 | **Understanding Tool Nuances:** 160 | 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. 161 | 162 | When making changes to your Roam graph, precision in your requests is crucial for achieving desired outcomes. 163 | 164 | **Specificity in Requests:** 165 | 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. 166 | 167 | **Example of Specificity:** 168 | Instead of: 169 | `"parent_string": "My project notes"` 170 | 171 | Prefer: 172 | `"parent_uid": "((some-unique-uid))"` 173 | 174 | **Caveat Regarding Heading Formatting:** 175 | 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. 176 | 177 | --- 178 | 179 | ## Example Prompts 180 | 181 | 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. 182 | 183 | ### Example 1: Creating a Project Outline 184 | 185 | This prompt demonstrates creating a new page and populating it with a structured outline using a single `roam_process_batch_actions` call. 186 | 187 | ``` 188 | "Create a new Roam page titled 'Project Alpha Planning' and add the following outline: 189 | - Overview 190 | - Goals 191 | - Scope 192 | - Team Members 193 | - John Doe 194 | - Jane Smith 195 | - Tasks 196 | - Task 1 197 | - Subtask 1.1 198 | - Subtask 1.2 199 | - Task 2 200 | - Deadlines" 201 | ``` 202 | 203 | ### Example 2: Updating Multiple To-Dos and Adding a New One 204 | 205 | This example shows how to mark existing to-do items as `DONE` and add a new one, all within a single batch. 206 | 207 | ``` 208 | "Mark 'Finish report' and 'Review presentation' as done on today's daily page, and add a new todo 'Prepare for meeting'." 209 | ``` 210 | 211 | ### Example 3: Moving and Updating a Block 212 | 213 | This demonstrates moving a block from one location to another and simultaneously updating its content. 214 | 215 | ``` 216 | "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'." 217 | ``` 218 | 219 | ### Example 4: Making a Table 220 | 221 | This demonstrates moving a block from one location to another and simultaneously updating its content. 222 | 223 | ``` 224 | "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." 225 | ``` 226 | 227 | --- 228 | 229 | ## Setup 230 | 231 | 1. Create a [Roam Research API token](https://x.com/RoamResearch/status/1789358175474327881): 232 | 233 | - Go to your graph settings 234 | - Navigate to the "API tokens" section (Settings > "Graph" tab > "API Tokens" section and click on the "+ New API Token" button) 235 | - Create a new token 236 | 237 | 2. Configure the environment variables: 238 | You have two options for configuring the required environment variables: 239 | 240 | Option 1: Using a .env file (Recommended for development) 241 | Create a `.env` file in the roam-research directory: 242 | 243 | ``` 244 | ROAM_API_TOKEN=your-api-token 245 | ROAM_GRAPH_NAME=your-graph-name 246 | MEMORIES_TAG='#[[LLM/Memories]]' 247 | CUSTOM_INSTRUCTIONS_PATH='/path/to/your/custom_instructions_file.md' 248 | HTTP_STREAM_PORT=8088 # Or your desired port for HTTP Stream communication 249 | SSE_PORT=8087 # Or your desired port for SSE communication 250 | ``` 251 | 252 | Option 2: Using MCP settings (Alternative method) 253 | 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. 254 | 255 | - For Cline (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`): 256 | - For Claude desktop app (`~/Library/Application Support/Claude/claude_desktop_config.json`): 257 | 258 | ```json 259 | { 260 | "mcpServers": { 261 | "roam-research": { 262 | "command": "node", 263 | "args": ["/path/to/roam-research-mcp/build/index.js"], 264 | "env": { 265 | "ROAM_API_TOKEN": "your-api-token", 266 | "ROAM_GRAPH_NAME": "your-graph-name", 267 | "MEMORIES_TAG": "#[[LLM/Memories]]", 268 | "CUSTOM_INSTRUCTIONS_PATH": "/path/to/your/custom_instructions_file.md", 269 | "HTTP_STREAM_PORT": "8088", 270 | "SSE_PORT": "8087" 271 | } 272 | } 273 | } 274 | } 275 | ``` 276 | 277 | Note: The server will first try to load from .env file, then fall back to environment variables from MCP settings. 278 | 279 | 3. Build the server (make sure you're in the root directory of the MCP): 280 | 281 | Note: Customize 'Roam_Markdown_Cheatsheet.md' with any notes and preferences specific to your graph BEFORE building. 282 | 283 | ```bash 284 | cd roam-research-mcp 285 | npm install 286 | npm run build 287 | ``` 288 | 289 | ## Error Handling 290 | 291 | The server provides comprehensive error handling for common scenarios: 292 | 293 | - Configuration errors: 294 | - Missing API token or graph name 295 | - Invalid environment variables 296 | - API errors: 297 | - Authentication failures 298 | - Invalid requests 299 | - Failed operations 300 | - Tool-specific errors: 301 | - Page not found (with case-insensitive search) 302 | - Block not found by string match 303 | - Invalid markdown format 304 | - Missing required parameters 305 | - Invalid outline structure or content 306 | 307 | Each error response includes: 308 | 309 | - Standard MCP error code 310 | - Detailed error message 311 | - Suggestions for resolution when applicable 312 | 313 | --- 314 | 315 | ## Development 316 | 317 | ### Building 318 | 319 | To build the server: 320 | 321 | ```bash 322 | npm install 323 | npm run build 324 | ``` 325 | 326 | This will: 327 | 328 | 1. Install all required dependencies 329 | 2. Compile TypeScript to JavaScript 330 | 3. Make the output file executable 331 | 332 | You can also use `npm run watch` during development to automatically recompile when files change. 333 | 334 | ### Testing with MCP Inspector 335 | 336 | The MCP Inspector is a tool that helps test and debug MCP servers. To test the server: 337 | 338 | ```bash 339 | # Inspect with npx: 340 | npx @modelcontextprotocol/inspector node build/index.js 341 | ``` 342 | 343 | This will: 344 | 345 | 1. Start the server in inspector mode 346 | 2. Provide an interactive interface to: 347 | - List available tools and resources 348 | - Execute tools with custom parameters 349 | - View tool responses and error handling 350 | 351 | ## License 352 | 353 | MIT License 354 | 355 | --- 356 | 357 | ## About the Author 358 | 359 | This project is maintained by [Ian Shen](https://github.com/2b3pro). 360 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import { RoamServer } from './server/roam-server.js'; 3 | 4 | const server = new RoamServer(); 5 | server.run().catch(() => { /* handle error silently */ }); 6 | ``` -------------------------------------------------------------------------------- /src/search/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export * from './types.js'; 2 | export * from './utils.js'; 3 | export * from './tag-search.js'; 4 | export * from './status-search.js'; 5 | export * from './block-ref-search.js'; 6 | export * from './hierarchy-search.js'; 7 | export * from './text-search.js'; 8 | export * from './datomic-search.js'; 9 | ``` -------------------------------------------------------------------------------- /src/types/roam.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Interface for Roam block structure 2 | export interface RoamBlock { 3 | uid: string; 4 | string: string; 5 | order: number; 6 | heading?: number | null; 7 | children: RoamBlock[]; 8 | } 9 | 10 | export type RoamBatchAction = { 11 | action: 'create-block' | 'update-block' | 'move-block' | 'delete-block'; 12 | [key: string]: any; 13 | }; 14 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": [ 14 | "src/**/*", 15 | "tests/test-addMarkdownText.ts", 16 | "tests/test-queries.ts" 17 | ], 18 | "exclude": [ 19 | "node_modules" 20 | ] 21 | } ``` -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Helper function to get ordinal suffix for numbers (1st, 2nd, 3rd, etc.) 2 | export function getOrdinalSuffix(n: number): string { 3 | const j = n % 10; 4 | const k = n % 100; 5 | if (j === 1 && k !== 11) return "st"; 6 | if (j === 2 && k !== 12) return "nd"; 7 | if (j === 3 && k !== 13) return "rd"; 8 | return "th"; 9 | } 10 | 11 | // Format date in Roam's preferred format (e.g., "January 1st, 2024") 12 | export function formatRoamDate(date: Date): string { 13 | const month = date.toLocaleDateString('en-US', { month: 'long' }); 14 | const day = date.getDate(); 15 | const year = date.getFullYear(); 16 | return `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; 17 | } 18 | ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- ```yaml 1 | version: '3.8' 2 | 3 | services: 4 | roam-mcp: 5 | image: roam-research-mcp 6 | build: . 7 | container_name: roam-mcp 8 | ports: 9 | - "3010:3000" 10 | - "8047:8087" 11 | - "8048:8088" 12 | env_file: 13 | - .env 14 | networks: 15 | - n8n-net 16 | 17 | # n8n: 18 | # image: n8nio/n8n:latest 19 | # container_name: n8n 20 | # ports: 21 | # - "5678:5678" 22 | # volumes: 23 | # - ~/.n8n:/home/node/.n8n 24 | # environment: 25 | # - N8N_BASIC_AUTH_ACTIVE=true 26 | # - N8N_BASIC_AUTH_USER=admin 27 | # - N8N_BASIC_AUTH_PASSWORD=hallelujah 28 | # depends_on: 29 | # - roam-mcp 30 | # networks: 31 | # - n8n-net 32 | 33 | networks: 34 | n8n-net: 35 | driver: bridge ``` -------------------------------------------------------------------------------- /src/tools/types/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph } from '@roam-research/roam-api-sdk'; 2 | import type { RoamBlock } from '../../types/roam.js'; 3 | 4 | export interface ToolHandlerDependencies { 5 | graph: Graph; 6 | } 7 | 8 | export interface SearchResult { 9 | block_uid: string; 10 | content: string; 11 | page_title?: string; 12 | } 13 | 14 | export interface BlockUpdateResult { 15 | block_uid: string; 16 | content: string; 17 | success: boolean; 18 | error?: string; 19 | } 20 | 21 | export interface BlockUpdate { 22 | block_uid: string; 23 | content?: string; 24 | transform?: { 25 | find: string; 26 | replace: string; 27 | global?: boolean; 28 | }; 29 | } 30 | 31 | export interface OutlineItem { 32 | text: string | undefined; 33 | level: number; 34 | heading?: number; 35 | children_view_type?: 'bullet' | 'document' | 'numbered'; 36 | } 37 | 38 | export interface NestedBlock { 39 | uid: string; 40 | text: string; 41 | level: number; 42 | order: number; 43 | children?: NestedBlock[]; 44 | } 45 | 46 | export { RoamBlock }; 47 | ``` -------------------------------------------------------------------------------- /src/search/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { Graph } from '@roam-research/roam-api-sdk'; 2 | 3 | export interface SearchResult { 4 | success: boolean; 5 | matches: Array<{ 6 | block_uid: string; 7 | content: string; 8 | page_title?: string; 9 | [key: string]: any; // Additional context-specific fields 10 | }>; 11 | message: string; 12 | total_count?: number; // Added for total count of matches 13 | } 14 | 15 | export interface SearchHandler { 16 | execute(): Promise<SearchResult>; 17 | } 18 | 19 | // Tag Search Types 20 | export interface TagSearchParams { 21 | primary_tag: string; 22 | page_title_uid?: string; 23 | near_tag?: string; 24 | exclude_tag?: string; 25 | case_sensitive?: boolean; 26 | limit?: number; 27 | offset?: number; 28 | } 29 | 30 | // Text Search Types 31 | export interface TextSearchParams { 32 | text: string; 33 | page_title_uid?: string; 34 | case_sensitive?: boolean; 35 | limit?: number; 36 | offset?: number; 37 | } 38 | 39 | // Base class for all search handlers 40 | export abstract class BaseSearchHandler implements SearchHandler { 41 | constructor(protected graph: Graph) { } 42 | abstract execute(): Promise<SearchResult>; 43 | } 44 | ``` -------------------------------------------------------------------------------- /src/tools/operations/search/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { SearchResult } from '../../types/index.js'; 2 | 3 | // Base search parameters 4 | export interface BaseSearchParams { 5 | page_title_uid?: string; 6 | } 7 | 8 | // Datomic search parameters 9 | export interface DatomicSearchParams { 10 | query: string; 11 | inputs?: unknown[]; 12 | } 13 | 14 | // Tag search parameters 15 | export interface TagSearchParams extends BaseSearchParams { 16 | primary_tag: string; 17 | near_tag?: string; 18 | } 19 | 20 | // Block reference search parameters 21 | export interface BlockRefSearchParams extends BaseSearchParams { 22 | block_uid?: string; 23 | } 24 | 25 | // Hierarchy search parameters 26 | export interface HierarchySearchParams extends BaseSearchParams { 27 | parent_uid?: string; 28 | child_uid?: string; 29 | max_depth?: number; 30 | } 31 | 32 | // Text search parameters 33 | export interface TextSearchParams extends BaseSearchParams { 34 | text: string; 35 | } 36 | 37 | // Status search parameters 38 | export interface StatusSearchParams extends BaseSearchParams { 39 | status: 'TODO' | 'DONE'; 40 | } 41 | 42 | // Common search result type 43 | export interface SearchHandlerResult { 44 | success: boolean; 45 | matches: SearchResult[]; 46 | message: string; 47 | } 48 | ``` -------------------------------------------------------------------------------- /src/utils/net.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createServer } from 'node:net'; 2 | 3 | /** 4 | * Checks if a given port is currently in use. 5 | * @param port The port to check. 6 | * @returns A promise that resolves to true if the port is in use, and false otherwise. 7 | */ 8 | export function isPortInUse(port: number): Promise<boolean> { 9 | return new Promise((resolve) => { 10 | const server = createServer(); 11 | 12 | server.once('error', (err: NodeJS.ErrnoException) => { 13 | if (err.code === 'EADDRINUSE') { 14 | resolve(true); 15 | } else { 16 | // Handle other errors if necessary, but for this check, we assume other errors mean the port is available. 17 | resolve(false); 18 | } 19 | }); 20 | 21 | server.once('listening', () => { 22 | server.close(); 23 | resolve(false); 24 | }); 25 | 26 | server.listen(port); 27 | }); 28 | } 29 | 30 | /** 31 | * Finds an available port, starting from a given port and incrementing by a specified amount. 32 | * @param startPort The port to start checking from. 33 | * @param incrementBy The amount to increment the port by if it's in use. Defaults to 2. 34 | * @returns A promise that resolves to an available port number. 35 | */ 36 | export async function findAvailablePort(startPort: number, incrementBy = 2): Promise<number> { 37 | let port = startPort; 38 | while (await isPortInUse(port)) { 39 | port += incrementBy; 40 | } 41 | return port; 42 | } 43 | ``` -------------------------------------------------------------------------------- /src/tools/operations/batch.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, batchActions as roamBatchActions } from '@roam-research/roam-api-sdk'; 2 | import { RoamBatchAction } from '../../types/roam.js'; 3 | 4 | export class BatchOperations { 5 | constructor(private graph: Graph) {} 6 | 7 | async processBatch(actions: any[]): Promise<any> { 8 | const batchActions: RoamBatchAction[] = actions.map(action => { 9 | const { action: actionType, ...rest } = action; 10 | const roamAction: any = { action: actionType }; 11 | 12 | if (rest.location) { 13 | roamAction.location = { 14 | 'parent-uid': rest.location['parent-uid'], 15 | order: rest.location.order, 16 | }; 17 | } 18 | 19 | const block: any = {}; 20 | if (rest.string) block.string = rest.string; 21 | if (rest.uid) block.uid = rest.uid; 22 | if (rest.open !== undefined) block.open = rest.open; 23 | if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) { 24 | block.heading = rest.heading; 25 | } 26 | if (rest['text-align']) block['text-align'] = rest['text-align']; 27 | if (rest['children-view-type']) block['children-view-type'] = rest['children-view-type']; 28 | 29 | if (Object.keys(block).length > 0) { 30 | roamAction.block = block; 31 | } 32 | 33 | return roamAction; 34 | }); 35 | 36 | return await roamBatchActions(this.graph, {actions: batchActions}); 37 | } 38 | } 39 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Use an official Node.js runtime as a parent image for building 2 | FROM node:lts-alpine AS builder 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json to the working directory 8 | COPY package.json ./ 9 | COPY package-lock.json ./ 10 | 11 | # Install development and production dependencies 12 | RUN --mount=type=cache,target=/root/.npm npm install 13 | 14 | # Copy source code and TypeScript configuration 15 | COPY src /app/src 16 | COPY tsconfig.json /app/tsconfig.json 17 | COPY Roam_Markdown_Cheatsheet.md /app/Roam_Markdown_Cheatsheet.md 18 | 19 | # Build the TypeScript project 20 | RUN npm run build 21 | 22 | 23 | # Use a minimal Node.js runtime as the base for the release image 24 | FROM node:lts-alpine AS release 25 | 26 | # Set environment to production 27 | ENV NODE_ENV=production 28 | 29 | # Set the working directory 30 | WORKDIR /app 31 | 32 | # Copy only the built application (from /app/build) and production dependencies from the builder stage 33 | COPY --from=builder /app/build /app/build 34 | COPY --from=builder /app/package.json /app/package.json 35 | COPY --from=builder /app/package-lock.json /app/package-lock.json 36 | 37 | # Install only production dependencies (based on package-lock.json) 38 | # This keeps the final image small and secure by omitting development dependencies 39 | RUN npm ci --ignore-scripts --omit-dev 40 | 41 | # Expose the ports the app runs on (3000 for standard, 8087 for SSE, 8088 for HTTP Stream) 42 | EXPOSE 3000 43 | EXPOSE 8087 44 | EXPOSE 8088 45 | 46 | # Run the application 47 | ENTRYPOINT ["node", "build/index.js"] 48 | ``` -------------------------------------------------------------------------------- /src/tools/helpers/refs.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, q } from '@roam-research/roam-api-sdk'; 2 | 3 | /** 4 | * Collects all referenced block UIDs from text 5 | */ 6 | export const collectRefs = (text: string, depth: number = 0, refs: Set<string> = new Set()): Set<string> => { 7 | if (depth >= 4) return refs; // Max recursion depth 8 | 9 | const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g; 10 | let match; 11 | 12 | while ((match = refRegex.exec(text)) !== null) { 13 | const [_, uid] = match; 14 | refs.add(uid); 15 | } 16 | 17 | return refs; 18 | }; 19 | 20 | /** 21 | * Resolves block references in text by replacing them with their content 22 | */ 23 | export const resolveRefs = async (graph: Graph, text: string, depth: number = 0): Promise<string> => { 24 | if (depth >= 4) return text; // Max recursion depth 25 | 26 | const refs = collectRefs(text, depth); 27 | if (refs.size === 0) return text; 28 | 29 | // Get referenced block contents 30 | const refQuery = `[:find ?uid ?string 31 | :in $ [?uid ...] 32 | :where [?b :block/uid ?uid] 33 | [?b :block/string ?string]]`; 34 | const refResults = await q(graph, refQuery, [Array.from(refs)]) as [string, string][]; 35 | 36 | // Create lookup map of uid -> string 37 | const refMap = new Map<string, string>(); 38 | refResults.forEach(([uid, string]) => { 39 | refMap.set(uid, string); 40 | }); 41 | 42 | // Replace references with their content 43 | let resolvedText = text; 44 | for (const uid of refs) { 45 | const refContent = refMap.get(uid); 46 | if (refContent) { 47 | // Recursively resolve nested references 48 | const resolvedContent = await resolveRefs(graph, refContent, depth + 1); 49 | resolvedText = resolvedText.replace( 50 | new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), 51 | resolvedContent 52 | ); 53 | } 54 | } 55 | 56 | return resolvedText; 57 | }; 58 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "roam-research-mcp", 3 | "version": "0.36.3", 4 | "description": "A Model Context Protocol (MCP) server for Roam Research API integration", 5 | "private": false, 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/2b3pro/roam-research-mcp.git" 9 | }, 10 | "keywords": [ 11 | "mcp", 12 | "roam-research", 13 | "api", 14 | "claude", 15 | "model-context-protocol" 16 | ], 17 | "author": "Ian Shen / 2B3 PRODUCTIONS LLC", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/2b3pro/roam-research-mcp/issues" 21 | }, 22 | "homepage": "https://github.com/2b3pro/roam-research-mcp#readme", 23 | "type": "module", 24 | "bin": { 25 | "roam-research-mcp": "./build/index.js" 26 | }, 27 | "files": [ 28 | "build" 29 | ], 30 | "scripts": { 31 | "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", 32 | "clean": "rm -rf build", 33 | "watch": "tsc --watch", 34 | "inspector": "npx @modelcontextprotocol/inspector build/index.js", 35 | "start": "node build/index.js", 36 | "prepublishOnly": "npm run clean && npm run build", 37 | "release:patch": "npm version patch && git push origin v$(node -p \"require('./package.json').version\")", 38 | "release:minor": "npm version minor && git push origin v$(node -p \"require('./package.json').version\")", 39 | "release:major": "npm version major && git push origin v$(node -p \"require('./package.json').version\")" 40 | }, 41 | "dependencies": { 42 | "@modelcontextprotocol/sdk": "^1.13.2", 43 | "@roam-research/roam-api-sdk": "^0.10.0", 44 | "dotenv": "^16.4.7" 45 | }, 46 | "devDependencies": { 47 | "@types/node": "^20.11.24", 48 | "ts-node": "^10.9.2", 49 | "typescript": "^5.3.3" 50 | } 51 | } ``` -------------------------------------------------------------------------------- /src/tools/operations/todos.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, q, createBlock, createPage, batchActions } from '@roam-research/roam-api-sdk'; 2 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 3 | import { formatRoamDate } from '../../utils/helpers.js'; 4 | 5 | export class TodoOperations { 6 | constructor(private graph: Graph) {} 7 | 8 | async addTodos(todos: string[]): Promise<{ success: boolean }> { 9 | if (!Array.isArray(todos) || todos.length === 0) { 10 | throw new McpError( 11 | ErrorCode.InvalidRequest, 12 | 'todos must be a non-empty array' 13 | ); 14 | } 15 | 16 | // Get today's date 17 | const today = new Date(); 18 | const dateStr = formatRoamDate(today); 19 | 20 | // Try to find today's page 21 | const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; 22 | const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; 23 | 24 | let targetPageUid: string; 25 | 26 | if (findResults && findResults.length > 0) { 27 | targetPageUid = findResults[0][0]; 28 | } else { 29 | // Create today's page if it doesn't exist 30 | try { 31 | await createPage(this.graph, { 32 | action: 'create-page', 33 | page: { title: dateStr } 34 | }); 35 | 36 | // Get the new page's UID 37 | const results = await q(this.graph, findQuery, [dateStr]) as [string][]; 38 | if (!results || results.length === 0) { 39 | throw new Error('Could not find created today\'s page'); 40 | } 41 | targetPageUid = results[0][0]; 42 | } catch (error) { 43 | throw new Error('Failed to create today\'s page'); 44 | } 45 | } 46 | 47 | const todo_tag = "{{TODO}}"; 48 | const actions = todos.map((todo, index) => ({ 49 | action: 'create-block', 50 | location: { 51 | 'parent-uid': targetPageUid, 52 | order: index 53 | }, 54 | block: { 55 | string: `${todo_tag} ${todo}` 56 | } 57 | })); 58 | 59 | const result = await batchActions(this.graph, { 60 | action: 'batch-actions', 61 | actions 62 | }); 63 | 64 | if (!result) { 65 | throw new Error('Failed to create todo blocks'); 66 | } 67 | 68 | return { success: true }; 69 | } 70 | } 71 | ``` -------------------------------------------------------------------------------- /src/config/environment.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as dotenv from 'dotenv'; 2 | import { fileURLToPath } from 'url'; 3 | import { dirname, join } from 'path'; 4 | import { existsSync } from 'fs'; 5 | 6 | // Get the project root from the script path 7 | const scriptPath = process.argv[1]; // Full path to the running script 8 | const projectRoot = dirname(dirname(scriptPath)); // Go up two levels from build/index.js 9 | 10 | // Try to load .env from project root 11 | const envPath = join(projectRoot, '.env'); 12 | if (existsSync(envPath)) { 13 | dotenv.config({ path: envPath }); 14 | } 15 | 16 | // Required environment variables 17 | const API_TOKEN = process.env.ROAM_API_TOKEN as string; 18 | const GRAPH_NAME = process.env.ROAM_GRAPH_NAME as string; 19 | 20 | // Validate environment variables 21 | if (!API_TOKEN || !GRAPH_NAME) { 22 | const missingVars = []; 23 | if (!API_TOKEN) missingVars.push('ROAM_API_TOKEN'); 24 | if (!GRAPH_NAME) missingVars.push('ROAM_GRAPH_NAME'); 25 | 26 | throw new Error( 27 | `Missing required environment variables: ${missingVars.join(', ')}\n\n` + 28 | 'Please configure these variables either:\n' + 29 | '1. In your MCP settings file:\n' + 30 | ' - For Cline: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json\n' + 31 | ' - For Claude: ~/Library/Application Support/Claude/claude_desktop_config.json\n\n' + 32 | ' Example configuration:\n' + 33 | ' {\n' + 34 | ' "mcpServers": {\n' + 35 | ' "roam-research": {\n' + 36 | ' "command": "node",\n' + 37 | ' "args": ["/path/to/roam-research-mcp/build/index.js"],\n' + 38 | ' "env": {\n' + 39 | ' "ROAM_API_TOKEN": "your-api-token",\n' + 40 | ' "ROAM_GRAPH_NAME": "your-graph-name"\n' + 41 | ' }\n' + 42 | ' }\n' + 43 | ' }\n' + 44 | ' }\n\n' + 45 | '2. Or in a .env file in the roam-research directory:\n' + 46 | ' ROAM_API_TOKEN=your-api-token\n' + 47 | ' ROAM_GRAPH_NAME=your-graph-name' 48 | ); 49 | } 50 | 51 | const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8088 52 | const SSE_PORT = process.env.SSE_PORT || '8087'; // Default to 8087 53 | const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5678'; 54 | 55 | export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT, CORS_ORIGIN }; 56 | ``` -------------------------------------------------------------------------------- /src/search/status-search.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { q } from '@roam-research/roam-api-sdk'; 2 | import type { Graph } from '@roam-research/roam-api-sdk'; 3 | import { BaseSearchHandler, SearchResult } from './types.js'; 4 | import { SearchUtils } from './utils.js'; 5 | import { resolveRefs } from '../tools/helpers/refs.js'; 6 | 7 | export interface StatusSearchParams { 8 | status: 'TODO' | 'DONE'; 9 | page_title_uid?: string; 10 | } 11 | 12 | export class StatusSearchHandler extends BaseSearchHandler { 13 | constructor( 14 | graph: Graph, 15 | private params: StatusSearchParams 16 | ) { 17 | super(graph); 18 | } 19 | 20 | async execute(): Promise<SearchResult> { 21 | const { status, page_title_uid } = this.params; 22 | 23 | // Get target page UID if provided 24 | let targetPageUid: string | undefined; 25 | if (page_title_uid) { 26 | targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); 27 | } 28 | 29 | // Build query based on whether we're searching in a specific page 30 | let queryStr: string; 31 | let queryParams: any[]; 32 | 33 | if (targetPageUid) { 34 | queryStr = `[:find ?block-uid ?block-str 35 | :in $ ?status ?page-uid 36 | :where [?p :block/uid ?page-uid] 37 | [?b :block/page ?p] 38 | [?b :block/string ?block-str] 39 | [?b :block/uid ?block-uid] 40 | [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`; 41 | queryParams = [status, targetPageUid]; 42 | } else { 43 | queryStr = `[:find ?block-uid ?block-str ?page-title 44 | :in $ ?status 45 | :where [?b :block/string ?block-str] 46 | [?b :block/uid ?block-uid] 47 | [?b :block/page ?p] 48 | [?p :node/title ?page-title] 49 | [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`; 50 | queryParams = [status]; 51 | } 52 | 53 | const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?][]; 54 | 55 | // Resolve block references in content 56 | const resolvedResults = await Promise.all( 57 | rawResults.map(async ([uid, content, pageTitle]) => { 58 | const resolvedContent = await resolveRefs(this.graph, content); 59 | return [uid, resolvedContent, pageTitle] as [string, string, string?]; 60 | }) 61 | ); 62 | 63 | return SearchUtils.formatSearchResults(resolvedResults, `with status ${status}`, !targetPageUid); 64 | } 65 | } 66 | ``` -------------------------------------------------------------------------------- /src/tools/operations/search/handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph } from '@roam-research/roam-api-sdk'; 2 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 3 | import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler, DatomicSearchHandler, StatusSearchHandler } from '../../../search/index.js'; 4 | import type { 5 | TagSearchParams, 6 | BlockRefSearchParams, 7 | HierarchySearchParams, 8 | TextSearchParams, 9 | SearchHandlerResult, 10 | DatomicSearchParams, 11 | StatusSearchParams 12 | } from './types.js'; 13 | 14 | // Base class for all search handlers 15 | export abstract class BaseSearchHandler { 16 | constructor(protected graph: Graph) {} 17 | abstract execute(): Promise<SearchHandlerResult>; 18 | } 19 | 20 | // Tag search handler 21 | export class TagSearchHandlerImpl extends BaseSearchHandler { 22 | constructor(graph: Graph, private params: TagSearchParams) { 23 | super(graph); 24 | } 25 | 26 | async execute() { 27 | const handler = new TagSearchHandler(this.graph, this.params); 28 | return handler.execute(); 29 | } 30 | } 31 | 32 | // Block reference search handler 33 | export class BlockRefSearchHandlerImpl extends BaseSearchHandler { 34 | constructor(graph: Graph, private params: BlockRefSearchParams) { 35 | super(graph); 36 | } 37 | 38 | async execute() { 39 | const handler = new BlockRefSearchHandler(this.graph, this.params); 40 | return handler.execute(); 41 | } 42 | } 43 | 44 | // Hierarchy search handler 45 | export class HierarchySearchHandlerImpl extends BaseSearchHandler { 46 | constructor(graph: Graph, private params: HierarchySearchParams) { 47 | super(graph); 48 | } 49 | 50 | async execute() { 51 | const handler = new HierarchySearchHandler(this.graph, this.params); 52 | return handler.execute(); 53 | } 54 | } 55 | 56 | // Text search handler 57 | export class TextSearchHandlerImpl extends BaseSearchHandler { 58 | constructor(graph: Graph, private params: TextSearchParams) { 59 | super(graph); 60 | } 61 | 62 | async execute() { 63 | const handler = new TextSearchHandler(this.graph, this.params); 64 | return handler.execute(); 65 | } 66 | } 67 | 68 | // Status search handler 69 | export class StatusSearchHandlerImpl extends BaseSearchHandler { 70 | constructor(graph: Graph, private params: StatusSearchParams) { 71 | super(graph); 72 | } 73 | 74 | async execute() { 75 | const handler = new StatusSearchHandler(this.graph, this.params); 76 | return handler.execute(); 77 | } 78 | } 79 | 80 | // Datomic query handler 81 | export class DatomicSearchHandlerImpl extends BaseSearchHandler { 82 | constructor(graph: Graph, private params: DatomicSearchParams) { 83 | super(graph); 84 | } 85 | 86 | async execute() { 87 | const handler = new DatomicSearchHandler(this.graph, this.params); 88 | return handler.execute(); 89 | } 90 | } 91 | ``` -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- ```typescript 1 | declare module '@roam-research/roam-api-sdk' { 2 | interface Graph { 3 | token: string; 4 | graph: string; 5 | } 6 | 7 | interface RoamBlockLocation { 8 | 'parent-uid': string; 9 | order: number | string; 10 | } 11 | 12 | interface RoamBlock { 13 | string: string; 14 | uid?: string; 15 | open?: boolean; 16 | heading?: number; 17 | 'text-align'?: boolean; 18 | 'children-view-type'?: string; 19 | } 20 | 21 | interface RoamCreateBlock { 22 | action?: 'create-block'; 23 | location: RoamBlockLocation; 24 | block: RoamBlock; 25 | } 26 | 27 | export function initializeGraph(config: { token: string; graph: string }): Graph; 28 | 29 | export function q( 30 | graph: Graph, 31 | query: string, 32 | inputs: any[] 33 | ): Promise<any[]>; 34 | 35 | interface RoamCreatePage { 36 | action?: 'create-page'; 37 | page: { 38 | title: string; 39 | uid?: string; 40 | 'children-view-type'?: string; 41 | }; 42 | } 43 | 44 | export function createPage( 45 | graph: Graph, 46 | options: RoamCreatePage 47 | ): Promise<boolean>; 48 | 49 | export function createBlock( 50 | graph: Graph, 51 | options: RoamCreateBlock 52 | ): Promise<boolean>; 53 | 54 | interface RoamUpdateBlock { 55 | action?: 'update-block'; 56 | block: { 57 | string?: string; 58 | uid: string; 59 | open?: boolean; 60 | heading?: number; 61 | 'text-align'?: boolean; 62 | 'children-view-type'?: string; 63 | }; 64 | } 65 | 66 | export function updateBlock( 67 | graph: Graph, 68 | options: RoamUpdateBlock 69 | ): Promise<boolean>; 70 | 71 | export function deleteBlock( 72 | graph: Graph, 73 | options: { uid: string } 74 | ): Promise<void>; 75 | 76 | export function pull( 77 | graph: Graph, 78 | pattern: string, 79 | eid: string 80 | ): Promise<any>; 81 | 82 | export function pull_many( 83 | graph: Graph, 84 | pattern: string, 85 | eids: string 86 | ): Promise<any>; 87 | 88 | interface RoamMoveBlock { 89 | action?: 'move-block'; 90 | location: RoamBlockLocation; 91 | block: { 92 | uid: RoamBlock['uid']; 93 | }; 94 | } 95 | 96 | export function moveBlock( 97 | graph: Graph, 98 | options: RoamMoveBlock 99 | ): Promise<boolean>; 100 | 101 | interface RoamDeletePage { 102 | action?: 'delete-page'; 103 | page: { 104 | uid: string; 105 | }; 106 | } 107 | 108 | export function deletePage( 109 | graph: Graph, 110 | options: RoamDeletePage 111 | ): Promise<boolean>; 112 | 113 | interface RoamDeleteBlock { 114 | action?: 'delete-block'; 115 | block: { 116 | uid: string; 117 | }; 118 | } 119 | 120 | export function deleteBlock( 121 | graph: Graph, 122 | options: RoamDeleteBlock 123 | ): Promise<boolean>; 124 | 125 | interface RoamBatchActions { 126 | action?: 'batch-actions'; 127 | actions: Array< 128 | | RoamDeletePage 129 | | RoamUpdatePage 130 | | RoamCreatePage 131 | | RoamDeleteBlock 132 | | RoamUpdateBlock 133 | | RoamMoveBlock 134 | | RoamCreateBlock 135 | >; 136 | } 137 | 138 | export function batchActions( 139 | graph: Graph, 140 | options: RoamBatchActions 141 | ): Promise<any>; 142 | } 143 | ``` -------------------------------------------------------------------------------- /src/search/datomic-search.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { q } from '@roam-research/roam-api-sdk'; 2 | import type { Graph } from '@roam-research/roam-api-sdk'; 3 | import { BaseSearchHandler, SearchResult } from './types.js'; 4 | // import { resolveRefs } from '../helpers/refs.js'; 5 | 6 | export interface DatomicSearchParams { 7 | query: string; 8 | inputs?: unknown[]; 9 | regexFilter?: string; 10 | regexFlags?: string; 11 | regexTargetField?: string[]; 12 | } 13 | 14 | export class DatomicSearchHandler extends BaseSearchHandler { 15 | constructor( 16 | graph: Graph, 17 | private params: DatomicSearchParams 18 | ) { 19 | super(graph); 20 | } 21 | 22 | async execute(): Promise<SearchResult> { 23 | try { 24 | // Execute the datomic query using the Roam API 25 | let results = await q(this.graph, this.params.query, this.params.inputs || []) as unknown[]; 26 | 27 | if (this.params.regexFilter) { 28 | let regex: RegExp; 29 | try { 30 | regex = new RegExp(this.params.regexFilter, this.params.regexFlags); 31 | } catch (e) { 32 | return { 33 | success: false, 34 | matches: [], 35 | message: `Invalid regex filter provided: ${e instanceof Error ? e.message : String(e)}` 36 | }; 37 | } 38 | 39 | results = results.filter(result => { 40 | if (this.params.regexTargetField && this.params.regexTargetField.length > 0) { 41 | for (const field of this.params.regexTargetField) { 42 | // Access nested fields if path is provided (e.g., "prop.nested") 43 | const fieldPath = field.split('.'); 44 | let value: any = result; 45 | for (const part of fieldPath) { 46 | if (typeof value === 'object' && value !== null && part in value) { 47 | value = value[part]; 48 | } else { 49 | value = undefined; // Field not found 50 | break; 51 | } 52 | } 53 | if (typeof value === 'string' && regex.test(value)) { 54 | return true; 55 | } 56 | } 57 | return false; 58 | } else { 59 | // Default to stringifying the whole result if no target field is specified 60 | return regex.test(JSON.stringify(result)); 61 | } 62 | }); 63 | } 64 | 65 | return { 66 | success: true, 67 | matches: results.map(result => ({ 68 | content: JSON.stringify(result), 69 | block_uid: '', // Datomic queries may not always return block UIDs 70 | page_title: '' // Datomic queries may not always return page titles 71 | })), 72 | message: `Query executed successfully. Found ${results.length} results.` 73 | }; 74 | } catch (error) { 75 | return { 76 | success: false, 77 | matches: [], 78 | message: `Failed to execute query: ${error instanceof Error ? error.message : String(error)}` 79 | }; 80 | } 81 | } 82 | } 83 | ``` -------------------------------------------------------------------------------- /src/tools/helpers/text.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Capitalizes each word in a string 3 | */ 4 | import { q } from '@roam-research/roam-api-sdk'; 5 | import type { Graph } from '@roam-research/roam-api-sdk'; 6 | 7 | /** 8 | * Capitalizes each word in a string 9 | */ 10 | export const capitalizeWords = (str: string): string => { 11 | return str.split(' ').map(word => 12 | word.charAt(0).toUpperCase() + word.slice(1) 13 | ).join(' '); 14 | }; 15 | 16 | /** 17 | * Retrieves a block's UID based on its exact text content. 18 | * This function is intended for internal use by other MCP tools. 19 | * @param graph The Roam graph instance. 20 | * @param blockText The exact text content of the block to find. 21 | * @returns The UID of the block if found, otherwise null. 22 | */ 23 | export const getBlockUidByText = async (graph: Graph, blockText: string): Promise<string | null> => { 24 | const query = `[:find ?uid . 25 | :in $ ?blockString 26 | :where [?b :block/string ?blockString] 27 | [?b :block/uid ?uid]]`; 28 | const result = await q(graph, query, [blockText]) as [string][] | null; 29 | return result && result.length > 0 ? result[0][0] : null; 30 | }; 31 | 32 | /** 33 | * Retrieves all UIDs nested under a given block_uid or block_text (exact match). 34 | * This function is intended for internal use by other MCP tools. 35 | * @param graph The Roam graph instance. 36 | * @param rootIdentifier The UID or exact text content of the root block. 37 | * @returns An array of UIDs of all descendant blocks, including the root block's UID. 38 | */ 39 | export const getNestedUids = async (graph: Graph, rootIdentifier: string): Promise<string[]> => { 40 | let rootUid: string | null = rootIdentifier; 41 | 42 | // If the rootIdentifier is not a UID (simple check for 9 alphanumeric characters), try to resolve it as block text 43 | if (!rootIdentifier.match(/^[a-zA-Z0-9]{9}$/)) { 44 | rootUid = await getBlockUidByText(graph, rootIdentifier); 45 | } 46 | 47 | if (!rootUid) { 48 | return []; // No root block found 49 | } 50 | 51 | const query = `[:find ?child-uid 52 | :in $ ?root-uid 53 | :where 54 | [?root-block :block/uid ?root-uid] 55 | [?root-block :block/children ?child-block] 56 | [?child-block :block/uid ?child-uid]]`; 57 | 58 | const results = await q(graph, query, [rootUid]) as [string][]; 59 | return results.map(r => r[0]); 60 | }; 61 | 62 | /** 63 | * Retrieves all UIDs nested under a given block_text (exact match). 64 | * This function is intended for internal use by other MCP tools. 65 | * It strictly requires an exact text match for the root block. 66 | * @param graph The Roam graph instance. 67 | * @param blockText The exact text content of the root block. 68 | * @returns An array of UIDs of all descendant blocks, including the root block's UID. 69 | */ 70 | export const getNestedUidsByText = async (graph: Graph, blockText: string): Promise<string[]> => { 71 | const rootUid = await getBlockUidByText(graph, blockText); 72 | if (!rootUid) { 73 | return []; // No root block found with exact text match 74 | } 75 | return getNestedUids(graph, rootUid); 76 | }; 77 | ``` -------------------------------------------------------------------------------- /Roam Import JSON Schema.md: -------------------------------------------------------------------------------- ```markdown 1 | - **Description** 2 | - 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. 3 | - **Objects** 4 | - Page 5 | - description:: An object representing a page. The only required key is the title 6 | - keys:: 7 | - title 8 | - children 9 | - create-time 10 | - edit-time 11 | - edit-user 12 | - Block 13 | - description:: An object representing a block. The only required key is the string 14 | - keys:: 15 | - string 16 | - uid 17 | - children 18 | - create-time 19 | - edit-time 20 | - edit-user 21 | - heading 22 | - text-align 23 | - **Keys** 24 | - title 25 | - 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. 26 | - type:: string 27 | - string 28 | - description:: The string of a block 29 | - type:: string 30 | - uid 31 | - 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 32 | - type:: string 33 | - children 34 | - description:: An array of blocks, the order is implicit from the order of the array 35 | - type:: array of Blocks 36 | - create-time 37 | - 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. 38 | - type:: integer 39 | - Epoch time in milliseconds (13-digit numbers) 40 | - edit-time 41 | - 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. 42 | - type:: integer 43 | - edit-user 44 | - description:: The user who last edited the object. 45 | - type:: json object of the format `{":user/uid" "ROAM-USER-UID"}` 46 | - heading 47 | - description:: Determines what heading tag to wrap the block in, default is no heading (0) 48 | - type:: integer, 0 | 1 | 2 | 3 49 | - For level of heading, 0 being no heading (the default) 1 heading h1, etc 50 | - text-align 51 | - description:: The text-align style for a block 52 | - type:: string, "left" | "center" | "right" | "justify" 53 | - By default is left (as determined by the browser defaults) 54 | - **Example** 55 | - ```javascript 56 | [{:title "December 10th 2018" 57 | :create-email "[email protected]" 58 | :create-time 1576025237000 59 | :children [{:string "[[Meeting]] with [[Tim]]" 60 | :children [{:string "Meeting went well"}]} 61 | {:string "[[Call]] with [[John]]"}]} 62 | {:title "December 11th 2018"}] 63 | ``` 64 | - More (better) examples can be found by exporting roam to json 65 | ``` -------------------------------------------------------------------------------- /src/tools/operations/block-retrieval.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, q } from '@roam-research/roam-api-sdk'; 2 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 3 | import { RoamBlock } from '../../types/roam.js'; 4 | 5 | export class BlockRetrievalOperations { 6 | constructor(private graph: Graph) { } 7 | 8 | async fetchBlockWithChildren(block_uid_raw: string, depth: number = 4): Promise<RoamBlock | null> { 9 | if (!block_uid_raw) { 10 | throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required.'); 11 | } 12 | 13 | const block_uid = block_uid_raw.replace(/^\(\((.*)\)\)$/, '$1'); 14 | 15 | const fetchChildren = async (parentUids: string[], currentDepth: number): Promise<Record<string, RoamBlock[]>> => { 16 | if (currentDepth >= depth || parentUids.length === 0) { 17 | return {}; 18 | } 19 | 20 | const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading 21 | :in $ [?parentUid ...] 22 | :where [?parent :block/uid ?parentUid] 23 | [?parent :block/children ?child] 24 | [?child :block/uid ?childUid] 25 | [?child :block/string ?childString] 26 | [?child :block/order ?childOrder] 27 | [(get-else $ ?child :block/heading 0) ?childHeading]]`; 28 | 29 | const childrenResults = await q(this.graph, childrenQuery, [parentUids]) as [string, string, string, number, number | null][]; 30 | 31 | const childrenByParent: Record<string, RoamBlock[]> = {}; 32 | const allChildUids: string[] = []; 33 | 34 | for (const [parentUid, childUid, childString, childOrder, childHeading] of childrenResults) { 35 | if (!childrenByParent[parentUid]) { 36 | childrenByParent[parentUid] = []; 37 | } 38 | childrenByParent[parentUid].push({ 39 | uid: childUid, 40 | string: childString, 41 | order: childOrder, 42 | heading: childHeading || undefined, 43 | children: [], 44 | }); 45 | allChildUids.push(childUid); 46 | } 47 | 48 | const grandChildren = await fetchChildren(allChildUids, currentDepth + 1); 49 | 50 | for (const parentUid in childrenByParent) { 51 | for (const child of childrenByParent[parentUid]) { 52 | child.children = grandChildren[child.uid] || []; 53 | } 54 | childrenByParent[parentUid].sort((a, b) => a.order - b.order); 55 | } 56 | 57 | return childrenByParent; 58 | }; 59 | 60 | try { 61 | const rootBlockQuery = `[:find ?string ?order ?heading 62 | :in $ ?blockUid 63 | :where [?b :block/uid ?blockUid] 64 | [?b :block/string ?string] 65 | [?b :block/order ?order] 66 | [(get-else $ ?b :block/heading 0) ?heading]]`; 67 | const rootBlockResult = await q(this.graph, rootBlockQuery, [block_uid]) as [string, number, number | null] | null; 68 | 69 | if (!rootBlockResult) { 70 | return null; 71 | } 72 | 73 | const [rootString, rootOrder, rootHeading] = rootBlockResult; 74 | const childrenMap = await fetchChildren([block_uid], 0); 75 | 76 | return { 77 | uid: block_uid, 78 | string: rootString, 79 | order: rootOrder, 80 | heading: rootHeading || undefined, 81 | children: childrenMap[block_uid] || [], 82 | }; 83 | } catch (error) { 84 | throw new McpError( 85 | ErrorCode.InternalError, 86 | `Failed to fetch block with children: ${error instanceof Error ? error.message : String(error)}` 87 | ); 88 | } 89 | } 90 | } 91 | ``` -------------------------------------------------------------------------------- /src/search/block-ref-search.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { q } from '@roam-research/roam-api-sdk'; 2 | import type { Graph } from '@roam-research/roam-api-sdk'; 3 | import { BaseSearchHandler, SearchResult } from './types.js'; 4 | import { SearchUtils } from './utils.js'; 5 | import { resolveRefs } from '../tools/helpers/refs.js'; 6 | 7 | export interface BlockRefSearchParams { 8 | block_uid?: string; 9 | page_title_uid?: string; 10 | } 11 | 12 | export class BlockRefSearchHandler extends BaseSearchHandler { 13 | constructor( 14 | graph: Graph, 15 | private params: BlockRefSearchParams 16 | ) { 17 | super(graph); 18 | } 19 | 20 | async execute(): Promise<SearchResult> { 21 | const { block_uid, page_title_uid } = this.params; 22 | 23 | // Get target page UID if provided 24 | let targetPageUid: string | undefined; 25 | if (page_title_uid) { 26 | targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); 27 | } 28 | 29 | // Build query based on whether we're searching for references to a specific block 30 | // or all block references within a page/graph 31 | let queryStr: string; 32 | let queryParams: any[]; 33 | 34 | if (block_uid) { 35 | // Search for references to a specific block 36 | if (targetPageUid) { 37 | queryStr = `[:find ?block-uid ?block-str 38 | :in $ ?ref-uid ?page-uid 39 | :where [?p :block/uid ?page-uid] 40 | [?b :block/page ?p] 41 | [?b :block/string ?block-str] 42 | [?b :block/uid ?block-uid] 43 | [(clojure.string/includes? ?block-str ?ref-uid)]]`; 44 | queryParams = [`((${block_uid}))`, targetPageUid]; 45 | } else { 46 | queryStr = `[:find ?block-uid ?block-str ?page-title 47 | :in $ ?ref-uid 48 | :where [?b :block/string ?block-str] 49 | [?b :block/uid ?block-uid] 50 | [?b :block/page ?p] 51 | [?p :node/title ?page-title] 52 | [(clojure.string/includes? ?block-str ?ref-uid)]]`; 53 | queryParams = [`((${block_uid}))`]; 54 | } 55 | } else { 56 | // Search for any block references 57 | if (targetPageUid) { 58 | queryStr = `[:find ?block-uid ?block-str 59 | :in $ ?page-uid 60 | :where [?p :block/uid ?page-uid] 61 | [?b :block/page ?p] 62 | [?b :block/string ?block-str] 63 | [?b :block/uid ?block-uid] 64 | [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`; 65 | queryParams = [targetPageUid]; 66 | } else { 67 | queryStr = `[:find ?block-uid ?block-str ?page-title 68 | :where [?b :block/string ?block-str] 69 | [?b :block/uid ?block-uid] 70 | [?b :block/page ?p] 71 | [?p :node/title ?page-title] 72 | [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`; 73 | queryParams = []; 74 | } 75 | } 76 | 77 | const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?][]; 78 | 79 | // Resolve block references in content 80 | const resolvedResults = await Promise.all( 81 | rawResults.map(async ([uid, content, pageTitle]) => { 82 | const resolvedContent = await resolveRefs(this.graph, content); 83 | return [uid, resolvedContent, pageTitle] as [string, string, string?]; 84 | }) 85 | ); 86 | 87 | const searchDescription = block_uid 88 | ? `referencing block ((${block_uid}))` 89 | : 'containing block references'; 90 | 91 | return SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid); 92 | } 93 | } 94 | ``` -------------------------------------------------------------------------------- /src/search/text-search.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { q } from '@roam-research/roam-api-sdk'; 2 | import type { Graph } from '@roam-research/roam-api-sdk'; 3 | import { BaseSearchHandler, SearchResult, TextSearchParams } from './types.js'; 4 | import { SearchUtils } from './utils.js'; 5 | import { resolveRefs } from '../tools/helpers/refs.js'; 6 | 7 | export class TextSearchHandler extends BaseSearchHandler { 8 | constructor( 9 | graph: Graph, 10 | private params: TextSearchParams 11 | ) { 12 | super(graph); 13 | } 14 | 15 | async execute(): Promise<SearchResult> { 16 | const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0 } = this.params; 17 | 18 | // Get target page UID if provided for scoped search 19 | let targetPageUid: string | undefined; 20 | if (page_title_uid) { 21 | targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); 22 | } 23 | 24 | const searchTerms: string[] = []; 25 | if (case_sensitive) { 26 | searchTerms.push(text); 27 | } else { 28 | searchTerms.push(text); 29 | // Add capitalized version (e.g., "Hypnosis") 30 | searchTerms.push(text.charAt(0).toUpperCase() + text.slice(1)); 31 | // Add all caps version (e.g., "HYPNOSIS") 32 | searchTerms.push(text.toUpperCase()); 33 | // Add all lowercase version (e.g., "hypnosis") 34 | searchTerms.push(text.toLowerCase()); 35 | } 36 | 37 | const whereClauses = searchTerms.map(term => `[(clojure.string/includes? ?block-str "${term}")]`).join(' '); 38 | 39 | let queryStr: string; 40 | let queryParams: (string | number)[] = []; 41 | let queryLimit = limit === -1 ? '' : `:limit ${limit}`; 42 | let queryOffset = offset === 0 ? '' : `:offset ${offset}`; 43 | let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID 44 | 45 | 46 | let baseQueryWhereClauses = ` 47 | [?b :block/string ?block-str] 48 | (or ${whereClauses}) 49 | [?b :block/uid ?block-uid] 50 | [?b :block/page ?p] 51 | [?p :node/title ?page-title] 52 | [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting 53 | 54 | if (targetPageUid) { 55 | queryStr = `[:find ?block-uid ?block-str ?page-title 56 | :in $ ?page-uid ${queryLimit} ${queryOffset} ${queryOrder} 57 | :where 58 | ${baseQueryWhereClauses} 59 | [?p :block/uid ?page-uid]]`; 60 | queryParams = [targetPageUid]; 61 | } else { 62 | queryStr = `[:find ?block-uid ?block-str ?page-title 63 | :in $ ${queryLimit} ${queryOffset} ${queryOrder} 64 | :where 65 | ${baseQueryWhereClauses}]`; 66 | } 67 | 68 | const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?][]; 69 | 70 | // Query to get total count without limit 71 | const countQueryStr = `[:find (count ?b) 72 | :in $ 73 | :where 74 | ${baseQueryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query 75 | 76 | const totalCountResults = await q(this.graph, countQueryStr, queryParams) as number[][]; 77 | const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0; 78 | 79 | // Resolve block references in content 80 | const resolvedResults = await Promise.all( 81 | rawResults.map(async ([uid, content, pageTitle]) => { 82 | const resolvedContent = await resolveRefs(this.graph, content); 83 | return [uid, resolvedContent, pageTitle] as [string, string, string?]; 84 | }) 85 | ); 86 | 87 | const searchDescription = `containing "${text}"`; 88 | const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid); 89 | formattedResults.total_count = totalCount; 90 | return formattedResults; 91 | } 92 | } 93 | ``` -------------------------------------------------------------------------------- /src/search/utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; 2 | import { q } from '@roam-research/roam-api-sdk'; 3 | import type { Graph } from '@roam-research/roam-api-sdk'; 4 | import type { SearchResult } from './types.js'; 5 | 6 | export class SearchUtils { 7 | /** 8 | * Find a page by title or UID 9 | */ 10 | static async findPageByTitleOrUid(graph: Graph, titleOrUid: string): Promise<string> { 11 | // Try to find page by title 12 | const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; 13 | const findResults = await q(graph, findQuery, [titleOrUid]) as [string][]; 14 | 15 | if (findResults && findResults.length > 0) { 16 | return findResults[0][0]; 17 | } 18 | 19 | // Try as UID 20 | const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`; 21 | const uidResults = await q(graph, uidQuery, []) as [string][]; 22 | 23 | if (!uidResults || uidResults.length === 0) { 24 | throw new McpError( 25 | ErrorCode.InvalidRequest, 26 | `Page with title/UID "${titleOrUid}" not found` 27 | ); 28 | } 29 | 30 | return uidResults[0][0]; 31 | } 32 | 33 | /** 34 | * Format search results into a standard structure 35 | */ 36 | static formatSearchResults( 37 | results: [string, string, string?][], 38 | searchDescription: string, 39 | includePageTitle: boolean = true 40 | ): SearchResult { 41 | if (!results || results.length === 0) { 42 | return { 43 | success: true, 44 | matches: [], 45 | message: `No blocks found ${searchDescription}` 46 | }; 47 | } 48 | 49 | const matches = results.map(([uid, content, pageTitle]) => ({ 50 | block_uid: uid, 51 | content, 52 | ...(includePageTitle && pageTitle && { page_title: pageTitle }) 53 | })); 54 | 55 | return { 56 | success: true, 57 | matches, 58 | message: `Found ${matches.length} block(s) ${searchDescription}` 59 | }; 60 | } 61 | 62 | /** 63 | * Format a tag for searching, handling both # and [[]] formats 64 | * @param tag Tag without prefix 65 | * @returns Array of possible formats to search for 66 | */ 67 | static formatTag(tag: string): string[] { 68 | // Remove any existing prefixes 69 | const cleanTag = tag.replace(/^#|\[\[|\]\]$/g, ''); 70 | // Return both formats for comprehensive search 71 | return [`#${cleanTag}`, `[[${cleanTag}]]`]; 72 | } 73 | 74 | /** 75 | * Parse a date string into a Roam-formatted date 76 | */ 77 | static parseDate(dateStr: string): string { 78 | const date = new Date(dateStr); 79 | const months = [ 80 | 'January', 'February', 'March', 'April', 'May', 'June', 81 | 'July', 'August', 'September', 'October', 'November', 'December' 82 | ]; 83 | // Adjust for timezone to ensure consistent date comparison 84 | const utcDate = new Date(date.getTime() + date.getTimezoneOffset() * 60000); 85 | return `${months[utcDate.getMonth()]} ${utcDate.getDate()}${this.getOrdinalSuffix(utcDate.getDate())}, ${utcDate.getFullYear()}`; 86 | } 87 | 88 | /** 89 | * Parse a date string into a Roam-formatted date range 90 | * Returns [startDate, endDate] with endDate being inclusive (end of day) 91 | */ 92 | static parseDateRange(startStr: string, endStr: string): [string, string] { 93 | const startDate = new Date(startStr); 94 | const endDate = new Date(endStr); 95 | endDate.setHours(23, 59, 59, 999); // Make end date inclusive 96 | 97 | const months = [ 98 | 'January', 'February', 'March', 'April', 'May', 'June', 99 | 'July', 'August', 'September', 'October', 'November', 'December' 100 | ]; 101 | 102 | // Adjust for timezone 103 | const utcStart = new Date(startDate.getTime() + startDate.getTimezoneOffset() * 60000); 104 | const utcEnd = new Date(endDate.getTime() + endDate.getTimezoneOffset() * 60000); 105 | 106 | return [ 107 | `${months[utcStart.getMonth()]} ${utcStart.getDate()}${this.getOrdinalSuffix(utcStart.getDate())}, ${utcStart.getFullYear()}`, 108 | `${months[utcEnd.getMonth()]} ${utcEnd.getDate()}${this.getOrdinalSuffix(utcEnd.getDate())}, ${utcEnd.getFullYear()}` 109 | ]; 110 | } 111 | 112 | private static getOrdinalSuffix(day: number): string { 113 | if (day > 3 && day < 21) return 'th'; 114 | switch (day % 10) { 115 | case 1: return 'st'; 116 | case 2: return 'nd'; 117 | case 3: return 'rd'; 118 | default: return 'th'; 119 | } 120 | } 121 | } 122 | ``` -------------------------------------------------------------------------------- /Roam_Research_Datalog_Cheatsheet.md: -------------------------------------------------------------------------------- ```markdown 1 | # Roam Research Datalog Cheatsheet ([Gist](https://gist.github.com/2b3pro/231e4f230ed41e3f52e8a89ebf49848b)) 2 | 3 | ## Basic Structure 4 | 5 | - Roam uses Datascript (JavaScript/ClojureScript Datalog implementation) 6 | - Each fact is a datom: `[entity-id attribute value transaction-id]` 7 | 8 | ## Core Components 9 | 10 | ### Entity IDs 11 | 12 | - Hidden ID: Internal database entity-id 13 | - Public ID: Block reference (e.g., `((GGv3cyL6Y))`) or page title (`[[Page Title]]`) 14 | 15 | ### Common Block Attributes 16 | 17 | ```clojure 18 | :block/uid # Nine-character block reference 19 | :create/email # Creator's email 20 | :create/time # Creation timestamp 21 | :edit/email # Editor's email 22 | :edit/time # Last edit timestamp 23 | ``` 24 | 25 | ### Page-Specific Attributes 26 | 27 | ```clojure 28 | :node/title # Page title (pages only) 29 | ``` 30 | 31 | ### Block Attributes 32 | 33 | ```clojure 34 | :block/page # Reference to page entity-id 35 | :block/order # Sequence within parent 36 | :block/string # Block content 37 | :block/parents # List of ancestor blocks 38 | ``` 39 | 40 | ### Optional Block Attributes 41 | 42 | ```clojure 43 | :children/view-type # 'bullet', 'document', 'numbered' 44 | :block/heading # 1, 2, 3 for H1-H3 45 | :block/props # Image/iframe sizing, slider position 46 | :block/text-align # 'left', 'center', 'right', 'justify' 47 | ``` 48 | 49 | ## Query Examples 50 | 51 | ### Graph Statistics 52 | 53 | #### Count Pages 54 | 55 | ```clojure 56 | [:find (count ?title) 57 | :where [_ :node/title ?title]] 58 | ``` 59 | 60 | #### Count Blocks 61 | 62 | ```clojure 63 | [:find (count ?string) 64 | :where [_ :block/string ?string]] 65 | ``` 66 | 67 | #### Find Blocks with Most Descendants 68 | 69 | ```clojure 70 | [:find ?ancestor (count ?block) 71 | :in $ % 72 | :where 73 | [?ancestor :block/string] 74 | [?block :block/string] 75 | (ancestor ?block ?ancestor)] 76 | ``` 77 | 78 | ### Page Queries 79 | 80 | #### List Pages in Namespace 81 | 82 | ```clojure 83 | [:find ?title:name ?title:uid ?time:date 84 | :where 85 | [?page :node/title ?title:name] 86 | [?page :block/uid ?title:uid] 87 | [?page :edit/time ?time:date] 88 | [(clojure.string/starts-with? ?title:name "roam/")]] 89 | ``` 90 | 91 | #### Find Pages Modified Today 92 | 93 | ```clojure 94 | [:find ?page_title:name ?page_title:uid 95 | :in $ ?start_of_day % 96 | :where 97 | [?page :node/title ?page_title:name] 98 | [?page :block/uid ?page_title:uid] 99 | (ancestor ?block ?page) 100 | [?block :edit/time ?time] 101 | [(> ?time ?start_of_day)]] 102 | ``` 103 | 104 | ### Block Queries 105 | 106 | #### Find Direct Children 107 | 108 | ```clojure 109 | [:find ?block_string 110 | :where 111 | [?p :node/title "Page Title"] 112 | [?p :block/children ?c] 113 | [?c :block/string ?block_string]] 114 | ``` 115 | 116 | #### Find with Pull Pattern 117 | 118 | ```clojure 119 | [:find (pull ?e [*{:block/children [*]}]) 120 | :where [?e :node/title "Page Title"]] 121 | ``` 122 | 123 | ### Advanced Queries 124 | 125 | #### Search with Case-Insensitive Pattern 126 | 127 | ```javascript 128 | let fragment = "search_term"; 129 | let query = `[:find ?title:name ?title:uid ?time:date 130 | :where [?page :node/title ?title:name] 131 | [?page :block/uid ?title:uid] 132 | [?page :edit/time ?time:date]]`; 133 | 134 | let results = window.roamAlphaAPI 135 | .q(query) 136 | .filter((item, index) => item[0].toLowerCase().indexOf(fragment) > 0) 137 | .sort((a, b) => a[0].localeCompare(b[0])); 138 | ``` 139 | 140 | #### List Namespace Attributes 141 | 142 | ```clojure 143 | [:find ?namespace ?attribute 144 | :where [_ ?attribute] 145 | [(namespace ?attribute) ?namespace]] 146 | ``` 147 | 148 | ## Tips 149 | 150 | - Use `:block/parents` for ancestors (includes all levels) 151 | - Use `:block/children` for immediate descendants only 152 | - Combine `clojure.string` functions for complex text matching 153 | - Use `distinct` to avoid duplicate results 154 | - Use Pull patterns for hierarchical data retrieval 155 | - Handle case sensitivity in string operations carefully 156 | - Chain ancestry rules for multi-level traversal 157 | 158 | ## Common Predicates 159 | 160 | Available functions: 161 | 162 | - clojure.string/includes? 163 | - clojure.string/starts-with? 164 | - clojure.string/ends-with? 165 | - count 166 | - <, >, <=, >=, =, not=, != 167 | 168 | ## Aggregates 169 | 170 | Available functions: 171 | 172 | - sum 173 | - max 174 | - min 175 | - avg 176 | - count 177 | - distinct 178 | 179 | # Sources/References: 180 | 181 | - [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) 182 | - [Query Reference | Datomic](https://docs.datomic.com/query/query-data-reference.html) 183 | - [Datalog Queries for Roam Research | David Bieber](https://davidbieber.com/snippets/2020-12-22-datalog-queries-for-roam-research/) 184 | ``` -------------------------------------------------------------------------------- /Roam_Markdown_Cheatsheet.md: -------------------------------------------------------------------------------- ```markdown 1 | !!!! IMPORTANT: Always consult this cheatsheet for correct Roam-flavored markdown syntax BEFORE making any Roam tool calls. 2 | 3 | # Roam Markdown Cheatsheet 4 | 5 | ⭐️📋 > > > START 📋⭐️ 6 | 7 | ## Markdown Styles in Roam: 8 | 9 | - **Bold Text here** 10 | - **Italics Text here** 11 | - External Link: `[Link text](URL)` 12 | - Image Embed: `` 13 | - ^^Highlighted Text here^^ 14 | - Bullet points: - or \* followed by a space and the text 15 | - {{[[TODO]]}} todo text 16 | - {{[[DONE]]}} todo text 17 | - LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$` 18 | - Bullet points use dashes not asterisks. 19 | 20 | ## Roam-specific Markdown: 21 | 22 | - Dates are in ordinal format: `[[January 1st, 2025]]` 23 | - Block references: `((block-id))` This inserts a reference to the content of a specific block. 24 | - Page references: `[[Page name]]` This creates a link to another page within your Roam graph. 25 | - Link to blocks: `[Link Text](<((block-id))>)` This will link to the block. 26 | - Embed block in a block: `{{[[embed]]: ((block-id))}}` 27 | - To-do items: `{{[[TODO]]}} todo text` or `{{[[DONE]]}} todo text` 28 | - Syntax highlighting for fenced code blocks (add language next to backticks before fenced code block - all in one block) - Example: 29 | ```javascript 30 | const foo(bar) => { 31 | return bar; 32 | } 33 | ``` 34 | - Tags: 35 | - one-word: `#word` 36 | - multiple words: `#[[two or more words]]` 37 | - hyphenated words: `#self-esteem` 38 | 39 | ## Roam Tables 40 | 41 | 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. 42 | 43 | - The `{{[[table]]}}` block acts as the container for the entire table. 44 | - The first header block should be at level 2 (one level deeper than `{{[[table]]}}`). 45 | - Subsequent header blocks must increase their level by one. 46 | - Each row starts at level 2. 47 | - The first data cell in a row is at level 3 (one level deeper than the row block). 48 | - Subsequent data cells within the same row must increase their level by one. 49 | 50 | Example of a 4x4 table structure: 51 | 52 | ``` 53 | {{[[table]]}} 54 | - Header 1 55 | - Header 2 56 | - Header 3 57 | - Header 4 58 | - Row 1 59 | - Data 1.1 60 | - Data 1.2 61 | - Data 1.3 62 | - Data 1.4 63 | - Row 2 64 | - Data 2.1 65 | - Data 2.2 66 | - Data 2.3 67 | - Data 2.4 68 | - Row 3 69 | - Data 3.1 70 | - Data 3.2 71 | - Data 3.3 72 | - Data 3.4 73 | - Row 4 74 | - Data 4.1 75 | - Data 4.2 76 | - Data 4.3 77 | - Data 4.4 78 | ``` 79 | 80 | ## Roam Mermaid 81 | 82 | 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. 83 | 84 | ``` 85 | - {{[[mermaid]]}} 86 | - graph TD 87 | - A[Start] --> B{Decision Point} 88 | - B -->|Yes| C[Process A] 89 | - B -->|No| D[Process B] 90 | - C --> E[Merge Results] 91 | - D --> E 92 | - E --> F[End] 93 | ``` 94 | 95 | ## Roam Kanban Boards 96 | 97 | 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. 98 | 99 | ``` 100 | - {{[[kanban]]}} 101 | - card title 1 102 | - bullet point 1.1 103 | - bullet point 1.2 104 | - card title 2 105 | - bullet point 2.1 106 | - bullet point 2.2 107 | ``` 108 | 109 | --- 110 | 111 | ## Roam Hiccup 112 | 113 | 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"}]` 114 | 115 | ## Specific notes and preferences concerning my Roam Research graph 116 | ``` -------------------------------------------------------------------------------- /src/tools/operations/search/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph } from '@roam-research/roam-api-sdk'; 2 | import type { SearchResult } from '../../types/index.js'; 3 | import type { 4 | TagSearchParams, 5 | BlockRefSearchParams, 6 | HierarchySearchParams, 7 | TextSearchParams, 8 | SearchHandlerResult 9 | } from './types.js'; 10 | import { 11 | TagSearchHandlerImpl, 12 | BlockRefSearchHandlerImpl, 13 | HierarchySearchHandlerImpl, 14 | TextSearchHandlerImpl, 15 | StatusSearchHandlerImpl 16 | } from './handlers.js'; 17 | 18 | export class SearchOperations { 19 | constructor(private graph: Graph) {} 20 | 21 | async searchByStatus( 22 | status: 'TODO' | 'DONE', 23 | page_title_uid?: string, 24 | include?: string, 25 | exclude?: string 26 | ): Promise<SearchHandlerResult> { 27 | const handler = new StatusSearchHandlerImpl(this.graph, { 28 | status, 29 | page_title_uid, 30 | }); 31 | const result = await handler.execute(); 32 | 33 | // Post-process results with include/exclude filters 34 | let matches = result.matches; 35 | 36 | if (include) { 37 | const includeTerms = include.split(',').map(term => term.trim()); 38 | matches = matches.filter((match: SearchResult) => { 39 | const matchContent = match.content; 40 | const matchTitle = match.page_title; 41 | const terms = includeTerms; 42 | return terms.some(term => 43 | matchContent.includes(term) || 44 | (matchTitle && matchTitle.includes(term)) 45 | ); 46 | }); 47 | } 48 | 49 | if (exclude) { 50 | const excludeTerms = exclude.split(',').map(term => term.trim()); 51 | matches = matches.filter((match: SearchResult) => { 52 | const matchContent = match.content; 53 | const matchTitle = match.page_title; 54 | const terms = excludeTerms; 55 | return !terms.some(term => 56 | matchContent.includes(term) || 57 | (matchTitle && matchTitle.includes(term)) 58 | ); 59 | }); 60 | } 61 | 62 | return { 63 | success: true, 64 | matches, 65 | message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}` 66 | }; 67 | } 68 | 69 | async searchForTag( 70 | primary_tag: string, 71 | page_title_uid?: string, 72 | near_tag?: string 73 | ): Promise<SearchHandlerResult> { 74 | const handler = new TagSearchHandlerImpl(this.graph, { 75 | primary_tag, 76 | page_title_uid, 77 | near_tag, 78 | }); 79 | return handler.execute(); 80 | } 81 | 82 | async searchBlockRefs(params: BlockRefSearchParams): Promise<SearchHandlerResult> { 83 | const handler = new BlockRefSearchHandlerImpl(this.graph, params); 84 | return handler.execute(); 85 | } 86 | 87 | async searchHierarchy(params: HierarchySearchParams): Promise<SearchHandlerResult> { 88 | const handler = new HierarchySearchHandlerImpl(this.graph, params); 89 | return handler.execute(); 90 | } 91 | 92 | async searchByText(params: TextSearchParams): Promise<SearchHandlerResult> { 93 | const handler = new TextSearchHandlerImpl(this.graph, params); 94 | return handler.execute(); 95 | } 96 | 97 | async searchByDate(params: { 98 | start_date: string; 99 | end_date?: string; 100 | type: 'created' | 'modified' | 'both'; 101 | scope: 'blocks' | 'pages' | 'both'; 102 | include_content: boolean; 103 | }): Promise<{ 104 | success: boolean; 105 | matches: Array<{ 106 | uid: string; 107 | type: string; 108 | time: number; 109 | content?: string; 110 | page_title?: string 111 | }>; 112 | message: string 113 | }> { 114 | // Convert dates to timestamps 115 | const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime(); 116 | const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined; 117 | 118 | // Use text search handler for content-based filtering 119 | const handler = new TextSearchHandlerImpl(this.graph, { 120 | text: '', // Empty text to match all blocks 121 | }); 122 | 123 | const result = await handler.execute(); 124 | 125 | // Filter results by date 126 | const matches = result.matches 127 | .filter(match => { 128 | const time = params.type === 'created' ? 129 | new Date(match.content || '').getTime() : // Use content date for creation time 130 | Date.now(); // Use current time for modification time (simplified) 131 | 132 | return time >= startTimestamp && (!endTimestamp || time <= endTimestamp); 133 | }) 134 | .map(match => ({ 135 | uid: match.block_uid, 136 | type: 'block', 137 | time: params.type === 'created' ? 138 | new Date(match.content || '').getTime() : 139 | Date.now(), 140 | ...(params.include_content && { content: match.content }), 141 | page_title: match.page_title 142 | })); 143 | 144 | // Sort by time 145 | const sortedMatches = matches.sort((a, b) => b.time - a.time); 146 | 147 | return { 148 | success: true, 149 | matches: sortedMatches, 150 | message: `Found ${sortedMatches.length} matches for the given date range and criteria` 151 | }; 152 | } 153 | } 154 | ``` -------------------------------------------------------------------------------- /src/search/hierarchy-search.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { q } from '@roam-research/roam-api-sdk'; 2 | import type { Graph } from '@roam-research/roam-api-sdk'; 3 | import { BaseSearchHandler, SearchResult } from './types.js'; 4 | import { SearchUtils } from './utils.js'; 5 | import { resolveRefs } from '../tools/helpers/refs.js'; 6 | 7 | export interface HierarchySearchParams { 8 | parent_uid?: string; // Search for children of this block 9 | child_uid?: string; // Search for parents of this block 10 | page_title_uid?: string; 11 | max_depth?: number; // How many levels deep to search (default: 1) 12 | } 13 | 14 | export class HierarchySearchHandler extends BaseSearchHandler { 15 | constructor( 16 | graph: Graph, 17 | private params: HierarchySearchParams 18 | ) { 19 | super(graph); 20 | } 21 | 22 | async execute(): Promise<SearchResult> { 23 | const { parent_uid, child_uid, page_title_uid, max_depth = 1 } = this.params; 24 | 25 | if (!parent_uid && !child_uid) { 26 | return { 27 | success: false, 28 | matches: [], 29 | message: 'Either parent_uid or child_uid must be provided' 30 | }; 31 | } 32 | 33 | // Get target page UID if provided 34 | let targetPageUid: string | undefined; 35 | if (page_title_uid) { 36 | targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); 37 | } 38 | 39 | // Define ancestor rule for recursive traversal 40 | const ancestorRule = `[ 41 | [ (ancestor ?child ?parent) 42 | [?parent :block/children ?child] ] 43 | [ (ancestor ?child ?a) 44 | [?parent :block/children ?child] 45 | (ancestor ?parent ?a) ] 46 | ]`; 47 | 48 | let queryStr: string; 49 | let queryParams: any[]; 50 | 51 | if (parent_uid) { 52 | // Search for all descendants using ancestor rule 53 | if (targetPageUid) { 54 | queryStr = `[:find ?block-uid ?block-str ?depth 55 | :in $ % ?parent-uid ?page-uid 56 | :where [?p :block/uid ?page-uid] 57 | [?parent :block/uid ?parent-uid] 58 | (ancestor ?b ?parent) 59 | [?b :block/string ?block-str] 60 | [?b :block/uid ?block-uid] 61 | [?b :block/page ?p] 62 | [(get-else $ ?b :block/path-length 1) ?depth]]`; 63 | queryParams = [ancestorRule, parent_uid, targetPageUid]; 64 | } else { 65 | queryStr = `[:find ?block-uid ?block-str ?page-title ?depth 66 | :in $ % ?parent-uid 67 | :where [?parent :block/uid ?parent-uid] 68 | (ancestor ?b ?parent) 69 | [?b :block/string ?block-str] 70 | [?b :block/uid ?block-uid] 71 | [?b :block/page ?p] 72 | [?p :node/title ?page-title] 73 | [(get-else $ ?b :block/path-length 1) ?depth]]`; 74 | queryParams = [ancestorRule, parent_uid]; 75 | } 76 | } else { 77 | // Search for ancestors using the same rule 78 | if (targetPageUid) { 79 | queryStr = `[:find ?block-uid ?block-str ?depth 80 | :in $ % ?child-uid ?page-uid 81 | :where [?p :block/uid ?page-uid] 82 | [?child :block/uid ?child-uid] 83 | (ancestor ?child ?b) 84 | [?b :block/string ?block-str] 85 | [?b :block/uid ?block-uid] 86 | [?b :block/page ?p] 87 | [(get-else $ ?b :block/path-length 1) ?depth]]`; 88 | queryParams = [ancestorRule, child_uid, targetPageUid]; 89 | } else { 90 | queryStr = `[:find ?block-uid ?block-str ?page-title ?depth 91 | :in $ % ?child-uid 92 | :where [?child :block/uid ?child-uid] 93 | (ancestor ?child ?b) 94 | [?b :block/string ?block-str] 95 | [?b :block/uid ?block-uid] 96 | [?b :block/page ?p] 97 | [?p :node/title ?page-title] 98 | [(get-else $ ?b :block/path-length 1) ?depth]]`; 99 | queryParams = [ancestorRule, child_uid]; 100 | } 101 | } 102 | 103 | const rawResults = await q(this.graph, queryStr, queryParams) as [string, string, string?, number?][]; 104 | 105 | // Resolve block references and format results to include depth information 106 | const matches = await Promise.all(rawResults.map(async ([uid, content, pageTitle, depth]) => { 107 | const resolvedContent = await resolveRefs(this.graph, content); 108 | return { 109 | block_uid: uid, 110 | content: resolvedContent, 111 | depth: depth || 1, 112 | ...(pageTitle && { page_title: pageTitle }) 113 | }; 114 | })); 115 | 116 | const searchDescription = parent_uid 117 | ? `descendants of block ${parent_uid}` 118 | : `ancestors of block ${child_uid}`; 119 | 120 | return { 121 | success: true, 122 | matches, 123 | message: `Found ${matches.length} block(s) as ${searchDescription}` 124 | }; 125 | } 126 | } 127 | ``` -------------------------------------------------------------------------------- /src/search/tag-search.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { q } from '@roam-research/roam-api-sdk'; 2 | import type { Graph } from '@roam-research/roam-api-sdk'; 3 | import { BaseSearchHandler, TagSearchParams, SearchResult } from './types.js'; 4 | import { SearchUtils } from './utils.js'; 5 | import { resolveRefs } from '../tools/helpers/refs.js'; 6 | 7 | export class TagSearchHandler extends BaseSearchHandler { 8 | constructor( 9 | graph: Graph, 10 | private params: TagSearchParams 11 | ) { 12 | super(graph); 13 | } 14 | 15 | async execute(): Promise<SearchResult> { 16 | const { primary_tag, page_title_uid, near_tag, exclude_tag, case_sensitive = false, limit = -1, offset = 0 } = this.params; 17 | 18 | let nearTagUid: string | undefined; 19 | if (near_tag) { 20 | nearTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, near_tag); 21 | if (!nearTagUid) { 22 | return { 23 | success: false, 24 | matches: [], 25 | message: `Near tag "${near_tag}" not found.`, 26 | total_count: 0 27 | }; 28 | } 29 | } 30 | 31 | let excludeTagUid: string | undefined; 32 | if (exclude_tag) { 33 | excludeTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, exclude_tag); 34 | if (!excludeTagUid) { 35 | return { 36 | success: false, 37 | matches: [], 38 | message: `Exclude tag "${exclude_tag}" not found.`, 39 | total_count: 0 40 | }; 41 | } 42 | } 43 | 44 | // Get target page UID if provided for scoped search 45 | let targetPageUid: string | undefined; 46 | if (page_title_uid) { 47 | targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid); 48 | } 49 | 50 | const searchTags: string[] = []; 51 | if (case_sensitive) { 52 | searchTags.push(primary_tag); 53 | } else { 54 | searchTags.push(primary_tag); 55 | searchTags.push(primary_tag.charAt(0).toUpperCase() + primary_tag.slice(1)); 56 | searchTags.push(primary_tag.toUpperCase()); 57 | searchTags.push(primary_tag.toLowerCase()); 58 | } 59 | 60 | const tagWhereClauses = searchTags.map(tag => { 61 | // Roam tags can be [[tag name]] or #tag-name or #[[tag name]] 62 | // The :node/title for a tag page is just the tag name without any # or [[ ]] 63 | return `[?ref-page :node/title "${tag}"]`; 64 | }).join(' '); 65 | 66 | let inClause = `:in $`; 67 | let queryLimit = limit === -1 ? '' : `:limit ${limit}`; 68 | let queryOffset = offset === 0 ? '' : `:offset ${offset}`; 69 | let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID 70 | 71 | let queryWhereClauses = ` 72 | (or ${tagWhereClauses}) 73 | [?b :block/refs ?ref-page] 74 | [?b :block/string ?block-str] 75 | [?b :block/uid ?block-uid] 76 | [?b :block/page ?p] 77 | [?p :node/title ?page-title] 78 | [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting 79 | 80 | if (nearTagUid) { 81 | queryWhereClauses += ` 82 | [?b :block/refs ?near-tag-page] 83 | [?near-tag-page :block/uid "${nearTagUid}"]`; 84 | } 85 | 86 | if (excludeTagUid) { 87 | queryWhereClauses += ` 88 | (not [?b :block/refs ?exclude-tag-page]) 89 | [?exclude-tag-page :block/uid "${excludeTagUid}"]`; 90 | } 91 | 92 | if (targetPageUid) { 93 | inClause += ` ?target-page-uid`; 94 | queryWhereClauses += ` 95 | [?p :block/uid ?target-page-uid]`; 96 | } 97 | 98 | const queryStr = `[:find ?block-uid ?block-str ?page-title 99 | ${inClause} ${queryLimit} ${queryOffset} ${queryOrder} 100 | :where 101 | ${queryWhereClauses}]`; 102 | 103 | const queryArgs: (string | number)[] = []; 104 | if (targetPageUid) { 105 | queryArgs.push(targetPageUid); 106 | } 107 | 108 | const rawResults = await q(this.graph, queryStr, queryArgs) as [string, string, string?][]; 109 | 110 | // Query to get total count without limit 111 | const countQueryStr = `[:find (count ?b) 112 | ${inClause} 113 | :where 114 | ${queryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query 115 | 116 | const totalCountResults = await q(this.graph, countQueryStr, queryArgs) as number[][]; 117 | const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0; 118 | 119 | // Resolve block references in content 120 | const resolvedResults = await Promise.all( 121 | rawResults.map(async ([uid, content, pageTitle]) => { 122 | const resolvedContent = await resolveRefs(this.graph, content); 123 | return [uid, resolvedContent, pageTitle] as [string, string, string?]; 124 | }) 125 | ); 126 | 127 | const searchDescription = `referencing "${primary_tag}"`; 128 | const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid); 129 | formattedResults.total_count = totalCount; 130 | return formattedResults; 131 | } 132 | } 133 | ``` -------------------------------------------------------------------------------- /src/tools/tool-handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph } from '@roam-research/roam-api-sdk'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | import { PageOperations } from './operations/pages.js'; 6 | import { BlockOperations } from './operations/blocks.js'; 7 | import { BlockRetrievalOperations } from './operations/block-retrieval.js'; // New import 8 | import { SearchOperations } from './operations/search/index.js'; 9 | import { MemoryOperations } from './operations/memory.js'; 10 | import { TodoOperations } from './operations/todos.js'; 11 | import { OutlineOperations } from './operations/outline.js'; 12 | import { BatchOperations } from './operations/batch.js'; 13 | import { DatomicSearchHandlerImpl } from './operations/search/handlers.js'; 14 | 15 | export class ToolHandlers { 16 | private pageOps: PageOperations; 17 | private blockOps: BlockOperations; 18 | private blockRetrievalOps: BlockRetrievalOperations; // New instance 19 | private searchOps: SearchOperations; 20 | private memoryOps: MemoryOperations; 21 | private todoOps: TodoOperations; 22 | private outlineOps: OutlineOperations; 23 | private batchOps: BatchOperations; 24 | 25 | constructor(private graph: Graph) { 26 | this.pageOps = new PageOperations(graph); 27 | this.blockOps = new BlockOperations(graph); 28 | this.blockRetrievalOps = new BlockRetrievalOperations(graph); // Initialize new instance 29 | this.searchOps = new SearchOperations(graph); 30 | this.memoryOps = new MemoryOperations(graph); 31 | this.todoOps = new TodoOperations(graph); 32 | this.outlineOps = new OutlineOperations(graph); 33 | this.batchOps = new BatchOperations(graph); 34 | } 35 | 36 | // Page Operations 37 | async findPagesModifiedToday(limit: number = 50, offset: number = 0, sort_order: 'asc' | 'desc' = 'desc') { 38 | return this.pageOps.findPagesModifiedToday(limit, offset, sort_order); 39 | } 40 | 41 | async createPage(title: string, content?: Array<{ text: string; level: number; heading?: number }>) { 42 | return this.pageOps.createPage(title, content); 43 | } 44 | 45 | async fetchPageByTitle(title: string, format?: 'markdown' | 'raw') { 46 | return this.pageOps.fetchPageByTitle(title, format); 47 | } 48 | 49 | // Block Operations 50 | async fetchBlockWithChildren(block_uid: string, depth?: number) { 51 | return this.blockRetrievalOps.fetchBlockWithChildren(block_uid, depth); 52 | } 53 | 54 | // Search Operations 55 | async searchByStatus( 56 | status: 'TODO' | 'DONE', 57 | page_title_uid?: string, 58 | include?: string, 59 | exclude?: string 60 | ) { 61 | return this.searchOps.searchByStatus(status, page_title_uid, include, exclude); 62 | } 63 | 64 | async searchForTag( 65 | primary_tag: string, 66 | page_title_uid?: string, 67 | near_tag?: string 68 | ) { 69 | return this.searchOps.searchForTag(primary_tag, page_title_uid, near_tag); 70 | } 71 | 72 | async searchBlockRefs(params: { block_uid?: string; page_title_uid?: string }) { 73 | return this.searchOps.searchBlockRefs(params); 74 | } 75 | 76 | async searchHierarchy(params: { 77 | parent_uid?: string; 78 | child_uid?: string; 79 | page_title_uid?: string; 80 | max_depth?: number; 81 | }) { 82 | return this.searchOps.searchHierarchy(params); 83 | } 84 | 85 | async searchByText(params: { 86 | text: string; 87 | page_title_uid?: string; 88 | }) { 89 | return this.searchOps.searchByText(params); 90 | } 91 | 92 | async searchByDate(params: { 93 | start_date: string; 94 | end_date?: string; 95 | type: 'created' | 'modified' | 'both'; 96 | scope: 'blocks' | 'pages' | 'both'; 97 | include_content: boolean; 98 | }) { 99 | return this.searchOps.searchByDate(params); 100 | } 101 | 102 | // Datomic query 103 | async executeDatomicQuery(params: { query: string; inputs?: unknown[] }) { 104 | const handler = new DatomicSearchHandlerImpl(this.graph, params); 105 | return handler.execute(); 106 | } 107 | 108 | // Memory Operations 109 | async remember(memory: string, categories?: string[]) { 110 | return this.memoryOps.remember(memory, categories); 111 | } 112 | 113 | async recall(sort_by: 'newest' | 'oldest' = 'newest', filter_tag?: string) { 114 | return this.memoryOps.recall(sort_by, filter_tag); 115 | } 116 | 117 | // Todo Operations 118 | async addTodos(todos: string[]) { 119 | return this.todoOps.addTodos(todos); 120 | } 121 | 122 | // Outline Operations 123 | async createOutline(outline: Array<{ text: string | undefined; level: number }>, page_title_uid?: string, block_text_uid?: string) { 124 | return this.outlineOps.createOutline(outline, page_title_uid, block_text_uid); 125 | } 126 | 127 | async importMarkdown( 128 | content: string, 129 | page_uid?: string, 130 | page_title?: string, 131 | parent_uid?: string, 132 | parent_string?: string, 133 | order: 'first' | 'last' = 'first' 134 | ) { 135 | return this.outlineOps.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order); 136 | } 137 | 138 | // Batch Operations 139 | async processBatch(actions: any[]) { 140 | return this.batchOps.processBatch(actions); 141 | } 142 | 143 | async getRoamMarkdownCheatsheet() { 144 | const __filename = fileURLToPath(import.meta.url); 145 | const __dirname = path.dirname(__filename); 146 | const cheatsheetPath = path.join(__dirname, '../../Roam_Markdown_Cheatsheet.md'); 147 | let cheatsheetContent = fs.readFileSync(cheatsheetPath, 'utf-8'); 148 | 149 | const customInstructionsPath = process.env.CUSTOM_INSTRUCTIONS_PATH; 150 | if (customInstructionsPath && fs.existsSync(customInstructionsPath)) { 151 | try { 152 | const customInstructionsContent = fs.readFileSync(customInstructionsPath, 'utf-8'); 153 | cheatsheetContent += `\n\n${customInstructionsContent}`; 154 | } catch (error) { 155 | console.warn(`Could not read custom instructions file at ${customInstructionsPath}: ${error}`); 156 | } 157 | } 158 | return cheatsheetContent; 159 | } 160 | } 161 | ``` -------------------------------------------------------------------------------- /src/tools/operations/memory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, q, createPage, batchActions } from '@roam-research/roam-api-sdk'; 2 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 3 | import { formatRoamDate } from '../../utils/helpers.js'; 4 | import { resolveRefs } from '../helpers/refs.js'; 5 | import { SearchOperations } from './search/index.js'; 6 | import type { SearchResult } from '../types/index.js'; 7 | 8 | export class MemoryOperations { 9 | private searchOps: SearchOperations; 10 | 11 | constructor(private graph: Graph) { 12 | this.searchOps = new SearchOperations(graph); 13 | } 14 | 15 | async remember(memory: string, categories?: string[]): Promise<{ success: boolean }> { 16 | // Get today's date 17 | const today = new Date(); 18 | const dateStr = formatRoamDate(today); 19 | 20 | // Try to find today's page 21 | const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; 22 | const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; 23 | 24 | let pageUid: string; 25 | 26 | if (findResults && findResults.length > 0) { 27 | pageUid = findResults[0][0]; 28 | } else { 29 | // Create today's page if it doesn't exist 30 | try { 31 | await createPage(this.graph, { 32 | action: 'create-page', 33 | page: { title: dateStr } 34 | }); 35 | 36 | // Get the new page's UID 37 | const results = await q(this.graph, findQuery, [dateStr]) as [string][]; 38 | if (!results || results.length === 0) { 39 | throw new McpError( 40 | ErrorCode.InternalError, 41 | 'Could not find created today\'s page' 42 | ); 43 | } 44 | pageUid = results[0][0]; 45 | } catch (error) { 46 | throw new McpError( 47 | ErrorCode.InternalError, 48 | 'Failed to create today\'s page' 49 | ); 50 | } 51 | } 52 | 53 | // Get memories tag from environment 54 | const memoriesTag = process.env.MEMORIES_TAG; 55 | if (!memoriesTag) { 56 | throw new McpError( 57 | ErrorCode.InternalError, 58 | 'MEMORIES_TAG environment variable not set' 59 | ); 60 | } 61 | 62 | // Format categories as Roam tags if provided 63 | const categoryTags = categories?.map(cat => { 64 | // Handle multi-word categories 65 | return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`; 66 | }).join(' ') || ''; 67 | 68 | // Create block with memory, memories tag, and optional categories 69 | const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim(); 70 | 71 | const actions = [{ 72 | action: 'create-block', 73 | location: { 74 | 'parent-uid': pageUid, 75 | order: 'last' 76 | }, 77 | block: { 78 | string: blockContent 79 | } 80 | }]; 81 | 82 | try { 83 | const result = await batchActions(this.graph, { 84 | action: 'batch-actions', 85 | actions 86 | }); 87 | 88 | if (!result) { 89 | throw new McpError( 90 | ErrorCode.InternalError, 91 | 'Failed to create memory block via batch action' 92 | ); 93 | } 94 | } catch (error) { 95 | throw new McpError( 96 | ErrorCode.InternalError, 97 | `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}` 98 | ); 99 | } 100 | 101 | return { success: true }; 102 | } 103 | 104 | async recall(sort_by: 'newest' | 'oldest' = 'newest', filter_tag?: string): Promise<{ success: boolean; memories: string[] }> { 105 | // Get memories tag from environment 106 | var memoriesTag = process.env.MEMORIES_TAG; 107 | if (!memoriesTag) { 108 | memoriesTag = "Memories" 109 | } 110 | 111 | // Extract the tag text, removing any formatting 112 | const tagText = memoriesTag 113 | .replace(/^#/, '') // Remove leading # 114 | .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]] 115 | 116 | try { 117 | // Get page blocks using query to access actual block content 118 | const ancestorRule = `[ 119 | [ (ancestor ?b ?a) 120 | [?a :block/children ?b] ] 121 | [ (ancestor ?b ?a) 122 | [?parent :block/children ?b] 123 | (ancestor ?parent ?a) ] 124 | ]`; 125 | 126 | // Query to find all blocks on the page 127 | const pageQuery = `[:find ?string ?time 128 | :in $ % ?title 129 | :where 130 | [?page :node/title ?title] 131 | [?block :block/string ?string] 132 | [?block :create/time ?time] 133 | (ancestor ?block ?page)]`; 134 | 135 | // Execute query 136 | const pageResults = await q(this.graph, pageQuery, [ancestorRule, tagText]) as [string, number][]; 137 | 138 | // Process page blocks with sorting 139 | let pageMemories = pageResults 140 | .sort(([_, aTime], [__, bTime]) => 141 | sort_by === 'newest' ? bTime - aTime : aTime - bTime 142 | ) 143 | .map(([content]) => content); 144 | 145 | // Get tagged blocks from across the graph 146 | const tagResults = await this.searchOps.searchForTag(tagText); 147 | 148 | // Process tagged blocks with sorting 149 | let taggedMemories = tagResults.matches 150 | .sort((a: SearchResult, b: SearchResult) => { 151 | const aTime = a.block_uid ? parseInt(a.block_uid.split('-')[0], 16) : 0; 152 | const bTime = b.block_uid ? parseInt(b.block_uid.split('-')[0], 16) : 0; 153 | return sort_by === 'newest' ? bTime - aTime : aTime - bTime; 154 | }) 155 | .map(match => match.content); 156 | 157 | // Resolve any block references in both sets 158 | const resolvedPageMemories = await Promise.all( 159 | pageMemories.map(async (content: string) => resolveRefs(this.graph, content)) 160 | ); 161 | const resolvedTaggedMemories = await Promise.all( 162 | taggedMemories.map(async (content: string) => resolveRefs(this.graph, content)) 163 | ); 164 | 165 | // Combine both sets and remove duplicates while preserving order 166 | let uniqueMemories = [ 167 | ...resolvedPageMemories, 168 | ...resolvedTaggedMemories 169 | ].filter((memory, index, self) => 170 | self.indexOf(memory) === index 171 | ); 172 | 173 | // Format filter tag with exact Roam tag syntax 174 | const filterTagFormatted = filter_tag ? 175 | (filter_tag.includes(' ') ? `#[[${filter_tag}]]` : `#${filter_tag}`) : null; 176 | 177 | // Filter by exact tag match if provided 178 | if (filterTagFormatted) { 179 | uniqueMemories = uniqueMemories.filter(memory => memory.includes(filterTagFormatted)); 180 | } 181 | 182 | // Format memories tag for removal and clean up memories tag 183 | const memoriesTagFormatted = tagText.includes(' ') || tagText.includes('/') ? `#[[${tagText}]]` : `#${tagText}`; 184 | uniqueMemories = uniqueMemories.map(memory => memory.replace(memoriesTagFormatted, '').trim()); 185 | 186 | // return { 187 | // success: true, 188 | // memories: [ 189 | // `memoriesTag = ${memoriesTag}`, 190 | // `filter_tag = ${filter_tag}`, 191 | // `filterTagFormatted = ${filterTagFormatted}`, 192 | // `memoriesTagFormatted = ${memoriesTagFormatted}`, 193 | // ] 194 | // } 195 | return { 196 | success: true, 197 | memories: uniqueMemories 198 | }; 199 | } catch (error: any) { 200 | throw new McpError( 201 | ErrorCode.InternalError, 202 | `Failed to recall memories: ${error.message}` 203 | ); 204 | } 205 | } 206 | } 207 | ``` -------------------------------------------------------------------------------- /src/markdown-utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | RoamCreateBlock, 3 | RoamCreatePage, 4 | RoamUpdateBlock, 5 | RoamDeleteBlock, 6 | RoamDeletePage, 7 | RoamMoveBlock 8 | } from '@roam-research/roam-api-sdk'; 9 | 10 | export type BatchAction = 11 | | RoamCreateBlock 12 | | RoamCreatePage 13 | | RoamUpdateBlock 14 | | RoamDeleteBlock 15 | | RoamDeletePage 16 | | RoamMoveBlock; 17 | 18 | interface MarkdownNode { 19 | content: string; 20 | level: number; 21 | heading_level?: number; // Optional heading level (1-3) for heading nodes 22 | children_view_type?: 'bullet' | 'document' | 'numbered'; // Optional view type for children 23 | children: MarkdownNode[]; 24 | } 25 | 26 | /** 27 | * Check if text has a traditional markdown table 28 | */ 29 | function hasMarkdownTable(text: string): boolean { 30 | return /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+$/.test(text); 31 | } 32 | 33 | /** 34 | * Converts a markdown table to Roam format 35 | */ 36 | function convertTableToRoamFormat(text: string) { 37 | const lines = text.split('\n') 38 | .map(line => line.trim()) 39 | .filter(line => line.length > 0); 40 | 41 | const tableRegex = /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+/m; 42 | 43 | if (!tableRegex.test(text)) { 44 | return text; 45 | } 46 | 47 | const rows = lines 48 | .filter((_, index) => index !== 1) 49 | .map(line => 50 | line.trim() 51 | .replace(/^\||\|$/g, '') 52 | .split('|') 53 | .map(cell => cell.trim()) 54 | ); 55 | 56 | let roamTable = '{{[[table]]}}\n'; 57 | 58 | // First row becomes column headers 59 | const headers = rows[0]; 60 | for (let i = 0; i < headers.length; i++) { 61 | roamTable += `${' '.repeat(i + 1)}- ${headers[i]}\n`; 62 | } 63 | 64 | // Remaining rows become nested under each column 65 | for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) { 66 | const row = rows[rowIndex]; 67 | for (let colIndex = 0; colIndex < row.length; colIndex++) { 68 | roamTable += `${' '.repeat(colIndex + 1)}- ${row[colIndex]}\n`; 69 | } 70 | } 71 | 72 | return roamTable.trim(); 73 | } 74 | 75 | function convertAllTables(text: string) { 76 | return text.replaceAll( 77 | /(^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+)/gm, 78 | (match) => { 79 | return '\n' + convertTableToRoamFormat(match) + '\n'; 80 | } 81 | ); 82 | } 83 | 84 | /** 85 | * Parse markdown heading syntax (e.g. "### Heading") and return the heading level (1-3) and content. 86 | * Heading level is determined by the number of # characters (e.g. # = h1, ## = h2, ### = h3). 87 | * Returns heading_level: 0 for non-heading content. 88 | */ 89 | function parseMarkdownHeadingLevel(text: string): { heading_level: number; content: string } { 90 | const match = text.match(/^(#{1,3})\s+(.+)$/); 91 | if (match) { 92 | return { 93 | heading_level: match[1].length, // Number of # characters determines heading level 94 | content: match[2].trim() 95 | }; 96 | } 97 | return { 98 | heading_level: 0, // Not a heading 99 | content: text.trim() 100 | }; 101 | } 102 | 103 | function convertToRoamMarkdown(text: string): string { 104 | // Handle double asterisks/underscores (bold) 105 | text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks 106 | 107 | // Handle single asterisks/underscores (italic) 108 | text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore 109 | text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore 110 | 111 | // Handle highlights 112 | text = text.replace(/==(.+?)==/g, '^^$1^^'); 113 | 114 | // Convert tasks 115 | text = text.replace(/- \[ \]/g, '- {{[[TODO]]}}'); 116 | text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}'); 117 | 118 | // Convert tables 119 | text = convertAllTables(text); 120 | 121 | return text; 122 | } 123 | 124 | function parseMarkdown(markdown: string): MarkdownNode[] { 125 | markdown = convertToRoamMarkdown(markdown); 126 | 127 | const originalLines = markdown.split('\n'); 128 | const processedLines: string[] = []; 129 | 130 | // Pre-process lines to handle mid-line code blocks without splice 131 | for (const line of originalLines) { 132 | const trimmedLine = line.trimEnd(); 133 | const codeStartIndex = trimmedLine.indexOf('```'); 134 | 135 | if (codeStartIndex > 0) { 136 | const indentationWhitespace = line.match(/^\s*/)?.[0] ?? ''; 137 | processedLines.push(indentationWhitespace + trimmedLine.substring(0, codeStartIndex)); 138 | processedLines.push(indentationWhitespace + trimmedLine.substring(codeStartIndex)); 139 | } else { 140 | processedLines.push(line); 141 | } 142 | } 143 | 144 | const rootNodes: MarkdownNode[] = []; 145 | const stack: MarkdownNode[] = []; 146 | let inCodeBlock = false; 147 | let codeBlockContent = ''; 148 | let codeBlockIndentation = 0; 149 | let codeBlockParentLevel = 0; 150 | 151 | for (let i = 0; i < processedLines.length; i++) { 152 | const line = processedLines[i]; 153 | const trimmedLine = line.trimEnd(); 154 | 155 | if (trimmedLine.match(/^(\s*)```/)) { 156 | if (!inCodeBlock) { 157 | inCodeBlock = true; 158 | codeBlockContent = trimmedLine.trimStart() + '\n'; 159 | codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0; 160 | codeBlockParentLevel = stack.length; 161 | } else { 162 | inCodeBlock = false; 163 | codeBlockContent += trimmedLine.trimStart(); 164 | 165 | const linesInCodeBlock = codeBlockContent.split('\n'); 166 | 167 | let baseIndentation = ''; 168 | for (let j = 1; j < linesInCodeBlock.length - 1; j++) { 169 | const codeLine = linesInCodeBlock[j]; 170 | if (codeLine.trim().length > 0) { 171 | const indentMatch = codeLine.match(/^[\t ]*/); 172 | if (indentMatch) { 173 | baseIndentation = indentMatch[0]; 174 | break; 175 | } 176 | } 177 | } 178 | 179 | const processedCodeLines = linesInCodeBlock.map((codeLine, index) => { 180 | if (index === 0 || index === linesInCodeBlock.length - 1) return codeLine.trimStart(); 181 | 182 | if (codeLine.trim().length === 0) return ''; 183 | 184 | if (codeLine.startsWith(baseIndentation)) { 185 | return codeLine.slice(baseIndentation.length); 186 | } 187 | return codeLine.trimStart(); 188 | }); 189 | 190 | const level = Math.floor(codeBlockIndentation / 2); 191 | const node: MarkdownNode = { 192 | content: processedCodeLines.join('\n'), 193 | level, 194 | children: [] 195 | }; 196 | 197 | while (stack.length > codeBlockParentLevel) { 198 | stack.pop(); 199 | } 200 | if (level === 0) { 201 | rootNodes.push(node); 202 | stack[0] = node; 203 | } else { 204 | while (stack.length > level) { 205 | stack.pop(); 206 | } 207 | if (stack[level - 1]) { 208 | stack[level - 1].children.push(node); 209 | } else { 210 | rootNodes.push(node); 211 | } 212 | stack[level] = node; 213 | } 214 | 215 | codeBlockContent = ''; 216 | } 217 | continue; 218 | } 219 | 220 | if (inCodeBlock) { 221 | codeBlockContent += line + '\n'; 222 | continue; 223 | } 224 | 225 | if (trimmedLine === '') { 226 | continue; 227 | } 228 | 229 | const indentation = line.match(/^\s*/)?.[0].length ?? 0; 230 | let level = Math.floor(indentation / 2); 231 | 232 | let contentToParse: string; 233 | const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/); 234 | if (bulletMatch) { 235 | level = Math.floor(bulletMatch[1].length / 2); 236 | contentToParse = trimmedLine.substring(bulletMatch[0].length); 237 | } else { 238 | contentToParse = trimmedLine; 239 | } 240 | 241 | const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse); 242 | 243 | const node: MarkdownNode = { 244 | content: finalContent, 245 | level, 246 | ...(heading_level > 0 && { heading_level }), 247 | children: [] 248 | }; 249 | 250 | while (stack.length > level) { 251 | stack.pop(); 252 | } 253 | 254 | if (level === 0 || !stack[level - 1]) { 255 | rootNodes.push(node); 256 | stack[0] = node; 257 | } else { 258 | stack[level - 1].children.push(node); 259 | } 260 | stack[level] = node; 261 | } 262 | 263 | return rootNodes; 264 | } 265 | 266 | function parseTableRows(lines: string[]): MarkdownNode[] { 267 | const tableNodes: MarkdownNode[] = []; 268 | let currentLevel = -1; 269 | 270 | for (const line of lines) { 271 | const trimmedLine = line.trimEnd(); 272 | if (!trimmedLine) continue; 273 | 274 | // Calculate indentation level 275 | const indentation = line.match(/^\s*/)?.[0].length ?? 0; 276 | const level = Math.floor(indentation / 2); 277 | 278 | // Extract content after bullet point 279 | const content = trimmedLine.replace(/^\s*[-*+]\s*/, ''); 280 | 281 | // Create node for this cell 282 | const node: MarkdownNode = { 283 | content, 284 | level, 285 | children: [] 286 | }; 287 | 288 | // Track the first level we see to maintain relative nesting 289 | if (currentLevel === -1) { 290 | currentLevel = level; 291 | } 292 | 293 | // Add node to appropriate parent based on level 294 | if (level === currentLevel) { 295 | tableNodes.push(node); 296 | } else { 297 | // Find parent by walking back through nodes 298 | let parent = tableNodes[tableNodes.length - 1]; 299 | while (parent && parent.level < level - 1) { 300 | parent = parent.children[parent.children.length - 1]; 301 | } 302 | if (parent) { 303 | parent.children.push(node); 304 | } 305 | } 306 | } 307 | 308 | return tableNodes; 309 | } 310 | 311 | function generateBlockUid(): string { 312 | // Generate a random string of 9 characters (Roam's format) 313 | const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'; 314 | let uid = ''; 315 | for (let i = 0; i < 9; i++) { 316 | uid += chars.charAt(Math.floor(Math.random() * chars.length)); 317 | } 318 | return uid; 319 | } 320 | 321 | interface BlockInfo { 322 | uid: string; 323 | content: string; 324 | heading_level?: number; // Optional heading level (1-3) for heading nodes 325 | children_view_type?: 'bullet' | 'document' | 'numbered'; // Optional view type for children 326 | children: BlockInfo[]; 327 | } 328 | 329 | function convertNodesToBlocks(nodes: MarkdownNode[]): BlockInfo[] { 330 | return nodes.map(node => ({ 331 | uid: generateBlockUid(), 332 | content: node.content, 333 | ...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present 334 | children: convertNodesToBlocks(node.children) 335 | })); 336 | } 337 | 338 | function convertToRoamActions( 339 | nodes: MarkdownNode[], 340 | parentUid: string, 341 | order: 'first' | 'last' | number = 'last' 342 | ): BatchAction[] { 343 | // First convert nodes to blocks with UIDs 344 | const blocks = convertNodesToBlocks(nodes); 345 | const actions: BatchAction[] = []; 346 | 347 | // Helper function to recursively create actions 348 | function createBlockActions(blocks: BlockInfo[], parentUid: string, order: 'first' | 'last' | number): void { 349 | for (let i = 0; i < blocks.length; i++) { 350 | const block = blocks[i]; 351 | // Create the current block 352 | const action: RoamCreateBlock = { 353 | action: 'create-block', 354 | location: { 355 | 'parent-uid': parentUid, 356 | order: typeof order === 'number' ? order + i : i 357 | }, 358 | block: { 359 | uid: block.uid, 360 | string: block.content, 361 | ...(block.heading_level && { heading: block.heading_level }), 362 | ...(block.children_view_type && { 'children-view-type': block.children_view_type }) 363 | } 364 | }; 365 | 366 | actions.push(action); 367 | 368 | // Create child blocks if any 369 | if (block.children.length > 0) { 370 | createBlockActions(block.children, block.uid, 'last'); 371 | } 372 | } 373 | } 374 | 375 | // Create all block actions 376 | createBlockActions(blocks, parentUid, order); 377 | 378 | return actions; 379 | } 380 | 381 | // Export public functions and types 382 | export { 383 | parseMarkdown, 384 | convertToRoamActions, 385 | hasMarkdownTable, 386 | convertAllTables, 387 | convertToRoamMarkdown, 388 | parseMarkdownHeadingLevel 389 | }; 390 | ``` -------------------------------------------------------------------------------- /src/tools/operations/blocks.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk'; 2 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 3 | import { formatRoamDate } from '../../utils/helpers.js'; 4 | import { 5 | parseMarkdown, 6 | convertToRoamActions, 7 | convertToRoamMarkdown, 8 | hasMarkdownTable, 9 | type BatchAction 10 | } from '../../markdown-utils.js'; 11 | import type { BlockUpdate, BlockUpdateResult } from '../types/index.js'; 12 | 13 | export class BlockOperations { 14 | constructor(private graph: Graph) {} 15 | 16 | async createBlock(content: string, page_uid?: string, title?: string, heading?: number): Promise<{ success: boolean; block_uid?: string; parent_uid: string }> { 17 | // If page_uid provided, use it directly 18 | let targetPageUid = page_uid; 19 | 20 | // If no page_uid but title provided, search for page by title 21 | if (!targetPageUid && title) { 22 | const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; 23 | const findResults = await q(this.graph, findQuery, [title]) as [string][]; 24 | 25 | if (findResults && findResults.length > 0) { 26 | targetPageUid = findResults[0][0]; 27 | } else { 28 | // Create page with provided title if it doesn't exist 29 | try { 30 | await createPage(this.graph, { 31 | action: 'create-page', 32 | page: { title } 33 | }); 34 | 35 | // Get the new page's UID 36 | const results = await q(this.graph, findQuery, [title]) as [string][]; 37 | if (!results || results.length === 0) { 38 | throw new Error('Could not find created page'); 39 | } 40 | targetPageUid = results[0][0]; 41 | } catch (error) { 42 | throw new McpError( 43 | ErrorCode.InternalError, 44 | `Failed to create page: ${error instanceof Error ? error.message : String(error)}` 45 | ); 46 | } 47 | } 48 | } 49 | 50 | // If neither page_uid nor title provided, use today's date page 51 | if (!targetPageUid) { 52 | const today = new Date(); 53 | const dateStr = formatRoamDate(today); 54 | 55 | // Try to find today's page 56 | const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; 57 | const findResults = await q(this.graph, findQuery, [dateStr]) as [string][]; 58 | 59 | if (findResults && findResults.length > 0) { 60 | targetPageUid = findResults[0][0]; 61 | } else { 62 | // Create today's page if it doesn't exist 63 | try { 64 | await createPage(this.graph, { 65 | action: 'create-page', 66 | page: { title: dateStr } 67 | }); 68 | 69 | // Get the new page's UID 70 | const results = await q(this.graph, findQuery, [dateStr]) as [string][]; 71 | if (!results || results.length === 0) { 72 | throw new Error('Could not find created today\'s page'); 73 | } 74 | targetPageUid = results[0][0]; 75 | } catch (error) { 76 | throw new McpError( 77 | ErrorCode.InternalError, 78 | `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}` 79 | ); 80 | } 81 | } 82 | } 83 | 84 | try { 85 | // If the content has multiple lines or is a table, use nested import 86 | if (content.includes('\n')) { 87 | let nodes; 88 | 89 | // If heading parameter is provided, manually construct nodes to preserve heading 90 | if (heading) { 91 | const lines = content.split('\n'); 92 | const firstLine = lines[0].trim(); 93 | const remainingLines = lines.slice(1); 94 | 95 | // Create the first node with heading formatting 96 | const firstNode = { 97 | content: firstLine, 98 | level: 0, 99 | heading_level: heading, 100 | children: [] 101 | }; 102 | 103 | // If there are remaining lines, parse them as children or siblings 104 | if (remainingLines.length > 0 && remainingLines.some(line => line.trim())) { 105 | const remainingContent = remainingLines.join('\n'); 106 | const convertedRemainingContent = convertToRoamMarkdown(remainingContent); 107 | const remainingNodes = parseMarkdown(convertedRemainingContent); 108 | 109 | // Add remaining nodes as siblings to the first node 110 | nodes = [firstNode, ...remainingNodes]; 111 | } else { 112 | nodes = [firstNode]; 113 | } 114 | } else { 115 | // No heading parameter, use original parsing logic 116 | const convertedContent = convertToRoamMarkdown(content); 117 | nodes = parseMarkdown(convertedContent); 118 | } 119 | 120 | const actions = convertToRoamActions(nodes, targetPageUid, 'last'); 121 | 122 | // Execute batch actions to create the nested structure 123 | const result = await batchActions(this.graph, { 124 | action: 'batch-actions', 125 | actions 126 | }); 127 | 128 | if (!result) { 129 | throw new Error('Failed to create nested blocks'); 130 | } 131 | 132 | const blockUid = result.created_uids?.[0]; 133 | return { 134 | success: true, 135 | block_uid: blockUid, 136 | parent_uid: targetPageUid! 137 | }; 138 | } else { 139 | // For single block content, use the same convertToRoamActions approach that works in roam_create_page 140 | const nodes = [{ 141 | content: content, 142 | level: 0, 143 | ...(heading && typeof heading === 'number' && heading > 0 && { heading_level: heading }), 144 | children: [] 145 | }]; 146 | 147 | if (!targetPageUid) { 148 | throw new McpError(ErrorCode.InternalError, 'targetPageUid is undefined'); 149 | } 150 | 151 | const actions = convertToRoamActions(nodes, targetPageUid, 'last'); 152 | 153 | // Execute batch actions to create the block 154 | const result = await batchActions(this.graph, { 155 | action: 'batch-actions', 156 | actions 157 | }); 158 | 159 | if (!result) { 160 | throw new Error('Failed to create block'); 161 | } 162 | 163 | const blockUid = result.created_uids?.[0]; 164 | return { 165 | success: true, 166 | block_uid: blockUid, 167 | parent_uid: targetPageUid! 168 | }; 169 | } 170 | } catch (error) { 171 | throw new McpError( 172 | ErrorCode.InternalError, 173 | `Failed to create block: ${error instanceof Error ? error.message : String(error)}` 174 | ); 175 | } 176 | } 177 | 178 | async updateBlock(block_uid: string, content?: string, transform?: (currentContent: string) => string): Promise<{ success: boolean; content: string }> { 179 | if (!block_uid) { 180 | throw new McpError( 181 | ErrorCode.InvalidRequest, 182 | 'block_uid is required' 183 | ); 184 | } 185 | 186 | // Get current block content 187 | const blockQuery = `[:find ?string . 188 | :where [?b :block/uid "${block_uid}"] 189 | [?b :block/string ?string]]`; 190 | const result = await q(this.graph, blockQuery, []); 191 | if (result === null || result === undefined) { 192 | throw new McpError( 193 | ErrorCode.InvalidRequest, 194 | `Block with UID "${block_uid}" not found` 195 | ); 196 | } 197 | const currentContent = String(result); 198 | 199 | if (currentContent === null || currentContent === undefined) { 200 | throw new McpError( 201 | ErrorCode.InvalidRequest, 202 | `Block with UID "${block_uid}" not found` 203 | ); 204 | } 205 | 206 | // Determine new content 207 | let newContent: string; 208 | if (content) { 209 | newContent = content; 210 | } else if (transform) { 211 | newContent = transform(currentContent); 212 | } else { 213 | throw new McpError( 214 | ErrorCode.InvalidRequest, 215 | 'Either content or transform function must be provided' 216 | ); 217 | } 218 | 219 | try { 220 | await updateRoamBlock(this.graph, { 221 | action: 'update-block', 222 | block: { 223 | uid: block_uid, 224 | string: newContent 225 | } 226 | }); 227 | 228 | return { 229 | success: true, 230 | content: newContent 231 | }; 232 | } catch (error: any) { 233 | throw new McpError( 234 | ErrorCode.InternalError, 235 | `Failed to update block: ${error.message}` 236 | ); 237 | } 238 | } 239 | 240 | async updateBlocks(updates: BlockUpdate[]): Promise<{ success: boolean; results: BlockUpdateResult[] }> { 241 | if (!Array.isArray(updates) || updates.length === 0) { 242 | throw new McpError( 243 | ErrorCode.InvalidRequest, 244 | 'updates must be a non-empty array' 245 | ); 246 | } 247 | 248 | // Validate each update has required fields 249 | updates.forEach((update, index) => { 250 | if (!update.block_uid) { 251 | throw new McpError( 252 | ErrorCode.InvalidRequest, 253 | `Update at index ${index} missing block_uid` 254 | ); 255 | } 256 | if (!update.content && !update.transform) { 257 | throw new McpError( 258 | ErrorCode.InvalidRequest, 259 | `Update at index ${index} must have either content or transform` 260 | ); 261 | } 262 | }); 263 | 264 | // Get current content for all blocks 265 | const blockUids = updates.map(u => u.block_uid); 266 | const blockQuery = `[:find ?uid ?string 267 | :in $ [?uid ...] 268 | :where [?b :block/uid ?uid] 269 | [?b :block/string ?string]]`; 270 | const blockResults = await q(this.graph, blockQuery, [blockUids]) as [string, string][]; 271 | 272 | // Create map of uid -> current content 273 | const contentMap = new Map<string, string>(); 274 | blockResults.forEach(([uid, string]) => { 275 | contentMap.set(uid, string); 276 | }); 277 | 278 | // Prepare batch actions 279 | const actions: BatchAction[] = []; 280 | const results: BlockUpdateResult[] = []; 281 | 282 | for (const update of updates) { 283 | try { 284 | const currentContent = contentMap.get(update.block_uid); 285 | if (!currentContent) { 286 | results.push({ 287 | block_uid: update.block_uid, 288 | content: '', 289 | success: false, 290 | error: `Block with UID "${update.block_uid}" not found` 291 | }); 292 | continue; 293 | } 294 | 295 | // Determine new content 296 | let newContent: string; 297 | if (update.content) { 298 | newContent = update.content; 299 | } else if (update.transform) { 300 | const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : ''); 301 | newContent = currentContent.replace(regex, update.transform.replace); 302 | } else { 303 | // This shouldn't happen due to earlier validation 304 | throw new Error('Invalid update configuration'); 305 | } 306 | 307 | // Add to batch actions 308 | actions.push({ 309 | action: 'update-block', 310 | block: { 311 | uid: update.block_uid, 312 | string: newContent 313 | } 314 | }); 315 | 316 | results.push({ 317 | block_uid: update.block_uid, 318 | content: newContent, 319 | success: true 320 | }); 321 | } catch (error: any) { 322 | results.push({ 323 | block_uid: update.block_uid, 324 | content: contentMap.get(update.block_uid) || '', 325 | success: false, 326 | error: error.message 327 | }); 328 | } 329 | } 330 | 331 | // Execute batch update if we have any valid actions 332 | if (actions.length > 0) { 333 | try { 334 | const batchResult = await batchActions(this.graph, { 335 | action: 'batch-actions', 336 | actions 337 | }); 338 | 339 | if (!batchResult) { 340 | throw new Error('Batch update failed'); 341 | } 342 | } catch (error: any) { 343 | // Mark all previously successful results as failed 344 | results.forEach(result => { 345 | if (result.success) { 346 | result.success = false; 347 | result.error = `Batch update failed: ${error.message}`; 348 | } 349 | }); 350 | } 351 | } 352 | 353 | return { 354 | success: results.every(r => r.success), 355 | results 356 | }; 357 | } 358 | } 359 | ``` -------------------------------------------------------------------------------- /src/tools/operations/pages.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Graph, q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk'; 2 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 3 | import { capitalizeWords } from '../helpers/text.js'; 4 | import { resolveRefs } from '../helpers/refs.js'; 5 | import type { RoamBlock } from '../types/index.js'; 6 | import { 7 | parseMarkdown, 8 | convertToRoamActions, 9 | convertToRoamMarkdown, 10 | hasMarkdownTable 11 | } from '../../markdown-utils.js'; 12 | 13 | // Helper to get ordinal suffix for dates 14 | function getOrdinalSuffix(day: number): string { 15 | if (day > 3 && day < 21) return 'th'; // Handles 11th, 12th, 13th 16 | switch (day % 10) { 17 | case 1: return 'st'; 18 | case 2: return 'nd'; 19 | case 3: return 'rd'; 20 | default: return 'th'; 21 | } 22 | } 23 | 24 | export class PageOperations { 25 | constructor(private graph: Graph) { } 26 | 27 | async findPagesModifiedToday(limit: number = 50, offset: number = 0, sort_order: 'asc' | 'desc' = 'desc') { 28 | // Define ancestor rule for traversing block hierarchy 29 | const ancestorRule = `[ 30 | [ (ancestor ?b ?a) 31 | [?a :block/children ?b] ] 32 | [ (ancestor ?b ?a) 33 | [?parent :block/children ?b] 34 | (ancestor ?parent ?a) ] 35 | ]`; 36 | 37 | // Get start of today 38 | const startOfDay = new Date(); 39 | startOfDay.setHours(0, 0, 0, 0); 40 | 41 | try { 42 | // Query for pages modified today, including modification time for sorting 43 | let query = `[:find ?title ?time 44 | :in $ ?start_of_day % 45 | :where 46 | [?page :node/title ?title] 47 | (ancestor ?block ?page) 48 | [?block :edit/time ?time] 49 | [(> ?time ?start_of_day)]]`; 50 | 51 | if (limit !== -1) { 52 | query += ` :limit ${limit}`; 53 | } 54 | if (offset > 0) { 55 | query += ` :offset ${offset}`; 56 | } 57 | 58 | const results = await q( 59 | this.graph, 60 | query, 61 | [startOfDay.getTime(), ancestorRule] 62 | ) as [string, number][]; 63 | 64 | if (!results || results.length === 0) { 65 | return { 66 | success: true, 67 | pages: [], 68 | message: 'No pages have been modified today' 69 | }; 70 | } 71 | 72 | // Sort results by modification time 73 | results.sort((a, b) => { 74 | if (sort_order === 'desc') { 75 | return b[1] - a[1]; // Newest first 76 | } else { 77 | return a[1] - b[1]; // Oldest first 78 | } 79 | }); 80 | 81 | // Extract unique page titles from sorted results 82 | const uniquePages = Array.from(new Set(results.map(([title]) => title))); 83 | 84 | return { 85 | success: true, 86 | pages: uniquePages, 87 | message: `Found ${uniquePages.length} page(s) modified today` 88 | }; 89 | } catch (error: any) { 90 | throw new McpError( 91 | ErrorCode.InternalError, 92 | `Failed to find modified pages: ${error.message}` 93 | ); 94 | } 95 | } 96 | 97 | async createPage(title: string, content?: Array<{ text: string; level: number; heading?: number }>): Promise<{ success: boolean; uid: string }> { 98 | // Ensure title is properly formatted 99 | const pageTitle = String(title).trim(); 100 | 101 | // First try to find if the page exists 102 | const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; 103 | type FindResult = [string]; 104 | const findResults = await q(this.graph, findQuery, [pageTitle]) as FindResult[]; 105 | 106 | let pageUid: string | undefined; 107 | 108 | if (findResults && findResults.length > 0) { 109 | // Page exists, use its UID 110 | pageUid = findResults[0][0]; 111 | } else { 112 | // Create new page 113 | try { 114 | await createRoamPage(this.graph, { 115 | action: 'create-page', 116 | page: { 117 | title: pageTitle 118 | } 119 | }); 120 | 121 | // Get the new page's UID 122 | const results = await q(this.graph, findQuery, [pageTitle]) as FindResult[]; 123 | if (!results || results.length === 0) { 124 | throw new Error('Could not find created page'); 125 | } 126 | pageUid = results[0][0]; 127 | } catch (error) { 128 | throw new McpError( 129 | ErrorCode.InternalError, 130 | `Failed to create page: ${error instanceof Error ? error.message : String(error)}` 131 | ); 132 | } 133 | } 134 | 135 | // If content is provided, create blocks using batch operations 136 | if (content && content.length > 0) { 137 | try { 138 | // Convert content array to MarkdownNode format expected by convertToRoamActions 139 | const nodes = content.map(block => ({ 140 | content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')), 141 | level: block.level, 142 | ...(block.heading && { heading_level: block.heading }), 143 | children: [] 144 | })); 145 | 146 | // Create hierarchical structure based on levels 147 | const rootNodes: any[] = []; 148 | const levelMap: { [level: number]: any } = {}; 149 | 150 | for (const node of nodes) { 151 | if (node.level === 1) { 152 | rootNodes.push(node); 153 | levelMap[1] = node; 154 | } else { 155 | const parentLevel = node.level - 1; 156 | const parent = levelMap[parentLevel]; 157 | 158 | if (!parent) { 159 | throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`); 160 | } 161 | 162 | parent.children.push(node); 163 | levelMap[node.level] = node; 164 | } 165 | } 166 | 167 | // Generate batch actions for all blocks 168 | const actions = convertToRoamActions(rootNodes, pageUid, 'last'); 169 | 170 | // Execute batch operation 171 | if (actions.length > 0) { 172 | const batchResult = await batchActions(this.graph, { 173 | action: 'batch-actions', 174 | actions 175 | }); 176 | 177 | if (!batchResult) { 178 | throw new Error('Failed to create blocks'); 179 | } 180 | } 181 | } catch (error) { 182 | throw new McpError( 183 | ErrorCode.InternalError, 184 | `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}` 185 | ); 186 | } 187 | } 188 | 189 | // Add a link to the created page on today's daily page 190 | try { 191 | const today = new Date(); 192 | const day = today.getDate(); 193 | const month = today.toLocaleString('en-US', { month: 'long' }); 194 | const year = today.getFullYear(); 195 | const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; 196 | 197 | const dailyPageQuery = `[:find ?uid . 198 | :where [?e :node/title "${formattedTodayTitle}"] 199 | [?e :block/uid ?uid]]`; 200 | const dailyPageResult = await q(this.graph, dailyPageQuery, []); 201 | const dailyPageUid = dailyPageResult ? String(dailyPageResult) : null; 202 | 203 | if (dailyPageUid) { 204 | await createBlock(this.graph, { 205 | action: 'create-block', 206 | block: { 207 | string: `Created page: [[${pageTitle}]]` 208 | }, 209 | location: { 210 | 'parent-uid': dailyPageUid, 211 | order: 'last' 212 | } 213 | }); 214 | } else { 215 | console.warn(`Could not find daily page with title: ${formattedTodayTitle}. Link to created page not added.`); 216 | } 217 | } catch (error) { 218 | console.error(`Failed to add link to daily page: ${error instanceof Error ? error.message : String(error)}`); 219 | } 220 | 221 | return { success: true, uid: pageUid }; 222 | } 223 | 224 | async fetchPageByTitle( 225 | title: string, 226 | format: 'markdown' | 'raw' = 'raw' 227 | ): Promise<string | RoamBlock[]> { 228 | if (!title) { 229 | throw new McpError(ErrorCode.InvalidRequest, 'title is required'); 230 | } 231 | 232 | // Try different case variations 233 | const variations = [ 234 | title, // Original 235 | capitalizeWords(title), // Each word capitalized 236 | title.toLowerCase() // All lowercase 237 | ]; 238 | 239 | let uid: string | null = null; 240 | for (const variation of variations) { 241 | const searchQuery = `[:find ?uid . 242 | :where [?e :node/title "${variation}"] 243 | [?e :block/uid ?uid]]`; 244 | const result = await q(this.graph, searchQuery, []); 245 | uid = (result === null || result === undefined) ? null : String(result); 246 | if (uid) break; 247 | } 248 | 249 | if (!uid) { 250 | throw new McpError( 251 | ErrorCode.InvalidRequest, 252 | `Page with title "${title}" not found (tried original, capitalized words, and lowercase)` 253 | ); 254 | } 255 | 256 | // Define ancestor rule for traversing block hierarchy 257 | const ancestorRule = `[ 258 | [ (ancestor ?b ?a) 259 | [?a :block/children ?b] ] 260 | [ (ancestor ?b ?a) 261 | [?parent :block/children ?b] 262 | (ancestor ?parent ?a) ] 263 | ]`; 264 | 265 | // Get all blocks under this page using ancestor rule 266 | const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid 267 | :in $ % ?page-title 268 | :where [?page :node/title ?page-title] 269 | [?block :block/string ?block-str] 270 | [?block :block/uid ?block-uid] 271 | [?block :block/order ?order] 272 | (ancestor ?block ?page) 273 | [?parent :block/children ?block] 274 | [?parent :block/uid ?parent-uid]]`; 275 | const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]); 276 | 277 | if (!blocks || blocks.length === 0) { 278 | if (format === 'raw') { 279 | return []; 280 | } 281 | return `${title} (no content found)`; 282 | } 283 | 284 | // Get heading information for blocks that have it 285 | const headingsQuery = `[:find ?block-uid ?heading 286 | :in $ % ?page-title 287 | :where [?page :node/title ?page-title] 288 | [?block :block/uid ?block-uid] 289 | [?block :block/heading ?heading] 290 | (ancestor ?block ?page)]`; 291 | const headings = await q(this.graph, headingsQuery, [ancestorRule, title]); 292 | 293 | // Create a map of block UIDs to heading levels 294 | const headingMap = new Map<string, number>(); 295 | if (headings) { 296 | for (const [blockUid, heading] of headings) { 297 | headingMap.set(blockUid, heading as number); 298 | } 299 | } 300 | 301 | // Create a map of all blocks 302 | const blockMap = new Map<string, RoamBlock>(); 303 | const rootBlocks: RoamBlock[] = []; 304 | 305 | // First pass: Create all block objects 306 | for (const [blockUid, blockStr, order, parentUid] of blocks) { 307 | const resolvedString = await resolveRefs(this.graph, blockStr); 308 | const block = { 309 | uid: blockUid, 310 | string: resolvedString, 311 | order: order as number, 312 | heading: headingMap.get(blockUid) || null, 313 | children: [] 314 | }; 315 | blockMap.set(blockUid, block); 316 | 317 | // If no parent or parent is the page itself, it's a root block 318 | if (!parentUid || parentUid === uid) { 319 | rootBlocks.push(block); 320 | } 321 | } 322 | 323 | // Second pass: Build parent-child relationships 324 | for (const [blockUid, _, __, parentUid] of blocks) { 325 | if (parentUid && parentUid !== uid) { 326 | const child = blockMap.get(blockUid); 327 | const parent = blockMap.get(parentUid); 328 | if (child && parent && !parent.children.includes(child)) { 329 | parent.children.push(child); 330 | } 331 | } 332 | } 333 | 334 | // Sort blocks recursively 335 | const sortBlocks = (blocks: RoamBlock[]) => { 336 | blocks.sort((a, b) => a.order - b.order); 337 | blocks.forEach(block => { 338 | if (block.children.length > 0) { 339 | sortBlocks(block.children); 340 | } 341 | }); 342 | }; 343 | sortBlocks(rootBlocks); 344 | 345 | if (format === 'raw') { 346 | return JSON.stringify(rootBlocks); 347 | } 348 | 349 | // Convert to markdown with proper nesting 350 | const toMarkdown = (blocks: RoamBlock[], level: number = 0): string => { 351 | return blocks 352 | .map(block => { 353 | const indent = ' '.repeat(level); 354 | let md: string; 355 | 356 | // Check block heading level and format accordingly 357 | if (block.heading && block.heading > 0) { 358 | // Format as heading with appropriate number of hashtags 359 | const hashtags = '#'.repeat(block.heading); 360 | md = `${indent}${hashtags} ${block.string}`; 361 | } else { 362 | // No heading, use bullet point (current behavior) 363 | md = `${indent}- ${block.string}`; 364 | } 365 | 366 | if (block.children.length > 0) { 367 | md += '\n' + toMarkdown(block.children, level + 1); 368 | } 369 | return md; 370 | }) 371 | .join('\n'); 372 | }; 373 | 374 | return `# ${title}\n\n${toMarkdown(rootBlocks)}`; 375 | } 376 | } 377 | ``` -------------------------------------------------------------------------------- /src/server/roam-server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 4 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 5 | import { 6 | CallToolRequestSchema, 7 | ErrorCode, 8 | ListResourcesRequestSchema, 9 | ReadResourceRequestSchema, 10 | McpError, 11 | Resource, 12 | ListToolsRequestSchema, 13 | ListPromptsRequestSchema, 14 | } from '@modelcontextprotocol/sdk/types.js'; 15 | import { initializeGraph, type Graph } from '@roam-research/roam-api-sdk'; 16 | import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT } from '../config/environment.js'; 17 | import { toolSchemas } from '../tools/schemas.js'; 18 | import { ToolHandlers } from '../tools/tool-handlers.js'; 19 | import { readFileSync } from 'node:fs'; 20 | import { join, dirname } from 'node:path'; 21 | import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 22 | import { fileURLToPath } from 'node:url'; 23 | import { findAvailablePort } from '../utils/net.js'; 24 | import { CORS_ORIGIN } from '../config/environment.js'; 25 | 26 | const __filename = fileURLToPath(import.meta.url); 27 | const __dirname = dirname(__filename); 28 | 29 | // Read package.json to get the version 30 | const packageJsonPath = join(__dirname, '../../package.json'); 31 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); 32 | const serverVersion = packageJson.version; 33 | 34 | export class RoamServer { 35 | private toolHandlers: ToolHandlers; 36 | private graph: Graph; 37 | 38 | constructor() { 39 | // console.log('RoamServer: Constructor started.'); 40 | try { 41 | this.graph = initializeGraph({ 42 | token: API_TOKEN, 43 | graph: GRAPH_NAME, 44 | }); 45 | } catch (error: unknown) { 46 | const errorMessage = error instanceof Error ? error.message : String(error); 47 | throw new McpError(ErrorCode.InternalError, `Failed to initialize Roam graph: ${errorMessage}`); 48 | } 49 | 50 | try { 51 | this.toolHandlers = new ToolHandlers(this.graph); 52 | } catch (error: unknown) { 53 | const errorMessage = error instanceof Error ? error.message : String(error); 54 | throw new McpError(ErrorCode.InternalError, `Failed to initialize tool handlers: ${errorMessage}`); 55 | } 56 | 57 | // Ensure toolSchemas is not empty before proceeding 58 | if (Object.keys(toolSchemas).length === 0) { 59 | throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts'); 60 | } 61 | // console.log('RoamServer: Constructor finished.'); 62 | } 63 | 64 | // Refactored to accept a Server instance 65 | private setupRequestHandlers(mcpServer: Server) { 66 | // List available tools 67 | mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ 68 | tools: Object.values(toolSchemas), 69 | })); 70 | 71 | // List available resources 72 | mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => { 73 | const resources: Resource[] = []; // No resources, as cheatsheet is now a tool 74 | return { resources }; 75 | }); 76 | 77 | // Access resource - no resources handled directly here anymore 78 | mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { 79 | throw new McpError(ErrorCode.InternalError, `Resource not found: ${request.params.uri}`); 80 | }); 81 | 82 | // List available prompts 83 | mcpServer.setRequestHandler(ListPromptsRequestSchema, async () => { 84 | return { prompts: [] }; 85 | }); 86 | 87 | // Handle tool calls 88 | mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { 89 | try { 90 | switch (request.params.name) { 91 | case 'roam_markdown_cheatsheet': { 92 | const content = await this.toolHandlers.getRoamMarkdownCheatsheet(); 93 | return { 94 | content: [{ type: 'text', text: content }], 95 | }; 96 | } 97 | case 'roam_remember': { 98 | const { memory, categories } = request.params.arguments as { 99 | memory: string; 100 | categories?: string[]; 101 | }; 102 | const result = await this.toolHandlers.remember(memory, categories); 103 | return { 104 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 105 | }; 106 | } 107 | 108 | case 'roam_fetch_page_by_title': { 109 | const { title, format } = request.params.arguments as { 110 | title: string; 111 | format?: 'markdown' | 'raw'; 112 | }; 113 | const content = await this.toolHandlers.fetchPageByTitle(title, format); 114 | return { 115 | content: [{ type: 'text', text: content }], 116 | }; 117 | } 118 | 119 | case 'roam_create_page': { 120 | const { title, content } = request.params.arguments as { 121 | title: string; 122 | content?: Array<{ 123 | text: string; 124 | level: number; 125 | }>; 126 | }; 127 | const result = await this.toolHandlers.createPage(title, content); 128 | return { 129 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 130 | }; 131 | } 132 | 133 | 134 | case 'roam_import_markdown': { 135 | const { 136 | content, 137 | page_uid, 138 | page_title, 139 | parent_uid, 140 | parent_string, 141 | order = 'first' 142 | } = request.params.arguments as { 143 | content: string; 144 | page_uid?: string; 145 | page_title?: string; 146 | parent_uid?: string; 147 | parent_string?: string; 148 | order?: 'first' | 'last'; 149 | }; 150 | const result = await this.toolHandlers.importMarkdown( 151 | content, 152 | page_uid, 153 | page_title, 154 | parent_uid, 155 | parent_string, 156 | order 157 | ); 158 | return { 159 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 160 | }; 161 | } 162 | 163 | case 'roam_add_todo': { 164 | const { todos } = request.params.arguments as { todos: string[] }; 165 | const result = await this.toolHandlers.addTodos(todos); 166 | return { 167 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 168 | }; 169 | } 170 | 171 | case 'roam_create_outline': { 172 | const { outline, page_title_uid, block_text_uid } = request.params.arguments as { 173 | outline: Array<{ text: string | undefined; level: number }>; 174 | page_title_uid?: string; 175 | block_text_uid?: string; 176 | }; 177 | const result = await this.toolHandlers.createOutline( 178 | outline, 179 | page_title_uid, 180 | block_text_uid 181 | ); 182 | return { 183 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 184 | }; 185 | } 186 | 187 | case 'roam_search_for_tag': { 188 | const { primary_tag, page_title_uid, near_tag } = request.params.arguments as { 189 | primary_tag: string; 190 | page_title_uid?: string; 191 | near_tag?: string; 192 | }; 193 | const result = await this.toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag); 194 | return { 195 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 196 | }; 197 | } 198 | 199 | case 'roam_search_by_status': { 200 | const { status, page_title_uid, include, exclude } = request.params.arguments as { 201 | status: 'TODO' | 'DONE'; 202 | page_title_uid?: string; 203 | include?: string; 204 | exclude?: string; 205 | }; 206 | const result = await this.toolHandlers.searchByStatus(status, page_title_uid, include, exclude); 207 | return { 208 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 209 | }; 210 | } 211 | 212 | case 'roam_search_block_refs': { 213 | const params = request.params.arguments as { 214 | block_uid?: string; 215 | page_title_uid?: string; 216 | }; 217 | const result = await this.toolHandlers.searchBlockRefs(params); 218 | return { 219 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 220 | }; 221 | } 222 | 223 | case 'roam_search_hierarchy': { 224 | const params = request.params.arguments as { 225 | parent_uid?: string; 226 | child_uid?: string; 227 | page_title_uid?: string; 228 | max_depth?: number; 229 | }; 230 | 231 | // Validate that either parent_uid or child_uid is provided, but not both 232 | if ((!params.parent_uid && !params.child_uid) || (params.parent_uid && params.child_uid)) { 233 | throw new McpError( 234 | ErrorCode.InvalidRequest, 235 | 'Either parent_uid or child_uid must be provided, but not both' 236 | ); 237 | } 238 | 239 | const result = await this.toolHandlers.searchHierarchy(params); 240 | return { 241 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 242 | }; 243 | } 244 | 245 | case 'roam_find_pages_modified_today': { 246 | const { max_num_pages } = request.params.arguments as { 247 | max_num_pages?: number; 248 | }; 249 | const result = await this.toolHandlers.findPagesModifiedToday(max_num_pages || 50); 250 | return { 251 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 252 | }; 253 | } 254 | 255 | case 'roam_search_by_text': { 256 | const params = request.params.arguments as { 257 | text: string; 258 | page_title_uid?: string; 259 | }; 260 | const result = await this.toolHandlers.searchByText(params); 261 | return { 262 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 263 | }; 264 | } 265 | 266 | case 'roam_search_by_date': { 267 | const params = request.params.arguments as { 268 | start_date: string; 269 | end_date?: string; 270 | type: 'created' | 'modified' | 'both'; 271 | scope: 'blocks' | 'pages' | 'both'; 272 | include_content: boolean; 273 | }; 274 | const result = await this.toolHandlers.searchByDate(params); 275 | return { 276 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 277 | }; 278 | } 279 | 280 | 281 | case 'roam_recall': { 282 | const { sort_by = 'newest', filter_tag } = request.params.arguments as { 283 | sort_by?: 'newest' | 'oldest'; 284 | filter_tag?: string; 285 | }; 286 | const result = await this.toolHandlers.recall(sort_by, filter_tag); 287 | return { 288 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 289 | }; 290 | } 291 | 292 | 293 | case 'roam_datomic_query': { 294 | const { query, inputs } = request.params.arguments as { 295 | query: string; 296 | inputs?: unknown[]; 297 | }; 298 | const result = await this.toolHandlers.executeDatomicQuery({ query, inputs }); 299 | return { 300 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 301 | }; 302 | } 303 | 304 | case 'roam_process_batch_actions': { 305 | const { actions } = request.params.arguments as { 306 | actions: any[]; 307 | }; 308 | const result = await this.toolHandlers.processBatch(actions); 309 | return { 310 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 311 | }; 312 | } 313 | 314 | case 'roam_fetch_block_with_children': { 315 | const { block_uid, depth } = request.params.arguments as { 316 | block_uid: string; 317 | depth?: number; 318 | }; 319 | const result = await this.toolHandlers.fetchBlockWithChildren(block_uid, depth); 320 | return { 321 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 322 | }; 323 | } 324 | 325 | default: 326 | throw new McpError( 327 | ErrorCode.MethodNotFound, 328 | `Unknown tool: ${request.params.name}` 329 | ); 330 | } 331 | } catch (error: unknown) { 332 | if (error instanceof McpError) { 333 | throw error; 334 | } 335 | const errorMessage = error instanceof Error ? error.message : String(error); 336 | throw new McpError( 337 | ErrorCode.InternalError, 338 | `Roam API error: ${errorMessage}` 339 | ); 340 | } 341 | }); 342 | } 343 | 344 | async run() { 345 | // console.log('RoamServer: run() method started.'); 346 | try { 347 | // console.log('RoamServer: Attempting to create stdioMcpServer...'); 348 | const stdioMcpServer = new Server( 349 | { 350 | name: 'roam-research', 351 | version: serverVersion, 352 | }, 353 | { 354 | capabilities: { 355 | tools: { 356 | ...Object.fromEntries( 357 | (Object.keys(toolSchemas) as Array<keyof typeof toolSchemas>).map((toolName) => [toolName, toolSchemas[toolName].inputSchema]) 358 | ), 359 | }, 360 | resources: {}, // No resources exposed via capabilities 361 | prompts: {}, // No prompts exposed via capabilities 362 | }, 363 | } 364 | ); 365 | // console.log('RoamServer: stdioMcpServer created. Setting up request handlers...'); 366 | this.setupRequestHandlers(stdioMcpServer); 367 | // console.log('RoamServer: stdioMcpServer handlers setup complete. Connecting transport...'); 368 | 369 | const stdioTransport = new StdioServerTransport(); 370 | await stdioMcpServer.connect(stdioTransport); 371 | // console.log('RoamServer: stdioTransport connected. Attempting to create httpMcpServer...'); 372 | 373 | const httpMcpServer = new Server( 374 | { 375 | name: 'roam-research-http', // A distinct name for the HTTP server 376 | version: serverVersion, 377 | }, 378 | { 379 | capabilities: { 380 | tools: { 381 | ...Object.fromEntries( 382 | (Object.keys(toolSchemas) as Array<keyof typeof toolSchemas>).map((toolName) => [toolName, toolSchemas[toolName].inputSchema]) 383 | ), 384 | }, 385 | resources: { // No resources exposed via capabilities 386 | }, 387 | prompts: {}, // No prompts exposed via capabilities 388 | }, 389 | } 390 | ); 391 | // console.log('RoamServer: httpMcpServer created. Setting up request handlers...'); 392 | this.setupRequestHandlers(httpMcpServer); 393 | // console.log('RoamServer: httpMcpServer handlers setup complete. Connecting transport...'); 394 | 395 | const httpStreamTransport = new StreamableHTTPServerTransport({ 396 | sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), 397 | }); 398 | await httpMcpServer.connect(httpStreamTransport); 399 | // console.log('RoamServer: httpStreamTransport connected.'); 400 | 401 | const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { 402 | // Set CORS headers 403 | res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN); 404 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 405 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 406 | 407 | // Handle preflight OPTIONS requests 408 | if (req.method === 'OPTIONS') { 409 | res.writeHead(204); // No Content 410 | res.end(); 411 | return; 412 | } 413 | 414 | try { 415 | await httpStreamTransport.handleRequest(req, res); 416 | } catch (error) { 417 | // // console.error('HTTP Stream Server error:', error); 418 | if (!res.headersSent) { 419 | res.writeHead(500, { 'Content-Type': 'application/json' }); 420 | res.end(JSON.stringify({ error: 'Internal Server Error' })); 421 | } 422 | } 423 | }); 424 | 425 | const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT)); 426 | httpServer.listen(availableHttpPort, () => { 427 | // // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`); 428 | }); 429 | 430 | } catch (error: unknown) { 431 | const errorMessage = error instanceof Error ? error.message : String(error); 432 | throw new McpError(ErrorCode.InternalError, `Failed to connect MCP server: ${errorMessage}`); 433 | } 434 | } 435 | } 436 | ```