roam-research-mcp
Version:
MCP server and CLI for Roam Research
429 lines (428 loc) • 21.7 kB
JavaScript
import { q } from '@roam-research/roam-api-sdk';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { formatRoamDate } from '../../utils/helpers.js';
import { findOrCreatePage, getPageUid, getOrCreateTodayPage } from '../helpers/page-resolution.js';
import { executeBatch } from '../helpers/batch-utils.js';
import { parseMarkdown, nestUnderHeadings, convertToRoamActions, convertToRoamActionsWithBlocks, convertToRoamMarkdown } from '../../markdown-utils.js';
import { executeStagedBatch } from '../../shared/staged-batch.js';
import { ednString } from '../../shared/datalog.js';
// Threshold for skipping child fetch during verification
const VERIFICATION_THRESHOLD = 5;
/**
* Map the client-minted block tree (from convertToRoamActionsWithBlocks) into
* the NestedBlock shape returned to callers. No API calls — the UIDs are the
* ones we generated and sent in the create-block actions.
*/
function blockInfoToNestedBlocks(blocks, level = 0) {
return blocks.map((block, index) => ({
uid: block.uid,
text: block.content,
level,
order: index,
children: block.children.length > 0 ? blockInfoToNestedBlocks(block.children, level + 1) : undefined
}));
}
export class OutlineOperations {
constructor(graph) {
this.graph = graph;
/**
* Helper function to check if string is a valid Roam UID (9 characters)
*/
this.isValidUid = (str) => {
return typeof str === 'string' && str.length === 9;
};
}
/**
* Helper function to find block with reduced retries for rate limit efficiency.
* Uses only the most reliable query strategy with 2 retries max.
*/
async findBlockWithRetry(pageUid, blockString, maxRetries = 2, initialDelay = 1000) {
// Use only the most reliable query strategy (direct page and string match)
const query = `[:find ?b-uid ?order
:where [?p :block/uid ${ednString(pageUid)}]
[?b :block/page ?p]
[?b :block/string ${ednString(blockString)}]
[?b :block/order ?order]
[?b :block/uid ?b-uid]]`;
for (let retry = 0; retry < maxRetries; retry++) {
const blockResults = await q(this.graph, query, []);
if (blockResults && blockResults.length > 0) {
// Use the most recently created block (highest order)
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
return sorted[0][0];
}
// Exponential backoff between retries
if (retry < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, retry);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after ${maxRetries} attempts`);
}
;
/**
* Helper function to create and verify block with improved error handling
*/
async createAndVerifyBlock(content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) {
try {
// Initial delay before any operations
if (!isRetry) {
await new Promise(resolve => setTimeout(resolve, initialDelay));
}
for (let retry = 0; retry < maxRetries; retry++) {
console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
// Create block using batch action
await executeBatch(this.graph, [{
action: 'create-block',
location: {
'parent-uid': parentUid,
order: 'last'
},
block: { string: content }
}], `create block "${content}"`);
// Wait with exponential backoff
const delay = initialDelay * Math.pow(2, retry);
await new Promise(resolve => setTimeout(resolve, delay));
try {
// Try to find the block using our improved findBlockWithRetry
return await this.findBlockWithRetry(parentUid, content);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
if (retry === maxRetries - 1)
throw error;
}
}
throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
}
catch (error) {
// If this is already a retry, throw the error
if (isRetry)
throw error;
// Otherwise, try one more time with a clean slate
// console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
return this.createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
}
}
;
/**
* Helper function to fetch a block and its children recursively
*/
async fetchBlockWithChildren(blockUid, level = 1) {
const query = `
[:find ?childUid ?childString ?childOrder
:in $ ?parentUid
:where
[?parentEntity :block/uid ?parentUid]
[?parentEntity :block/children ?childEntity] ; This ensures direct children
[?childEntity :block/uid ?childUid]
[?childEntity :block/string ?childString]
[?childEntity :block/order ?childOrder]]
`;
const blockQuery = `
[:find ?string
:in $ ?uid
:where
[?e :block/uid ?uid]
[?e :block/string ?string]]
`;
try {
const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
if (!blockStringResult || blockStringResult.length === 0) {
return null;
}
const text = blockStringResult[0][0];
const childrenResults = await q(this.graph, query, [blockUid]);
const children = [];
if (childrenResults && childrenResults.length > 0) {
// Sort children by order
const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
for (const childResult of sortedChildren) {
const childUid = childResult[0];
const nestedChild = await this.fetchBlockWithChildren(childUid, level + 1);
if (nestedChild) {
children.push(nestedChild);
}
}
}
// The order of the root block is not available from this query, so we set it to 0
return { uid: blockUid, text, level, order: 0, children: children.length > 0 ? children : undefined };
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
}
}
;
/**
* Creates an outline structure on a Roam Research page, optionally under a specific block.
*
* @param outline - An array of OutlineItem objects, each containing text and a level.
* Markdown heading syntax (#, ##, ###) in the text will be recognized
* and converted to Roam headings while preserving the outline's hierarchical
* structure based on indentation.
* @param page_title_uid - The title or UID of the page where the outline should be created.
* If not provided, today's daily page will be used.
* @param block_text_uid - Optional. The text content or UID of an existing block under which
* the outline should be inserted. If a text string is provided and
* no matching block is found, a new block with that text will be created
* on the page to serve as the parent. If a UID is provided and the block
* is not found, an error will be thrown.
* @returns An object containing success status, page UID, parent UID, and a nested array of created block UIDs.
*/
async createOutline(outline, page_title_uid, block_text_uid, order) {
// Validate input
if (!Array.isArray(outline) || outline.length === 0) {
throw new McpError(ErrorCode.InvalidRequest, 'outline must be a non-empty array');
}
// Filter out items with undefined text
const validOutline = outline.filter(item => item.text !== undefined);
if (validOutline.length === 0) {
throw new McpError(ErrorCode.InvalidRequest, 'outline must contain at least one item with text');
}
// Validate outline structure
const invalidItems = validOutline.filter(item => typeof item.level !== 'number' ||
item.level < 1 ||
item.level > 10 ||
typeof item.text !== 'string' ||
item.text.trim().length === 0);
if (invalidItems.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
}
// Get or create the target page
const targetPageUid = await findOrCreatePage(this.graph, page_title_uid || formatRoamDate(new Date()));
// Get or create the parent block
let targetParentUid;
if (!block_text_uid) {
targetParentUid = targetPageUid;
}
else {
try {
if (this.isValidUid(block_text_uid)) {
// First try to find block by UID
const uidQuery = `[:find ?uid
:where [?e :block/uid ${ednString(block_text_uid)}]
[?e :block/uid ?uid]]`;
const uidResult = await q(this.graph, uidQuery, []);
if (uidResult && uidResult.length > 0) {
// Use existing block if found
targetParentUid = uidResult[0][0];
}
else {
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_text_uid}" not found`);
}
}
else {
// Create header block and get its UID if not a valid UID
targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
}
}
// Initialize result variable
let result;
try {
// Validate level sequence
if (validOutline.length > 0 && validOutline[0].level !== 1) {
throw new McpError(ErrorCode.InvalidRequest, 'Invalid outline structure - the first item must be at level 1');
}
let prevLevel = 0;
for (const item of validOutline) {
// Level should not increase by more than 1 at a time
if (item.level > prevLevel + 1) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}`);
}
prevLevel = item.level;
}
// Convert outline items to markdown-like structure
const markdownContent = validOutline
.map(item => {
const indent = ' '.repeat(item.level - 1);
// If the item text starts with a markdown heading (e.g., #, ##, ###),
// treat it as a direct heading without adding a bullet or outline indentation.
// NEW CHANGE: Handle standalone code blocks - do not prepend bullet
const isCodeBlock = item.text?.startsWith('```') && item.text.endsWith('```') && item.text.includes('\n');
return isCodeBlock ? `${indent}${item.text?.trim()}` : `${indent}- ${item.text?.trim()}`;
})
.join('\n');
// Convert to Roam markdown format
const convertedContent = convertToRoamMarkdown(markdownContent);
// Parse markdown into hierarchical structure
// We pass the original OutlineItem properties (heading, children_view_type)
// along with the parsed content to the nodes.
const nodes = parseMarkdown(convertedContent).map((node, index) => {
const outlineItem = validOutline[index];
return {
...node,
...(outlineItem?.heading && { heading_level: outlineItem.heading }),
...(outlineItem?.children_view_type && { children_view_type: outlineItem.children_view_type })
};
});
// Convert nodes to batch actions (flat list)
const resolvedOrder = order !== undefined ? order : 'last';
const actions = convertToRoamActions(nodes, targetParentUid, resolvedOrder);
if (actions.length === 0) {
throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
}
// Execute with staged batch to avoid race conditions
// where child blocks are created before their parent blocks exist
result = await executeStagedBatch(this.graph, actions, {
context: 'outline creation',
delayBetweenLevels: 100
});
}
catch (error) {
if (error instanceof McpError)
throw error;
throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
}
// Post-creation verification to get actual UIDs for top-level blocks
const createdBlocks = [];
// Only query for top-level blocks (level 1) based on the original outline input
const topLevelOutlineItems = validOutline.filter(item => item.level === 1);
// Skip recursive child fetching for large batches to reduce API calls
const skipChildFetch = topLevelOutlineItems.length > VERIFICATION_THRESHOLD;
for (const item of topLevelOutlineItems) {
try {
// Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
if (foundUid) {
if (skipChildFetch) {
// Large batch: just return parent UID, skip recursive child queries
createdBlocks.push({ uid: foundUid, text: item.text, level: 1, order: 0 });
}
else {
// Small batch: full verification with children (current behavior)
const nestedBlock = await this.fetchBlockWithChildren(foundUid);
if (nestedBlock) {
createdBlocks.push(nestedBlock);
}
}
}
}
catch (error) {
// This is a warning because even if one block fails to fetch, others might succeed.
// The error will be logged but not re-thrown to allow partial success reporting.
// console.warn(`Could not fetch nested block for "${item.text}": ${error.message}`);
}
}
return {
success: true,
page_uid: targetPageUid,
parent_uid: targetParentUid,
created_blocks: createdBlocks
};
}
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
// First get the page UID
let targetPageUid = page_uid;
// If page_uid is provided, verify it exists
if (page_uid) {
const verifyQuery = `[:find ?uid :where [?e :block/uid ${ednString(page_uid)}] [?e :block/uid ?uid]]`;
const verifyResult = await q(this.graph, verifyQuery, []);
if (!verifyResult || verifyResult.length === 0) {
throw new McpError(ErrorCode.InvalidRequest, `Page/block with UID "${page_uid}" not found`);
}
targetPageUid = page_uid;
}
else if (page_title) {
const foundUid = await getPageUid(this.graph, page_title);
if (!foundUid) {
throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
}
targetPageUid = foundUid;
}
// If no page specified, use today's date page
if (!targetPageUid) {
targetPageUid = await getOrCreateTodayPage(this.graph);
}
// Now get the parent block UID
let targetParentUid = parent_uid;
if (!targetParentUid && parent_string) {
if (!targetPageUid) {
throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
}
// Find block by exact string match within the page
const findBlockQuery = `[:find ?b-uid
:in $ ?page-uid ?block-string
:where [?p :block/uid ?page-uid]
[?b :block/page ?p]
[?b :block/string ?block-string]
[?b :block/uid ?b-uid]]`;
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, parent_string]);
if (blockResults && blockResults.length > 0) {
targetParentUid = blockResults[0][0];
}
else {
// If parent_string block doesn't exist, create it
targetParentUid = await this.createAndVerifyBlock(parent_string, targetPageUid);
}
}
// If no parent specified, use page as parent
if (!targetParentUid) {
targetParentUid = targetPageUid;
}
// Always use parseMarkdown for content with multiple lines or any markdown formatting
const isMultilined = content.includes('\n');
if (isMultilined) {
// Parse markdown into hierarchical structure, then nest by heading level so
// flush-left, heading-structured markdown (e.g. report skeletons) imports as
// a proper outline instead of a flat list of root-level siblings.
const convertedContent = convertToRoamMarkdown(content);
const nodes = nestUnderHeadings(parseMarkdown(convertedContent));
// Convert markdown nodes to batch actions, keeping the minted block tree.
// Roam's write API does not echo back transacted UIDs, so we report the
// UIDs we generated client-side rather than issuing a fragile post-write
// re-query (which was also skipped entirely past VERIFICATION_THRESHOLD,
// leaving created_blocks empty for larger imports).
const { actions, blocks } = convertToRoamActionsWithBlocks(nodes, targetParentUid, order);
// Execute batch actions to add content
await executeBatch(this.graph, actions, 'import nested markdown content');
return {
success: true,
page_uid: targetPageUid,
parent_uid: targetParentUid,
created_blocks: blockInfoToNestedBlocks(blocks)
};
}
else {
// Create a simple block for non-nested content
await executeBatch(this.graph, [{
action: 'create-block',
location: {
"parent-uid": targetParentUid,
"order": order
},
block: { string: content }
}], 'create content block');
// For single-line content, we still need to fetch the UID and construct a NestedBlock
const createdBlocks = [];
try {
const foundUid = await this.findBlockWithRetry(targetParentUid, content);
if (foundUid) {
createdBlocks.push({
uid: foundUid,
text: content,
level: 0,
order: 0,
children: []
});
}
}
catch (error) {
// Log warning but don't re-throw, as the block might be created, just not immediately verifiable
// console.warn(`Could not verify single block creation for "${content}": ${error.message}`);
}
return {
success: true,
page_uid: targetPageUid,
parent_uid: targetParentUid,
created_blocks: createdBlocks
};
}
}
}