@knowall-ai/reverie 0.4.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/LICENSE +21 -0
- package/README.md +528 -0
- package/build/cypher-guard.js +92 -0
- package/build/embeddings.js +303 -0
- package/build/handlers/index.js +654 -0
- package/build/hygiene.js +43 -0
- package/build/index.js +65 -0
- package/build/neo4j-client.js +182 -0
- package/build/search.js +92 -0
- package/build/server.js +71 -0
- package/build/tools/definitions.js +251 -0
- package/build/tools/guidance-tool.js +284 -0
- package/build/types.js +115 -0
- package/package.json +63 -0
package/build/hygiene.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { EMBEDDING_FIELDS } from './embeddings.js';
|
|
2
|
+
/** Above this many real properties a node is "bloated": facts are being piled on instead of modelled. */
|
|
3
|
+
export const DEFAULT_MAX_PROPERTIES = 30;
|
|
4
|
+
export const MAX_PROPERTIES_CAP = 500;
|
|
5
|
+
const HOUSEKEEPING = new Set([...EMBEDDING_FIELDS, 'created_at', 'updated_at', 'status', 'archived_at']);
|
|
6
|
+
const DATE_SUFFIX_RE = /(_|-)\d{4}(_|-)\d{2}(_|-)\d{2}$/;
|
|
7
|
+
const LONG_TEXT = 120;
|
|
8
|
+
/** REVERIE_MAX_PROPERTIES must be a whole number in 1..MAX_PROPERTIES_CAP; anything else falls back to the default. */
|
|
9
|
+
export function maxProperties(env = process.env) {
|
|
10
|
+
const raw = (env.REVERIE_MAX_PROPERTIES ?? '').trim();
|
|
11
|
+
if (!/^\d+$/.test(raw)) {
|
|
12
|
+
return DEFAULT_MAX_PROPERTIES;
|
|
13
|
+
}
|
|
14
|
+
const value = Number(raw);
|
|
15
|
+
return value >= 1 && value <= MAX_PROPERTIES_CAP ? value : DEFAULT_MAX_PROPERTIES;
|
|
16
|
+
}
|
|
17
|
+
/** User-visible properties only: no `_id`-style metadata, timestamps, status or embedding fields. */
|
|
18
|
+
export function contentKeys(props) {
|
|
19
|
+
return Object.keys(props).filter((key) => !key.startsWith('_') && !HOUSEKEEPING.has(key));
|
|
20
|
+
}
|
|
21
|
+
/** Keys that look like facts or episodes rather than attributes: dated names, or long prose values. */
|
|
22
|
+
export function factLikeKeys(props) {
|
|
23
|
+
return contentKeys(props).filter((key) => {
|
|
24
|
+
const value = props[key];
|
|
25
|
+
return DATE_SUFFIX_RE.test(key) || (typeof value === 'string' && value.length > LONG_TEXT);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export const DEFAULT_LAZY_EMBED_BATCH = 100;
|
|
29
|
+
export const MAX_LAZY_EMBED_BATCH = 1000;
|
|
30
|
+
/** REVERIE_LAZY_EMBED_BATCH: stale candidates embedded per search; whole number 1..1000, else the default. */
|
|
31
|
+
export function lazyEmbedBatch(env = process.env) {
|
|
32
|
+
const raw = (env.REVERIE_LAZY_EMBED_BATCH ?? '').trim();
|
|
33
|
+
if (!/^\d+$/.test(raw)) {
|
|
34
|
+
return DEFAULT_LAZY_EMBED_BATCH;
|
|
35
|
+
}
|
|
36
|
+
const value = Number(raw);
|
|
37
|
+
return value >= 1 && value <= MAX_LAZY_EMBED_BATCH ? value : DEFAULT_LAZY_EMBED_BATCH;
|
|
38
|
+
}
|
|
39
|
+
export function bloatHint(name, count, limit) {
|
|
40
|
+
return `"${name}" now has ${count} properties (limit ${limit}). The graph maps entities and relationships; ` +
|
|
41
|
+
'keep only durable attributes here. Fold dated facts and episodes into a short attribute, a relationship, ' +
|
|
42
|
+
'or your notes. See get_guidance("best-practices").';
|
|
43
|
+
}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Neo4jServer } from './server.js';
|
|
3
|
+
// Debug environment for Smithery
|
|
4
|
+
if (process.env.DEBUG_SMITHERY) {
|
|
5
|
+
console.error('Environment variables:', {
|
|
6
|
+
NEO4J_URI: process.env.NEO4J_URI,
|
|
7
|
+
NEO4J_USERNAME: process.env.NEO4J_USERNAME,
|
|
8
|
+
NEO4J_PASSWORD: process.env.NEO4J_PASSWORD,
|
|
9
|
+
NEO4J_DATABASE: process.env.NEO4J_DATABASE
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
// Check if we have database configuration (ignore empty strings)
|
|
13
|
+
const uri = process.env.NEO4J_URI?.trim();
|
|
14
|
+
const username = process.env.NEO4J_USERNAME?.trim();
|
|
15
|
+
const password = process.env.NEO4J_PASSWORD?.trim();
|
|
16
|
+
const hasDbConfig = uri && username && password;
|
|
17
|
+
// Only validate environment variables if at least one non-empty value is provided
|
|
18
|
+
if ((uri || username || password) && !hasDbConfig) {
|
|
19
|
+
// If any Neo4j env var is set, all required ones must be set
|
|
20
|
+
if (!password) {
|
|
21
|
+
console.error('Error: NEO4J_PASSWORD environment variable is required');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
if (!uri) {
|
|
25
|
+
console.error('Error: NEO4J_URI environment variable is required');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
if (!username) {
|
|
29
|
+
console.error('Error: NEO4J_USERNAME environment variable is required');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const config = hasDbConfig ? {
|
|
34
|
+
uri: uri,
|
|
35
|
+
username: username,
|
|
36
|
+
password: password,
|
|
37
|
+
database: process.env.NEO4J_DATABASE?.trim() || undefined, // Optional for Neo4j Community Edition
|
|
38
|
+
} : undefined;
|
|
39
|
+
// サーバーの起動
|
|
40
|
+
const server = new Neo4jServer(config);
|
|
41
|
+
server.run().catch((error) => {
|
|
42
|
+
console.error('Failed to start Neo4j MCP server:', error);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
45
|
+
// 終了時のクリーンアップ
|
|
46
|
+
process.on('SIGINT', async () => {
|
|
47
|
+
try {
|
|
48
|
+
await server.close();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error('Error during shutdown:', error);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
process.on('SIGTERM', async () => {
|
|
57
|
+
try {
|
|
58
|
+
await server.close();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error('Error during shutdown:', error);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { cypherIdentifier } from './types.js';
|
|
2
|
+
import neo4j, { Integer, Node, Relationship } from 'neo4j-driver';
|
|
3
|
+
const CLOSE_TIMEOUT_MS = 5_000;
|
|
4
|
+
/** session.close() awaits connection release with no deadline; a stuck release must not hang a tool call. */
|
|
5
|
+
async function closeBounded(session) {
|
|
6
|
+
let timer;
|
|
7
|
+
const deadline = new Promise((resolve) => { timer = setTimeout(resolve, CLOSE_TIMEOUT_MS); });
|
|
8
|
+
try {
|
|
9
|
+
await Promise.race([session.close().catch(() => undefined), deadline]);
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class Neo4jClient {
|
|
16
|
+
driver;
|
|
17
|
+
database;
|
|
18
|
+
constructor(uri, username, password, database) {
|
|
19
|
+
this.driver = neo4j.driver(uri, neo4j.auth.basic(username, password));
|
|
20
|
+
this.database = database;
|
|
21
|
+
}
|
|
22
|
+
convertNestedIntegers(value) {
|
|
23
|
+
if (value instanceof Integer) {
|
|
24
|
+
return value.toNumber();
|
|
25
|
+
}
|
|
26
|
+
if (value instanceof Node) {
|
|
27
|
+
return {
|
|
28
|
+
...this.convertNestedIntegers(value.properties),
|
|
29
|
+
_id: value.identity.toNumber(),
|
|
30
|
+
_labels: value.labels,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (value instanceof Relationship) {
|
|
34
|
+
return {
|
|
35
|
+
...this.convertNestedIntegers(value.properties),
|
|
36
|
+
_id: value.identity.toNumber(),
|
|
37
|
+
_type: value.type,
|
|
38
|
+
_start: value.start.toNumber(),
|
|
39
|
+
_end: value.end.toNumber(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (neo4j.isPath(value)) {
|
|
43
|
+
return {
|
|
44
|
+
start: this.convertNestedIntegers(value.start),
|
|
45
|
+
end: this.convertNestedIntegers(value.end),
|
|
46
|
+
segments: value.segments.map((segment) => ({
|
|
47
|
+
start: this.convertNestedIntegers(segment.start),
|
|
48
|
+
relationship: this.convertNestedIntegers(segment.relationship),
|
|
49
|
+
end: this.convertNestedIntegers(segment.end),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (neo4j.isPoint(value)) {
|
|
54
|
+
return { srid: this.convertNestedIntegers(value.srid), x: value.x, y: value.y, z: value.z };
|
|
55
|
+
}
|
|
56
|
+
if (neo4j.isDate(value) || neo4j.isDateTime(value) || neo4j.isLocalDateTime(value) ||
|
|
57
|
+
neo4j.isTime(value) || neo4j.isLocalTime(value) || neo4j.isDuration(value)) {
|
|
58
|
+
return value.toString();
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return value.map((item) => this.convertNestedIntegers(item));
|
|
62
|
+
}
|
|
63
|
+
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
64
|
+
const converted = {};
|
|
65
|
+
for (const [key, val] of Object.entries(value)) {
|
|
66
|
+
converted[key] = this.convertNestedIntegers(val);
|
|
67
|
+
}
|
|
68
|
+
return converted;
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
recordToObject(record) {
|
|
73
|
+
const obj = {};
|
|
74
|
+
for (const key of record.keys) {
|
|
75
|
+
obj[key] = this.convertNestedIntegers(record.get(key));
|
|
76
|
+
}
|
|
77
|
+
return obj;
|
|
78
|
+
}
|
|
79
|
+
async executeQuery(query, params = {}) {
|
|
80
|
+
const session = this.driver.session({
|
|
81
|
+
database: this.database
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const result = await session.run(query, params);
|
|
85
|
+
return result.records.map((record) => this.recordToObject(record));
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await closeBounded(session);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Caller-supplied Cypher: runs in a READ transaction (the server rejects writes), stops
|
|
93
|
+
* consuming after `limit` records instead of materialising the whole result, and is
|
|
94
|
+
* bounded by a transaction timeout.
|
|
95
|
+
*/
|
|
96
|
+
async executeReadQuery(query, params, options) {
|
|
97
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 0) {
|
|
98
|
+
throw new Error('Query limit must be a non-negative integer');
|
|
99
|
+
}
|
|
100
|
+
if (options.limit === 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const session = this.driver.session({
|
|
104
|
+
database: this.database,
|
|
105
|
+
defaultAccessMode: neo4j.session.READ
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
return await session.executeRead(async (tx) => {
|
|
109
|
+
const rows = [];
|
|
110
|
+
for await (const record of tx.run(query, params)) {
|
|
111
|
+
rows.push(this.recordToObject(record));
|
|
112
|
+
if (rows.length >= options.limit) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return rows;
|
|
117
|
+
}, { timeout: options.timeoutMs });
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await closeBounded(session);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async getNodes(label) {
|
|
124
|
+
return this.executeQuery(`MATCH (n:${cypherIdentifier(label)}) RETURN n as memory`);
|
|
125
|
+
}
|
|
126
|
+
async createNode(label, properties) {
|
|
127
|
+
const result = await this.executeQuery(`CREATE (n:${cypherIdentifier(label)} $props) RETURN n as memory`, { props: properties });
|
|
128
|
+
return result[0];
|
|
129
|
+
}
|
|
130
|
+
async createRelationship(fromNodeId, toNodeId, relationType, properties = {}) {
|
|
131
|
+
const result = await this.executeQuery(`MATCH (a), (b)
|
|
132
|
+
WHERE id(a) = $fromId AND id(b) = $toId
|
|
133
|
+
CREATE (a)-[r:${cypherIdentifier(relationType)} $props]->(b)
|
|
134
|
+
RETURN r as relationship`, {
|
|
135
|
+
fromId: neo4j.int(fromNodeId),
|
|
136
|
+
toId: neo4j.int(toNodeId),
|
|
137
|
+
props: properties,
|
|
138
|
+
});
|
|
139
|
+
return result[0];
|
|
140
|
+
}
|
|
141
|
+
async updateNode(nodeId, properties) {
|
|
142
|
+
const result = await this.executeQuery(`MATCH (n) WHERE id(n) = $nodeId
|
|
143
|
+
SET n += $props
|
|
144
|
+
RETURN n as memory`, {
|
|
145
|
+
nodeId: neo4j.int(nodeId),
|
|
146
|
+
props: properties,
|
|
147
|
+
});
|
|
148
|
+
return result[0];
|
|
149
|
+
}
|
|
150
|
+
async updateRelationship(fromNodeId, toNodeId, relationType, properties) {
|
|
151
|
+
const result = await this.executeQuery(`MATCH (a)-[r:${cypherIdentifier(relationType)}]->(b)
|
|
152
|
+
WHERE id(a) = $fromId AND id(b) = $toId
|
|
153
|
+
SET r += $props
|
|
154
|
+
RETURN r as relationship`, {
|
|
155
|
+
fromId: neo4j.int(fromNodeId),
|
|
156
|
+
toId: neo4j.int(toNodeId),
|
|
157
|
+
props: properties,
|
|
158
|
+
});
|
|
159
|
+
return result[0];
|
|
160
|
+
}
|
|
161
|
+
async deleteNode(nodeId) {
|
|
162
|
+
const result = await this.executeQuery(`MATCH (n) WHERE id(n) = $nodeId
|
|
163
|
+
DETACH DELETE n
|
|
164
|
+
RETURN count(n) as deletedCount`, {
|
|
165
|
+
nodeId: neo4j.int(nodeId),
|
|
166
|
+
});
|
|
167
|
+
return result[0];
|
|
168
|
+
}
|
|
169
|
+
async deleteRelationship(fromNodeId, toNodeId, relationType) {
|
|
170
|
+
const result = await this.executeQuery(`MATCH (a)-[r:${cypherIdentifier(relationType)}]->(b)
|
|
171
|
+
WHERE id(a) = $fromId AND id(b) = $toId
|
|
172
|
+
DELETE r
|
|
173
|
+
RETURN count(r) as deletedCount`, {
|
|
174
|
+
fromId: neo4j.int(fromNodeId),
|
|
175
|
+
toId: neo4j.int(toNodeId),
|
|
176
|
+
});
|
|
177
|
+
return result[0];
|
|
178
|
+
}
|
|
179
|
+
async close() {
|
|
180
|
+
await this.driver.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
package/build/search.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { cosine } from './embeddings.js';
|
|
2
|
+
import { contentKeys } from './hygiene.js';
|
|
3
|
+
/** Case-insensitive equality on name, aliases or email: the lookup to run before creating a memory. */
|
|
4
|
+
export function exactMatches(query, props) {
|
|
5
|
+
const wanted = query.trim().toLowerCase();
|
|
6
|
+
if (!wanted)
|
|
7
|
+
return false;
|
|
8
|
+
const values = [props.name, props.email, ...(Array.isArray(props.aliases) ? props.aliases : [])];
|
|
9
|
+
return values.some((v) => typeof v === 'string' && v.trim().toLowerCase() === wanted);
|
|
10
|
+
}
|
|
11
|
+
export function keywordMatches(query, props) {
|
|
12
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
13
|
+
if (!trimmedQuery) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const words = trimmedQuery.split(/\s+/);
|
|
17
|
+
// Only user content is searchable: timestamps, status and embedding fields never match.
|
|
18
|
+
for (const key of contentKeys(props)) {
|
|
19
|
+
const value = props[key];
|
|
20
|
+
if (value === null || value === undefined) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const haystack = Array.isArray(value)
|
|
24
|
+
? value.map((item) => item?.toString() || '').join(' ').toLowerCase()
|
|
25
|
+
: value.toString().toLowerCase();
|
|
26
|
+
if (words.some((word) => haystack.includes(word))) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
export function rank(candidates, opts) {
|
|
33
|
+
const trimmedQuery = opts.query.trim();
|
|
34
|
+
if (!trimmedQuery) {
|
|
35
|
+
return [...candidates]
|
|
36
|
+
.map((candidate) => ({ id: candidate.id, score: 0, match: 'keyword' }))
|
|
37
|
+
.sort(compareRankedBy(candidates));
|
|
38
|
+
}
|
|
39
|
+
const results = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
if (opts.mode === 'exact') {
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (exactMatches(trimmedQuery, candidate.props)) {
|
|
44
|
+
results.push({ id: candidate.id, score: 1, match: 'exact' });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return results.sort(compareRankedBy(candidates));
|
|
48
|
+
}
|
|
49
|
+
if (opts.mode === 'hybrid' || opts.mode === 'keyword') {
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
if (keywordMatches(trimmedQuery, candidate.props)) {
|
|
52
|
+
results.push({ id: candidate.id, score: 1, match: 'keyword' });
|
|
53
|
+
seen.add(candidate.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if ((opts.mode === 'hybrid' || opts.mode === 'semantic') && opts.queryEmbedding && opts.modelId) {
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
if (seen.has(candidate.id)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (candidate.props.embedding_model !== opts.modelId) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const score = Math.max(similarity(opts.queryEmbedding, candidate.props.embedding), similarity(opts.queryEmbedding, candidate.props.name_embedding));
|
|
66
|
+
if (score >= opts.threshold) {
|
|
67
|
+
results.push({ id: candidate.id, score, match: 'semantic' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return results.sort(compareRankedBy(candidates));
|
|
72
|
+
}
|
|
73
|
+
function similarity(query, vector) {
|
|
74
|
+
return Array.isArray(vector) ? cosine(query, vector.map((item) => Number(item))) : 0;
|
|
75
|
+
}
|
|
76
|
+
function compareRankedBy(candidates) {
|
|
77
|
+
const createdAt = new Map(candidates.map((candidate) => [
|
|
78
|
+
candidate.id,
|
|
79
|
+
typeof candidate.props.created_at === 'string' ? candidate.props.created_at : ''
|
|
80
|
+
]));
|
|
81
|
+
return (left, right) => {
|
|
82
|
+
if (right.score !== left.score) {
|
|
83
|
+
return right.score - left.score;
|
|
84
|
+
}
|
|
85
|
+
const leftCreatedAt = createdAt.get(left.id) ?? '';
|
|
86
|
+
const rightCreatedAt = createdAt.get(right.id) ?? '';
|
|
87
|
+
if (rightCreatedAt !== leftCreatedAt) {
|
|
88
|
+
return rightCreatedAt.localeCompare(leftCreatedAt);
|
|
89
|
+
}
|
|
90
|
+
return left.id - right.id;
|
|
91
|
+
};
|
|
92
|
+
}
|
package/build/server.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { createEmbedder } from './embeddings.js';
|
|
5
|
+
import { handleToolCall } from './handlers/index.js';
|
|
6
|
+
import { Neo4jClient } from './neo4j-client.js';
|
|
7
|
+
import { tools } from './tools/definitions.js';
|
|
8
|
+
/** A misconfigured provider (missing key, http endpoint…) must not stop the server; search degrades to keyword. */
|
|
9
|
+
function safeCreateEmbedder() {
|
|
10
|
+
try {
|
|
11
|
+
return createEmbedder();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
console.error('Embeddings disabled:', error instanceof Error ? error.message : error);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class Neo4jServer {
|
|
19
|
+
server;
|
|
20
|
+
neo4j;
|
|
21
|
+
embedder;
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this.server = new Server({
|
|
24
|
+
name: 'reverie',
|
|
25
|
+
version: '0.4.0',
|
|
26
|
+
}, {
|
|
27
|
+
capabilities: {
|
|
28
|
+
tools: {},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
this.neo4j = config ? new Neo4jClient(config.uri, config.username, config.password, config.database) : null;
|
|
32
|
+
this.embedder = config ? safeCreateEmbedder() : null;
|
|
33
|
+
this.setupToolHandlers();
|
|
34
|
+
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
35
|
+
process.on('SIGINT', async () => {
|
|
36
|
+
await this.close();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
setupToolHandlers() {
|
|
41
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
42
|
+
tools,
|
|
43
|
+
}));
|
|
44
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
45
|
+
if (!this.neo4j) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: 'Neo4j connection not configured. Please set NEO4J_URI, NEO4J_USERNAME, and NEO4J_PASSWORD environment variables.',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
isError: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const { name, arguments: args } = request.params;
|
|
57
|
+
return handleToolCall(name, args, this.neo4j, this.embedder);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async run() {
|
|
61
|
+
const transport = new StdioServerTransport();
|
|
62
|
+
await this.server.connect(transport);
|
|
63
|
+
console.error('Neo4j MCP server running on stdio');
|
|
64
|
+
}
|
|
65
|
+
async close() {
|
|
66
|
+
if (this.neo4j) {
|
|
67
|
+
await this.neo4j.close();
|
|
68
|
+
}
|
|
69
|
+
await this.server.close();
|
|
70
|
+
}
|
|
71
|
+
}
|