roam-research-mcp 0.36.0 → 1.3.2
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 +95 -46
- package/build/Roam_Markdown_Cheatsheet.md +519 -175
- package/build/cache/page-uid-cache.js +55 -0
- package/build/cli/import-markdown.js +98 -0
- package/build/config/environment.js +5 -4
- package/build/index.js +4 -1
- package/build/markdown-utils.js +56 -7
- package/build/search/datomic-search.js +40 -1
- package/build/server/roam-server.js +92 -50
- package/build/shared/errors.js +84 -0
- package/build/shared/index.js +5 -0
- package/build/shared/validation.js +268 -0
- package/build/tools/operations/batch.js +165 -3
- package/build/tools/operations/memory.js +29 -19
- package/build/tools/operations/outline.js +110 -70
- package/build/tools/operations/pages.js +174 -62
- package/build/tools/operations/table.js +142 -0
- package/build/tools/schemas.js +121 -17
- package/build/tools/tool-handlers.js +35 -14
- package/package.json +5 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory cache for page title -> UID mappings.
|
|
3
|
+
* Pages are stable entities that rarely get deleted, making them safe to cache.
|
|
4
|
+
* This reduces redundant API queries when looking up the same page multiple times.
|
|
5
|
+
*/
|
|
6
|
+
class PageUidCache {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.cache = new Map(); // title (lowercase) -> UID
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Get a cached page UID by title.
|
|
12
|
+
* @param title - Page title (case-insensitive)
|
|
13
|
+
* @returns The cached UID or undefined if not cached
|
|
14
|
+
*/
|
|
15
|
+
get(title) {
|
|
16
|
+
return this.cache.get(title.toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Cache a page title -> UID mapping.
|
|
20
|
+
* @param title - Page title (will be stored lowercase)
|
|
21
|
+
* @param uid - Page UID
|
|
22
|
+
*/
|
|
23
|
+
set(title, uid) {
|
|
24
|
+
this.cache.set(title.toLowerCase(), uid);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if a page title is cached.
|
|
28
|
+
* @param title - Page title (case-insensitive)
|
|
29
|
+
*/
|
|
30
|
+
has(title) {
|
|
31
|
+
return this.cache.has(title.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Called when a page is created - immediately add to cache.
|
|
35
|
+
* @param title - Page title
|
|
36
|
+
* @param uid - Page UID
|
|
37
|
+
*/
|
|
38
|
+
onPageCreated(title, uid) {
|
|
39
|
+
this.set(title, uid);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Clear the cache (useful for testing or session reset).
|
|
43
|
+
*/
|
|
44
|
+
clear() {
|
|
45
|
+
this.cache.clear();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get the current cache size.
|
|
49
|
+
*/
|
|
50
|
+
get size() {
|
|
51
|
+
return this.cache.size;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Singleton instance - shared across all operations
|
|
55
|
+
export const pageUidCache = new PageUidCache();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
|
|
4
|
+
import { PageOperations } from '../tools/operations/pages.js';
|
|
5
|
+
import { parseMarkdown } from '../markdown-utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* Flatten nested MarkdownNode[] to flat array with absolute levels
|
|
8
|
+
*/
|
|
9
|
+
function flattenNodes(nodes, baseLevel = 1) {
|
|
10
|
+
const result = [];
|
|
11
|
+
for (const node of nodes) {
|
|
12
|
+
result.push({
|
|
13
|
+
text: node.content,
|
|
14
|
+
level: baseLevel,
|
|
15
|
+
...(node.heading_level && { heading: node.heading_level })
|
|
16
|
+
});
|
|
17
|
+
if (node.children.length > 0) {
|
|
18
|
+
result.push(...flattenNodes(node.children, baseLevel + 1));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read all input from stdin
|
|
25
|
+
*/
|
|
26
|
+
async function readStdin() {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of process.stdin) {
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Show usage help
|
|
35
|
+
*/
|
|
36
|
+
function showUsage() {
|
|
37
|
+
console.error('Usage: roam-import <page-title>');
|
|
38
|
+
console.error('');
|
|
39
|
+
console.error('Reads markdown from stdin and imports to Roam Research.');
|
|
40
|
+
console.error('');
|
|
41
|
+
console.error('Examples:');
|
|
42
|
+
console.error(' cat document.md | roam-import "Meeting Notes"');
|
|
43
|
+
console.error(' pbpaste | roam-import "Ideas"');
|
|
44
|
+
console.error(' echo "- Item 1\\n- Item 2" | roam-import "Quick Note"');
|
|
45
|
+
console.error('');
|
|
46
|
+
console.error('Environment variables required:');
|
|
47
|
+
console.error(' ROAM_API_TOKEN Your Roam Research API token');
|
|
48
|
+
console.error(' ROAM_GRAPH_NAME Your Roam graph name');
|
|
49
|
+
}
|
|
50
|
+
async function main() {
|
|
51
|
+
// Parse CLI arguments
|
|
52
|
+
const args = process.argv.slice(2);
|
|
53
|
+
const pageTitle = args[0];
|
|
54
|
+
if (!pageTitle || pageTitle === '--help' || pageTitle === '-h') {
|
|
55
|
+
showUsage();
|
|
56
|
+
process.exit(pageTitle ? 0 : 1);
|
|
57
|
+
}
|
|
58
|
+
// Check if stdin is a TTY (no input piped)
|
|
59
|
+
if (process.stdin.isTTY) {
|
|
60
|
+
console.error('Error: No input received. Pipe markdown content to this command.');
|
|
61
|
+
console.error('');
|
|
62
|
+
showUsage();
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
// Read markdown from stdin
|
|
66
|
+
const markdownContent = await readStdin();
|
|
67
|
+
if (!markdownContent.trim()) {
|
|
68
|
+
console.error('Error: Empty input received.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
// Initialize Roam graph
|
|
72
|
+
const graph = initializeGraph({
|
|
73
|
+
token: API_TOKEN,
|
|
74
|
+
graph: GRAPH_NAME
|
|
75
|
+
});
|
|
76
|
+
// Parse markdown to nodes
|
|
77
|
+
const nodes = parseMarkdown(markdownContent);
|
|
78
|
+
// Flatten nested structure to content blocks
|
|
79
|
+
const contentBlocks = flattenNodes(nodes);
|
|
80
|
+
if (contentBlocks.length === 0) {
|
|
81
|
+
console.error('Error: No content blocks parsed from input.');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
// Create page with content
|
|
85
|
+
const pageOps = new PageOperations(graph);
|
|
86
|
+
const result = await pageOps.createPage(pageTitle, contentBlocks);
|
|
87
|
+
if (result.success) {
|
|
88
|
+
console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.error(`Failed to create page '${pageTitle}'`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
main().catch((error) => {
|
|
96
|
+
console.error(`Error: ${error.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
@@ -29,7 +29,7 @@ if (!API_TOKEN || !GRAPH_NAME) {
|
|
|
29
29
|
' "mcpServers": {\n' +
|
|
30
30
|
' "roam-research": {\n' +
|
|
31
31
|
' "command": "node",\n' +
|
|
32
|
-
' "args": ["/path/to/roam-research/build/index.js"],\n' +
|
|
32
|
+
' "args": ["/path/to/roam-research-mcp/build/index.js"],\n' +
|
|
33
33
|
' "env": {\n' +
|
|
34
34
|
' "ROAM_API_TOKEN": "your-api-token",\n' +
|
|
35
35
|
' "ROAM_GRAPH_NAME": "your-graph-name"\n' +
|
|
@@ -42,6 +42,7 @@ if (!API_TOKEN || !GRAPH_NAME) {
|
|
|
42
42
|
' ROAM_GRAPH_NAME=your-graph-name');
|
|
43
43
|
}
|
|
44
44
|
const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8088
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5678,https://roamresearch.com')
|
|
46
|
+
.split(',')
|
|
47
|
+
.map(origin => origin.trim());
|
|
48
|
+
export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, CORS_ORIGINS };
|
package/build/index.js
CHANGED
package/build/markdown-utils.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
1
2
|
/**
|
|
2
3
|
* Check if text has a traditional markdown table
|
|
3
4
|
*/
|
|
@@ -91,6 +92,51 @@ function parseMarkdown(markdown) {
|
|
|
91
92
|
processedLines.push(line);
|
|
92
93
|
}
|
|
93
94
|
}
|
|
95
|
+
// First pass: collect all unique indentation values to build level mapping
|
|
96
|
+
const indentationSet = new Set();
|
|
97
|
+
indentationSet.add(0); // Always include level 0
|
|
98
|
+
let inCodeBlockFirstPass = false;
|
|
99
|
+
for (const line of processedLines) {
|
|
100
|
+
const trimmedLine = line.trimEnd();
|
|
101
|
+
if (trimmedLine.match(/^(\s*)```/)) {
|
|
102
|
+
inCodeBlockFirstPass = !inCodeBlockFirstPass;
|
|
103
|
+
if (!inCodeBlockFirstPass)
|
|
104
|
+
continue; // Skip closing ```
|
|
105
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
106
|
+
indentationSet.add(indent);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (inCodeBlockFirstPass || trimmedLine === '')
|
|
110
|
+
continue;
|
|
111
|
+
const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
|
|
112
|
+
if (bulletMatch) {
|
|
113
|
+
indentationSet.add(bulletMatch[1].length);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
117
|
+
indentationSet.add(indent);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Create sorted array of indentation values and map to sequential levels
|
|
121
|
+
const sortedIndents = Array.from(indentationSet).sort((a, b) => a - b);
|
|
122
|
+
const indentToLevel = new Map();
|
|
123
|
+
sortedIndents.forEach((indent, index) => {
|
|
124
|
+
indentToLevel.set(indent, index);
|
|
125
|
+
});
|
|
126
|
+
// Helper to get level from indentation, finding closest match
|
|
127
|
+
function getLevel(indent) {
|
|
128
|
+
if (indentToLevel.has(indent)) {
|
|
129
|
+
return indentToLevel.get(indent);
|
|
130
|
+
}
|
|
131
|
+
// Find the closest smaller indentation
|
|
132
|
+
let closestLevel = 0;
|
|
133
|
+
for (const [ind, lvl] of indentToLevel) {
|
|
134
|
+
if (ind <= indent && lvl > closestLevel) {
|
|
135
|
+
closestLevel = lvl;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return closestLevel;
|
|
139
|
+
}
|
|
94
140
|
const rootNodes = [];
|
|
95
141
|
const stack = [];
|
|
96
142
|
let inCodeBlock = false;
|
|
@@ -132,7 +178,7 @@ function parseMarkdown(markdown) {
|
|
|
132
178
|
}
|
|
133
179
|
return codeLine.trimStart();
|
|
134
180
|
});
|
|
135
|
-
const level =
|
|
181
|
+
const level = getLevel(codeBlockIndentation);
|
|
136
182
|
const node = {
|
|
137
183
|
content: processedCodeLines.join('\n'),
|
|
138
184
|
level,
|
|
@@ -168,17 +214,18 @@ function parseMarkdown(markdown) {
|
|
|
168
214
|
if (trimmedLine === '') {
|
|
169
215
|
continue;
|
|
170
216
|
}
|
|
171
|
-
|
|
172
|
-
let level = Math.floor(indentation / 2);
|
|
217
|
+
let indentation;
|
|
173
218
|
let contentToParse;
|
|
174
219
|
const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
|
|
175
220
|
if (bulletMatch) {
|
|
176
|
-
|
|
221
|
+
indentation = bulletMatch[1].length;
|
|
177
222
|
contentToParse = trimmedLine.substring(bulletMatch[0].length);
|
|
178
223
|
}
|
|
179
224
|
else {
|
|
225
|
+
indentation = line.match(/^\s*/)?.[0].length ?? 0;
|
|
180
226
|
contentToParse = trimmedLine;
|
|
181
227
|
}
|
|
228
|
+
const level = getLevel(indentation);
|
|
182
229
|
const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse);
|
|
183
230
|
const node = {
|
|
184
231
|
content: finalContent,
|
|
@@ -239,12 +286,14 @@ function parseTableRows(lines) {
|
|
|
239
286
|
}
|
|
240
287
|
return tableNodes;
|
|
241
288
|
}
|
|
242
|
-
function generateBlockUid() {
|
|
243
|
-
// Generate a random string of 9 characters (Roam's format)
|
|
289
|
+
export function generateBlockUid() {
|
|
290
|
+
// Generate a random string of 9 characters (Roam's format) using crypto for better randomness
|
|
244
291
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';
|
|
292
|
+
// 64 chars, which divides 256 evenly (256 = 64 * 4), so simple modulo is unbiased
|
|
293
|
+
const bytes = randomBytes(9);
|
|
245
294
|
let uid = '';
|
|
246
295
|
for (let i = 0; i < 9; i++) {
|
|
247
|
-
uid += chars
|
|
296
|
+
uid += chars[bytes[i] % 64];
|
|
248
297
|
}
|
|
249
298
|
return uid;
|
|
250
299
|
}
|
|
@@ -8,7 +8,46 @@ export class DatomicSearchHandler extends BaseSearchHandler {
|
|
|
8
8
|
async execute() {
|
|
9
9
|
try {
|
|
10
10
|
// Execute the datomic query using the Roam API
|
|
11
|
-
|
|
11
|
+
let results = await q(this.graph, this.params.query, this.params.inputs || []);
|
|
12
|
+
if (this.params.regexFilter) {
|
|
13
|
+
let regex;
|
|
14
|
+
try {
|
|
15
|
+
regex = new RegExp(this.params.regexFilter, this.params.regexFlags);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
return {
|
|
19
|
+
success: false,
|
|
20
|
+
matches: [],
|
|
21
|
+
message: `Invalid regex filter provided: ${e instanceof Error ? e.message : String(e)}`
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
results = results.filter(result => {
|
|
25
|
+
if (this.params.regexTargetField && this.params.regexTargetField.length > 0) {
|
|
26
|
+
for (const field of this.params.regexTargetField) {
|
|
27
|
+
// Access nested fields if path is provided (e.g., "prop.nested")
|
|
28
|
+
const fieldPath = field.split('.');
|
|
29
|
+
let value = result;
|
|
30
|
+
for (const part of fieldPath) {
|
|
31
|
+
if (typeof value === 'object' && value !== null && part in value) {
|
|
32
|
+
value = value[part];
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
value = undefined; // Field not found
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (typeof value === 'string' && regex.test(value)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// Default to stringifying the whole result if no target field is specified
|
|
47
|
+
return regex.test(JSON.stringify(result));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
12
51
|
return {
|
|
13
52
|
success: true,
|
|
14
53
|
matches: results.map(result => ({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
-
import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
6
6
|
import { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT } from '../config/environment.js';
|
|
7
7
|
import { toolSchemas } from '../tools/schemas.js';
|
|
@@ -11,7 +11,7 @@ import { join, dirname } from 'node:path';
|
|
|
11
11
|
import { createServer } from 'node:http';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { findAvailablePort } from '../utils/net.js';
|
|
14
|
-
import {
|
|
14
|
+
import { CORS_ORIGINS } from '../config/environment.js';
|
|
15
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
16
|
const __dirname = dirname(__filename);
|
|
17
17
|
// Read package.json to get the version
|
|
@@ -20,7 +20,6 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
|
20
20
|
const serverVersion = packageJson.version;
|
|
21
21
|
export class RoamServer {
|
|
22
22
|
constructor() {
|
|
23
|
-
// console.log('RoamServer: Constructor started.');
|
|
24
23
|
try {
|
|
25
24
|
this.graph = initializeGraph({
|
|
26
25
|
token: API_TOKEN,
|
|
@@ -42,7 +41,23 @@ export class RoamServer {
|
|
|
42
41
|
if (Object.keys(toolSchemas).length === 0) {
|
|
43
42
|
throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts');
|
|
44
43
|
}
|
|
45
|
-
|
|
44
|
+
}
|
|
45
|
+
// Helper to create and configure MCP server instance
|
|
46
|
+
createMcpServer(nameSuffix = '') {
|
|
47
|
+
const server = new Server({
|
|
48
|
+
name: `roam-research${nameSuffix}`,
|
|
49
|
+
version: serverVersion,
|
|
50
|
+
}, {
|
|
51
|
+
capabilities: {
|
|
52
|
+
tools: {
|
|
53
|
+
...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, toolSchemas[toolName].inputSchema])),
|
|
54
|
+
},
|
|
55
|
+
resources: {}, // No resources exposed via capabilities
|
|
56
|
+
prompts: {}, // No prompts exposed via capabilities
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
this.setupRequestHandlers(server);
|
|
60
|
+
return server;
|
|
46
61
|
}
|
|
47
62
|
// Refactored to accept a Server instance
|
|
48
63
|
setupRequestHandlers(mcpServer) {
|
|
@@ -59,6 +74,10 @@ export class RoamServer {
|
|
|
59
74
|
mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
60
75
|
throw new McpError(ErrorCode.InternalError, `Resource not found: ${request.params.uri}`);
|
|
61
76
|
});
|
|
77
|
+
// List available prompts
|
|
78
|
+
mcpServer.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
79
|
+
return { prompts: [] };
|
|
80
|
+
});
|
|
62
81
|
// Handle tool calls
|
|
63
82
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
64
83
|
try {
|
|
@@ -192,6 +211,18 @@ export class RoamServer {
|
|
|
192
211
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
193
212
|
};
|
|
194
213
|
}
|
|
214
|
+
case 'roam_create_table': {
|
|
215
|
+
const { parent_uid, order, headers, rows } = request.params.arguments;
|
|
216
|
+
const result = await this.toolHandlers.createTable({
|
|
217
|
+
parent_uid,
|
|
218
|
+
order,
|
|
219
|
+
headers,
|
|
220
|
+
rows
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
195
226
|
default:
|
|
196
227
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
197
228
|
}
|
|
@@ -206,65 +237,77 @@ export class RoamServer {
|
|
|
206
237
|
});
|
|
207
238
|
}
|
|
208
239
|
async run() {
|
|
209
|
-
// console.log('RoamServer: run() method started.');
|
|
210
240
|
try {
|
|
211
|
-
|
|
212
|
-
const stdioMcpServer = new Server({
|
|
213
|
-
name: 'roam-research',
|
|
214
|
-
version: serverVersion,
|
|
215
|
-
}, {
|
|
216
|
-
capabilities: {
|
|
217
|
-
tools: {
|
|
218
|
-
...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, toolSchemas[toolName].inputSchema])),
|
|
219
|
-
},
|
|
220
|
-
resources: {
|
|
221
|
-
'roam-markdown-cheatsheet.md': {}
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
});
|
|
225
|
-
// console.log('RoamServer: stdioMcpServer created. Setting up request handlers...');
|
|
226
|
-
this.setupRequestHandlers(stdioMcpServer);
|
|
227
|
-
// console.log('RoamServer: stdioMcpServer handlers setup complete. Connecting transport...');
|
|
241
|
+
const stdioMcpServer = this.createMcpServer();
|
|
228
242
|
const stdioTransport = new StdioServerTransport();
|
|
229
243
|
await stdioMcpServer.connect(stdioTransport);
|
|
230
|
-
//
|
|
231
|
-
const
|
|
232
|
-
name: 'roam-research-http', // A distinct name for the HTTP server
|
|
233
|
-
version: serverVersion,
|
|
234
|
-
}, {
|
|
235
|
-
capabilities: {
|
|
236
|
-
tools: {
|
|
237
|
-
...Object.fromEntries(Object.keys(toolSchemas).map((toolName) => [toolName, toolSchemas[toolName].inputSchema])),
|
|
238
|
-
},
|
|
239
|
-
resources: {
|
|
240
|
-
'roam-markdown-cheatsheet.md': {}
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
// console.log('RoamServer: httpMcpServer created. Setting up request handlers...');
|
|
245
|
-
this.setupRequestHandlers(httpMcpServer);
|
|
246
|
-
// console.log('RoamServer: httpMcpServer handlers setup complete. Connecting transport...');
|
|
247
|
-
const httpStreamTransport = new StreamableHTTPServerTransport({
|
|
248
|
-
sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
|
|
249
|
-
});
|
|
250
|
-
await httpMcpServer.connect(httpStreamTransport);
|
|
251
|
-
// console.log('RoamServer: httpStreamTransport connected.');
|
|
244
|
+
// Track active transports by session ID for proper session management
|
|
245
|
+
const activeSessions = new Map();
|
|
252
246
|
const httpServer = createServer(async (req, res) => {
|
|
253
|
-
// Set CORS headers
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
247
|
+
// Set CORS headers dynamically based on request origin
|
|
248
|
+
const requestOrigin = req.headers.origin;
|
|
249
|
+
if (requestOrigin && CORS_ORIGINS.includes(requestOrigin)) {
|
|
250
|
+
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
251
|
+
}
|
|
252
|
+
else if (CORS_ORIGINS.includes('*')) {
|
|
253
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
254
|
+
}
|
|
255
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
256
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
|
|
257
|
+
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
258
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
257
259
|
// Handle preflight OPTIONS requests
|
|
258
260
|
if (req.method === 'OPTIONS') {
|
|
259
261
|
res.writeHead(204); // No Content
|
|
260
262
|
res.end();
|
|
261
263
|
return;
|
|
262
264
|
}
|
|
265
|
+
// Check for existing session ID in header
|
|
266
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
267
|
+
// Handle session termination (DELETE request)
|
|
268
|
+
if (req.method === 'DELETE' && sessionId) {
|
|
269
|
+
const transport = activeSessions.get(sessionId);
|
|
270
|
+
if (transport) {
|
|
271
|
+
await transport.close();
|
|
272
|
+
activeSessions.delete(sessionId);
|
|
273
|
+
res.writeHead(200);
|
|
274
|
+
res.end();
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
278
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
263
282
|
try {
|
|
283
|
+
// If we have an existing session, use that transport
|
|
284
|
+
if (sessionId && activeSessions.has(sessionId)) {
|
|
285
|
+
const transport = activeSessions.get(sessionId);
|
|
286
|
+
await transport.handleRequest(req, res);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Create new transport and server for new sessions
|
|
290
|
+
const httpMcpServer = this.createMcpServer('-http');
|
|
291
|
+
const httpStreamTransport = new StreamableHTTPServerTransport({
|
|
292
|
+
sessionIdGenerator: () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
|
|
293
|
+
onsessioninitialized: (newSessionId) => {
|
|
294
|
+
activeSessions.set(newSessionId, httpStreamTransport);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
// Clean up session when transport closes
|
|
298
|
+
httpStreamTransport.onclose = () => {
|
|
299
|
+
const entries = activeSessions.entries();
|
|
300
|
+
for (const [key, value] of entries) {
|
|
301
|
+
if (value === httpStreamTransport) {
|
|
302
|
+
activeSessions.delete(key);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
await httpMcpServer.connect(httpStreamTransport);
|
|
264
308
|
await httpStreamTransport.handleRequest(req, res);
|
|
265
309
|
}
|
|
266
310
|
catch (error) {
|
|
267
|
-
// // console.error('HTTP Stream Server error:', error);
|
|
268
311
|
if (!res.headersSent) {
|
|
269
312
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
270
313
|
res.end(JSON.stringify({ error: 'Internal Server Error' }));
|
|
@@ -273,7 +316,6 @@ export class RoamServer {
|
|
|
273
316
|
});
|
|
274
317
|
const availableHttpPort = await findAvailablePort(parseInt(HTTP_STREAM_PORT));
|
|
275
318
|
httpServer.listen(availableHttpPort, () => {
|
|
276
|
-
// // console.log(`MCP Roam Research server running HTTP Stream on port ${availableHttpPort}`);
|
|
277
319
|
});
|
|
278
320
|
}
|
|
279
321
|
catch (error) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the Roam MCP server.
|
|
3
|
+
* Provides consistent error handling across all tools.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Creates a structured validation error response.
|
|
7
|
+
*/
|
|
8
|
+
export function createValidationError(message, details, recovery) {
|
|
9
|
+
return {
|
|
10
|
+
code: 'VALIDATION_ERROR',
|
|
11
|
+
message,
|
|
12
|
+
details,
|
|
13
|
+
recovery
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Creates a structured rate limit error response.
|
|
18
|
+
*/
|
|
19
|
+
export function createRateLimitError(retryAfterMs) {
|
|
20
|
+
return {
|
|
21
|
+
code: 'RATE_LIMIT',
|
|
22
|
+
message: 'Too many requests, please retry after backoff',
|
|
23
|
+
recovery: {
|
|
24
|
+
retry_after_ms: retryAfterMs ?? 60000,
|
|
25
|
+
suggestion: 'Wait for the specified duration before retrying'
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a structured API error response.
|
|
31
|
+
*/
|
|
32
|
+
export function createApiError(message, details) {
|
|
33
|
+
return {
|
|
34
|
+
code: 'API_ERROR',
|
|
35
|
+
message,
|
|
36
|
+
details
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Creates a structured transaction failed error response.
|
|
41
|
+
*/
|
|
42
|
+
export function createTransactionFailedError(message, failedAtAction, committed) {
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
error: {
|
|
46
|
+
code: 'TRANSACTION_FAILED',
|
|
47
|
+
message,
|
|
48
|
+
details: failedAtAction !== undefined ? { action_index: failedAtAction } : undefined
|
|
49
|
+
},
|
|
50
|
+
committed
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Checks if an error is a rate limit error based on error message.
|
|
55
|
+
*/
|
|
56
|
+
export function isRateLimitError(error) {
|
|
57
|
+
if (error instanceof Error) {
|
|
58
|
+
const message = error.message.toLowerCase();
|
|
59
|
+
return message.includes('too many requests') ||
|
|
60
|
+
message.includes('rate limit') ||
|
|
61
|
+
message.includes('try again in');
|
|
62
|
+
}
|
|
63
|
+
if (typeof error === 'string') {
|
|
64
|
+
const message = error.toLowerCase();
|
|
65
|
+
return message.includes('too many requests') ||
|
|
66
|
+
message.includes('rate limit') ||
|
|
67
|
+
message.includes('try again in');
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Checks if an error is a network error.
|
|
73
|
+
*/
|
|
74
|
+
export function isNetworkError(error) {
|
|
75
|
+
if (error instanceof Error) {
|
|
76
|
+
const message = error.message.toLowerCase();
|
|
77
|
+
return message.includes('network') ||
|
|
78
|
+
message.includes('econnrefused') ||
|
|
79
|
+
message.includes('econnreset') ||
|
|
80
|
+
message.includes('etimedout') ||
|
|
81
|
+
message.includes('socket');
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|