@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.
Files changed (41) hide show
  1. package/README.md +203 -0
  2. package/bin/cortex.mjs +621 -0
  3. package/docs/MCP_MARKETPLACE.md +160 -0
  4. package/package.json +42 -0
  5. package/scaffold/.context/config.yaml +21 -0
  6. package/scaffold/.context/ontology.cypher +63 -0
  7. package/scaffold/.context/rules.yaml +25 -0
  8. package/scaffold/.githooks/_cortex-update-runner.sh +58 -0
  9. package/scaffold/.githooks/post-checkout +22 -0
  10. package/scaffold/.githooks/post-merge +14 -0
  11. package/scaffold/docs/architecture.md +22 -0
  12. package/scaffold/mcp/package-lock.json +2623 -0
  13. package/scaffold/mcp/package.json +29 -0
  14. package/scaffold/mcp/src/embed.ts +416 -0
  15. package/scaffold/mcp/src/embeddings.ts +192 -0
  16. package/scaffold/mcp/src/graph.ts +666 -0
  17. package/scaffold/mcp/src/loadGraph.ts +597 -0
  18. package/scaffold/mcp/src/paths.ts +33 -0
  19. package/scaffold/mcp/src/search.ts +412 -0
  20. package/scaffold/mcp/src/server.ts +98 -0
  21. package/scaffold/mcp/src/types.ts +109 -0
  22. package/scaffold/mcp/tests/server.test.mjs +60 -0
  23. package/scaffold/mcp/tsconfig.json +13 -0
  24. package/scaffold/scripts/bootstrap.sh +57 -0
  25. package/scaffold/scripts/capture-note.sh +55 -0
  26. package/scaffold/scripts/context.sh +109 -0
  27. package/scaffold/scripts/embed.sh +15 -0
  28. package/scaffold/scripts/ingest.mjs +1118 -0
  29. package/scaffold/scripts/ingest.sh +20 -0
  30. package/scaffold/scripts/install-git-hooks.sh +21 -0
  31. package/scaffold/scripts/load-kuzu.sh +6 -0
  32. package/scaffold/scripts/load-ryu.sh +18 -0
  33. package/scaffold/scripts/parsers/javascript.mjs +390 -0
  34. package/scaffold/scripts/parsers/package-lock.json +51 -0
  35. package/scaffold/scripts/parsers/package.json +17 -0
  36. package/scaffold/scripts/plan-state-engine.cjs +310 -0
  37. package/scaffold/scripts/plan-state.sh +71 -0
  38. package/scaffold/scripts/refresh.sh +9 -0
  39. package/scaffold/scripts/status.sh +282 -0
  40. package/scaffold/scripts/update-context.sh +18 -0
  41. 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
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }