#
tokens: 49251/50000 40/43 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | ![](./roam-research-mcp-image.jpeg)
  2 | 
  3 | # Roam Research MCP Server
  4 | 
  5 | [![npm version](https://badge.fury.io/js/roam-research-mcp.svg)](https://badge.fury.io/js/roam-research-mcp)
  6 | [![Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public.](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip)
  7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
  8 | [![GitHub](https://img.shields.io/github/license/2b3pro/roam-research-mcp)](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: `![Alt text](URL)`
 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 | 
```
Page 1/2FirstPrevNextLast