roam-research-mcp
Version:
MCP server and CLI for Roam Research
679 lines (661 loc) • 32.8 kB
JavaScript
import { Command } from 'commander';
import { PageOperations } from '../../tools/operations/pages.js';
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
import { SearchOperations } from '../../tools/operations/search/index.js';
import { FullPageViewOperations } from '../../tools/operations/full-page-view.js';
import { formatPageOutput, formatBlockOutput, formatTodoOutput, formatGroupedOutput, flattenBlocks, blocksToMarkdown, printDebug, exitWithError } from '../utils/output.js';
import { resolveGraph } from '../utils/graph.js';
import { readStdin } from '../utils/input.js';
import { resolveRefs } from '../../tools/helpers/refs.js';
import { resolveRelativeDate, parseRoamUrl, isRoamUid } from '../../utils/helpers.js';
import { SearchUtils } from '../../search/utils.js';
import { sortResults, groupResults, getDefaultDirection } from '../utils/sort-group.js';
// Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
/**
* Recursively resolve block references in a RoamBlock tree
*/
async function resolveBlockRefsInTree(graph, block, maxDepth) {
// Only resolve if string is valid
const resolvedString = typeof block.string === 'string'
? await resolveRefs(graph, block.string, 0, maxDepth)
: block.string || '';
const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefsInTree(graph, child, maxDepth)));
return {
...block,
string: resolvedString,
children: resolvedChildren
};
}
/**
* Resolve refs in an array of blocks
*/
async function resolveBlocksRefsInTree(graph, blocks, maxDepth) {
return Promise.all(blocks.map(block => resolveBlockRefsInTree(graph, block, maxDepth)));
}
/**
* Normalize a tag by stripping #, [[, ]] wrappers
*/
function normalizeTag(tag) {
return tag.replace(/^
}
/**
* Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats)
* Case-insensitive matching.
*/
function contentHasTag(content, tag) {
const normalized = normalizeTag(tag).toLowerCase();
const lowerContent = content.toLowerCase();
return (lowerContent.includes(`[[${normalized}]]`) ||
lowerContent.includes(`
lowerContent.includes(`
}
/**
* Create the 'page' subcommand for explicit page retrieval
*/
function createPageSubcommand() {
return new Command('page')
.description('Fetch a page by UID, URL, or title')
.argument('<identifier>', 'Page UID, Roam URL, or page title')
.option('-j, --json', 'Output as JSON instead of markdown')
.option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
.option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
.option('-f, --flat', 'Flatten hierarchy to single-level list')
.option('-u, --uid', 'Return only the page UID')
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
.option('--debug', 'Show query metadata')
.addHelpText('after', `
Examples:
roam get page "Project Notes"
roam get page "January 10th, 2026"
roam get page abc123def
roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def"
roam get page "Project Notes" --uid
`)
.action(async (identifier, options) => {
try {
const graph = resolveGraph(options, false);
const depth = parseInt(options.depth || '4', 10);
const refsDepth = options.refs !== undefined
? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1))
: 0;
const outputOptions = {
json: options.json,
flat: options.flat,
debug: options.debug
};
if (options.debug) {
printDebug('Identifier', identifier);
printDebug('Graph', options.graph || 'default');
}
// Resolve identifier to page UID
let pageUid = null;
let pageTitle = null;
// 1. Check if it's a Roam URL
const urlParsed = parseRoamUrl(identifier);
if (urlParsed) {
pageUid = urlParsed.uid;
if (options.debug) {
printDebug('Parsed URL', { uid: pageUid, graph: urlParsed.graph });
}
}
// 2. Check if it's a direct UID
else if (isRoamUid(identifier)) {
pageUid = identifier;
if (options.debug) {
printDebug('Direct UID', pageUid);
}
}
// 3. Otherwise treat as page title
else {
pageTitle = resolveRelativeDate(identifier);
if (options.debug && pageTitle !== identifier) {
printDebug('Resolved date', `${identifier} → ${pageTitle}`);
}
}
const pageOps = new PageOperations(graph);
// If --uid flag, just return the UID
if (options.uid) {
if (pageUid) {
console.log(pageUid);
}
else if (pageTitle) {
const uid = await pageOps.getPageUid(pageTitle);
if (!uid) {
exitWithError(`Page "${pageTitle}" not found`);
}
console.log(uid);
}
return;
}
// Fetch page content
let blocks;
let displayTitle;
if (pageUid) {
// Fetch by UID - first need to get page title for display
const result = await pageOps.fetchPageByUid(pageUid);
if (!result) {
exitWithError(`Page with UID "${pageUid}" not found`);
}
blocks = result.blocks;
displayTitle = result.title;
}
else if (pageTitle) {
// Fetch by title
const result = await pageOps.fetchPageByTitle(pageTitle, 'raw');
if (typeof result === 'string') {
try {
blocks = JSON.parse(result);
}
catch {
exitWithError(result);
return;
}
}
else {
blocks = result;
}
displayTitle = pageTitle;
}
else {
exitWithError('Could not parse identifier');
return;
}
// Resolve block references if requested
if (refsDepth > 0) {
blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
}
console.log(formatPageOutput(displayTitle, blocks, outputOptions));
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(message);
}
});
}
/**
* Create the 'full' subcommand for full page view (content + linked references)
*/
function createFullSubcommand() {
return new Command('full')
.description('Fetch full page view: content + linked references with breadcrumb context')
.argument('<title>', 'Page title (for date pages use "January 2nd, 2025")')
.option('-d, --depth <n>', 'Child depth for referring blocks (default: 4)', '4')
.option('-n, --max-refs <n>', 'Max linked references (default: 200)', '200')
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
.option('--debug', 'Show debug information')
.addHelpText('after', `
Examples:
roam get full "Project Notes"
roam get full "TODO" -n 50
roam get full "Meeting Notes" -d 2
`)
.action(async (title, options) => {
try {
const resolvedTitle = resolveRelativeDate(title);
const depth = parseInt(options.depth || '4', 10);
const maxRefs = parseInt(options.maxRefs || '200', 10);
if (options.debug) {
printDebug('Title', resolvedTitle);
printDebug('Children depth', depth);
printDebug('Max references', maxRefs);
printDebug('Graph', options.graph || 'default');
}
const graph = resolveGraph(options, false);
const pageOps = new PageOperations(graph);
const fullViewOps = new FullPageViewOperations(graph, pageOps);
const result = await fullViewOps.fetchPageFullView(resolvedTitle, depth, maxRefs);
console.log(result);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(message);
}
});
}
/**
* Create the 'subpages' subcommand for namespace-based sub-page listing
*/
function createSubpagesSubcommand() {
return new Command('subpages')
.description('List sub-pages under a namespace prefix (e.g. "Project/", "Framework/")')
.argument('<prefix>', 'Namespace prefix (trailing "/" added automatically)')
.option('--filter-tag <tag>', 'Only sub-pages containing this tag')
.option('--content', 'Include each sub-page\'s block content')
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
.option('--debug', 'Show debug information')
.addHelpText('after', `
Examples:
roam get subpages "Project"
roam get subpages "Framework" --content
roam get subpages "Project" --filter-tag active
`)
.action(async (prefix, options) => {
try {
if (options.debug) {
printDebug('Prefix', prefix);
printDebug('Filter tag', options.filterTag || 'none');
printDebug('Include content', options.content || false);
printDebug('Graph', options.graph || 'default');
}
const graph = resolveGraph(options, false);
const pageOps = new PageOperations(graph);
const fullViewOps = new FullPageViewOperations(graph, pageOps);
const result = await fullViewOps.fetchSubPages(prefix, options.filterTag, options.content);
console.log(result);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(message);
}
});
}
export function createGetCommand() {
const cmd = new Command('get')
.description('Fetch pages, blocks, or TODO/DONE items with optional ref expansion')
.argument('[target]', 'Page title, block UID, or relative date. Reads from stdin if "-" or omitted.')
.option('-j, --json', 'Output as JSON instead of markdown')
.option('-s, --structure', 'Output flattened structure with UIDs and order (for surgical updates)')
.option('-d, --depth <n>', 'Child levels to fetch (default: 4, 0 = no children)', '4')
.option('-a, --ancestors', 'Include ancestor chain up to page root')
.option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
.option('-f, --flat', 'Flatten hierarchy to single-level list')
.option('-u, --uid', 'Return only the page UID (resolve title to UID)')
.option('--todo', 'Fetch TODO items')
.option('--done', 'Fetch DONE items')
.option('-p, --page <ref>', 'Scope to page title or UID (for TODOs, tags, text)')
.option('-i, --include <terms>', 'Include items matching these terms (comma-separated)')
.option('-e, --exclude <terms>', 'Exclude items matching these terms (comma-separated)')
.option('--tag <tag>', 'Get blocks with tag (repeatable, comma-separated)', (val, prev) => {
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
return prev ? [...prev, ...tags] : tags;
}, [])
.option('--text <text>', 'Get blocks containing text')
.option('--any', 'Use OR logic for multiple tags (default is AND)')
.option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => {
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
return prev ? [...prev, ...tags] : tags;
}, [])
.option('-n, --limit <n>', 'Limit number of blocks fetched (default: 20 for tag/text)', '20')
.option('--showall', 'Show all results (no limit)')
.option('--sort <field>', 'Sort results by: created, modified, page')
.option('--asc', 'Sort ascending (default for page)')
.option('--desc', 'Sort descending (default for dates)')
.option('--group-by <field>', 'Group results by: page, tag')
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
.option('--debug', 'Show query metadata')
.addHelpText('after', `
Examples:
roam get "Project Notes"
roam get today
roam get yesterday
roam get page abc123def
roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def"
roam get "Project Notes" --uid
roam get today -u
roam get abc123def
roam get "((abc123def))"
echo "Project A" | roam get
echo "abc123def" | roam get
cat uids.txt | roam get --json
roam get "Page" -j
roam get "Page" -f
roam get abc123def -d 2
roam get abc123def -a
roam get abc123def -a -d 0
roam get "Page" -r
roam get "Page" -r 3
roam get --todo
roam get --done
roam get --todo -p "Work"
roam get --tag TODO
roam get --tag Project,Active
roam get --tag Project --tag Active --any
roam get --tag Task --negtag Done
roam get --tag Meeting -p "Work"
roam get --text "urgent"
roam get --text "meeting" --tag Project
roam get --text "TODO" -p today
roam get --tag Convention --sort created
roam get --todo --sort modified --asc
roam get --tag Project --sort page
roam get --tag Convention --group-by page
roam get --tag Convention --group-by tag
roam get --tag Convention --group-by tag --sort modified
Output format:
Markdown: Content with hierarchy (no UIDs). Use --json for UIDs.
JSON: Full block structure including uid field.
JSON output fields:
Page: { title, children: [Block...] }
Block: { uid, string, order, heading?, children: [Block...] }
TODO/DONE: [{ block_uid, content, page_title }]
Tag/Text: [{ uid, string, order, heading?, children: [...] }]
Note: For flat results with UIDs, use 'roam search' instead.
`)
.action(async (target, options) => {
try {
const graph = resolveGraph(options, false);
const depth = parseInt(options.depth || '4', 10);
const ancestors = options.ancestors || false;
// Parse refs: true/string means enabled, number sets max depth (default 1, max 4)
const refsDepth = options.refs !== undefined
? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1))
: 0;
const outputOptions = {
json: options.json,
flat: options.flat,
debug: options.debug
};
if (options.debug) {
printDebug('Target', target || 'stdin');
printDebug('Graph', options.graph || 'default');
printDebug('Options', { depth, refs: refsDepth || 'off', uid: options.uid || false, ...outputOptions });
}
// Handle --uid flag: return just the page UID
if (options.uid) {
if (!target || target === '-') {
exitWithError('--uid requires a page title argument');
}
const resolvedTarget = resolveRelativeDate(target);
if (options.debug && resolvedTarget !== target) {
printDebug('Resolved date', `${target} → ${resolvedTarget}`);
}
// Check if target is already a block UID
const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
if (uidMatch) {
// Already a UID, just output it
console.log(uidMatch[1]);
return;
}
const pageOps = new PageOperations(graph);
const pageUid = await pageOps.getPageUid(resolvedTarget);
if (!pageUid) {
exitWithError(`Page "${resolvedTarget}" not found`);
}
console.log(pageUid);
return;
}
// Handle --structure flag: return flattened structure for surgical updates
if (options.structure) {
if (!target || target === '-') {
exitWithError('--structure requires a page title argument');
}
const resolvedTarget = resolveRelativeDate(target);
if (options.debug && resolvedTarget !== target) {
printDebug('Resolved date', `${target} → ${resolvedTarget}`);
}
const pageOps = new PageOperations(graph);
const result = await pageOps.fetchPageByTitle(resolvedTarget, 'structure');
console.log(result);
return;
}
// Parse sort/group options
const sortField = options.sort;
const groupByField = options.groupBy;
const sortDirection = sortField
? (options.asc ? 'asc' : options.desc ? 'desc' : getDefaultDirection(sortField))
: undefined;
// Handle --todo or --done flags (these ignore target arg usually, but could filter by page if target is used as page?)
// The help says "-p" is for page. So we strictly follow flags.
if (options.todo || options.done) {
const status = options.todo ? 'TODO' : 'DONE';
if (options.debug) {
printDebug('Status search', { status, page: options.page, include: options.include, exclude: options.exclude });
if (sortField)
printDebug('Sort', { field: sortField, direction: sortDirection });
if (groupByField)
printDebug('Group by', groupByField);
}
const searchOps = new SearchOperations(graph);
const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude);
let matches = result.matches;
// Apply sorting
if (sortField && sortDirection) {
matches = sortResults(matches, { field: sortField, direction: sortDirection });
}
// Apply grouping
if (groupByField) {
// For TODO/DONE, only page grouping makes sense (no tags on search results)
if (groupByField === 'tag') {
exitWithError('--group-by tag is not supported for TODO/DONE search. Use --group-by page instead.');
}
const grouped = groupResults(matches, { by: groupByField });
console.log(formatGroupedOutput(grouped, outputOptions));
}
else {
console.log(formatTodoOutput(matches, status, outputOptions));
}
return;
}
// Handle --tag and/or --text flags (search-based retrieval with full children)
const tags = options.tag || [];
if (tags.length > 0 || options.text) {
const searchOps = new SearchOperations(graph);
const blockOps = new BlockRetrievalOperations(graph);
const limit = options.showall ? Infinity : parseInt(options.limit || '20', 10);
const useOrLogic = options.any || false;
// Resolve page scope
const pageScope = options.page ? resolveRelativeDate(options.page) : undefined;
if (options.debug) {
printDebug('Tag/Text search', {
tags,
text: options.text,
page: pageScope,
any: useOrLogic,
negtag: options.negtag,
limit
});
if (sortField)
printDebug('Sort', { field: sortField, direction: sortDirection });
if (groupByField)
printDebug('Group by', groupByField);
}
// Get initial matches
let matches = [];
if (options.text) {
// Text search
const result = await searchOps.searchByText({
text: options.text,
page_title_uid: pageScope
});
matches = result.matches;
}
else if (tags.length > 0) {
// Tag search (use first tag as primary)
const normalizedTags = tags.map(normalizeTag);
const result = await searchOps.searchForTag(normalizedTags[0], pageScope);
matches = result.matches;
}
// Apply additional tag filters
if (tags.length > 0 && matches.length > 0) {
const normalizedTags = tags.map(normalizeTag);
// For text search with tags, filter by ALL tags
// For tag search with multiple tags, filter by remaining tags based on --any
if (options.text || normalizedTags.length > 1) {
matches = matches.filter(m => {
if (useOrLogic) {
return normalizedTags.some(tag => contentHasTag(m.content, tag));
}
else {
return normalizedTags.every(tag => contentHasTag(m.content, tag));
}
});
}
}
// Apply negative tag filter
const negTags = options.negtag || [];
if (negTags.length > 0) {
const normalizedNegTags = negTags.map(normalizeTag);
matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
}
// Apply sorting before limit (so we get the top N sorted items)
if (sortField && sortDirection) {
matches = sortResults(matches, { field: sortField, direction: sortDirection });
}
// Apply limit
const limitedMatches = matches.slice(0, limit);
if (limitedMatches.length === 0) {
console.log(options.json ? '[]' : 'No blocks found matching criteria.');
return;
}
// For tag grouping, fetch all tags for matched blocks
if (groupByField === 'tag') {
const blockUids = limitedMatches.map(m => m.block_uid);
const tagMap = await SearchUtils.fetchBlockTags(graph, blockUids);
// Attach tags to matches
for (const match of limitedMatches) {
match.tags = tagMap.get(match.block_uid) || [];
}
// Group and output
const primaryTag = tags.length > 0 ? normalizeTag(tags[0]) : '';
const grouped = groupResults(limitedMatches, { by: 'tag', searchTag: primaryTag });
console.log(formatGroupedOutput(grouped, outputOptions));
return;
}
// For page grouping, output grouped matches
if (groupByField === 'page') {
const grouped = groupResults(limitedMatches, { by: 'page' });
console.log(formatGroupedOutput(grouped, outputOptions));
return;
}
// Standard output: fetch full blocks with children
const blocks = [];
for (const match of limitedMatches) {
let block = await blockOps.fetchBlock(match.block_uid, depth, ancestors);
if (block) {
// Resolve refs if requested (default: enabled for tag/text search)
const effectiveRefsDepth = refsDepth > 0 ? refsDepth : 1;
block = await resolveBlockRefsInTree(graph, block, effectiveRefsDepth);
blocks.push(block);
}
}
// Output
if (options.json) {
const data = options.flat
? blocks.flatMap(b => flattenBlocks([b]))
: blocks;
console.log(JSON.stringify(data, null, 2));
}
else {
const displayBlocks = options.flat
? blocks.flatMap(b => flattenBlocks([b]))
: blocks;
// Show count header
const countMsg = matches.length > limit
? `Found ${matches.length} blocks (showing first ${limit}):\n\n`
: `Found ${blocks.length} block(s):\n\n`;
console.log(countMsg + blocksToMarkdown(displayBlocks));
}
return;
}
// Determine targets
let targets = [];
if (target && target !== '-') {
targets = [target];
}
else {
// Read from stdin if no target or explicit '-'
if (process.stdin.isTTY && target !== '-') {
// If TTY and no target, show error
exitWithError('Target is required. Use: roam get <page-title>, roam get --todo, roam get --tag <tag>, roam get --text <text>, or pipe targets via stdin');
}
const input = await readStdin();
if (input) {
targets = input.split('\n').map(t => t.trim()).filter(Boolean);
}
}
if (targets.length === 0) {
exitWithError('No targets provided');
}
// Helper to process a single target
const processTarget = async (item) => {
// Resolve relative date keywords (today, yesterday, tomorrow)
const resolvedTarget = resolveRelativeDate(item);
if (options.debug && resolvedTarget !== item) {
printDebug('Resolved date', `${item} → ${resolvedTarget}`);
}
// Check if target is a block UID
const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
if (uidMatch) {
// Fetch block by UID
const blockUid = uidMatch[1];
if (options.debug)
printDebug('Fetching block', { uid: blockUid });
const blockOps = new BlockRetrievalOperations(graph);
let block = await blockOps.fetchBlock(blockUid, depth, ancestors);
if (!block) {
// If fetching multiple, maybe warn instead of exit?
// For now, consistent behavior: print error message to stderr but continue?
// Or simpler: just return a "not found" string/object.
// formatBlockOutput doesn't handle null.
return options.json ? JSON.stringify({ error: `Block ${blockUid} not found` }) : `Block ${blockUid} not found`;
}
// Resolve block references if requested
if (refsDepth > 0) {
block = await resolveBlockRefsInTree(graph, block, refsDepth);
}
return formatBlockOutput(block, outputOptions);
}
else {
// Fetch page by title
if (options.debug)
printDebug('Fetching page', { title: resolvedTarget });
const pageOps = new PageOperations(graph);
const result = await pageOps.fetchPageByTitle(resolvedTarget, 'raw');
// Parse the raw result
let blocks;
if (typeof result === 'string') {
try {
blocks = JSON.parse(result);
}
catch {
// Result is already formatted as string (e.g., "Page Title (no content found)")
// But wait, fetchPageByTitle returns string if not found or empty?
// Actually fetchPageByTitle 'raw' returns JSON string of blocks OR empty array JSON string?
// Let's assume result is valid JSON or error message string.
return options.json ? JSON.stringify({ title: resolvedTarget, error: result }) : result;
}
}
else {
blocks = result;
}
// Resolve block references if requested
if (refsDepth > 0) {
blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
}
return formatPageOutput(resolvedTarget, blocks, outputOptions);
}
};
// Execute sequentially
for (const t of targets) {
const output = await processTarget(t);
console.log(output);
}
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(message);
}
});
// Add subcommands
cmd.addCommand(createPageSubcommand());
cmd.addCommand(createFullSubcommand());
cmd.addCommand(createSubpagesSubcommand());
return cmd;
}