@danielblomma/cortex-mcp 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/README.md +203 -0
- package/bin/cortex.mjs +621 -0
- package/docs/MCP_MARKETPLACE.md +160 -0
- package/package.json +42 -0
- package/scaffold/.context/config.yaml +21 -0
- package/scaffold/.context/ontology.cypher +63 -0
- package/scaffold/.context/rules.yaml +25 -0
- package/scaffold/.githooks/_cortex-update-runner.sh +58 -0
- package/scaffold/.githooks/post-checkout +22 -0
- package/scaffold/.githooks/post-merge +14 -0
- package/scaffold/docs/architecture.md +22 -0
- package/scaffold/mcp/package-lock.json +2623 -0
- package/scaffold/mcp/package.json +29 -0
- package/scaffold/mcp/src/embed.ts +416 -0
- package/scaffold/mcp/src/embeddings.ts +192 -0
- package/scaffold/mcp/src/graph.ts +666 -0
- package/scaffold/mcp/src/loadGraph.ts +597 -0
- package/scaffold/mcp/src/paths.ts +33 -0
- package/scaffold/mcp/src/search.ts +412 -0
- package/scaffold/mcp/src/server.ts +98 -0
- package/scaffold/mcp/src/types.ts +109 -0
- package/scaffold/mcp/tests/server.test.mjs +60 -0
- package/scaffold/mcp/tsconfig.json +13 -0
- package/scaffold/scripts/bootstrap.sh +57 -0
- package/scaffold/scripts/capture-note.sh +55 -0
- package/scaffold/scripts/context.sh +109 -0
- package/scaffold/scripts/embed.sh +15 -0
- package/scaffold/scripts/ingest.mjs +1118 -0
- package/scaffold/scripts/ingest.sh +20 -0
- package/scaffold/scripts/install-git-hooks.sh +21 -0
- package/scaffold/scripts/load-kuzu.sh +6 -0
- package/scaffold/scripts/load-ryu.sh +18 -0
- package/scaffold/scripts/parsers/javascript.mjs +390 -0
- package/scaffold/scripts/parsers/package-lock.json +51 -0
- package/scaffold/scripts/parsers/package.json +17 -0
- package/scaffold/scripts/plan-state-engine.cjs +310 -0
- package/scaffold/scripts/plan-state.sh +71 -0
- package/scaffold/scripts/refresh.sh +9 -0
- package/scaffold/scripts/status.sh +282 -0
- package/scaffold/scripts/update-context.sh +18 -0
- package/scaffold/scripts/watch.sh +374 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { embedQuery, getEmbeddingRuntimeWarning, loadEmbeddingIndex } from "./embeddings.js";
|
|
2
|
+
import { loadContextData } from "./graph.js";
|
|
3
|
+
import type {
|
|
4
|
+
ContextData,
|
|
5
|
+
JsonObject,
|
|
6
|
+
RelatedParams,
|
|
7
|
+
RelationRecord,
|
|
8
|
+
RulesParams,
|
|
9
|
+
SearchEntity,
|
|
10
|
+
SearchParams,
|
|
11
|
+
ToolPayload
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
function tokenize(value: string): string[] {
|
|
15
|
+
return value
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.split(/[^a-z0-9]+/g)
|
|
18
|
+
.map((part) => part.trim())
|
|
19
|
+
.filter((part) => part.length >= 2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function daysSince(isoDate: string): number {
|
|
23
|
+
const timestamp = Date.parse(isoDate);
|
|
24
|
+
if (Number.isNaN(timestamp)) {
|
|
25
|
+
return 3650;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
return Math.max(0, (now - timestamp) / (1000 * 60 * 60 * 24));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recencyScore(isoDate: string): number {
|
|
33
|
+
const days = daysSince(isoDate);
|
|
34
|
+
return 1 / (1 + days / 30);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function semanticScore(query: string, text: string): number {
|
|
38
|
+
const queryTokens = tokenize(query);
|
|
39
|
+
if (queryTokens.length === 0) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const haystack = text.toLowerCase();
|
|
44
|
+
let matched = 0;
|
|
45
|
+
for (const token of queryTokens) {
|
|
46
|
+
if (haystack.includes(token)) {
|
|
47
|
+
matched += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const overlap = matched / queryTokens.length;
|
|
52
|
+
const phraseBonus = haystack.includes(query.toLowerCase()) ? 0.25 : 0;
|
|
53
|
+
return Math.min(1, overlap * 0.85 + phraseBonus);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
57
|
+
if (a.length === 0 || b.length === 0 || a.length !== b.length) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let dot = 0;
|
|
62
|
+
let normA = 0;
|
|
63
|
+
let normB = 0;
|
|
64
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
65
|
+
const av = a[index];
|
|
66
|
+
const bv = b[index];
|
|
67
|
+
dot += av * bv;
|
|
68
|
+
normA += av * av;
|
|
69
|
+
normB += bv * bv;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (normA === 0 || normB === 0) {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function groupRuleLinks(relations: RelationRecord[]): Map<string, string[]> {
|
|
79
|
+
const links = new Map<string, string[]>();
|
|
80
|
+
for (const relation of relations) {
|
|
81
|
+
if (relation.relation !== "CONSTRAINS" && relation.relation !== "IMPLEMENTS") {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (relation.relation === "CONSTRAINS") {
|
|
86
|
+
const list = links.get(relation.to) ?? [];
|
|
87
|
+
list.push(relation.from);
|
|
88
|
+
links.set(relation.to, list);
|
|
89
|
+
} else {
|
|
90
|
+
const list = links.get(relation.from) ?? [];
|
|
91
|
+
list.push(relation.to);
|
|
92
|
+
links.set(relation.from, list);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return links;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildSearchEntities(data: ContextData, includeContent: boolean): SearchEntity[] {
|
|
99
|
+
const entities: SearchEntity[] = [];
|
|
100
|
+
const ruleLinks = groupRuleLinks(data.relations);
|
|
101
|
+
const adrPathSet = new Set(
|
|
102
|
+
data.adrs
|
|
103
|
+
.map((adr) => adr.path.trim().toLowerCase())
|
|
104
|
+
.filter((adrPath) => adrPath.length > 0)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
for (const document of data.documents) {
|
|
108
|
+
const normalizedPath = document.path.trim().toLowerCase();
|
|
109
|
+
// ADR content is represented by ADR entities below; avoid duplicate results.
|
|
110
|
+
if (document.kind === "ADR" && adrPathSet.has(normalizedPath)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
entities.push({
|
|
115
|
+
id: document.id,
|
|
116
|
+
entity_type: "File",
|
|
117
|
+
kind: document.kind,
|
|
118
|
+
label: document.path,
|
|
119
|
+
path: document.path,
|
|
120
|
+
text: `${document.path}\n${document.excerpt}\n${document.content}`,
|
|
121
|
+
status: document.status,
|
|
122
|
+
source_of_truth: document.source_of_truth,
|
|
123
|
+
trust_level: document.trust_level,
|
|
124
|
+
updated_at: document.updated_at,
|
|
125
|
+
snippet: document.excerpt,
|
|
126
|
+
matched_rules: ruleLinks.get(document.id) ?? [],
|
|
127
|
+
content: includeContent ? document.content : undefined
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const rule of data.rules) {
|
|
132
|
+
entities.push({
|
|
133
|
+
id: rule.id,
|
|
134
|
+
entity_type: "Rule",
|
|
135
|
+
kind: "RULE",
|
|
136
|
+
label: rule.title || rule.id,
|
|
137
|
+
path: "",
|
|
138
|
+
text: `${rule.id}\n${rule.title}\n${rule.body}`,
|
|
139
|
+
status: rule.status,
|
|
140
|
+
source_of_truth: rule.source_of_truth,
|
|
141
|
+
trust_level: rule.trust_level,
|
|
142
|
+
updated_at: rule.updated_at,
|
|
143
|
+
snippet: rule.body.slice(0, 500),
|
|
144
|
+
matched_rules: [rule.id],
|
|
145
|
+
content: includeContent ? rule.body : undefined
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const adr of data.adrs) {
|
|
150
|
+
entities.push({
|
|
151
|
+
id: adr.id,
|
|
152
|
+
entity_type: "ADR",
|
|
153
|
+
kind: "ADR",
|
|
154
|
+
label: adr.title || adr.id,
|
|
155
|
+
path: adr.path,
|
|
156
|
+
text: `${adr.path}\n${adr.title}\n${adr.body}`,
|
|
157
|
+
status: adr.status,
|
|
158
|
+
source_of_truth: adr.source_of_truth,
|
|
159
|
+
trust_level: adr.trust_level,
|
|
160
|
+
updated_at: adr.decision_date,
|
|
161
|
+
snippet: adr.body.slice(0, 500),
|
|
162
|
+
matched_rules: [],
|
|
163
|
+
content: includeContent ? adr.body : undefined
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return entities;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function relationDegree(relations: RelationRecord[]): Map<string, number> {
|
|
171
|
+
const degrees = new Map<string, number>();
|
|
172
|
+
|
|
173
|
+
for (const relation of relations) {
|
|
174
|
+
degrees.set(relation.from, (degrees.get(relation.from) ?? 0) + 1);
|
|
175
|
+
degrees.set(relation.to, (degrees.get(relation.to) ?? 0) + 1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return degrees;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function entityCatalog(data: ContextData): Map<string, JsonObject> {
|
|
182
|
+
const catalog = new Map<string, JsonObject>();
|
|
183
|
+
|
|
184
|
+
for (const file of data.documents) {
|
|
185
|
+
catalog.set(file.id, {
|
|
186
|
+
id: file.id,
|
|
187
|
+
type: "File",
|
|
188
|
+
label: file.path,
|
|
189
|
+
status: file.status,
|
|
190
|
+
source_of_truth: file.source_of_truth
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const rule of data.rules) {
|
|
195
|
+
catalog.set(rule.id, {
|
|
196
|
+
id: rule.id,
|
|
197
|
+
type: "Rule",
|
|
198
|
+
label: rule.title,
|
|
199
|
+
status: rule.status,
|
|
200
|
+
source_of_truth: rule.source_of_truth
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const adr of data.adrs) {
|
|
205
|
+
catalog.set(adr.id, {
|
|
206
|
+
id: adr.id,
|
|
207
|
+
type: "ADR",
|
|
208
|
+
label: adr.title || adr.id,
|
|
209
|
+
status: adr.status,
|
|
210
|
+
source_of_truth: adr.source_of_truth
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return catalog;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function runContextSearch(parsed: SearchParams): Promise<ToolPayload> {
|
|
218
|
+
const data = await loadContextData();
|
|
219
|
+
const degreeByEntity = relationDegree(data.relations);
|
|
220
|
+
const candidates = buildSearchEntities(data, parsed.include_content).filter(
|
|
221
|
+
(entity) => parsed.include_deprecated || entity.status.toLowerCase() !== "deprecated"
|
|
222
|
+
);
|
|
223
|
+
const embeddings = loadEmbeddingIndex();
|
|
224
|
+
const queryVector =
|
|
225
|
+
embeddings.model && embeddings.vectors.size > 0
|
|
226
|
+
? await embedQuery(parsed.query, embeddings.model)
|
|
227
|
+
: null;
|
|
228
|
+
|
|
229
|
+
const results = candidates
|
|
230
|
+
.map((entity) => {
|
|
231
|
+
const lexicalSemantic = semanticScore(parsed.query, entity.text);
|
|
232
|
+
const entityVector = embeddings.vectors.get(entity.id);
|
|
233
|
+
const vectorSemantic =
|
|
234
|
+
queryVector && entityVector
|
|
235
|
+
? Math.max(0, Math.min(1, (cosineSimilarity(queryVector, entityVector) + 1) / 2))
|
|
236
|
+
: 0;
|
|
237
|
+
const semantic =
|
|
238
|
+
vectorSemantic > 0 ? vectorSemantic * 0.75 + lexicalSemantic * 0.25 : lexicalSemantic;
|
|
239
|
+
const graphScore = Math.min(1, (degreeByEntity.get(entity.id) ?? 0) / 4);
|
|
240
|
+
const trustScore = Math.max(0, Math.min(1, entity.trust_level / 100));
|
|
241
|
+
const dateScore = recencyScore(entity.updated_at);
|
|
242
|
+
|
|
243
|
+
let score = 0;
|
|
244
|
+
score += data.ranking.semantic * semantic;
|
|
245
|
+
score += data.ranking.graph * graphScore;
|
|
246
|
+
score += data.ranking.trust * trustScore;
|
|
247
|
+
score += data.ranking.recency * dateScore;
|
|
248
|
+
|
|
249
|
+
if (entity.source_of_truth) {
|
|
250
|
+
score += 0.1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
id: entity.id,
|
|
255
|
+
entity_type: entity.entity_type,
|
|
256
|
+
kind: entity.kind,
|
|
257
|
+
title: entity.label,
|
|
258
|
+
path: entity.path || undefined,
|
|
259
|
+
score: Number(score.toFixed(4)),
|
|
260
|
+
semantic_score: Number(semantic.toFixed(4)),
|
|
261
|
+
embedding_score: Number(vectorSemantic.toFixed(4)),
|
|
262
|
+
lexical_score: Number(lexicalSemantic.toFixed(4)),
|
|
263
|
+
graph_score: Number(graphScore.toFixed(4)),
|
|
264
|
+
source_of_truth: entity.source_of_truth,
|
|
265
|
+
status: entity.status,
|
|
266
|
+
updated_at: entity.updated_at,
|
|
267
|
+
matched_rules: entity.matched_rules,
|
|
268
|
+
excerpt: entity.snippet,
|
|
269
|
+
content: parsed.include_content ? entity.content : undefined
|
|
270
|
+
};
|
|
271
|
+
})
|
|
272
|
+
.filter((result) => result.score > 0)
|
|
273
|
+
.sort((a, b) => b.score - a.score)
|
|
274
|
+
.slice(0, parsed.top_k);
|
|
275
|
+
|
|
276
|
+
const warningMessages = [data.warning, embeddings.warning, getEmbeddingRuntimeWarning()].filter(Boolean);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
query: parsed.query,
|
|
280
|
+
top_k: parsed.top_k,
|
|
281
|
+
ranking: data.ranking,
|
|
282
|
+
total_candidates: candidates.length,
|
|
283
|
+
context_source: data.source,
|
|
284
|
+
warning: warningMessages.length > 0 ? warningMessages.join(" | ") : undefined,
|
|
285
|
+
semantic_engine:
|
|
286
|
+
queryVector && embeddings.model ? `embedding+lexical (${embeddings.model})` : "lexical-only",
|
|
287
|
+
results
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function runContextRelated(parsed: RelatedParams): Promise<ToolPayload> {
|
|
292
|
+
const data = await loadContextData();
|
|
293
|
+
const catalog = entityCatalog(data);
|
|
294
|
+
|
|
295
|
+
if (!catalog.has(parsed.entity_id)) {
|
|
296
|
+
return {
|
|
297
|
+
entity_id: parsed.entity_id,
|
|
298
|
+
depth: parsed.depth,
|
|
299
|
+
related: [],
|
|
300
|
+
edges: [],
|
|
301
|
+
context_source: data.source,
|
|
302
|
+
warning: "Entity not found in indexed context."
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const outgoing = new Map<string, RelationRecord[]>();
|
|
307
|
+
const incoming = new Map<string, RelationRecord[]>();
|
|
308
|
+
|
|
309
|
+
for (const relation of data.relations) {
|
|
310
|
+
const outList = outgoing.get(relation.from) ?? [];
|
|
311
|
+
outList.push(relation);
|
|
312
|
+
outgoing.set(relation.from, outList);
|
|
313
|
+
|
|
314
|
+
const inList = incoming.get(relation.to) ?? [];
|
|
315
|
+
inList.push(relation);
|
|
316
|
+
incoming.set(relation.to, inList);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const seen = new Set<string>([parsed.entity_id]);
|
|
320
|
+
const queue: Array<{ id: string; hop: number }> = [{ id: parsed.entity_id, hop: 0 }];
|
|
321
|
+
const related: JsonObject[] = [];
|
|
322
|
+
const traversedEdges: JsonObject[] = [];
|
|
323
|
+
const traversedEdgeKeys = new Set<string>();
|
|
324
|
+
|
|
325
|
+
while (queue.length > 0) {
|
|
326
|
+
const current = queue.shift() as { id: string; hop: number };
|
|
327
|
+
if (current.hop >= parsed.depth) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const neighbors = [
|
|
332
|
+
...(outgoing.get(current.id) ?? []).map((edge) => ({
|
|
333
|
+
edge,
|
|
334
|
+
next: edge.to,
|
|
335
|
+
direction: "outgoing"
|
|
336
|
+
})),
|
|
337
|
+
...(incoming.get(current.id) ?? []).map((edge) => ({
|
|
338
|
+
edge,
|
|
339
|
+
next: edge.from,
|
|
340
|
+
direction: "incoming"
|
|
341
|
+
}))
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
for (const neighbor of neighbors) {
|
|
345
|
+
const target = neighbor.next;
|
|
346
|
+
if (!seen.has(target)) {
|
|
347
|
+
seen.add(target);
|
|
348
|
+
queue.push({ id: target, hop: current.hop + 1 });
|
|
349
|
+
|
|
350
|
+
const entity = catalog.get(target) ?? {
|
|
351
|
+
id: target,
|
|
352
|
+
type: "Unknown",
|
|
353
|
+
label: target,
|
|
354
|
+
status: "unknown",
|
|
355
|
+
source_of_truth: false
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
related.push({
|
|
359
|
+
...entity,
|
|
360
|
+
hops: current.hop + 1,
|
|
361
|
+
via_relation: neighbor.edge.relation,
|
|
362
|
+
direction: neighbor.direction
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const edgeKey = `${neighbor.edge.from}|${neighbor.edge.relation}|${neighbor.edge.to}|${neighbor.edge.note}`;
|
|
367
|
+
if (!traversedEdgeKeys.has(edgeKey)) {
|
|
368
|
+
traversedEdgeKeys.add(edgeKey);
|
|
369
|
+
traversedEdges.push({
|
|
370
|
+
from: neighbor.edge.from,
|
|
371
|
+
to: neighbor.edge.to,
|
|
372
|
+
relation: neighbor.edge.relation,
|
|
373
|
+
note: neighbor.edge.note
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
entity_id: parsed.entity_id,
|
|
381
|
+
depth: parsed.depth,
|
|
382
|
+
context_source: data.source,
|
|
383
|
+
warning: data.warning,
|
|
384
|
+
related,
|
|
385
|
+
edges: parsed.include_edges ? traversedEdges : []
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function runContextRules(parsed: RulesParams): Promise<ToolPayload> {
|
|
390
|
+
const data = await loadContextData();
|
|
391
|
+
|
|
392
|
+
const rules = data.rules
|
|
393
|
+
.filter((rule) => parsed.include_inactive || rule.status === "active")
|
|
394
|
+
.filter((rule) => !parsed.scope || rule.scope === parsed.scope || rule.scope === "global")
|
|
395
|
+
.sort((a, b) => b.priority - a.priority)
|
|
396
|
+
.map((rule) => ({
|
|
397
|
+
id: rule.id,
|
|
398
|
+
title: rule.title,
|
|
399
|
+
description: rule.body,
|
|
400
|
+
priority: rule.priority,
|
|
401
|
+
scope: rule.scope,
|
|
402
|
+
status: rule.status
|
|
403
|
+
}));
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
scope: parsed.scope ?? "global",
|
|
407
|
+
count: rules.length,
|
|
408
|
+
context_source: data.source,
|
|
409
|
+
warning: data.warning,
|
|
410
|
+
rules
|
|
411
|
+
};
|
|
412
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { reloadContextGraph } from "./graph.js";
|
|
5
|
+
import { runContextRelated, runContextRules, runContextSearch } from "./search.js";
|
|
6
|
+
|
|
7
|
+
type ToolPayload = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
const SearchInput = z.object({
|
|
10
|
+
query: z.string().min(1),
|
|
11
|
+
top_k: z.number().int().positive().max(20).default(5),
|
|
12
|
+
include_deprecated: z.boolean().default(false),
|
|
13
|
+
include_content: z.boolean().default(false)
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const RelatedInput = z.object({
|
|
17
|
+
entity_id: z.string().min(1),
|
|
18
|
+
depth: z.number().int().positive().max(3).default(1),
|
|
19
|
+
include_edges: z.boolean().default(true)
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const RulesInput = z.object({
|
|
23
|
+
scope: z.string().optional(),
|
|
24
|
+
include_inactive: z.boolean().default(false)
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const ReloadInput = z.object({
|
|
28
|
+
force: z.boolean().default(true)
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function buildToolResult(data: ToolPayload) {
|
|
32
|
+
return {
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
type: "text" as const,
|
|
36
|
+
text: JSON.stringify(data, null, 2)
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
structuredContent: data
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function registerTools(server: McpServer): void {
|
|
44
|
+
server.registerTool(
|
|
45
|
+
"context.search",
|
|
46
|
+
{
|
|
47
|
+
description: "Search ranked context documents and code using semantic, graph and trust weighting.",
|
|
48
|
+
inputSchema: SearchInput
|
|
49
|
+
},
|
|
50
|
+
async (input) => buildToolResult(await runContextSearch(SearchInput.parse(input ?? {})))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
server.registerTool(
|
|
54
|
+
"context.get_related",
|
|
55
|
+
{
|
|
56
|
+
description: "Return related entities and graph edges for a context entity id.",
|
|
57
|
+
inputSchema: RelatedInput
|
|
58
|
+
},
|
|
59
|
+
async (input) => buildToolResult(await runContextRelated(RelatedInput.parse(input ?? {})))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
server.registerTool(
|
|
63
|
+
"context.get_rules",
|
|
64
|
+
{
|
|
65
|
+
description: "List indexed rules filtered by scope and active status.",
|
|
66
|
+
inputSchema: RulesInput.optional()
|
|
67
|
+
},
|
|
68
|
+
async (input) => buildToolResult(await runContextRules(RulesInput.parse(input ?? {})))
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
server.registerTool(
|
|
72
|
+
"context.reload",
|
|
73
|
+
{
|
|
74
|
+
description: "Reload RyuGraph connection after graph updates or maintenance.",
|
|
75
|
+
inputSchema: ReloadInput.optional()
|
|
76
|
+
},
|
|
77
|
+
async (input) => {
|
|
78
|
+
const parsed = ReloadInput.parse(input ?? {});
|
|
79
|
+
return buildToolResult(await reloadContextGraph(parsed.force));
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function main(): Promise<void> {
|
|
85
|
+
const server = new McpServer({
|
|
86
|
+
name: "cortex-context",
|
|
87
|
+
version: "0.1.0"
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
registerTools(server);
|
|
91
|
+
const transport = new StdioServerTransport();
|
|
92
|
+
await server.connect(transport);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main().catch((error) => {
|
|
96
|
+
process.stderr.write(`${error instanceof Error ? error.message : "Fatal error"}\n`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
|
2
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
3
|
+
export type UnknownRow = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export type DocumentRecord = {
|
|
6
|
+
id: string;
|
|
7
|
+
path: string;
|
|
8
|
+
kind: "DOC" | "CODE" | "ADR";
|
|
9
|
+
updated_at: string;
|
|
10
|
+
source_of_truth: boolean;
|
|
11
|
+
trust_level: number;
|
|
12
|
+
status: string;
|
|
13
|
+
excerpt: string;
|
|
14
|
+
content: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RuleRecord = {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
body: string;
|
|
21
|
+
scope: string;
|
|
22
|
+
updated_at: string;
|
|
23
|
+
source_of_truth: boolean;
|
|
24
|
+
trust_level: number;
|
|
25
|
+
status: string;
|
|
26
|
+
priority: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AdrRecord = {
|
|
30
|
+
id: string;
|
|
31
|
+
path: string;
|
|
32
|
+
title: string;
|
|
33
|
+
body: string;
|
|
34
|
+
decision_date: string;
|
|
35
|
+
supersedes_id: string;
|
|
36
|
+
source_of_truth: boolean;
|
|
37
|
+
trust_level: number;
|
|
38
|
+
status: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type RelationRecord = {
|
|
42
|
+
from: string;
|
|
43
|
+
to: string;
|
|
44
|
+
relation: "CONSTRAINS" | "IMPLEMENTS" | "SUPERSEDES";
|
|
45
|
+
note: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type RankingWeights = {
|
|
49
|
+
semantic: number;
|
|
50
|
+
graph: number;
|
|
51
|
+
trust: number;
|
|
52
|
+
recency: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ContextData = {
|
|
56
|
+
documents: DocumentRecord[];
|
|
57
|
+
adrs: AdrRecord[];
|
|
58
|
+
rules: RuleRecord[];
|
|
59
|
+
relations: RelationRecord[];
|
|
60
|
+
ranking: RankingWeights;
|
|
61
|
+
source: "cache" | "ryu";
|
|
62
|
+
warning?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type SearchEntity = {
|
|
66
|
+
id: string;
|
|
67
|
+
entity_type: "File" | "Rule" | "ADR";
|
|
68
|
+
kind: string;
|
|
69
|
+
label: string;
|
|
70
|
+
path: string;
|
|
71
|
+
text: string;
|
|
72
|
+
status: string;
|
|
73
|
+
source_of_truth: boolean;
|
|
74
|
+
trust_level: number;
|
|
75
|
+
updated_at: string;
|
|
76
|
+
snippet: string;
|
|
77
|
+
matched_rules: string[];
|
|
78
|
+
content?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type EmbeddingIndex = {
|
|
82
|
+
model: string | null;
|
|
83
|
+
vectors: Map<string, number[]>;
|
|
84
|
+
warning?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type SearchParams = {
|
|
88
|
+
query: string;
|
|
89
|
+
top_k: number;
|
|
90
|
+
include_deprecated: boolean;
|
|
91
|
+
include_content: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type RelatedParams = {
|
|
95
|
+
entity_id: string;
|
|
96
|
+
depth: number;
|
|
97
|
+
include_edges: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type RulesParams = {
|
|
101
|
+
scope?: string;
|
|
102
|
+
include_inactive: boolean;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type ReloadParams = {
|
|
106
|
+
force: boolean;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type ToolPayload = Record<string, unknown>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const MCP_DIR = path.resolve(__dirname, "..");
|
|
11
|
+
|
|
12
|
+
async function withClient(fn) {
|
|
13
|
+
const transport = new StdioClientTransport({
|
|
14
|
+
command: "node",
|
|
15
|
+
args: ["dist/server.js"],
|
|
16
|
+
cwd: MCP_DIR,
|
|
17
|
+
stderr: "pipe"
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const client = new Client({ name: "cortex-test-client", version: "0.1.0" });
|
|
21
|
+
await client.connect(transport);
|
|
22
|
+
try {
|
|
23
|
+
await fn(client);
|
|
24
|
+
} finally {
|
|
25
|
+
await client.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("context.get_rules accepts missing arguments", async () => {
|
|
30
|
+
await withClient(async (client) => {
|
|
31
|
+
const result = await client.callTool({ name: "context.get_rules" });
|
|
32
|
+
assert.notEqual(result.isError, true);
|
|
33
|
+
assert.ok(result.structuredContent);
|
|
34
|
+
assert.ok(Array.isArray(result.structuredContent.rules));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("context.search returns unified entity types", async () => {
|
|
39
|
+
await withClient(async (client) => {
|
|
40
|
+
const result = await client.callTool({
|
|
41
|
+
name: "context.search",
|
|
42
|
+
arguments: { query: "rule.source_of_truth", top_k: 10 }
|
|
43
|
+
});
|
|
44
|
+
assert.notEqual(result.isError, true);
|
|
45
|
+
assert.ok(result.structuredContent);
|
|
46
|
+
assert.ok(Array.isArray(result.structuredContent.results));
|
|
47
|
+
const types = new Set(result.structuredContent.results.map((item) => item.entity_type));
|
|
48
|
+
assert.ok(types.has("Rule"));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("context.reload returns reload metadata", async () => {
|
|
53
|
+
await withClient(async (client) => {
|
|
54
|
+
const result = await client.callTool({ name: "context.reload" });
|
|
55
|
+
assert.notEqual(result.isError, true);
|
|
56
|
+
assert.ok(result.structuredContent);
|
|
57
|
+
assert.equal(typeof result.structuredContent.reloaded, "boolean");
|
|
58
|
+
assert.ok(["ryu", "cache"].includes(String(result.structuredContent.context_source)));
|
|
59
|
+
});
|
|
60
|
+
});
|