roam-research-mcp 0.25.0 → 0.25.3

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.
@@ -17,7 +17,7 @@ export class RoamServer {
17
17
  this.toolHandlers = new ToolHandlers(this.graph);
18
18
  this.server = new Server({
19
19
  name: 'roam-research',
20
- version: '0.25.0',
20
+ version: '0.25.3',
21
21
  }, {
22
22
  capabilities: {
23
23
  tools: {
@@ -81,8 +81,8 @@ export class RoamServer {
81
81
  };
82
82
  }
83
83
  case 'roam_create_block': {
84
- const { content, page_uid, title } = request.params.arguments;
85
- const result = await this.toolHandlers.createBlock(content, page_uid, title);
84
+ const { content, page_uid, title, heading } = request.params.arguments;
85
+ const result = await this.toolHandlers.createBlock(content, page_uid, title, heading);
86
86
  return {
87
87
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
88
88
  };
@@ -1,4 +1,4 @@
1
- import { q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
1
+ import { q, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
@@ -7,7 +7,7 @@ export class BlockOperations {
7
7
  constructor(graph) {
8
8
  this.graph = graph;
9
9
  }
10
- async createBlock(content, page_uid, title) {
10
+ async createBlock(content, page_uid, title, heading) {
11
11
  // If page_uid provided, use it directly
12
12
  let targetPageUid = page_uid;
13
13
  // If no page_uid but title provided, search for page by title
@@ -68,9 +68,44 @@ export class BlockOperations {
68
68
  try {
69
69
  // If the content has multiple lines or is a table, use nested import
70
70
  if (content.includes('\n')) {
71
- // Parse and import the nested content
72
- const convertedContent = convertToRoamMarkdown(content);
73
- const nodes = parseMarkdown(convertedContent);
71
+ let nodes;
72
+ // If heading parameter is provided, manually construct nodes to preserve heading
73
+ if (heading) {
74
+ const lines = content.split('\n');
75
+ const firstLine = lines[0].trim();
76
+ const remainingLines = lines.slice(1);
77
+ // Create the first node with heading formatting
78
+ const firstNode = {
79
+ content: firstLine,
80
+ level: 0,
81
+ heading_level: heading,
82
+ children: []
83
+ };
84
+ // If there are remaining lines, parse them as children or siblings
85
+ if (remainingLines.length > 0 && remainingLines.some(line => line.trim())) {
86
+ const remainingContent = remainingLines.join('\n');
87
+ const convertedRemainingContent = convertToRoamMarkdown(remainingContent);
88
+ const remainingNodes = parseMarkdown(convertedRemainingContent);
89
+ // Add remaining nodes as siblings to the first node
90
+ nodes = [firstNode, ...remainingNodes];
91
+ }
92
+ else {
93
+ nodes = [firstNode];
94
+ }
95
+ }
96
+ else {
97
+ // No heading parameter, use original parsing logic
98
+ const convertedContent = convertToRoamMarkdown(content);
99
+ nodes = parseMarkdown(convertedContent);
100
+ // If we have simple newline-separated content (no markdown formatting),
101
+ // and parseMarkdown created nodes at level 0, reverse them to maintain original order
102
+ if (nodes.every(node => node.level === 0 && !node.heading_level)) {
103
+ const lines = content.split('\n').filter(line => line.trim());
104
+ if (lines.length === nodes.length) {
105
+ nodes.reverse();
106
+ }
107
+ }
108
+ }
74
109
  const actions = convertToRoamActions(nodes, targetPageUid, 'last');
75
110
  // Execute batch actions to create the nested structure
76
111
  const result = await batchActions(this.graph, {
@@ -88,27 +123,26 @@ export class BlockOperations {
88
123
  };
89
124
  }
90
125
  else {
91
- // For non-table content, create a simple block
92
- await createRoamBlock(this.graph, {
93
- action: 'create-block',
94
- location: {
95
- "parent-uid": targetPageUid,
96
- "order": "last"
97
- },
98
- block: { string: content }
126
+ // For single block content, use the same convertToRoamActions approach that works in roam_create_page
127
+ const nodes = [{
128
+ content: content,
129
+ level: 0,
130
+ ...(heading && typeof heading === 'number' && heading > 0 && { heading_level: heading }),
131
+ children: []
132
+ }];
133
+ if (!targetPageUid) {
134
+ throw new McpError(ErrorCode.InternalError, 'targetPageUid is undefined');
135
+ }
136
+ const actions = convertToRoamActions(nodes, targetPageUid, 'last');
137
+ // Execute batch actions to create the block
138
+ const result = await batchActions(this.graph, {
139
+ action: 'batch-actions',
140
+ actions
99
141
  });
100
- // Get the block's UID
101
- const findBlockQuery = `[:find ?uid
102
- :in $ ?parent ?string
103
- :where [?b :block/uid ?uid]
104
- [?b :block/string ?string]
105
- [?b :block/parents ?p]
106
- [?p :block/uid ?parent]]`;
107
- const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
108
- if (!blockResults || blockResults.length === 0) {
109
- throw new Error('Could not find created block');
142
+ if (!result) {
143
+ throw new Error('Failed to create block');
110
144
  }
111
- const blockUid = blockResults[0][0];
145
+ const blockUid = result.created_uids?.[0];
112
146
  return {
113
147
  success: true,
114
148
  block_uid: blockUid,
@@ -87,6 +87,7 @@ export class PageOperations {
87
87
  const nodes = content.map(block => ({
88
88
  content: block.text,
89
89
  level: block.level,
90
+ ...(block.heading && { heading_level: block.heading }),
90
91
  children: []
91
92
  }));
92
93
  // Create hierarchical structure based on levels
@@ -171,6 +172,21 @@ export class PageOperations {
171
172
  if (!blocks || blocks.length === 0) {
172
173
  return `${title} (no content found)`;
173
174
  }
175
+ // Get heading information for blocks that have it
176
+ const headingsQuery = `[:find ?block-uid ?heading
177
+ :in $ % ?page-title
178
+ :where [?page :node/title ?page-title]
179
+ [?block :block/uid ?block-uid]
180
+ [?block :block/heading ?heading]
181
+ (ancestor ?block ?page)]`;
182
+ const headings = await q(this.graph, headingsQuery, [ancestorRule, title]);
183
+ // Create a map of block UIDs to heading levels
184
+ const headingMap = new Map();
185
+ if (headings) {
186
+ for (const [blockUid, heading] of headings) {
187
+ headingMap.set(blockUid, heading);
188
+ }
189
+ }
174
190
  // Create a map of all blocks
175
191
  const blockMap = new Map();
176
192
  const rootBlocks = [];
@@ -181,6 +197,7 @@ export class PageOperations {
181
197
  uid: blockUid,
182
198
  string: resolvedString,
183
199
  order: order,
200
+ heading: headingMap.get(blockUid) || null,
184
201
  children: []
185
202
  };
186
203
  blockMap.set(blockUid, block);
@@ -213,7 +230,17 @@ export class PageOperations {
213
230
  const toMarkdown = (blocks, level = 0) => {
214
231
  return blocks.map(block => {
215
232
  const indent = ' '.repeat(level);
216
- let md = `${indent}- ${block.string}`;
233
+ let md;
234
+ // Check block heading level and format accordingly
235
+ if (block.heading && block.heading > 0) {
236
+ // Format as heading with appropriate number of hashtags
237
+ const hashtags = '#'.repeat(block.heading);
238
+ md = `${indent}${hashtags} ${block.string}`;
239
+ }
240
+ else {
241
+ // No heading, use bullet point (current behavior)
242
+ md = `${indent}- ${block.string}`;
243
+ }
217
244
  if (block.children.length > 0) {
218
245
  md += '\n' + toMarkdown(block.children, level + 1);
219
246
  }
@@ -34,7 +34,7 @@ export const toolSchemas = {
34
34
  },
35
35
  roam_create_page: {
36
36
  name: 'roam_create_page',
37
- description: 'Create a new standalone page in Roam with optional content using explicit nesting levels. Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.',
37
+ description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.',
38
38
  inputSchema: {
39
39
  type: 'object',
40
40
  properties: {
@@ -57,6 +57,12 @@ export const toolSchemas = {
57
57
  description: 'Indentation level (1-10, where 1 is top level)',
58
58
  minimum: 1,
59
59
  maximum: 10
60
+ },
61
+ heading: {
62
+ type: 'integer',
63
+ description: 'Optional: Heading formatting for this block (1-3)',
64
+ minimum: 1,
65
+ maximum: 3
60
66
  }
61
67
  },
62
68
  required: ['text', 'level']
@@ -68,7 +74,7 @@ export const toolSchemas = {
68
74
  },
69
75
  roam_create_block: {
70
76
  name: 'roam_create_block',
71
- description: 'Add a new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
77
+ description: 'Add new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
72
78
  inputSchema: {
73
79
  type: 'object',
74
80
  properties: {
@@ -84,6 +90,12 @@ export const toolSchemas = {
84
90
  type: 'string',
85
91
  description: 'Optional: Title of the page to add block to (defaults to today\'s date if neither page_uid nor title provided)',
86
92
  },
93
+ heading: {
94
+ type: 'integer',
95
+ description: 'Optional: Heading formatting for this block (1-3)',
96
+ minimum: 1,
97
+ maximum: 3
98
+ }
87
99
  },
88
100
  required: ['content'],
89
101
  },
@@ -33,8 +33,8 @@ export class ToolHandlers {
33
33
  return this.pageOps.fetchPageByTitle(title);
34
34
  }
35
35
  // Block Operations
36
- async createBlock(content, page_uid, title) {
37
- return this.blockOps.createBlock(content, page_uid, title);
36
+ async createBlock(content, page_uid, title, heading) {
37
+ return this.blockOps.createBlock(content, page_uid, title, heading);
38
38
  }
39
39
  async updateBlock(block_uid, content, transform) {
40
40
  return this.blockOps.updateBlock(block_uid, content, transform);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.25.0",
3
+ "version": "0.25.3",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {