roam-research-mcp 0.12.3 → 0.14.0

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.
package/build/index.js CHANGED
@@ -1,826 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
5
- import { initializeGraph, q, createPage, createBlock, batchActions, } from '@roam-research/roam-api-sdk';
6
- import * as dotenv from 'dotenv';
7
- import { dirname, join } from 'path';
8
- import { existsSync } from 'fs';
9
- import { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertToRoamMarkdown } from './markdown-utils.js';
10
- // Get the project root from the script path
11
- const scriptPath = process.argv[1]; // Full path to the running script
12
- const projectRoot = dirname(dirname(scriptPath)); // Go up two levels from build/index.js
13
- // Try to load .env from project root
14
- const envPath = join(projectRoot, '.env');
15
- if (existsSync(envPath)) {
16
- const result = dotenv.config({ path: envPath });
17
- }
18
- else {
19
- // No logging needed
20
- }
21
- const API_TOKEN = process.env.ROAM_API_TOKEN;
22
- const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
23
- if (!API_TOKEN || !GRAPH_NAME) {
24
- const missingVars = [];
25
- if (!API_TOKEN)
26
- missingVars.push('ROAM_API_TOKEN');
27
- if (!GRAPH_NAME)
28
- missingVars.push('ROAM_GRAPH_NAME');
29
- throw new Error(`Missing required environment variables: ${missingVars.join(', ')}\n\n` +
30
- 'Please configure these variables either:\n' +
31
- '1. In your MCP settings file:\n' +
32
- ' - For Cline: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json\n' +
33
- ' - For Claude: ~/Library/Application Support/Claude/claude_desktop_config.json\n\n' +
34
- ' Example configuration:\n' +
35
- ' {\n' +
36
- ' "mcpServers": {\n' +
37
- ' "roam-research": {\n' +
38
- ' "command": "node",\n' +
39
- ' "args": ["/path/to/roam-research/build/index.js"],\n' +
40
- ' "env": {\n' +
41
- ' "ROAM_API_TOKEN": "your-api-token",\n' +
42
- ' "ROAM_GRAPH_NAME": "your-graph-name"\n' +
43
- ' }\n' +
44
- ' }\n' +
45
- ' }\n' +
46
- ' }\n\n' +
47
- '2. Or in a .env file in the roam-research directory:\n' +
48
- ' ROAM_API_TOKEN=your-api-token\n' +
49
- ' ROAM_GRAPH_NAME=your-graph-name');
50
- }
51
- // Helper function to get ordinal suffix
52
- function getOrdinalSuffix(n) {
53
- const j = n % 10;
54
- const k = n % 100;
55
- if (j === 1 && k !== 11)
56
- return "st";
57
- if (j === 2 && k !== 12)
58
- return "nd";
59
- if (j === 3 && k !== 13)
60
- return "rd";
61
- return "th";
62
- }
63
- // Helper function to format date in Roam's format
64
- function formatRoamDate(date) {
65
- const month = date.toLocaleDateString('en-US', { month: 'long' });
66
- const day = date.getDate();
67
- const year = date.getFullYear();
68
- return `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
69
- }
70
- class RoamServer {
71
- server;
72
- graph;
73
- constructor() {
74
- this.graph = initializeGraph({
75
- token: API_TOKEN,
76
- graph: GRAPH_NAME,
77
- });
78
- this.server = new Server({
79
- name: 'roam-research',
80
- version: '0.12.1',
81
- }, {
82
- capabilities: {
83
- tools: {
84
- roam_add_todo: {},
85
- roam_fetch_page_by_title: {},
86
- roam_create_page: {},
87
- roam_create_block: {},
88
- roam_import_markdown: {}
89
- },
90
- },
91
- });
92
- this.setupToolHandlers();
93
- // Error handling
94
- this.server.onerror = (error) => { };
95
- process.on('SIGINT', async () => {
96
- await this.server.close();
97
- process.exit(0);
98
- });
99
- }
100
- setupToolHandlers() {
101
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
102
- tools: [
103
- // Add todo
104
- {
105
- name: 'roam_add_todo',
106
- description: 'Add a list of todo items as individual blocks to today\'s daily page in Roam. Each item becomes its own actionable block with todo status.',
107
- inputSchema: {
108
- type: 'object',
109
- properties: {
110
- todos: {
111
- type: 'array',
112
- items: {
113
- type: 'string',
114
- description: 'Todo item text'
115
- },
116
- description: 'List of todo items to add'
117
- }
118
- },
119
- required: ['todos'],
120
- },
121
- },
122
- // Read page
123
- {
124
- name: 'roam_fetch_page_by_title',
125
- description: 'Retrieve complete page contents by exact title, including all nested blocks and resolved block references. Use for reading and analyzing existing Roam pages.',
126
- inputSchema: {
127
- type: 'object',
128
- properties: {
129
- title: {
130
- type: 'string',
131
- description: 'Title of the page to fetch and read',
132
- },
133
- },
134
- required: ['title'],
135
- },
136
- },
137
- // Create page
138
- {
139
- name: 'roam_create_page',
140
- description: 'Create a new standalone page in Roam from markdown with given title. Best for hierarchical content, reference materials, markdown tables, and topics that deserve their own namespace. Optional initial content will be properly nested as blocks.',
141
- inputSchema: {
142
- type: 'object',
143
- properties: {
144
- title: {
145
- type: 'string',
146
- description: 'Title of the new page',
147
- },
148
- content: {
149
- type: 'string',
150
- description: 'Initial content for the page (optional)',
151
- },
152
- },
153
- required: ['title'],
154
- },
155
- },
156
- // Create block
157
- {
158
- name: 'roam_create_block',
159
- 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.',
160
- inputSchema: {
161
- type: 'object',
162
- properties: {
163
- content: {
164
- type: 'string',
165
- description: 'Content of the block',
166
- },
167
- page_uid: {
168
- type: 'string',
169
- description: 'Optional: UID of the page to add block to',
170
- },
171
- title: {
172
- type: 'string',
173
- description: 'Optional: Title of the page to add block to (defaults to today\'s date if neither page_uid nor title provided)',
174
- },
175
- },
176
- required: ['content'],
177
- },
178
- },
179
- // Import markdown
180
- {
181
- name: 'roam_import_markdown',
182
- description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID or by exact string match within a specific page.',
183
- inputSchema: {
184
- type: 'object',
185
- properties: {
186
- content: {
187
- type: 'string',
188
- description: 'Nested markdown content to import'
189
- },
190
- page_uid: {
191
- type: 'string',
192
- description: 'Optional: UID of the page containing the parent block'
193
- },
194
- page_title: {
195
- type: 'string',
196
- description: 'Optional: Title of the page containing the parent block (ignored if page_uid provided)'
197
- },
198
- parent_uid: {
199
- type: 'string',
200
- description: 'Optional: UID of the parent block to add content under'
201
- },
202
- parent_string: {
203
- type: 'string',
204
- description: 'Optional: Exact string content of the parent block to add content under (must provide either page_uid or page_title)'
205
- },
206
- order: {
207
- type: 'string',
208
- description: 'Optional: Where to add the content under the parent ("first" or "last")',
209
- enum: ['first', 'last'],
210
- default: 'first'
211
- }
212
- },
213
- required: ['content']
214
- }
215
- },
216
- ],
217
- }));
218
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
219
- try {
220
- switch (request.params.name) {
221
- case 'roam_fetch_page_by_title': {
222
- const { title } = request.params.arguments;
223
- if (!title) {
224
- throw new McpError(ErrorCode.InvalidRequest, 'title is required');
225
- }
226
- // Try to find the page with different case variations
227
- console.log('Finding page...');
228
- // Helper function to capitalize each word
229
- const capitalizeWords = (str) => {
230
- return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
231
- };
232
- // Try different case variations
233
- const variations = [
234
- title, // Original
235
- capitalizeWords(title), // Each word capitalized
236
- title.toLowerCase() // All lowercase
237
- ];
238
- let uid = null;
239
- for (const variation of variations) {
240
- const searchQuery = `[:find ?uid .
241
- :where [?e :node/title "${variation}"]
242
- [?e :block/uid ?uid]]`;
243
- const result = await q(this.graph, searchQuery, []);
244
- uid = (result === null || result === undefined) ? null : String(result);
245
- console.log(`Trying "${variation}" - UID:`, uid);
246
- if (uid)
247
- break;
248
- }
249
- if (!uid) {
250
- throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
251
- }
252
- // Helper function to collect all referenced block UIDs from text
253
- const collectRefs = (text, depth = 0, refs = new Set()) => {
254
- if (depth >= 4)
255
- return refs; // Max recursion depth
256
- const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
257
- let match;
258
- while ((match = refRegex.exec(text)) !== null) {
259
- const [_, uid] = match;
260
- refs.add(uid);
261
- }
262
- return refs;
263
- };
264
- // Helper function to resolve block references
265
- const resolveRefs = async (text, depth = 0) => {
266
- if (depth >= 4)
267
- return text; // Max recursion depth
268
- const refs = collectRefs(text, depth);
269
- if (refs.size === 0)
270
- return text;
271
- // Get referenced block contents
272
- const refQuery = `[:find ?uid ?string
273
- :in $ [?uid ...]
274
- :where [?b :block/uid ?uid]
275
- [?b :block/string ?string]]`;
276
- const refResults = await q(this.graph, refQuery, [Array.from(refs)]);
277
- // Create lookup map of uid -> string
278
- const refMap = new Map();
279
- refResults.forEach(([uid, string]) => {
280
- refMap.set(uid, string);
281
- });
282
- // Replace references with their content
283
- let resolvedText = text;
284
- for (const uid of refs) {
285
- const refContent = refMap.get(uid);
286
- if (refContent) {
287
- // Recursively resolve nested references
288
- const resolvedContent = await resolveRefs(refContent, depth + 1);
289
- resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
290
- }
291
- }
292
- return resolvedText;
293
- };
294
- // Get all blocks under this page with their order and parent relationships
295
- console.log('\nGetting blocks...');
296
- const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
297
- :where [?p :block/uid "${uid}"]
298
- [?b :block/page ?p]
299
- [?b :block/uid ?block-uid]
300
- [?b :block/string ?block-str]
301
- [?b :block/order ?order]
302
- [?b :block/parents ?parent]
303
- [?parent :block/uid ?parent-uid]]`;
304
- const blocks = await q(this.graph, blocksQuery, []);
305
- console.log('Found', blocks.length, 'blocks');
306
- if (blocks.length > 0) {
307
- const blockMap = new Map();
308
- for (const [uid, string, order] of blocks) {
309
- if (!blockMap.has(uid)) {
310
- const resolvedString = await resolveRefs(string);
311
- blockMap.set(uid, {
312
- uid,
313
- string: resolvedString,
314
- order: order,
315
- children: []
316
- });
317
- }
318
- }
319
- console.log('Created block map with', blockMap.size, 'entries');
320
- // Create a map of all blocks and resolve references
321
- // Build parent-child relationships
322
- let relationshipsBuilt = 0;
323
- blocks.forEach(([childUid, _, __, parentUid]) => {
324
- const child = blockMap.get(childUid);
325
- const parent = blockMap.get(parentUid);
326
- if (child && parent && !parent.children.includes(child)) {
327
- parent.children.push(child);
328
- relationshipsBuilt++;
329
- }
330
- });
331
- console.log('Built', relationshipsBuilt, 'parent-child relationships');
332
- // Get top-level blocks (those directly under the page)
333
- console.log('\nGetting top-level blocks...');
334
- const topQuery = `[:find ?block-uid ?block-str ?order
335
- :where [?p :block/uid "${uid}"]
336
- [?b :block/page ?p]
337
- [?b :block/uid ?block-uid]
338
- [?b :block/string ?block-str]
339
- [?b :block/order ?order]
340
- (not-join [?b]
341
- [?b :block/parents ?parent]
342
- [?parent :block/page ?p])]`;
343
- const topBlocks = await q(this.graph, topQuery, []);
344
- console.log('Found', topBlocks.length, 'top-level blocks');
345
- // Create root blocks
346
- const rootBlocks = topBlocks
347
- .map(([uid, string, order]) => ({
348
- uid,
349
- string,
350
- order: order,
351
- children: blockMap.get(uid)?.children || []
352
- }))
353
- .sort((a, b) => a.order - b.order);
354
- // Convert to markdown
355
- const toMarkdown = (blocks, level = 0) => {
356
- return blocks.map(block => {
357
- const indent = ' '.repeat(level);
358
- let md = `${indent}- ${block.string}\n`;
359
- if (block.children.length > 0) {
360
- md += toMarkdown(block.children.sort((a, b) => a.order - b.order), level + 1);
361
- }
362
- return md;
363
- }).join('');
364
- };
365
- const markdown = `# ${title}\n\n${toMarkdown(rootBlocks)}`;
366
- return {
367
- content: [
368
- {
369
- type: 'text',
370
- text: markdown,
371
- },
372
- ],
373
- };
374
- }
375
- else {
376
- return { content: [
377
- {
378
- type: 'text',
379
- text: `${title} (no content found)`
380
- }
381
- ] };
382
- }
383
- }
384
- case 'search_for_page_title': {
385
- const { search_string } = request.params.arguments;
386
- const query = `[:find ?page-title ?uid
387
- :in $ ?search-string
388
- :where [?e :node/title ?page-title]
389
- [?e :block/uid ?uid]
390
- [(clojure.string/includes? ?page-title ?search-string)]]`;
391
- const results = await q(this.graph, query, [search_string]);
392
- return {
393
- content: [
394
- {
395
- type: 'text',
396
- text: JSON.stringify(results, null, 2),
397
- },
398
- ],
399
- };
400
- }
401
- case 'search_blocks': {
402
- const { search_string } = request.params.arguments;
403
- const query = `[:find ?block-uid ?block-str
404
- :in $ ?search-string
405
- :where [?b :block/uid ?block-uid]
406
- [?b :block/string ?block-str]
407
- [(clojure.string/includes? ?block-str ?search-string)]]`;
408
- const results = await q(this.graph, query, [search_string]);
409
- return {
410
- content: [
411
- {
412
- type: 'text',
413
- text: JSON.stringify(results, null, 2),
414
- },
415
- ],
416
- };
417
- }
418
- case 'roam_create_page': {
419
- const { title, content } = request.params.arguments;
420
- // Ensure title is properly formatted
421
- const pageTitle = String(title).trim();
422
- // First try to find if the page exists
423
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
424
- const findResults = await q(this.graph, findQuery, [pageTitle]);
425
- let pageUid;
426
- if (findResults && findResults.length > 0) {
427
- // Page exists, use its UID
428
- pageUid = findResults[0][0];
429
- }
430
- else {
431
- // Create new page
432
- const success = await createPage(this.graph, {
433
- action: 'create-page',
434
- page: {
435
- title: pageTitle
436
- }
437
- });
438
- if (!success) {
439
- throw new Error('Failed to create page');
440
- }
441
- // Get the new page's UID
442
- const results = await q(this.graph, findQuery, [pageTitle]);
443
- if (!results || results.length === 0) {
444
- throw new Error('Could not find created page');
445
- }
446
- pageUid = results[0][0];
447
- }
448
- // If content is provided, check if it looks like nested markdown
449
- if (content) {
450
- const isMultilined = content.includes('\n') || hasMarkdownTable(content);
451
- if (isMultilined) {
452
- // Use import_nested_markdown functionality
453
- const convertedContent = convertToRoamMarkdown(content);
454
- const nodes = parseMarkdown(convertedContent);
455
- const actions = convertToRoamActions(nodes, pageUid, 'last');
456
- const result = await batchActions(this.graph, {
457
- action: 'batch-actions',
458
- actions
459
- });
460
- if (!result) {
461
- throw new Error('Failed to import nested markdown content');
462
- }
463
- }
464
- else {
465
- // Create a simple block for non-nested content
466
- const blockSuccess = await createBlock(this.graph, {
467
- action: 'create-block',
468
- location: {
469
- "parent-uid": pageUid,
470
- "order": "last"
471
- },
472
- block: { string: content }
473
- });
474
- if (!blockSuccess) {
475
- throw new Error('Failed to create content block');
476
- }
477
- }
478
- }
479
- return {
480
- content: [
481
- {
482
- type: 'text',
483
- text: JSON.stringify({ success: true, uid: pageUid }, null, 2),
484
- },
485
- ],
486
- };
487
- }
488
- case 'roam_create_block': {
489
- const { content, page_uid, title } = request.params.arguments;
490
- // If page_uid provided, use it directly
491
- let targetPageUid = page_uid;
492
- // If no page_uid but title provided, search for page by title
493
- if (!targetPageUid && title) {
494
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
495
- const findResults = await q(this.graph, findQuery, [title]);
496
- if (findResults && findResults.length > 0) {
497
- targetPageUid = findResults[0][0];
498
- }
499
- else {
500
- // Create page with provided title if it doesn't exist
501
- const success = await createPage(this.graph, {
502
- action: 'create-page',
503
- page: { title }
504
- });
505
- if (!success) {
506
- throw new Error('Failed to create page with provided title');
507
- }
508
- // Get the new page's UID
509
- const results = await q(this.graph, findQuery, [title]);
510
- if (!results || results.length === 0) {
511
- throw new Error('Could not find created page');
512
- }
513
- targetPageUid = results[0][0];
514
- }
515
- }
516
- // If neither page_uid nor title provided, use today's date page
517
- if (!targetPageUid) {
518
- const today = new Date();
519
- const dateStr = formatRoamDate(today);
520
- // Try to find today's page
521
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
522
- const findResults = await q(this.graph, findQuery, [dateStr]);
523
- if (findResults && findResults.length > 0) {
524
- targetPageUid = findResults[0][0];
525
- }
526
- else {
527
- // Create today's page if it doesn't exist
528
- const success = await createPage(this.graph, {
529
- action: 'create-page',
530
- page: { title: dateStr }
531
- });
532
- if (!success) {
533
- throw new Error('Failed to create today\'s page');
534
- }
535
- // Get the new page's UID
536
- const results = await q(this.graph, findQuery, [dateStr]);
537
- if (!results || results.length === 0) {
538
- throw new Error('Could not find created today\'s page');
539
- }
540
- targetPageUid = results[0][0];
541
- }
542
- }
543
- // If the converted content has multiple lines (e.g. from table conversion)
544
- // or is a table (which will be converted to multiple lines), use nested import
545
- if (content.includes('\n')) {
546
- // Parse and import the nested content
547
- const convertedContent = convertToRoamMarkdown(content);
548
- const nodes = parseMarkdown(convertedContent);
549
- const actions = convertToRoamActions(nodes, targetPageUid, 'last');
550
- // Execute batch actions to create the nested structure
551
- const result = await batchActions(this.graph, {
552
- action: 'batch-actions',
553
- actions
554
- });
555
- if (!result) {
556
- throw new Error('Failed to create nested blocks');
557
- }
558
- const blockUid = result.created_uids?.[0];
559
- return {
560
- content: [
561
- {
562
- type: 'text',
563
- text: JSON.stringify({
564
- success: true,
565
- block_uid: blockUid,
566
- parent_uid: targetPageUid
567
- }, null, 2),
568
- },
569
- ],
570
- };
571
- }
572
- else {
573
- // For non-table content, create a simple block
574
- const result = await createBlock(this.graph, {
575
- action: 'create-block',
576
- location: {
577
- "parent-uid": targetPageUid,
578
- "order": "last"
579
- },
580
- block: { string: content }
581
- });
582
- if (!result) {
583
- throw new Error('Failed to create block');
584
- }
585
- // Get the block's UID
586
- const findBlockQuery = `[:find ?uid
587
- :in $ ?parent ?string
588
- :where [?b :block/uid ?uid]
589
- [?b :block/string ?string]
590
- [?b :block/parents ?p]
591
- [?p :block/uid ?parent]]`;
592
- const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
593
- if (!blockResults || blockResults.length === 0) {
594
- throw new Error('Could not find created block');
595
- }
596
- const blockUid = blockResults[0][0];
597
- return {
598
- content: [
599
- {
600
- type: 'text',
601
- text: JSON.stringify({
602
- success: true,
603
- block_uid: blockUid,
604
- parent_uid: targetPageUid
605
- }, null, 2),
606
- },
607
- ],
608
- };
609
- }
610
- }
611
- case 'roam_import_markdown': {
612
- const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = request.params.arguments;
613
- // First get the page UID
614
- let targetPageUid = page_uid;
615
- if (!targetPageUid && page_title) {
616
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
617
- const findResults = await q(this.graph, findQuery, [page_title]);
618
- if (findResults && findResults.length > 0) {
619
- targetPageUid = findResults[0][0];
620
- }
621
- else {
622
- throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
623
- }
624
- }
625
- // If no page specified, use today's date page
626
- if (!targetPageUid) {
627
- const today = new Date();
628
- const dateStr = formatRoamDate(today);
629
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
630
- const findResults = await q(this.graph, findQuery, [dateStr]);
631
- if (findResults && findResults.length > 0) {
632
- targetPageUid = findResults[0][0];
633
- }
634
- else {
635
- // Create today's page
636
- const success = await createPage(this.graph, {
637
- action: 'create-page',
638
- page: { title: dateStr }
639
- });
640
- if (!success) {
641
- throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
642
- }
643
- const results = await q(this.graph, findQuery, [dateStr]);
644
- if (!results || results.length === 0) {
645
- throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
646
- }
647
- targetPageUid = results[0][0];
648
- }
649
- }
650
- // Now get the parent block UID
651
- let targetParentUid = parent_uid;
652
- if (!targetParentUid && parent_string) {
653
- if (!targetPageUid) {
654
- throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
655
- }
656
- // Find block by exact string match within the page
657
- const findBlockQuery = `[:find ?uid
658
- :where [?p :block/uid "${targetPageUid}"]
659
- [?b :block/page ?p]
660
- [?b :block/string "${parent_string}"]]`;
661
- const blockResults = await q(this.graph, findBlockQuery, []);
662
- if (!blockResults || blockResults.length === 0) {
663
- throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
664
- }
665
- targetParentUid = blockResults[0][0];
666
- }
667
- // If no parent specified, use page as parent
668
- if (!targetParentUid) {
669
- targetParentUid = targetPageUid;
670
- }
671
- // Always use parseMarkdown for content with multiple lines or any markdown formatting
672
- const isMultilined = content.includes('\n');
673
- if (isMultilined) {
674
- // Parse markdown into hierarchical structure
675
- const convertedContent = convertToRoamMarkdown(content);
676
- const nodes = parseMarkdown(convertedContent);
677
- // Convert markdown nodes to batch actions
678
- const actions = convertToRoamActions(nodes, targetParentUid, order);
679
- // Execute batch actions to add content
680
- const result = await batchActions(this.graph, {
681
- action: 'batch-actions',
682
- actions
683
- });
684
- if (!result) {
685
- throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
686
- }
687
- // Get the created block UIDs
688
- const createdUids = result.created_uids || [];
689
- return {
690
- content: [
691
- {
692
- type: 'text',
693
- text: JSON.stringify({
694
- success: true,
695
- page_uid: targetPageUid,
696
- parent_uid: targetParentUid,
697
- created_uids: createdUids
698
- }, null, 2),
699
- },
700
- ],
701
- };
702
- }
703
- else {
704
- // Create a simple block for non-nested content
705
- const blockSuccess = await createBlock(this.graph, {
706
- action: 'create-block',
707
- location: {
708
- "parent-uid": targetParentUid,
709
- order
710
- },
711
- block: { string: content }
712
- });
713
- if (!blockSuccess) {
714
- throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
715
- }
716
- return {
717
- content: [
718
- {
719
- type: 'text',
720
- text: JSON.stringify({
721
- success: true,
722
- page_uid: targetPageUid,
723
- parent_uid: targetParentUid
724
- }, null, 2),
725
- },
726
- ],
727
- };
728
- }
729
- }
730
- case 'roam_add_todo': {
731
- const { todos } = request.params.arguments;
732
- if (!Array.isArray(todos) || todos.length === 0) {
733
- throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
734
- }
735
- // Get today's date
736
- const today = new Date();
737
- const dateStr = formatRoamDate(today);
738
- // Try to find today's page
739
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
740
- const findResults = await q(this.graph, findQuery, [dateStr]);
741
- let targetPageUid;
742
- if (findResults && findResults.length > 0) {
743
- targetPageUid = findResults[0][0];
744
- }
745
- else {
746
- // Create today's page if it doesn't exist
747
- const success = await createPage(this.graph, {
748
- action: 'create-page',
749
- page: { title: dateStr }
750
- });
751
- if (!success) {
752
- throw new Error('Failed to create today\'s page');
753
- }
754
- // Get the new page's UID
755
- const results = await q(this.graph, findQuery, [dateStr]);
756
- if (!results || results.length === 0) {
757
- throw new Error('Could not find created today\'s page');
758
- }
759
- targetPageUid = results[0][0];
760
- }
761
- // If more than 10 todos, use batch actions
762
- const todo_tag = "{{TODO}}";
763
- if (todos.length > 10) {
764
- const actions = todos.map((todo, index) => ({
765
- action: 'create-block',
766
- location: {
767
- 'parent-uid': targetPageUid,
768
- order: index
769
- },
770
- block: {
771
- string: `${todo_tag} ${todo}`
772
- }
773
- }));
774
- const result = await batchActions(this.graph, {
775
- action: 'batch-actions',
776
- actions
777
- });
778
- if (!result) {
779
- throw new Error('Failed to create todo blocks');
780
- }
781
- }
782
- else {
783
- // Create todos sequentially
784
- for (const todo of todos) {
785
- const success = await createBlock(this.graph, {
786
- action: 'create-block',
787
- location: {
788
- "parent-uid": targetPageUid,
789
- "order": "last"
790
- },
791
- block: { string: `${todo_tag} ${todo}` }
792
- });
793
- if (!success) {
794
- throw new Error('Failed to create todo block');
795
- }
796
- }
797
- }
798
- return {
799
- content: [
800
- {
801
- type: 'text',
802
- text: JSON.stringify({ success: true }, null, 2),
803
- },
804
- ],
805
- };
806
- }
807
- default:
808
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
809
- }
810
- }
811
- catch (error) {
812
- if (error instanceof McpError) {
813
- throw error;
814
- }
815
- const errorMessage = error instanceof Error ? error.message : String(error);
816
- throw new McpError(ErrorCode.InternalError, `Roam API error: ${errorMessage}`);
817
- }
818
- });
819
- }
820
- async run() {
821
- const transport = new StdioServerTransport();
822
- await this.server.connect(transport);
823
- }
824
- }
2
+ import { RoamServer } from './server/roam-server.js';
825
3
  const server = new RoamServer();
826
4
  server.run().catch(() => { });