roam-research-mcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
  [![GitHub](https://img.shields.io/github/license/2b3pro/roam-research-mcp)](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE)
9
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) and HTTP Stream communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research)
10
+ A Model Context Protocol (MCP) server and standalone CLI that provides comprehensive access to Roam Research's API functionality. The MCP server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface, while the CLI (`roam`) lets you fetch, search, and import content directly from the command line. Supports standard input/output (stdio) and HTTP Stream communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research)
11
11
 
12
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
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>
@@ -83,21 +83,129 @@ Alternatively, if you have a `.env` file in the project root (which is copied in
83
83
  docker run -p 3000:3000 -p 8088:8088 --env-file .env roam-research-mcp
84
84
  ```
85
85
 
86
- ## Standalone CLI: roam-import
86
+ ## Standalone CLI: `roam`
87
87
 
88
- A standalone command-line tool for importing markdown content directly into Roam Research, without running the MCP server.
88
+ A standalone command-line tool for interacting with Roam Research directly, without running the MCP server. Provides four subcommands: `get`, `search`, `save`, and `refs`.
89
89
 
90
- ### Usage
90
+ ### Installation
91
+
92
+ After building the project, make the command globally available:
93
+
94
+ ```bash
95
+ npm link
96
+ ```
97
+
98
+ Or run directly without linking:
99
+
100
+ ```bash
101
+ node build/cli/roam.js <command> [options]
102
+ ```
103
+
104
+ ### Requirements
105
+
106
+ Same environment variables as the MCP server:
107
+ - `ROAM_API_TOKEN`: Your Roam Research API token
108
+ - `ROAM_GRAPH_NAME`: Your Roam graph name
109
+
110
+ Configure via `.env` file in the project root or set as environment variables.
111
+
112
+ ---
113
+
114
+ ### `roam get` - Fetch pages or blocks
115
+
116
+ Fetch content from Roam and output as markdown or JSON.
91
117
 
92
118
  ```bash
93
- # From a file
94
- cat document.md | roam-import "Meeting Notes"
119
+ # Fetch a page by title
120
+ roam get "Daily Notes"
121
+
122
+ # Fetch a block by UID
123
+ roam get "((AbCdEfGhI))"
124
+ roam get AbCdEfGhI
125
+
126
+ # Output as JSON
127
+ roam get "Daily Notes" --json
128
+
129
+ # Control child depth (default: 4)
130
+ roam get "Daily Notes" --depth 2
131
+
132
+ # Flatten hierarchy
133
+ roam get "Daily Notes" --flat
134
+
135
+ # Debug mode
136
+ roam get "Daily Notes" --debug
137
+ ```
138
+
139
+ **Options:**
140
+ - `--json` - Output as JSON instead of markdown
141
+ - `--depth <n>` - Child levels to fetch (default: 4)
142
+ - `--refs <n>` - Block ref expansion depth (default: 1)
143
+ - `--flat` - Flatten hierarchy to single-level list
144
+ - `--debug` - Show query metadata
145
+
146
+ ---
147
+
148
+ ### `roam search` - Search content
149
+
150
+ Search for blocks containing text or tags.
151
+
152
+ ```bash
153
+ # Full-text search
154
+ roam search "keyword"
155
+
156
+ # Multiple terms (AND logic)
157
+ roam search "term1" "term2"
95
158
 
96
- # From clipboard (macOS)
97
- pbpaste | roam-import "Ideas"
159
+ # Tag-only search
160
+ roam search --tag "[[Project]]"
161
+ roam search --tag "#TODO"
162
+
163
+ # Text + tag filter
164
+ roam search "meeting" --tag "[[Work]]"
165
+
166
+ # Scope to a specific page
167
+ roam search "task" --page "Daily Notes"
168
+
169
+ # Case-insensitive search
170
+ roam search "keyword" -i
171
+
172
+ # Limit results (default: 20)
173
+ roam search "keyword" -n 50
174
+
175
+ # Output as JSON
176
+ roam search "keyword" --json
177
+ ```
178
+
179
+ **Options:**
180
+ - `--tag <tag>` - Filter by tag (e.g., `#TODO` or `[[Project]]`)
181
+ - `--page <title>` - Scope search to a specific page
182
+ - `-i, --case-insensitive` - Case-insensitive search
183
+ - `-n, --limit <n>` - Limit number of results (default: 20)
184
+ - `--json` - Output as JSON
185
+ - `--debug` - Show query metadata
186
+
187
+ ---
188
+
189
+ ### `roam save` - Import markdown
190
+
191
+ Import markdown content to Roam, creating or updating pages.
192
+
193
+ ```bash
194
+ # From a file (title derived from filename)
195
+ roam save document.md
196
+
197
+ # With explicit title
198
+ roam save document.md --title "Meeting Notes"
199
+
200
+ # Update existing page with smart diff (preserves block UIDs)
201
+ roam save document.md --update
202
+
203
+ # From stdin (requires --title)
204
+ cat notes.md | roam save --title "Quick Notes"
205
+ pbpaste | roam save --title "Clipboard Content"
98
206
 
99
207
  # From here-doc
100
- roam-import "Quick Note" << EOF
208
+ roam save --title "Quick Note" << EOF
101
209
  # Heading
102
210
  - Item 1
103
211
  - Item 2
@@ -105,34 +213,68 @@ roam-import "Quick Note" << EOF
105
213
  EOF
106
214
  ```
107
215
 
108
- ### Features
216
+ **Options:**
217
+ - `--title <title>` - Page title (defaults to filename without `.md`)
218
+ - `--update` - Update existing page using smart diff (preserves block UIDs)
219
+ - `--debug` - Show debug information
109
220
 
110
- - Reads markdown from stdin
221
+ **Features:**
111
222
  - Creates a new page with the specified title (or appends to existing page)
112
223
  - Automatically links the new page from today's daily page
113
- - Converts standard markdown to Roam-flavored markdown (bold, italic, highlights, tasks, code blocks)
224
+ - Converts standard markdown to Roam-flavored markdown
225
+ - Smart diff mode (`--update`) preserves block UIDs for existing content
114
226
 
115
- ### Installation
227
+ ---
116
228
 
117
- After building the project, make the command globally available:
229
+ ### `roam refs` - Find references
230
+
231
+ Find blocks that reference a page or block (backlinks).
118
232
 
119
233
  ```bash
120
- npm link
121
- ```
234
+ # Find references to a page
235
+ roam refs "Project Alpha"
236
+ roam refs "December 30th, 2025"
122
237
 
123
- Or run directly without linking:
238
+ # Find references to a tag
239
+ roam refs "#TODO"
240
+ roam refs "[[Meeting Notes]]"
124
241
 
125
- ```bash
126
- cat document.md | node build/cli/import-markdown.js "Page Title"
242
+ # Find references to a block
243
+ roam refs "((AbCdEfGhI))"
244
+
245
+ # Limit results
246
+ roam refs "My Page" -n 100
247
+
248
+ # Output as JSON (for LLM/programmatic use)
249
+ roam refs "My Page" --json
250
+
251
+ # Raw output (for piping)
252
+ roam refs "My Page" --raw
127
253
  ```
128
254
 
129
- ### Requirements
255
+ **Options:**
256
+ - `-n, --limit <n>` - Limit number of results (default: 50)
257
+ - `--json` - Output as JSON array
258
+ - `--raw` - Output raw UID + content lines (no grouping)
259
+ - `--debug` - Show query metadata
130
260
 
131
- Same environment variables as the MCP server:
132
- - `ROAM_API_TOKEN`: Your Roam Research API token
133
- - `ROAM_GRAPH_NAME`: Your Roam graph name
261
+ **Output Formats:**
134
262
 
135
- Configure via `.env` file in the project root or set as environment variables.
263
+ Default output groups results by page:
264
+ ```
265
+ [[Reading List: Inbox]]
266
+ tiTqNBvYA Date Captured:: [[December 30th, 2025]]
267
+
268
+ [[Week 53, 2025]]
269
+ g0ur1z7Bs [Sun 28]([[December 28th, 2025]]) | [Mon 29](...
270
+ ```
271
+
272
+ JSON output for programmatic use:
273
+ ```json
274
+ [
275
+ {"uid": "tiTqNBvYA", "content": "Date Captured:: [[December 30th, 2025]]", "page": "Reading List: Inbox"}
276
+ ]
277
+ ```
136
278
 
137
279
  ---
138
280
 
@@ -167,7 +309,7 @@ The server provides powerful tools for interacting with Roam Research:
167
309
  5. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
168
310
  6. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
169
311
  7. `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`.)
170
- 8. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
312
+ 8. `roam_search_block_refs`: Search for block references within a page or across the entire graph. Now supports `title` parameter to find blocks referencing a page title using `:block/refs` (captures `[[page]]` and `#tag` links semantically).
171
313
  9. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
172
314
  10. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight), with pagination and sorting options.
173
315
  11. `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.
@@ -0,0 +1,79 @@
1
+ import { Command } from 'commander';
2
+ import { initializeGraph } from '@roam-research/roam-api-sdk';
3
+ import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
4
+ import { PageOperations } from '../../tools/operations/pages.js';
5
+ import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
6
+ import { formatPageOutput, formatBlockOutput, printDebug, exitWithError } from '../utils/output.js';
7
+ // Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
8
+ const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
9
+ export function createGetCommand() {
10
+ return new Command('get')
11
+ .description('Fetch a page or block from Roam')
12
+ .argument('<target>', 'Page title or block UID (e.g., "Page Title" or "((AbCdEfGhI))")')
13
+ .option('--json', 'Output as JSON instead of markdown')
14
+ .option('--depth <n>', 'Child levels to fetch (default: 4)', '4')
15
+ .option('--refs <n>', 'Block ref expansion depth (default: 1)', '1')
16
+ .option('--flat', 'Flatten hierarchy to single-level list')
17
+ .option('--debug', 'Show query metadata')
18
+ .action(async (target, options) => {
19
+ try {
20
+ const graph = initializeGraph({
21
+ token: API_TOKEN,
22
+ graph: GRAPH_NAME
23
+ });
24
+ const depth = parseInt(options.depth || '4', 10);
25
+ const outputOptions = {
26
+ json: options.json,
27
+ flat: options.flat,
28
+ debug: options.debug
29
+ };
30
+ if (options.debug) {
31
+ printDebug('Target', target);
32
+ printDebug('Options', { depth, refs: options.refs, ...outputOptions });
33
+ }
34
+ // Check if target is a block UID
35
+ const uidMatch = target.match(BLOCK_UID_PATTERN);
36
+ if (uidMatch) {
37
+ // Fetch block by UID
38
+ const blockUid = uidMatch[1];
39
+ if (options.debug) {
40
+ printDebug('Fetching block', { uid: blockUid, depth });
41
+ }
42
+ const blockOps = new BlockRetrievalOperations(graph);
43
+ const block = await blockOps.fetchBlockWithChildren(blockUid, depth);
44
+ if (!block) {
45
+ exitWithError(`Block with UID "${blockUid}" not found`);
46
+ }
47
+ console.log(formatBlockOutput(block, outputOptions));
48
+ }
49
+ else {
50
+ // Fetch page by title
51
+ if (options.debug) {
52
+ printDebug('Fetching page', { title: target, depth });
53
+ }
54
+ const pageOps = new PageOperations(graph);
55
+ const result = await pageOps.fetchPageByTitle(target, 'raw');
56
+ // Parse the raw result
57
+ let blocks;
58
+ if (typeof result === 'string') {
59
+ try {
60
+ blocks = JSON.parse(result);
61
+ }
62
+ catch {
63
+ // Result is already formatted as string (e.g., "Page Title (no content found)")
64
+ console.log(result);
65
+ return;
66
+ }
67
+ }
68
+ else {
69
+ blocks = result;
70
+ }
71
+ console.log(formatPageOutput(target, blocks, outputOptions));
72
+ }
73
+ }
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ exitWithError(message);
77
+ }
78
+ });
79
+ }
@@ -0,0 +1,122 @@
1
+ import { Command } from 'commander';
2
+ import { initializeGraph } from '@roam-research/roam-api-sdk';
3
+ import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
4
+ import { SearchOperations } from '../../tools/operations/search/index.js';
5
+ import { printDebug, exitWithError } from '../utils/output.js';
6
+ /**
7
+ * Format results grouped by page (default output)
8
+ */
9
+ function formatGrouped(matches, maxContentLength = 60) {
10
+ if (matches.length === 0) {
11
+ return 'No references found.';
12
+ }
13
+ // Group by page title
14
+ const byPage = new Map();
15
+ for (const match of matches) {
16
+ const pageTitle = match.page_title || 'Unknown Page';
17
+ if (!byPage.has(pageTitle)) {
18
+ byPage.set(pageTitle, []);
19
+ }
20
+ byPage.get(pageTitle).push(match);
21
+ }
22
+ // Format output
23
+ const lines = [];
24
+ for (const [pageTitle, pageMatches] of byPage) {
25
+ lines.push(`[[${pageTitle}]]`);
26
+ for (const match of pageMatches) {
27
+ const truncated = match.content.length > maxContentLength
28
+ ? match.content.slice(0, maxContentLength) + '...'
29
+ : match.content;
30
+ lines.push(` ${match.block_uid} ${truncated}`);
31
+ }
32
+ lines.push('');
33
+ }
34
+ return lines.join('\n').trim();
35
+ }
36
+ /**
37
+ * Format results as raw lines (UID + content)
38
+ */
39
+ function formatRaw(matches, maxContentLength = 60) {
40
+ if (matches.length === 0) {
41
+ return 'No references found.';
42
+ }
43
+ return matches
44
+ .map(match => {
45
+ const truncated = match.content.length > maxContentLength
46
+ ? match.content.slice(0, maxContentLength) + '...'
47
+ : match.content;
48
+ return `${match.block_uid} ${truncated}`;
49
+ })
50
+ .join('\n');
51
+ }
52
+ /**
53
+ * Parse identifier to determine if it's a block UID or page title
54
+ */
55
+ function parseIdentifier(identifier) {
56
+ // Check for ((uid)) format
57
+ const blockRefMatch = identifier.match(/^\(\(([^)]+)\)\)$/);
58
+ if (blockRefMatch) {
59
+ return { block_uid: blockRefMatch[1] };
60
+ }
61
+ // Check for [[page]] or #[[page]] format - extract page title
62
+ const pageRefMatch = identifier.match(/^#?\[\[(.+)\]\]$/);
63
+ if (pageRefMatch) {
64
+ return { title: pageRefMatch[1] };
65
+ }
66
+ // Check for #tag format
67
+ if (identifier.startsWith('#')) {
68
+ return { title: identifier.slice(1) };
69
+ }
70
+ // Default: treat as page title
71
+ return { title: identifier };
72
+ }
73
+ export function createRefsCommand() {
74
+ return new Command('refs')
75
+ .description('Find blocks referencing a page or block')
76
+ .argument('<identifier>', 'Page title or block UID (use ((uid)) for block refs)')
77
+ .option('-n, --limit <n>', 'Limit number of results', '50')
78
+ .option('--json', 'Output as JSON array')
79
+ .option('--raw', 'Output raw UID + content lines (no grouping)')
80
+ .option('--debug', 'Show query metadata')
81
+ .action(async (identifier, options) => {
82
+ try {
83
+ const graph = initializeGraph({
84
+ token: API_TOKEN,
85
+ graph: GRAPH_NAME
86
+ });
87
+ const limit = parseInt(options.limit || '50', 10);
88
+ const { block_uid, title } = parseIdentifier(identifier);
89
+ if (options.debug) {
90
+ printDebug('Identifier', identifier);
91
+ printDebug('Parsed', { block_uid, title });
92
+ printDebug('Options', options);
93
+ }
94
+ const searchOps = new SearchOperations(graph);
95
+ const result = await searchOps.searchBlockRefs({ block_uid, title });
96
+ if (options.debug) {
97
+ printDebug('Total matches', result.matches.length);
98
+ }
99
+ // Apply limit
100
+ const limitedMatches = result.matches.slice(0, limit);
101
+ // Format output
102
+ if (options.json) {
103
+ const jsonOutput = limitedMatches.map(m => ({
104
+ uid: m.block_uid,
105
+ content: m.content,
106
+ page: m.page_title
107
+ }));
108
+ console.log(JSON.stringify(jsonOutput, null, 2));
109
+ }
110
+ else if (options.raw) {
111
+ console.log(formatRaw(limitedMatches));
112
+ }
113
+ else {
114
+ console.log(formatGrouped(limitedMatches));
115
+ }
116
+ }
117
+ catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ exitWithError(message);
120
+ }
121
+ });
122
+ }
@@ -0,0 +1,121 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'fs';
3
+ import { basename } from 'path';
4
+ import { initializeGraph } from '@roam-research/roam-api-sdk';
5
+ import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
6
+ import { PageOperations } from '../../tools/operations/pages.js';
7
+ import { parseMarkdown } from '../../markdown-utils.js';
8
+ import { printDebug, exitWithError } from '../utils/output.js';
9
+ /**
10
+ * Flatten nested MarkdownNode[] to flat array with absolute levels
11
+ */
12
+ function flattenNodes(nodes, baseLevel = 1) {
13
+ const result = [];
14
+ for (const node of nodes) {
15
+ result.push({
16
+ text: node.content,
17
+ level: baseLevel,
18
+ ...(node.heading_level && { heading: node.heading_level })
19
+ });
20
+ if (node.children.length > 0) {
21
+ result.push(...flattenNodes(node.children, baseLevel + 1));
22
+ }
23
+ }
24
+ return result;
25
+ }
26
+ /**
27
+ * Read all input from stdin
28
+ */
29
+ async function readStdin() {
30
+ const chunks = [];
31
+ for await (const chunk of process.stdin) {
32
+ chunks.push(chunk);
33
+ }
34
+ return Buffer.concat(chunks).toString('utf-8');
35
+ }
36
+ export function createSaveCommand() {
37
+ return new Command('save')
38
+ .description('Import markdown to Roam')
39
+ .argument('[file]', 'Markdown file to import (or pipe content to stdin)')
40
+ .option('--title <title>', 'Page title (defaults to filename without .md)')
41
+ .option('--update', 'Update existing page using smart diff')
42
+ .option('--debug', 'Show debug information')
43
+ .action(async (file, options) => {
44
+ try {
45
+ let markdownContent;
46
+ let pageTitle;
47
+ if (file) {
48
+ // Read from file
49
+ try {
50
+ markdownContent = readFileSync(file, 'utf-8');
51
+ }
52
+ catch (err) {
53
+ exitWithError(`Could not read file: ${file}`);
54
+ }
55
+ // Derive title from filename if not provided
56
+ pageTitle = options.title || basename(file, '.md');
57
+ }
58
+ else {
59
+ // Read from stdin
60
+ if (process.stdin.isTTY) {
61
+ exitWithError('No file specified and no input piped. Use: roam save <file.md> or cat file.md | roam save --title "Title"');
62
+ }
63
+ if (!options.title) {
64
+ exitWithError('--title is required when piping from stdin');
65
+ }
66
+ markdownContent = await readStdin();
67
+ pageTitle = options.title;
68
+ }
69
+ if (!markdownContent.trim()) {
70
+ exitWithError('Empty content received');
71
+ }
72
+ if (options.debug) {
73
+ printDebug('Page title', pageTitle);
74
+ printDebug('Content length', markdownContent.length);
75
+ printDebug('Update mode', options.update || false);
76
+ }
77
+ const graph = initializeGraph({
78
+ token: API_TOKEN,
79
+ graph: GRAPH_NAME
80
+ });
81
+ const pageOps = new PageOperations(graph);
82
+ if (options.update) {
83
+ // Use smart diff to update existing page
84
+ const result = await pageOps.updatePageMarkdown(pageTitle, markdownContent, false // not dry run
85
+ );
86
+ if (result.success) {
87
+ console.log(`Updated page '${pageTitle}'`);
88
+ console.log(` ${result.summary}`);
89
+ if (result.preservedUids.length > 0) {
90
+ console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
91
+ }
92
+ }
93
+ else {
94
+ exitWithError(`Failed to update page '${pageTitle}'`);
95
+ }
96
+ }
97
+ else {
98
+ // Create new page (or add content to existing empty page)
99
+ const nodes = parseMarkdown(markdownContent);
100
+ const contentBlocks = flattenNodes(nodes);
101
+ if (contentBlocks.length === 0) {
102
+ exitWithError('No content blocks parsed from input');
103
+ }
104
+ if (options.debug) {
105
+ printDebug('Parsed blocks', contentBlocks.length);
106
+ }
107
+ const result = await pageOps.createPage(pageTitle, contentBlocks);
108
+ if (result.success) {
109
+ console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
110
+ }
111
+ else {
112
+ exitWithError(`Failed to create page '${pageTitle}'`);
113
+ }
114
+ }
115
+ }
116
+ catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ exitWithError(message);
119
+ }
120
+ });
121
+ }
@@ -0,0 +1,79 @@
1
+ import { Command } from 'commander';
2
+ import { initializeGraph } from '@roam-research/roam-api-sdk';
3
+ import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
4
+ import { SearchOperations } from '../../tools/operations/search/index.js';
5
+ import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
6
+ export function createSearchCommand() {
7
+ return new Command('search')
8
+ .description('Search for content in Roam')
9
+ .argument('[terms...]', 'Search terms (multiple terms use AND logic)')
10
+ .option('--tag <tag>', 'Filter by tag (e.g., "#TODO" or "[[Project]]")')
11
+ .option('--page <title>', 'Scope search to a specific page')
12
+ .option('-i, --case-insensitive', 'Case-insensitive search')
13
+ .option('-n, --limit <n>', 'Limit number of results (default: 20)', '20')
14
+ .option('--json', 'Output as JSON')
15
+ .option('--debug', 'Show query metadata')
16
+ .action(async (terms, options) => {
17
+ try {
18
+ const graph = initializeGraph({
19
+ token: API_TOKEN,
20
+ graph: GRAPH_NAME
21
+ });
22
+ const limit = parseInt(options.limit || '20', 10);
23
+ const outputOptions = {
24
+ json: options.json,
25
+ debug: options.debug
26
+ };
27
+ if (options.debug) {
28
+ printDebug('Search terms', terms);
29
+ printDebug('Options', options);
30
+ }
31
+ const searchOps = new SearchOperations(graph);
32
+ // Determine search type based on options
33
+ if (options.tag && terms.length === 0) {
34
+ // Tag-only search
35
+ const tagName = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
36
+ if (options.debug) {
37
+ printDebug('Tag search', { tag: tagName, page: options.page });
38
+ }
39
+ const result = await searchOps.searchForTag(tagName, options.page);
40
+ const limitedMatches = result.matches.slice(0, limit);
41
+ console.log(formatSearchResults(limitedMatches, outputOptions));
42
+ }
43
+ else if (terms.length > 0) {
44
+ // Text search (with optional tag filter)
45
+ const searchText = terms.join(' ');
46
+ if (options.debug) {
47
+ printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
48
+ }
49
+ const result = await searchOps.searchByText({
50
+ text: searchText,
51
+ page_title_uid: options.page
52
+ });
53
+ // Apply client-side filters
54
+ let matches = result.matches;
55
+ // Case-insensitive filter if requested
56
+ if (options.caseInsensitive) {
57
+ const lowerSearchText = searchText.toLowerCase();
58
+ matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
59
+ }
60
+ // Tag filter if provided
61
+ if (options.tag) {
62
+ const tagPattern = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
63
+ matches = matches.filter(m => m.content.includes(`[[${tagPattern}]]`) ||
64
+ m.content.includes(`#${tagPattern}`) ||
65
+ m.content.includes(`#[[${tagPattern}]]`));
66
+ }
67
+ // Apply limit
68
+ console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
69
+ }
70
+ else {
71
+ exitWithError('Please provide search terms or use --tag to search by tag');
72
+ }
73
+ }
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ exitWithError(message);
77
+ }
78
+ });
79
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createGetCommand } from './commands/get.js';
4
+ import { createSearchCommand } from './commands/search.js';
5
+ import { createSaveCommand } from './commands/save.js';
6
+ import { createRefsCommand } from './commands/refs.js';
7
+ const program = new Command();
8
+ program
9
+ .name('roam')
10
+ .description('CLI for Roam Research')
11
+ .version('1.6.0');
12
+ // Register subcommands
13
+ program.addCommand(createGetCommand());
14
+ program.addCommand(createSearchCommand());
15
+ program.addCommand(createSaveCommand());
16
+ program.addCommand(createRefsCommand());
17
+ // Parse arguments
18
+ program.parse();
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Convert RoamBlock hierarchy to markdown with proper indentation
3
+ */
4
+ export function blocksToMarkdown(blocks, level = 0) {
5
+ return blocks
6
+ .map(block => {
7
+ const indent = ' '.repeat(level);
8
+ let md;
9
+ // Check block heading level and format accordingly
10
+ if (block.heading && block.heading > 0) {
11
+ const hashtags = '#'.repeat(block.heading);
12
+ md = `${indent}${hashtags} ${block.string}`;
13
+ }
14
+ else {
15
+ md = `${indent}- ${block.string}`;
16
+ }
17
+ if (block.children && block.children.length > 0) {
18
+ md += '\n' + blocksToMarkdown(block.children, level + 1);
19
+ }
20
+ return md;
21
+ })
22
+ .join('\n');
23
+ }
24
+ /**
25
+ * Flatten block hierarchy to single-level list
26
+ */
27
+ export function flattenBlocks(blocks, result = []) {
28
+ for (const block of blocks) {
29
+ result.push({ ...block, children: [] });
30
+ if (block.children && block.children.length > 0) {
31
+ flattenBlocks(block.children, result);
32
+ }
33
+ }
34
+ return result;
35
+ }
36
+ /**
37
+ * Format page content for output
38
+ */
39
+ export function formatPageOutput(title, blocks, options) {
40
+ if (options.json) {
41
+ const data = options.flat ? flattenBlocks(blocks) : blocks;
42
+ return JSON.stringify({ title, children: data }, null, 2);
43
+ }
44
+ const displayBlocks = options.flat ? flattenBlocks(blocks) : blocks;
45
+ return `# ${title}\n\n${blocksToMarkdown(displayBlocks)}`;
46
+ }
47
+ /**
48
+ * Format block content for output
49
+ */
50
+ export function formatBlockOutput(block, options) {
51
+ if (options.json) {
52
+ const data = options.flat ? flattenBlocks([block]) : block;
53
+ return JSON.stringify(data, null, 2);
54
+ }
55
+ const displayBlocks = options.flat ? flattenBlocks([block]) : [block];
56
+ return blocksToMarkdown(displayBlocks);
57
+ }
58
+ /**
59
+ * Format search results for output
60
+ */
61
+ export function formatSearchResults(results, options) {
62
+ if (options.json) {
63
+ return JSON.stringify(results, null, 2);
64
+ }
65
+ if (results.length === 0) {
66
+ return 'No results found.';
67
+ }
68
+ let output = `Found ${results.length} result(s):\n\n`;
69
+ results.forEach((result, index) => {
70
+ const pageInfo = result.page_title ? ` (${result.page_title})` : '';
71
+ output += `[${index + 1}] ${result.block_uid}${pageInfo}\n`;
72
+ output += ` ${result.content}\n\n`;
73
+ });
74
+ return output.trim();
75
+ }
76
+ /**
77
+ * Print debug information
78
+ */
79
+ export function printDebug(label, data) {
80
+ console.error(`[DEBUG] ${label}:`, JSON.stringify(data, null, 2));
81
+ }
82
+ /**
83
+ * Print error message and exit
84
+ */
85
+ export function exitWithError(message, code = 1) {
86
+ console.error(`Error: ${message}`);
87
+ process.exit(code);
88
+ }
@@ -8,17 +8,42 @@ export class BlockRefSearchHandler extends BaseSearchHandler {
8
8
  this.params = params;
9
9
  }
10
10
  async execute() {
11
- const { block_uid, page_title_uid } = this.params;
11
+ const { block_uid, title, page_title_uid } = this.params;
12
12
  // Get target page UID if provided
13
13
  let targetPageUid;
14
14
  if (page_title_uid) {
15
15
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
16
  }
17
- // Build query based on whether we're searching for references to a specific block
18
- // or all block references within a page/graph
17
+ // Build query based on whether we're searching for references to a specific block,
18
+ // a page title, or all block references within a page/graph
19
19
  let queryStr;
20
20
  let queryParams;
21
- if (block_uid) {
21
+ if (title) {
22
+ // Search for references to a page by title using :block/refs
23
+ if (targetPageUid) {
24
+ queryStr = `[:find ?block-uid ?block-str
25
+ :in $ ?target-title ?page-uid
26
+ :where [?target :node/title ?target-title]
27
+ [?p :block/uid ?page-uid]
28
+ [?b :block/page ?p]
29
+ [?b :block/refs ?target]
30
+ [?b :block/string ?block-str]
31
+ [?b :block/uid ?block-uid]]`;
32
+ queryParams = [title, targetPageUid];
33
+ }
34
+ else {
35
+ queryStr = `[:find ?block-uid ?block-str ?page-title
36
+ :in $ ?target-title
37
+ :where [?target :node/title ?target-title]
38
+ [?b :block/refs ?target]
39
+ [?b :block/string ?block-str]
40
+ [?b :block/uid ?block-uid]
41
+ [?b :block/page ?p]
42
+ [?p :node/title ?page-title]]`;
43
+ queryParams = [title];
44
+ }
45
+ }
46
+ else if (block_uid) {
22
47
  // Search for references to a specific block
23
48
  if (targetPageUid) {
24
49
  queryStr = `[:find ?block-uid ?block-str
@@ -69,9 +94,11 @@ export class BlockRefSearchHandler extends BaseSearchHandler {
69
94
  const resolvedContent = await resolveRefs(this.graph, content);
70
95
  return [uid, resolvedContent, pageTitle];
71
96
  }));
72
- const searchDescription = block_uid
73
- ? `referencing block ((${block_uid}))`
74
- : 'containing block references';
97
+ const searchDescription = title
98
+ ? `referencing [[${title}]]`
99
+ : block_uid
100
+ ? `referencing block ((${block_uid}))`
101
+ : 'containing block references';
75
102
  return SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
76
103
  }
77
104
  }
@@ -259,13 +259,17 @@ export const toolSchemas = {
259
259
  },
260
260
  roam_search_block_refs: {
261
261
  name: 'roam_search_block_refs',
262
- description: 'Search for block references within a page or across the entire graph. Can search for references to a specific block or find all block references.',
262
+ description: 'Search for block references within a page or across the entire graph. Can search for references to a specific block, a page title, or find all block references.',
263
263
  inputSchema: {
264
264
  type: 'object',
265
265
  properties: {
266
266
  block_uid: {
267
267
  type: 'string',
268
- description: 'Optional: UID of the block to find references to'
268
+ description: 'Optional: UID of the block to find references to (searches for ((uid)) patterns in text)'
269
+ },
270
+ title: {
271
+ type: 'string',
272
+ description: 'Optional: Page title to find references to (uses :block/refs for [[page]] and #tag links)'
269
273
  },
270
274
  page_title_uid: {
271
275
  type: 'string',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {
@@ -23,13 +23,13 @@
23
23
  "type": "module",
24
24
  "bin": {
25
25
  "roam-research-mcp": "build/index.js",
26
- "roam-import": "build/cli/import-markdown.js"
26
+ "roam": "build/cli/roam.js"
27
27
  },
28
28
  "files": [
29
29
  "build"
30
30
  ],
31
31
  "scripts": {
32
- "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 build/cli/import-markdown.js",
32
+ "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 build/cli/roam.js",
33
33
  "clean": "rm -rf build",
34
34
  "watch": "tsc --watch",
35
35
  "inspector": "npx @modelcontextprotocol/inspector build/index.js",
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@modelcontextprotocol/sdk": "^1.13.2",
46
46
  "@roam-research/roam-api-sdk": "^0.10.0",
47
+ "commander": "^14.0.2",
47
48
  "dotenv": "^16.4.7"
48
49
  },
49
50
  "devDependencies": {
@@ -1,98 +0,0 @@
1
- #!/usr/bin/env node
2
- import { initializeGraph } from '@roam-research/roam-api-sdk';
3
- import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
4
- import { PageOperations } from '../tools/operations/pages.js';
5
- import { parseMarkdown } from '../markdown-utils.js';
6
- /**
7
- * Flatten nested MarkdownNode[] to flat array with absolute levels
8
- */
9
- function flattenNodes(nodes, baseLevel = 1) {
10
- const result = [];
11
- for (const node of nodes) {
12
- result.push({
13
- text: node.content,
14
- level: baseLevel,
15
- ...(node.heading_level && { heading: node.heading_level })
16
- });
17
- if (node.children.length > 0) {
18
- result.push(...flattenNodes(node.children, baseLevel + 1));
19
- }
20
- }
21
- return result;
22
- }
23
- /**
24
- * Read all input from stdin
25
- */
26
- async function readStdin() {
27
- const chunks = [];
28
- for await (const chunk of process.stdin) {
29
- chunks.push(chunk);
30
- }
31
- return Buffer.concat(chunks).toString('utf-8');
32
- }
33
- /**
34
- * Show usage help
35
- */
36
- function showUsage() {
37
- console.error('Usage: roam-import <page-title>');
38
- console.error('');
39
- console.error('Reads markdown from stdin and imports to Roam Research.');
40
- console.error('');
41
- console.error('Examples:');
42
- console.error(' cat document.md | roam-import "Meeting Notes"');
43
- console.error(' pbpaste | roam-import "Ideas"');
44
- console.error(' echo "- Item 1\\n- Item 2" | roam-import "Quick Note"');
45
- console.error('');
46
- console.error('Environment variables required:');
47
- console.error(' ROAM_API_TOKEN Your Roam Research API token');
48
- console.error(' ROAM_GRAPH_NAME Your Roam graph name');
49
- }
50
- async function main() {
51
- // Parse CLI arguments
52
- const args = process.argv.slice(2);
53
- const pageTitle = args[0];
54
- if (!pageTitle || pageTitle === '--help' || pageTitle === '-h') {
55
- showUsage();
56
- process.exit(pageTitle ? 0 : 1);
57
- }
58
- // Check if stdin is a TTY (no input piped)
59
- if (process.stdin.isTTY) {
60
- console.error('Error: No input received. Pipe markdown content to this command.');
61
- console.error('');
62
- showUsage();
63
- process.exit(1);
64
- }
65
- // Read markdown from stdin
66
- const markdownContent = await readStdin();
67
- if (!markdownContent.trim()) {
68
- console.error('Error: Empty input received.');
69
- process.exit(1);
70
- }
71
- // Initialize Roam graph
72
- const graph = initializeGraph({
73
- token: API_TOKEN,
74
- graph: GRAPH_NAME
75
- });
76
- // Parse markdown to nodes
77
- const nodes = parseMarkdown(markdownContent);
78
- // Flatten nested structure to content blocks
79
- const contentBlocks = flattenNodes(nodes);
80
- if (contentBlocks.length === 0) {
81
- console.error('Error: No content blocks parsed from input.');
82
- process.exit(1);
83
- }
84
- // Create page with content
85
- const pageOps = new PageOperations(graph);
86
- const result = await pageOps.createPage(pageTitle, contentBlocks);
87
- if (result.success) {
88
- console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
89
- }
90
- else {
91
- console.error(`Failed to create page '${pageTitle}'`);
92
- process.exit(1);
93
- }
94
- }
95
- main().catch((error) => {
96
- console.error(`Error: ${error.message}`);
97
- process.exit(1);
98
- });