UNPKG

roam-research-mcp

Version:
136 lines (135 loc) • 7.33 kB
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); } }