roam-research-mcp
Version:
MCP server and CLI for Roam Research
136 lines (135 loc) • 7.33 kB
JavaScript
import { q } from '@roam-research/roam-api-sdk';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { resolveBlockRefs } from '../helpers/refs.js';
import { fetchChildrenByDepth } from '../helpers/fetch-children.js';
import { pruneHiddenBlocks, isHiddenBlockString } from '../helpers/hidden.js';
import { ednString } from '../../shared/datalog.js';
export class BlockRetrievalOperations {
constructor(graph) {
this.graph = graph;
}
async fetchBlock(block_uid_raw, depth = 4, include_ancestors = false) {
if (!block_uid_raw) {
throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required.');
}
const block_uid = block_uid_raw.replace(/^\(\((.*)\)\)$/, '$1');
try {
const rootBlockQuery = `[:find ?string ?order ?heading
:in $ ?blockUid
:where [?b :block/uid ?blockUid]
[?b :block/string ?string]
[?b :block/order ?order]
[(get-else $ ?b :block/heading 0) ?heading]]`;
const rootBlockResults = await q(this.graph, rootBlockQuery, [block_uid]);
if (!rootBlockResults || rootBlockResults.length === 0) {
return null;
}
const [rootString, rootOrder, rootHeading] = rootBlockResults[0];
const childrenMap = await fetchChildrenByDepth(this.graph, [block_uid], depth);
// A block the user tagged #.rm-hide / #.rm-private is withheld outright,
// as is anything nested under it — same rule as the page read paths.
if (isHiddenBlockString(rootString)) {
return null;
}
const rootBlock = {
uid: block_uid,
string: rootString,
order: rootOrder,
heading: rootHeading || undefined,
children: pruneHiddenBlocks(childrenMap[block_uid] || []),
};
// Fetch ancestors if requested
if (include_ancestors) {
const ancestorQuery = `[:find (pull ?b [:block/uid :block/string
{:block/parents [:block/uid :block/string :node/title]}])
:where [?b :block/uid ${ednString(block_uid)}]]`;
const ancestorResults = await q(this.graph, ancestorQuery, []);
if (ancestorResults && ancestorResults.length > 0) {
const blockData = ancestorResults[0][0] || ancestorResults[0];
const parents = blockData[':block/parents'] || blockData.parents || [];
const ancestors = [];
for (const parent of parents) {
const uid = parent[':block/uid'] || parent.uid;
const str = parent[':block/string'] || parent.string;
const title = parent[':node/title'] || parent.title;
if (title) {
ancestors.push({ uid, title, is_page: true, depth: 0 });
}
else {
ancestors.push({ uid, string: str, depth: -1 }); // depth computed below
}
}
// Compute depths: page root is 0, work backward
// The parents array from Roam is unordered, so we need to establish order.
// Strategy: the page (is_page=true) is depth 0. Build the parent chain
// by querying direct parent relationships.
if (ancestors.length > 1) {
// Query to get direct parent mapping for ordering
const parentUids = ancestors.map(a => a.uid);
const orderQuery = `[:find ?childUid ?parentUid
:in $ [?childUid ...]
:where [?c :block/uid ?childUid]
[?c :block/parents ?p]
[?p :block/uid ?parentUid]
[?p :block/children ?c]]`;
const orderResults = await q(this.graph, orderQuery, [[block_uid, ...parentUids]]);
// Build child→parent map
const childToParent = {};
for (const [childUid, parentUid] of orderResults) {
childToParent[childUid] = parentUid;
}
// Walk from target block up to page root to determine order and depth
const orderedAncestors = [];
let current = block_uid;
let depthCounter = ancestors.length; // start high, count down
while (childToParent[current]) {
const parentUid = childToParent[current];
const ancestor = ancestors.find(a => a.uid === parentUid);
if (ancestor) {
orderedAncestors.push(ancestor);
}
current = parentUid;
}
// Assign depths: first ancestor (direct parent) gets highest depth, page root gets 0
for (let i = 0; i < orderedAncestors.length; i++) {
orderedAncestors[i].depth = orderedAncestors.length - i;
}
// Page root should be depth 0
const pageRoot = orderedAncestors.find(a => a.is_page);
if (pageRoot)
pageRoot.depth = 0;
rootBlock.ancestors = orderedAncestors;
}
else {
// Single ancestor = the page itself
ancestors.forEach(a => { if (a.depth === -1)
a.depth = 1; });
rootBlock.ancestors = ancestors;
}
// Set page_title from the page ancestor
const page = (rootBlock.ancestors || ancestors).find(a => a.is_page);
if (page?.title) {
rootBlock.page_title = page.title;
}
}
}
// Gather all blocks in the tree to scan for references
const allBlocks = [];
const traverse = (b) => {
allBlocks.push(b);
b.children.forEach(traverse);
};
traverse(rootBlock);
// Resolve references (max depth 2)
await resolveBlockRefs(this.graph, allBlocks, 2);
return rootBlock;
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to fetch block: ${error instanceof Error ? error.message : String(error)}`);
}
}
/** @deprecated Use fetchBlock() instead */
async fetchBlockWithChildren(block_uid, depth) {
return this.fetchBlock(block_uid, depth);
}
}