roam-research-mcp 0.25.7 → 0.30.1

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.
@@ -41,4 +41,6 @@ if (!API_TOKEN || !GRAPH_NAME) {
41
41
  ' ROAM_API_TOKEN=your-api-token\n' +
42
42
  ' ROAM_GRAPH_NAME=your-graph-name');
43
43
  }
44
- export { API_TOKEN, GRAPH_NAME };
44
+ const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8080
45
+ const SSE_PORT = process.env.SSE_PORT || '8087'; // Default to 8087
46
+ export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT };
@@ -284,7 +284,8 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
284
284
  block: {
285
285
  uid: block.uid,
286
286
  string: block.content,
287
- ...(block.heading_level && { heading: block.heading_level })
287
+ ...(block.heading_level && { heading: block.heading_level }),
288
+ ...(block.children_view_type && { 'children-view-type': block.children_view_type })
288
289
  }
289
290
  };
290
291
  actions.push(action);
@@ -1,13 +1,17 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
5
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
4
6
  import { initializeGraph } from '@roam-research/roam-api-sdk';
5
- import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
7
+ import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, SSE_PORT } from '../config/environment.js';
6
8
  import { toolSchemas } from '../tools/schemas.js';
7
9
  import { ToolHandlers } from '../tools/tool-handlers.js';
8
10
  import { readFileSync } from 'node:fs';
9
11
  import { join, dirname } from 'node:path';
12
+ import { createServer } from 'node:http';
10
13
  import { fileURLToPath } from 'node:url';
14
+ import { findAvailablePort } from '../utils/net.js';
11
15
  const __filename = fileURLToPath(import.meta.url);
12
16
  const __dirname = dirname(__filename);
13
17
  // Read package.json to get the version
@@ -37,38 +41,15 @@ export class RoamServer {
37
41
  if (Object.keys(toolSchemas).length === 0) {
38
42
  throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts');
39
43
  }
40
- this.server = new Server({
41
- name: 'roam-research',
42
- version: serverVersion, // Use the version from package.json
43
- }, {
44
- capabilities: {
45
- tools: {
46
- ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
47
- },
48
- },
49
- });
50
- this.setupRequestHandlers();
51
- // Error handling
52
- this.server.onerror = (error) => {
53
- // Re-throw as McpError to be caught by the MCP client
54
- if (error instanceof McpError) {
55
- throw error;
56
- }
57
- const errorMessage = error instanceof Error ? error.message : String(error);
58
- throw new McpError(ErrorCode.InternalError, `MCP server internal error: ${errorMessage}`);
59
- };
60
- process.on('SIGINT', async () => {
61
- await this.server.close();
62
- process.exit(0);
63
- });
64
44
  }
65
- setupRequestHandlers() {
45
+ // Refactored to accept a Server instance
46
+ setupRequestHandlers(mcpServer) {
66
47
  // List available tools
67
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
48
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
68
49
  tools: Object.values(toolSchemas),
69
50
  }));
70
51
  // Handle tool calls
71
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
52
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
72
53
  try {
73
54
  switch (request.params.name) {
74
55
  case 'roam_remember': {
@@ -92,13 +73,6 @@ export class RoamServer {
92
73
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
93
74
  };
94
75
  }
95
- case 'roam_create_block': {
96
- const { content, page_uid, title, heading } = request.params.arguments;
97
- const result = await this.toolHandlers.createBlock(content, page_uid, title, heading);
98
- return {
99
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
100
- };
101
- }
102
76
  case 'roam_import_markdown': {
103
77
  const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = request.params.arguments;
104
78
  const result = await this.toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
@@ -173,27 +147,6 @@ export class RoamServer {
173
147
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
174
148
  };
175
149
  }
176
- case 'roam_update_block': {
177
- const { block_uid, content, transform_pattern } = request.params.arguments;
178
- // Validate that either content or transform_pattern is provided, but not both or neither
179
- if ((!content && !transform_pattern) || (content && transform_pattern)) {
180
- throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform_pattern must be provided, but not both or neither');
181
- }
182
- let result;
183
- if (content) {
184
- result = await this.toolHandlers.updateBlock(block_uid, content);
185
- }
186
- else {
187
- // We know transform_pattern exists due to validation above
188
- result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
189
- const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
190
- return currentContent.replace(regex, transform_pattern.replace);
191
- });
192
- }
193
- return {
194
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
195
- };
196
- }
197
150
  case 'roam_recall': {
198
151
  const { sort_by = 'newest', filter_tag } = request.params.arguments;
199
152
  const result = await this.toolHandlers.recall(sort_by, filter_tag);
@@ -201,22 +154,16 @@ export class RoamServer {
201
154
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
202
155
  };
203
156
  }
204
- case 'roam_update_multiple_blocks': {
205
- const { updates } = request.params.arguments;
206
- // Validate that for each update, either content or transform is provided, but not both or neither
207
- for (const update of updates) {
208
- if ((!update.content && !update.transform) || (update.content && update.transform)) {
209
- throw new McpError(ErrorCode.InvalidRequest, 'For each update, either content or transform must be provided, but not both or neither');
210
- }
211
- }
212
- const result = await this.toolHandlers.updateBlocks(updates);
157
+ case 'roam_datomic_query': {
158
+ const { query, inputs } = request.params.arguments;
159
+ const result = await this.toolHandlers.executeDatomicQuery({ query, inputs });
213
160
  return {
214
161
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
215
162
  };
216
163
  }
217
- case 'roam_datomic_query': {
218
- const { query, inputs } = request.params.arguments;
219
- const result = await this.toolHandlers.executeDatomicQuery({ query, inputs });
164
+ case 'roam_process_batch_actions': {
165
+ const { actions } = request.params.arguments;
166
+ const result = await this.toolHandlers.processBatch(actions);
220
167
  return {
221
168
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
222
169
  };
@@ -236,8 +183,113 @@ export class RoamServer {
236
183
  }
237
184
  async run() {
238
185
  try {
239
- const transport = new StdioServerTransport();
240
- await this.server.connect(transport);
186
+ const stdioMcpServer = new Server({
187
+ name: 'roam-research',
188
+ version: serverVersion,
189
+ }, {
190
+ capabilities: {
191
+ tools: {
192
+ ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
193
+ },
194
+ },
195
+ });
196
+ this.setupRequestHandlers(stdioMcpServer);
197
+ const stdioTransport = new StdioServerTransport();
198
+ await stdioMcpServer.connect(stdioTransport);
199
+ const httpMcpServer = new Server({
200
+ name: 'roam-research-http', // A distinct name for the HTTP server
201
+ version: serverVersion,
202
+ }, {
203
+ capabilities: {
204
+ tools: {
205
+ ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
206
+ },
207
+ },
208
+ });
209
+ this.setupRequestHandlers(httpMcpServer);
210
+ const httpStreamTransport = new StreamableHTTPServerTransport({
211
+ sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
212
+ });
213
+ await httpMcpServer.connect(httpStreamTransport);
214
+ const httpServer = createServer(async (req, res) => {
215
+ try {
216
+ await httpStreamTransport.handleRequest(req, res);
217
+ }
218
+ catch (error) {
219
+ // console.error('HTTP Stream Server error:', error);
220
+ if (!res.headersSent) {
221
+ res.writeHead(500, { 'Content-Type': 'application/json' });
222
+ res.end(JSON.stringify({ error: 'Internal Server Error' }));
223
+ }
224
+ }
225
+ });
226
+ const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT));
227
+ httpServer.listen(availableHttpPort, () => {
228
+ // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`);
229
+ });
230
+ // SSE Server setup
231
+ const sseMcpServer = new Server({
232
+ name: 'roam-research-sse', // Distinct name for SSE server
233
+ version: serverVersion,
234
+ }, {
235
+ capabilities: {
236
+ tools: {
237
+ ...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, {}])),
238
+ },
239
+ },
240
+ });
241
+ this.setupRequestHandlers(sseMcpServer);
242
+ const sseHttpServer = createServer(async (req, res) => {
243
+ const parseBody = (request) => {
244
+ return new Promise((resolve, reject) => {
245
+ let body = '';
246
+ request.on('data', (chunk) => {
247
+ body += chunk.toString();
248
+ });
249
+ request.on('end', () => {
250
+ try {
251
+ resolve(body ? JSON.parse(body) : {});
252
+ }
253
+ catch (error) {
254
+ reject(error);
255
+ }
256
+ });
257
+ request.on('error', reject);
258
+ });
259
+ };
260
+ try {
261
+ if (req.url === '/sse') {
262
+ const sseTransport = new SSEServerTransport('/sse', res);
263
+ await sseMcpServer.connect(sseTransport);
264
+ if (req.method === 'GET') {
265
+ await sseTransport.start();
266
+ }
267
+ else if (req.method === 'POST') {
268
+ const parsedBody = await parseBody(req);
269
+ await sseTransport.handlePostMessage(req, res, parsedBody);
270
+ }
271
+ else {
272
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
273
+ res.end('Method Not Allowed');
274
+ }
275
+ }
276
+ else {
277
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
278
+ res.end('Not Found');
279
+ }
280
+ }
281
+ catch (error) {
282
+ // console.error('SSE HTTP Server error:', error);
283
+ if (!res.headersSent) {
284
+ res.writeHead(500, { 'Content-Type': 'application/json' });
285
+ res.end(JSON.stringify({ error: 'Internal Server Error' }));
286
+ }
287
+ }
288
+ });
289
+ const availableSsePort = await findAvailablePort(parseInt(SSE_PORT));
290
+ sseHttpServer.listen(availableSsePort, () => {
291
+ // console.log(`MCP Roam Research server running SSE on port ${availableSsePort}`);
292
+ });
241
293
  }
242
294
  catch (error) {
243
295
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,37 @@
1
+ import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk';
2
+ export class BatchOperations {
3
+ constructor(graph) {
4
+ this.graph = graph;
5
+ }
6
+ async processBatch(actions) {
7
+ const batchActions = actions.map(action => {
8
+ const { action: actionType, ...rest } = action;
9
+ const roamAction = { action: actionType };
10
+ if (rest.location) {
11
+ roamAction.location = {
12
+ 'parent-uid': rest.location['parent-uid'],
13
+ order: rest.location.order,
14
+ };
15
+ }
16
+ const block = {};
17
+ if (rest.string)
18
+ block.string = rest.string;
19
+ if (rest.uid)
20
+ block.uid = rest.uid;
21
+ if (rest.open !== undefined)
22
+ block.open = rest.open;
23
+ if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) {
24
+ block.heading = rest.heading;
25
+ }
26
+ if (rest['text-align'])
27
+ block['text-align'] = rest['text-align'];
28
+ if (rest['children-view-type'])
29
+ block['children-view-type'] = rest['children-view-type'];
30
+ if (Object.keys(block).length > 0) {
31
+ roamAction.block = block;
32
+ }
33
+ return roamAction;
34
+ });
35
+ return await roamBatchActions(this.graph, { actions: batchActions });
36
+ }
37
+ }
@@ -1,4 +1,4 @@
1
- import { q, createBlock, createPage } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, batchActions } 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 { resolveRefs } from '../helpers/refs.js';
@@ -49,18 +49,27 @@ export class MemoryOperations {
49
49
  }).join(' ') || '';
50
50
  // Create block with memory, memories tag, and optional categories
51
51
  const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
52
- try {
53
- await createBlock(this.graph, {
52
+ const actions = [{
54
53
  action: 'create-block',
55
54
  location: {
56
- "parent-uid": pageUid,
57
- "order": "last"
55
+ 'parent-uid': pageUid,
56
+ order: 'last'
58
57
  },
59
- block: { string: blockContent }
58
+ block: {
59
+ string: blockContent
60
+ }
61
+ }];
62
+ try {
63
+ const result = await batchActions(this.graph, {
64
+ action: 'batch-actions',
65
+ actions
60
66
  });
67
+ if (!result) {
68
+ throw new McpError(ErrorCode.InternalError, 'Failed to create memory block via batch action');
69
+ }
61
70
  }
62
71
  catch (error) {
63
- throw new McpError(ErrorCode.InternalError, 'Failed to create memory block');
72
+ throw new McpError(ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}`);
64
73
  }
65
74
  return { success: true };
66
75
  }
@@ -1,4 +1,4 @@
1
- import { q, createPage, createBlock, batchActions } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, batchActions } 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 { capitalizeWords } from '../helpers/text.js';
@@ -138,15 +138,21 @@ export class OutlineOperations {
138
138
  }
139
139
  for (let retry = 0; retry < maxRetries; retry++) {
140
140
  console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
141
- // Create block
142
- const success = await createBlock(this.graph, {
143
- action: 'create-block',
144
- location: {
145
- 'parent-uid': parentUid,
146
- order: 'last'
147
- },
148
- block: { string: content }
141
+ // Create block using batchActions
142
+ const batchResult = await batchActions(this.graph, {
143
+ action: 'batch-actions',
144
+ actions: [{
145
+ action: 'create-block',
146
+ location: {
147
+ 'parent-uid': parentUid,
148
+ order: 'last'
149
+ },
150
+ block: { string: content }
151
+ }]
149
152
  });
153
+ if (!batchResult) {
154
+ throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
155
+ }
150
156
  // Wait with exponential backoff
151
157
  const delay = initialDelay * Math.pow(2, retry);
152
158
  await new Promise(resolve => setTimeout(resolve, delay));
@@ -232,7 +238,13 @@ export class OutlineOperations {
232
238
  // Convert to Roam markdown format
233
239
  const convertedContent = convertToRoamMarkdown(markdownContent);
234
240
  // Parse markdown into hierarchical structure
235
- const nodes = parseMarkdown(convertedContent);
241
+ // We pass the original OutlineItem properties (heading, children_view_type)
242
+ // along with the parsed content to the nodes.
243
+ const nodes = parseMarkdown(convertedContent).map((node, index) => ({
244
+ ...node,
245
+ ...(validOutline[index].heading && { heading_level: validOutline[index].heading }),
246
+ ...(validOutline[index].children_view_type && { children_view_type: validOutline[index].children_view_type })
247
+ }));
236
248
  // Convert nodes to batch actions
237
249
  const actions = convertToRoamActions(nodes, targetParentUid, 'first');
238
250
  if (actions.length === 0) {
@@ -350,19 +362,26 @@ export class OutlineOperations {
350
362
  };
351
363
  }
352
364
  else {
353
- // Create a simple block for non-nested content
354
- try {
355
- await createBlock(this.graph, {
365
+ // Create a simple block for non-nested content using batchActions
366
+ const actions = [{
356
367
  action: 'create-block',
357
368
  location: {
358
369
  "parent-uid": targetParentUid,
359
370
  order
360
371
  },
361
372
  block: { string: content }
373
+ }];
374
+ try {
375
+ const result = await batchActions(this.graph, {
376
+ action: 'batch-actions',
377
+ actions
362
378
  });
379
+ if (!result) {
380
+ throw new McpError(ErrorCode.InternalError, 'Failed to create content block via batch action');
381
+ }
363
382
  }
364
383
  catch (error) {
365
- throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
384
+ throw new McpError(ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}`);
366
385
  }
367
386
  return {
368
387
  success: true,
@@ -1,4 +1,4 @@
1
- import { q, createBlock, createPage, batchActions } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage, batchActions } 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
  export class TodoOperations {
@@ -37,44 +37,23 @@ export class TodoOperations {
37
37
  throw new Error('Failed to create today\'s page');
38
38
  }
39
39
  }
40
- // If more than 10 todos, use batch actions
41
40
  const todo_tag = "{{TODO}}";
42
- if (todos.length > 10) {
43
- const actions = todos.map((todo, index) => ({
44
- action: 'create-block',
45
- location: {
46
- 'parent-uid': targetPageUid,
47
- order: index
48
- },
49
- block: {
50
- string: `${todo_tag} ${todo}`
51
- }
52
- }));
53
- const result = await batchActions(this.graph, {
54
- action: 'batch-actions',
55
- actions
56
- });
57
- if (!result) {
58
- throw new Error('Failed to create todo blocks');
59
- }
60
- }
61
- else {
62
- // Create todos sequentially
63
- for (const todo of todos) {
64
- try {
65
- await createBlock(this.graph, {
66
- action: 'create-block',
67
- location: {
68
- "parent-uid": targetPageUid,
69
- "order": "last"
70
- },
71
- block: { string: `${todo_tag} ${todo}` }
72
- });
73
- }
74
- catch (error) {
75
- throw new Error('Failed to create todo block');
76
- }
41
+ const actions = todos.map((todo, index) => ({
42
+ action: 'create-block',
43
+ location: {
44
+ 'parent-uid': targetPageUid,
45
+ order: index
46
+ },
47
+ block: {
48
+ string: `${todo_tag} ${todo}`
77
49
  }
50
+ }));
51
+ const result = await batchActions(this.graph, {
52
+ action: 'batch-actions',
53
+ actions
54
+ });
55
+ if (!result) {
56
+ throw new Error('Failed to create todo blocks');
78
57
  }
79
58
  return { success: true };
80
59
  }