roam-research-mcp 0.12.3 → 0.17.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/README.md +270 -1
- package/build/config/environment.js +44 -0
- package/build/index.js +1 -823
- package/build/markdown-utils.js +151 -37
- package/build/search/block-ref-search.js +72 -0
- package/build/search/hierarchy-search.js +105 -0
- package/build/search/index.js +7 -0
- package/build/search/status-search.js +43 -0
- package/build/search/tag-search.js +35 -0
- package/build/search/text-search.js +32 -0
- package/build/search/types.js +7 -0
- package/build/search/utils.js +98 -0
- package/build/server/roam-server.js +178 -0
- package/build/test-addMarkdownText.js +1 -1
- package/build/tools/schemas.js +317 -0
- package/build/tools/tool-handlers.js +972 -0
- package/build/types/roam.js +1 -0
- package/build/utils/helpers.js +19 -0
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -1,826 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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(() => { });
|