@rubytech/create-realagent 1.0.678 → 1.0.681
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/dist/index.js +232 -39
- package/package.json +1 -1
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.d.ts +2 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.js +112 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.d.ts +2 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.js +163 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts +38 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.js +130 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-validate.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/index.js +201 -45
- package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +78 -0
- package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/schema-cache.js +194 -0
- package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -0
- package/payload/platform/lib/graph-mcp/src/__tests__/cypher-validate.test.ts +141 -0
- package/payload/platform/lib/graph-mcp/src/__tests__/schema-cache.test.ts +169 -0
- package/payload/platform/lib/graph-mcp/src/cypher-validate.ts +157 -0
- package/payload/platform/lib/graph-mcp/src/index.ts +247 -47
- package/payload/platform/lib/graph-mcp/src/schema-cache.ts +212 -0
- package/payload/platform/lib/graph-trash/dist/index.d.ts +8 -0
- package/payload/platform/lib/graph-trash/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-trash/dist/index.js +109 -14
- package/payload/platform/lib/graph-trash/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-trash/src/index.ts +136 -21
- package/payload/platform/plugins/docs/references/deployment.md +4 -2
- package/payload/platform/plugins/docs/references/memory-guide.md +5 -1
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +20 -0
- package/payload/platform/plugins/memory/PLUGIN.md +1 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +54 -6
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.d.ts +36 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.js +86 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts +23 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js +47 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.d.ts +58 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.js +125 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.js.map +1 -0
- package/payload/platform/scripts/vnc.sh +12 -409
- package/payload/platform/templates/agents/admin/IDENTITY.md +16 -0
- package/payload/platform/templates/dotfiles/.tmux.conf +1 -0
- package/payload/platform/templates/systemd/maxy-ttyd.service +25 -0
- package/payload/server/chunk-3RBKKDHC.js +783 -0
- package/payload/server/maxy-edge.js +377 -8
- package/payload/server/public/assets/admin-CIkyOur7.js +362 -0
- package/payload/server/public/assets/admin-kHJ-D0s7.css +1 -0
- package/payload/server/public/index.html +2 -1
- package/payload/server/server.js +391 -412
- package/payload/server/public/assets/admin-BBL1no_g.js +0 -352
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
7
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
8
|
+
const schema_cache_js_1 = require("../schema-cache.js");
|
|
9
|
+
function makeFetcher(labels, rels, opts = {}) {
|
|
10
|
+
let called = 0;
|
|
11
|
+
let currentLabels = labels;
|
|
12
|
+
let currentRels = rels;
|
|
13
|
+
return {
|
|
14
|
+
get calls() {
|
|
15
|
+
return called;
|
|
16
|
+
},
|
|
17
|
+
async labels() {
|
|
18
|
+
called++;
|
|
19
|
+
if (opts.failOnce && called === 1)
|
|
20
|
+
throw new Error("transient");
|
|
21
|
+
return currentLabels;
|
|
22
|
+
},
|
|
23
|
+
async relationshipTypes() {
|
|
24
|
+
return currentRels;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
(0, node_test_1.default)("start() populates the snapshot and sets ready()", async () => {
|
|
29
|
+
const fetcher = makeFetcher(["Conversation", "Message"], ["PART_OF"]);
|
|
30
|
+
const emitted = [];
|
|
31
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, {
|
|
32
|
+
refreshIntervalMs: 0,
|
|
33
|
+
emit: (l) => emitted.push(l),
|
|
34
|
+
});
|
|
35
|
+
strict_1.default.equal(cache.ready(), false);
|
|
36
|
+
await cache.start();
|
|
37
|
+
strict_1.default.equal(cache.ready(), true);
|
|
38
|
+
const snap = cache.snapshot();
|
|
39
|
+
strict_1.default.ok(snap.labels.has("Conversation"));
|
|
40
|
+
strict_1.default.ok(snap.labels.has("Message"));
|
|
41
|
+
strict_1.default.ok(snap.relationshipTypes.has("PART_OF"));
|
|
42
|
+
strict_1.default.ok(emitted.some((l) => l.includes("[schema-cache] refresh")));
|
|
43
|
+
cache.stop();
|
|
44
|
+
});
|
|
45
|
+
(0, node_test_1.default)("start() with Neo4j unreachable leaves cache empty and not ready, but does not throw", async () => {
|
|
46
|
+
const fetcher = {
|
|
47
|
+
async labels() {
|
|
48
|
+
throw new Error("ECONNREFUSED");
|
|
49
|
+
},
|
|
50
|
+
async relationshipTypes() {
|
|
51
|
+
throw new Error("ECONNREFUSED");
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const emitted = [];
|
|
55
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, {
|
|
56
|
+
refreshIntervalMs: 0,
|
|
57
|
+
emit: (l) => emitted.push(l),
|
|
58
|
+
});
|
|
59
|
+
await cache.start();
|
|
60
|
+
strict_1.default.equal(cache.ready(), false);
|
|
61
|
+
strict_1.default.equal(cache.snapshot().labels.size, 0);
|
|
62
|
+
strict_1.default.ok(emitted.some((l) => l.includes("[schema-cache]") && l.includes("failure")), "expected a failure log line");
|
|
63
|
+
cache.stop();
|
|
64
|
+
});
|
|
65
|
+
(0, node_test_1.default)("refresh() picks up a newly-added relationship type", async () => {
|
|
66
|
+
let rels = ["PART_OF"];
|
|
67
|
+
const fetcher = {
|
|
68
|
+
async labels() {
|
|
69
|
+
return ["Conversation"];
|
|
70
|
+
},
|
|
71
|
+
async relationshipTypes() {
|
|
72
|
+
return rels;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, { refreshIntervalMs: 0 });
|
|
76
|
+
await cache.start();
|
|
77
|
+
strict_1.default.equal(cache.snapshot().relationshipTypes.has("MIGRATED_EDGE"), false);
|
|
78
|
+
rels = ["PART_OF", "MIGRATED_EDGE"];
|
|
79
|
+
await cache.refresh("interval");
|
|
80
|
+
strict_1.default.ok(cache.snapshot().relationshipTypes.has("MIGRATED_EDGE"));
|
|
81
|
+
cache.stop();
|
|
82
|
+
});
|
|
83
|
+
(0, node_test_1.default)("refresh() preserves last good cache on transient failure", async () => {
|
|
84
|
+
let fail = false;
|
|
85
|
+
const fetcher = {
|
|
86
|
+
async labels() {
|
|
87
|
+
if (fail)
|
|
88
|
+
throw new Error("transient");
|
|
89
|
+
return ["Conversation"];
|
|
90
|
+
},
|
|
91
|
+
async relationshipTypes() {
|
|
92
|
+
if (fail)
|
|
93
|
+
throw new Error("transient");
|
|
94
|
+
return ["PART_OF"];
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, { refreshIntervalMs: 0 });
|
|
98
|
+
await cache.start();
|
|
99
|
+
strict_1.default.equal(cache.ready(), true);
|
|
100
|
+
fail = true;
|
|
101
|
+
const ok = await cache.refresh("interval");
|
|
102
|
+
strict_1.default.equal(ok, false);
|
|
103
|
+
strict_1.default.equal(cache.ready(), true, "should remain ready after a transient failure");
|
|
104
|
+
strict_1.default.ok(cache.snapshot().labels.has("Conversation"));
|
|
105
|
+
cache.stop();
|
|
106
|
+
});
|
|
107
|
+
(0, node_test_1.default)("maybeRebuildOnStaleMiss() triggers refresh when an unknown token has a near match", async () => {
|
|
108
|
+
let rels = ["PART_OF"];
|
|
109
|
+
const fetcher = {
|
|
110
|
+
async labels() {
|
|
111
|
+
return ["Conversation"];
|
|
112
|
+
},
|
|
113
|
+
async relationshipTypes() {
|
|
114
|
+
return rels;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, {
|
|
118
|
+
refreshIntervalMs: 0,
|
|
119
|
+
staleMissDebounceMs: 0,
|
|
120
|
+
});
|
|
121
|
+
await cache.start();
|
|
122
|
+
rels = ["PART_OF", "PART_OFF"];
|
|
123
|
+
const triggered = await cache.maybeRebuildOnStaleMiss([
|
|
124
|
+
{ token: "PART_OFF", kind: "relationship", nearest: ["PART_OF"], hint: "" },
|
|
125
|
+
]);
|
|
126
|
+
strict_1.default.equal(triggered, true);
|
|
127
|
+
strict_1.default.ok(cache.snapshot().relationshipTypes.has("PART_OFF"));
|
|
128
|
+
cache.stop();
|
|
129
|
+
});
|
|
130
|
+
(0, node_test_1.default)("maybeRebuildOnStaleMiss() skips when no near match (likely typo, not migration)", async () => {
|
|
131
|
+
const fetcher = makeFetcher(["Conversation"], ["PART_OF"]);
|
|
132
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, {
|
|
133
|
+
refreshIntervalMs: 0,
|
|
134
|
+
staleMissDebounceMs: 0,
|
|
135
|
+
});
|
|
136
|
+
await cache.start();
|
|
137
|
+
const callsBefore = fetcher.calls;
|
|
138
|
+
const triggered = await cache.maybeRebuildOnStaleMiss([
|
|
139
|
+
{ token: "COMPLETELY_DIFFERENT", kind: "relationship", nearest: ["PART_OF"], hint: "" },
|
|
140
|
+
]);
|
|
141
|
+
strict_1.default.equal(triggered, false);
|
|
142
|
+
strict_1.default.equal(fetcher.calls, callsBefore, "no extra fetches when edit distance is far");
|
|
143
|
+
cache.stop();
|
|
144
|
+
});
|
|
145
|
+
(0, node_test_1.default)("maybeRebuildOnStaleMiss() debounces repeated triggers", async () => {
|
|
146
|
+
const fetcher = makeFetcher(["Conversation"], ["PART_OF"]);
|
|
147
|
+
const cache = new schema_cache_js_1.SchemaCache(fetcher, {
|
|
148
|
+
refreshIntervalMs: 0,
|
|
149
|
+
staleMissDebounceMs: 5000,
|
|
150
|
+
});
|
|
151
|
+
await cache.start();
|
|
152
|
+
const callsBefore = fetcher.calls;
|
|
153
|
+
const unknown = [
|
|
154
|
+
{ token: "PART_OFF", kind: "relationship", nearest: ["PART_OF"], hint: "" },
|
|
155
|
+
];
|
|
156
|
+
await cache.maybeRebuildOnStaleMiss(unknown);
|
|
157
|
+
const afterFirst = fetcher.calls;
|
|
158
|
+
await cache.maybeRebuildOnStaleMiss(unknown);
|
|
159
|
+
strict_1.default.equal(fetcher.calls, afterFirst, "second call within debounce should not refetch");
|
|
160
|
+
strict_1.default.ok(afterFirst > callsBefore, "first call did refetch");
|
|
161
|
+
cache.stop();
|
|
162
|
+
});
|
|
163
|
+
//# sourceMappingURL=schema-cache.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-cache.test.js","sourceRoot":"","sources":["../../src/__tests__/schema-cache.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cypher schema validation for the graph-mcp proxy (Task 654).
|
|
3
|
+
*
|
|
4
|
+
* Tokenises a cypher string for labels (`:Label`) and relationship types
|
|
5
|
+
* (`[:REL_TYPE]`, including `[r:TYPE]`, `[:TYPE*1..5]`, `[:A|B]`) and checks
|
|
6
|
+
* each against a schema snapshot. Unknown tokens are returned with the
|
|
7
|
+
* Levenshtein-nearest known tokens of the same kind as suggestions.
|
|
8
|
+
*
|
|
9
|
+
* Design posture:
|
|
10
|
+
* - Defence layer, not a cypher correctness gate. Pass-through on any
|
|
11
|
+
* unparseable pattern — Neo4j remains the syntax authority.
|
|
12
|
+
* - Empty schema snapshot (cache not ready, or Neo4j unreachable at boot)
|
|
13
|
+
* fails OPEN: ok=true, no rejections. The boot-race treatment is a
|
|
14
|
+
* deliberate choice — refusing all cypher would wedge the admin agent
|
|
15
|
+
* harder than the typo class this layer prevents. The caller emits
|
|
16
|
+
* `validated=false` on the existing [graph-query] line so operators
|
|
17
|
+
* still see the bypass.
|
|
18
|
+
* - String literals are stripped before tokenisation to avoid matching
|
|
19
|
+
* `:Foo` inside quoted content (e.g. `WHERE n.note CONTAINS ':Foo'`).
|
|
20
|
+
*/
|
|
21
|
+
export interface SchemaSnapshot {
|
|
22
|
+
readonly labels: ReadonlySet<string>;
|
|
23
|
+
readonly relationshipTypes: ReadonlySet<string>;
|
|
24
|
+
}
|
|
25
|
+
export interface UnknownToken {
|
|
26
|
+
token: string;
|
|
27
|
+
kind: "label" | "relationship";
|
|
28
|
+
nearest: string[];
|
|
29
|
+
hint: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ValidationResult {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
unknown: UnknownToken[];
|
|
34
|
+
labelTokens: string[];
|
|
35
|
+
edgeTokens: string[];
|
|
36
|
+
}
|
|
37
|
+
export declare function validate(cypher: string, snapshot: SchemaSnapshot): ValidationResult;
|
|
38
|
+
//# sourceMappingURL=cypher-validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cypher-validate.d.ts","sourceRoot":"","sources":["../src/cypher-validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,QAAQ,CAAC,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,GAAG,cAAc,CAAC;IAC/B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAkFD,wBAAgB,QAAQ,CACtB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,cAAc,GACvB,gBAAgB,CAiClB"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Cypher schema validation for the graph-mcp proxy (Task 654).
|
|
4
|
+
*
|
|
5
|
+
* Tokenises a cypher string for labels (`:Label`) and relationship types
|
|
6
|
+
* (`[:REL_TYPE]`, including `[r:TYPE]`, `[:TYPE*1..5]`, `[:A|B]`) and checks
|
|
7
|
+
* each against a schema snapshot. Unknown tokens are returned with the
|
|
8
|
+
* Levenshtein-nearest known tokens of the same kind as suggestions.
|
|
9
|
+
*
|
|
10
|
+
* Design posture:
|
|
11
|
+
* - Defence layer, not a cypher correctness gate. Pass-through on any
|
|
12
|
+
* unparseable pattern — Neo4j remains the syntax authority.
|
|
13
|
+
* - Empty schema snapshot (cache not ready, or Neo4j unreachable at boot)
|
|
14
|
+
* fails OPEN: ok=true, no rejections. The boot-race treatment is a
|
|
15
|
+
* deliberate choice — refusing all cypher would wedge the admin agent
|
|
16
|
+
* harder than the typo class this layer prevents. The caller emits
|
|
17
|
+
* `validated=false` on the existing [graph-query] line so operators
|
|
18
|
+
* still see the bypass.
|
|
19
|
+
* - String literals are stripped before tokenisation to avoid matching
|
|
20
|
+
* `:Foo` inside quoted content (e.g. `WHERE n.note CONTAINS ':Foo'`).
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.validate = validate;
|
|
24
|
+
// Bracket content containing a type reference. Examples this matches:
|
|
25
|
+
// [:PART_OF]
|
|
26
|
+
// [r:PART_OF]
|
|
27
|
+
// [:PART_OF*1..5]
|
|
28
|
+
// [r:PART_OF*]
|
|
29
|
+
// [:A|B]
|
|
30
|
+
// The captured group is the TYPE(|TYPE)* alternation; the consumer splits it.
|
|
31
|
+
const EDGE_PATTERN = /\[[^\]]*?:([A-Z_][A-Za-z0-9_]*(?:\|[A-Z_][A-Za-z0-9_]*)*)[^\]]*?\]/g;
|
|
32
|
+
// Label reference in a node pattern. Applied to a remainder string that has
|
|
33
|
+
// already had edge-bracket substrings stripped, so there is no overlap with
|
|
34
|
+
// the edge pattern. The [A-Z] anchor excludes lowercase map keys.
|
|
35
|
+
const LABEL_PATTERN = /:([A-Z][A-Za-z0-9_]*)/g;
|
|
36
|
+
function stripStringLiterals(cypher) {
|
|
37
|
+
// Replace single- and double-quoted literals with empty quotes. Preserves
|
|
38
|
+
// positional structure without retaining content that could match the
|
|
39
|
+
// label regex (e.g. ':SomeLabel' inside a string).
|
|
40
|
+
return cypher.replace(/'[^']*'|"[^"]*"/g, '""');
|
|
41
|
+
}
|
|
42
|
+
function extractTokens(cypher) {
|
|
43
|
+
const cleaned = stripStringLiterals(cypher);
|
|
44
|
+
const edges = new Set();
|
|
45
|
+
let match;
|
|
46
|
+
const edgePattern = new RegExp(EDGE_PATTERN.source, EDGE_PATTERN.flags);
|
|
47
|
+
while ((match = edgePattern.exec(cleaned)) !== null) {
|
|
48
|
+
for (const type of match[1].split("|")) {
|
|
49
|
+
const clean = type.trim();
|
|
50
|
+
if (clean)
|
|
51
|
+
edges.add(clean);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const remainder = cleaned.replace(edgePattern, "");
|
|
55
|
+
const labels = new Set();
|
|
56
|
+
const labelPattern = new RegExp(LABEL_PATTERN.source, LABEL_PATTERN.flags);
|
|
57
|
+
while ((match = labelPattern.exec(remainder)) !== null) {
|
|
58
|
+
labels.add(match[1]);
|
|
59
|
+
}
|
|
60
|
+
return { labels, edges };
|
|
61
|
+
}
|
|
62
|
+
function levenshtein(a, b) {
|
|
63
|
+
if (a === b)
|
|
64
|
+
return 0;
|
|
65
|
+
if (a.length === 0)
|
|
66
|
+
return b.length;
|
|
67
|
+
if (b.length === 0)
|
|
68
|
+
return a.length;
|
|
69
|
+
let prev = new Array(b.length + 1);
|
|
70
|
+
let curr = new Array(b.length + 1);
|
|
71
|
+
for (let j = 0; j <= b.length; j++)
|
|
72
|
+
prev[j] = j;
|
|
73
|
+
for (let i = 1; i <= a.length; i++) {
|
|
74
|
+
curr[0] = i;
|
|
75
|
+
for (let j = 1; j <= b.length; j++) {
|
|
76
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
77
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
78
|
+
}
|
|
79
|
+
[prev, curr] = [curr, prev];
|
|
80
|
+
}
|
|
81
|
+
return prev[b.length];
|
|
82
|
+
}
|
|
83
|
+
function nearestMatches(token, known, limit = 3) {
|
|
84
|
+
if (known.size === 0)
|
|
85
|
+
return [];
|
|
86
|
+
const scored = [];
|
|
87
|
+
for (const k of known)
|
|
88
|
+
scored.push([k, levenshtein(token, k)]);
|
|
89
|
+
scored.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
|
|
90
|
+
return scored.slice(0, limit).map(([k]) => k);
|
|
91
|
+
}
|
|
92
|
+
function hintFor(token, kind, nearest) {
|
|
93
|
+
const didYouMean = nearest.length > 0 ? `Did you mean :${nearest[0]}? ` : "";
|
|
94
|
+
return `${didYouMean}Unknown ${kind} '${token}' — not in the current Neo4j schema. See .docs/neo4j.md for the canonical taxonomy.`;
|
|
95
|
+
}
|
|
96
|
+
function validate(cypher, snapshot) {
|
|
97
|
+
const { labels, edges } = extractTokens(cypher);
|
|
98
|
+
// Fail-open when the snapshot is empty. An empty snapshot means "schema
|
|
99
|
+
// cache not loaded" (boot race, Neo4j unreachable). Rejecting every token
|
|
100
|
+
// would wedge the admin agent; letting it through preserves the observable
|
|
101
|
+
// `validated=false` signal on the existing [graph-query] line.
|
|
102
|
+
if (snapshot.labels.size === 0 && snapshot.relationshipTypes.size === 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
unknown: [],
|
|
106
|
+
labelTokens: [...labels],
|
|
107
|
+
edgeTokens: [...edges],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const unknown = [];
|
|
111
|
+
for (const token of labels) {
|
|
112
|
+
if (!snapshot.labels.has(token)) {
|
|
113
|
+
const nearest = nearestMatches(token, snapshot.labels);
|
|
114
|
+
unknown.push({ token, kind: "label", nearest, hint: hintFor(token, "label", nearest) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const token of edges) {
|
|
118
|
+
if (!snapshot.relationshipTypes.has(token)) {
|
|
119
|
+
const nearest = nearestMatches(token, snapshot.relationshipTypes);
|
|
120
|
+
unknown.push({ token, kind: "relationship", nearest, hint: hintFor(token, "relationship", nearest) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ok: unknown.length === 0,
|
|
125
|
+
unknown,
|
|
126
|
+
labelTokens: [...labels],
|
|
127
|
+
edgeTokens: [...edges],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=cypher-validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cypher-validate.js","sourceRoot":"","sources":["../src/cypher-validate.ts"],"names":[],"mappings":"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"}
|
|
@@ -29,6 +29,8 @@ const node_fs_1 = require("node:fs");
|
|
|
29
29
|
const node_path_1 = require("node:path");
|
|
30
30
|
const node_string_decoder_1 = require("node:string_decoder");
|
|
31
31
|
const index_js_1 = require("../../mcp-stderr-tee/dist/index.js");
|
|
32
|
+
const cypher_validate_js_1 = require("./cypher-validate.js");
|
|
33
|
+
const schema_cache_js_1 = require("./schema-cache.js");
|
|
32
34
|
const SERVER_NAME = "graph";
|
|
33
35
|
const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
|
|
34
36
|
(0, index_js_1.initStderrTee)(SERVER_NAME);
|
|
@@ -161,6 +163,37 @@ const childEnv = {
|
|
|
161
163
|
};
|
|
162
164
|
console.error(`[graph-mcp] boot brand=${brand} uri=${neo4jUri} user=${neo4jUser} ` +
|
|
163
165
|
`namespace=${namespace} readOnly=${readOnly} tokenLimit=${responseTokenLimit}`);
|
|
166
|
+
// Task 654 — async schema cache. The validator fails OPEN until the first
|
|
167
|
+
// refresh resolves; all cypher calls during that window are forwarded
|
|
168
|
+
// unvalidated and the existing [graph-query] line gains `validated=false`
|
|
169
|
+
// so operators see the bypass. One Neo4j driver is shared across refreshes;
|
|
170
|
+
// loaded lazily so test harnesses can exercise SchemaCache without pulling
|
|
171
|
+
// neo4j-driver into the module graph.
|
|
172
|
+
// neo4jUri is narrowed to `string` by the guard throw above, but the narrow
|
|
173
|
+
// doesn't flow into the closure below. Pin it to a local const whose type
|
|
174
|
+
// is `string` by construction.
|
|
175
|
+
const resolvedNeo4jUri = neo4jUri;
|
|
176
|
+
let schemaFetcherReady = null;
|
|
177
|
+
function getSchemaFetcher() {
|
|
178
|
+
if (!schemaFetcherReady) {
|
|
179
|
+
schemaFetcherReady = (0, schema_cache_js_1.neo4jSchemaFetcher)(resolvedNeo4jUri, neo4jUser, neo4jPassword);
|
|
180
|
+
}
|
|
181
|
+
return schemaFetcherReady;
|
|
182
|
+
}
|
|
183
|
+
const schemaCache = new schema_cache_js_1.SchemaCache({
|
|
184
|
+
async labels() {
|
|
185
|
+
const f = await getSchemaFetcher();
|
|
186
|
+
return f.labels();
|
|
187
|
+
},
|
|
188
|
+
async relationshipTypes() {
|
|
189
|
+
const f = await getSchemaFetcher();
|
|
190
|
+
return f.relationshipTypes();
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
void schemaCache.start().catch((err) => {
|
|
194
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
195
|
+
console.error(`[schema-cache] start failed error="${msg.replace(/"/g, "'")}"`);
|
|
196
|
+
});
|
|
164
197
|
const uvx = resolveUvxPath();
|
|
165
198
|
if (!uvx.path) {
|
|
166
199
|
syncEmit(`uvx unresolvable — checked env=${uvx.envChecked} home-local=${uvx.homeLocalChecked} which=${uvx.whichChecked}`);
|
|
@@ -198,11 +231,16 @@ function stripNamespace(toolName) {
|
|
|
198
231
|
const prefix = `${namespace}_`;
|
|
199
232
|
return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
|
|
200
233
|
}
|
|
201
|
-
|
|
234
|
+
const READ_CYPHER_TOOL = "read_neo4j_cypher";
|
|
235
|
+
const WRITE_CYPHER_TOOL = "write_neo4j_cypher";
|
|
236
|
+
function extractCypherFull(args) {
|
|
202
237
|
if (!args)
|
|
203
238
|
return null;
|
|
204
239
|
const q = args["query"] ?? args["cypher"];
|
|
205
|
-
return typeof q === "string" ?
|
|
240
|
+
return typeof q === "string" ? q : null;
|
|
241
|
+
}
|
|
242
|
+
function truncateForLog(cypher) {
|
|
243
|
+
return truncate(cypher.replace(/\s+/g, " ").trim(), 80);
|
|
206
244
|
}
|
|
207
245
|
function countRows(result) {
|
|
208
246
|
if (!result?.content || !Array.isArray(result.content))
|
|
@@ -213,69 +251,187 @@ function countRows(result) {
|
|
|
213
251
|
return rowsMatch[1];
|
|
214
252
|
return String(result.content.length);
|
|
215
253
|
}
|
|
216
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Render the admin-facing rejection or warnings text. Plain prose so the
|
|
256
|
+
* agent's Tool Failure Discipline reads it the same way it reads any tool
|
|
257
|
+
* error — no special-case structured JSON parser required.
|
|
258
|
+
*/
|
|
259
|
+
function renderUnknownTokens(unknown, mode) {
|
|
260
|
+
const heading = mode === "rejected"
|
|
261
|
+
? "schema-validation rejected — cypher NOT executed"
|
|
262
|
+
: "schema-validation warning — cypher executed but referenced unknown tokens";
|
|
263
|
+
const lines = unknown.map((u) => {
|
|
264
|
+
const nearest = u.nearest.length > 0 ? ` nearest=[${u.nearest.join(", ")}]` : "";
|
|
265
|
+
return ` - ${u.kind} '${u.token}':${nearest} — ${u.hint}`;
|
|
266
|
+
});
|
|
267
|
+
return `${heading}\n${lines.join("\n")}`;
|
|
268
|
+
}
|
|
269
|
+
function synthesiseRejection(id, unknown) {
|
|
270
|
+
const text = `${renderUnknownTokens(unknown, "rejected")}\n\nFix the token names, or if the schema has just changed, invoke ${namespace}_get_neo4j_schema to refresh your view.`;
|
|
271
|
+
const envelope = {
|
|
272
|
+
jsonrpc: "2.0",
|
|
273
|
+
id,
|
|
274
|
+
result: {
|
|
275
|
+
content: [{ type: "text", text }],
|
|
276
|
+
isError: true,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
return JSON.stringify(envelope);
|
|
280
|
+
}
|
|
281
|
+
function wrapReadWarnings(msg, warnings) {
|
|
282
|
+
const warningText = `${renderUnknownTokens(warnings, "warning")}\n\n--- results below (executed despite unknown tokens) ---\n`;
|
|
283
|
+
const original = msg.result?.content ?? [];
|
|
284
|
+
const wrapped = {
|
|
285
|
+
...msg,
|
|
286
|
+
result: {
|
|
287
|
+
...(msg.result ?? {}),
|
|
288
|
+
content: [{ type: "text", text: warningText }, ...original],
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
return JSON.stringify(wrapped);
|
|
292
|
+
}
|
|
293
|
+
function handleRequestLine(line) {
|
|
294
|
+
let msg;
|
|
217
295
|
try {
|
|
218
|
-
|
|
219
|
-
if (msg.method === "tools/call" && msg.id !== undefined) {
|
|
220
|
-
pending.set(msg.id, {
|
|
221
|
-
method: stripNamespace(msg.params?.name),
|
|
222
|
-
cypherPrefix: extractCypher(msg.params?.arguments),
|
|
223
|
-
startMs: Date.now(),
|
|
224
|
-
});
|
|
225
|
-
}
|
|
296
|
+
msg = JSON.parse(line);
|
|
226
297
|
}
|
|
227
298
|
catch {
|
|
228
|
-
|
|
299
|
+
return "forward";
|
|
300
|
+
}
|
|
301
|
+
if (msg.method !== "tools/call" || msg.id === undefined)
|
|
302
|
+
return "forward";
|
|
303
|
+
const methodName = stripNamespace(msg.params?.name);
|
|
304
|
+
const cypherFull = extractCypherFull(msg.params?.arguments);
|
|
305
|
+
const cypherPrefix = cypherFull ? truncateForLog(cypherFull) : null;
|
|
306
|
+
const isCypherCall = methodName === READ_CYPHER_TOOL || methodName === WRITE_CYPHER_TOOL;
|
|
307
|
+
const entry = {
|
|
308
|
+
method: methodName,
|
|
309
|
+
cypherPrefix,
|
|
310
|
+
startMs: Date.now(),
|
|
311
|
+
validated: false,
|
|
312
|
+
readWarnings: [],
|
|
313
|
+
};
|
|
314
|
+
if (!isCypherCall || !cypherFull) {
|
|
315
|
+
pending.set(msg.id, entry);
|
|
316
|
+
return "forward";
|
|
317
|
+
}
|
|
318
|
+
const isWrite = methodName === WRITE_CYPHER_TOOL;
|
|
319
|
+
const snapshot = schemaCache.snapshot();
|
|
320
|
+
const cacheReady = schemaCache.ready();
|
|
321
|
+
if (!cacheReady) {
|
|
322
|
+
console.error(`[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`);
|
|
323
|
+
pending.set(msg.id, entry);
|
|
324
|
+
return "forward";
|
|
325
|
+
}
|
|
326
|
+
const result = (0, cypher_validate_js_1.validate)(cypherFull, snapshot);
|
|
327
|
+
entry.validated = true;
|
|
328
|
+
if (result.ok) {
|
|
329
|
+
console.error(`[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`);
|
|
330
|
+
pending.set(msg.id, entry);
|
|
331
|
+
return "forward";
|
|
332
|
+
}
|
|
333
|
+
void schemaCache.maybeRebuildOnStaleMiss(result.unknown);
|
|
334
|
+
const tokenSummary = result.unknown
|
|
335
|
+
.map((u) => `${u.kind}:${u.token}`)
|
|
336
|
+
.join(",");
|
|
337
|
+
if (isWrite) {
|
|
338
|
+
console.error(`[cypher-validate] tool=write outcome=rejected unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`);
|
|
339
|
+
const response = synthesiseRejection(msg.id, result.unknown);
|
|
340
|
+
process.stdout.write(`${response}\n`);
|
|
341
|
+
console.error(`[graph-query] op=${methodName} brand=${brand} port=${neo4jPort} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}" rejected=true validated=true ms=${Date.now() - entry.startMs}`);
|
|
342
|
+
return "intercepted";
|
|
229
343
|
}
|
|
344
|
+
entry.readWarnings = result.unknown;
|
|
345
|
+
console.error(`[cypher-validate] tool=read outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`);
|
|
346
|
+
pending.set(msg.id, entry);
|
|
347
|
+
return "forward";
|
|
230
348
|
}
|
|
231
|
-
function
|
|
349
|
+
function handleResponseLine(line) {
|
|
350
|
+
let msg;
|
|
232
351
|
try {
|
|
233
|
-
|
|
234
|
-
if (msg.id === undefined || !pending.has(msg.id))
|
|
235
|
-
return;
|
|
236
|
-
const p = pending.get(msg.id);
|
|
237
|
-
pending.delete(msg.id);
|
|
238
|
-
const elapsed = Date.now() - p.startMs;
|
|
239
|
-
const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
|
|
240
|
-
if (msg.error) {
|
|
241
|
-
const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
|
|
242
|
-
console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ms=${elapsed}`);
|
|
243
|
-
}
|
|
244
|
-
else {
|
|
245
|
-
const rows = countRows(msg.result);
|
|
246
|
-
console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ms=${elapsed}`);
|
|
247
|
-
}
|
|
352
|
+
msg = JSON.parse(line);
|
|
248
353
|
}
|
|
249
354
|
catch {
|
|
250
|
-
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (msg.id === undefined || !pending.has(msg.id))
|
|
358
|
+
return null;
|
|
359
|
+
const p = pending.get(msg.id);
|
|
360
|
+
pending.delete(msg.id);
|
|
361
|
+
const elapsed = Date.now() - p.startMs;
|
|
362
|
+
const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
|
|
363
|
+
const validatedField = `validated=${p.validated}`;
|
|
364
|
+
if (msg.error) {
|
|
365
|
+
const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
|
|
366
|
+
console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ${validatedField} ms=${elapsed}`);
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const rows = countRows(msg.result);
|
|
370
|
+
const warnedField = p.readWarnings.length > 0 ? ` warned=${p.readWarnings.length}` : "";
|
|
371
|
+
console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ${validatedField}${warnedField} ms=${elapsed}`);
|
|
372
|
+
if (p.readWarnings.length > 0) {
|
|
373
|
+
try {
|
|
374
|
+
return wrapReadWarnings(msg, p.readWarnings);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
378
|
+
console.error(`[cypher-validate] warning-wrap failed op=${p.method} error="${errMsg.replace(/"/g, "'")}" — forwarding response unwrapped`);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
251
381
|
}
|
|
382
|
+
return null;
|
|
252
383
|
}
|
|
253
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Per-stream buffering splitter. Yields complete lines (without trailing \n)
|
|
386
|
+
* as they accumulate; leaves any partial tail in the buffer until more bytes
|
|
387
|
+
* arrive. Unlike the pre-Task-652 splitter, this one lets the caller decide
|
|
388
|
+
* per-line whether to forward or intercept — the original splitter was
|
|
389
|
+
* fire-and-forget and the raw chunk was forwarded unconditionally alongside,
|
|
390
|
+
* which ruled out interception.
|
|
391
|
+
*/
|
|
392
|
+
function makeLineBuffer() {
|
|
254
393
|
const decoder = new node_string_decoder_1.StringDecoder("utf8");
|
|
255
394
|
let buf = "";
|
|
256
|
-
return
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
395
|
+
return {
|
|
396
|
+
push(chunk) {
|
|
397
|
+
buf += decoder.write(chunk);
|
|
398
|
+
const out = [];
|
|
399
|
+
let idx;
|
|
400
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
401
|
+
out.push(buf.slice(0, idx));
|
|
402
|
+
buf = buf.slice(idx + 1);
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
},
|
|
265
406
|
};
|
|
266
407
|
}
|
|
267
|
-
const
|
|
408
|
+
const requestBuffer = makeLineBuffer();
|
|
268
409
|
process.stdin.on("data", (chunk) => {
|
|
269
|
-
|
|
270
|
-
|
|
410
|
+
for (const line of requestBuffer.push(chunk)) {
|
|
411
|
+
if (line.length === 0) {
|
|
412
|
+
child.stdin.write("\n");
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const decision = handleRequestLine(line);
|
|
416
|
+
if (decision === "forward") {
|
|
417
|
+
child.stdin.write(`${line}\n`);
|
|
418
|
+
}
|
|
419
|
+
// "intercepted" — synthesised response already written to stdout.
|
|
420
|
+
}
|
|
271
421
|
});
|
|
272
422
|
process.stdin.on("end", () => {
|
|
273
423
|
child.stdin.end();
|
|
274
424
|
});
|
|
275
|
-
const
|
|
425
|
+
const responseBuffer = makeLineBuffer();
|
|
276
426
|
child.stdout.on("data", (chunk) => {
|
|
277
|
-
|
|
278
|
-
|
|
427
|
+
for (const line of responseBuffer.push(chunk)) {
|
|
428
|
+
if (line.length === 0) {
|
|
429
|
+
process.stdout.write("\n");
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const rewritten = handleResponseLine(line);
|
|
433
|
+
process.stdout.write(`${rewritten ?? line}\n`);
|
|
434
|
+
}
|
|
279
435
|
});
|
|
280
436
|
child.stdout.on("end", () => {
|
|
281
437
|
process.stdout.end();
|