ctx-mcp 0.1.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 +169 -0
- package/migrations/001_init.sql +52 -0
- package/migrations/002_fts.sql +27 -0
- package/package.json +33 -0
- package/scripts/risk-check.ts +46 -0
- package/scripts/seed-trace.ts +144 -0
- package/scripts/similarity.ts +35 -0
- package/src/explain/explain.ts +125 -0
- package/src/graph/graph.ts +153 -0
- package/src/mcp/resources.ts +234 -0
- package/src/mcp/tools.ts +522 -0
- package/src/schemas.ts +238 -0
- package/src/server.ts +38 -0
- package/src/similarity/similarity.ts +299 -0
- package/src/storage/db.ts +17 -0
- package/src/storage/engine.ts +14 -0
- package/src/storage/migrations.ts +19 -0
- package/src/storage/queries.ts +294 -0
- package/src/storage/sqljsEngine.ts +207 -0
- package/src/util/fs.ts +15 -0
- package/src/util/hashing.ts +5 -0
- package/src/util/ids.ts +8 -0
- package/src/util/redact.ts +26 -0
- package/tests/explain.test.ts +81 -0
- package/tests/graph.test.ts +85 -0
- package/tests/query.test.ts +275 -0
- package/tests/similarity.test.ts +103 -0
- package/tests/storage.test.ts +91 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { EdgeRow, NodeRow } from "../storage/queries.js";
|
|
2
|
+
|
|
3
|
+
export type EdgeRef = {
|
|
4
|
+
edge_id: string;
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
relation_type: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Graph = {
|
|
11
|
+
nodes: NodeRow[];
|
|
12
|
+
edges: EdgeRow[];
|
|
13
|
+
out: Map<string, EdgeRef[]>;
|
|
14
|
+
inbound: Map<string, EdgeRef[]>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function buildGraph(nodes: NodeRow[], edges: EdgeRow[]): Graph {
|
|
18
|
+
const out = new Map<string, EdgeRef[]>();
|
|
19
|
+
const inbound = new Map<string, EdgeRef[]>();
|
|
20
|
+
|
|
21
|
+
for (const edge of edges) {
|
|
22
|
+
const ref: EdgeRef = {
|
|
23
|
+
edge_id: edge.edge_id,
|
|
24
|
+
from: edge.from_node_id,
|
|
25
|
+
to: edge.to_node_id,
|
|
26
|
+
relation_type: edge.relation_type,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const outList = out.get(ref.from) ?? [];
|
|
30
|
+
outList.push(ref);
|
|
31
|
+
out.set(ref.from, outList);
|
|
32
|
+
|
|
33
|
+
const inList = inbound.get(ref.to) ?? [];
|
|
34
|
+
inList.push(ref);
|
|
35
|
+
inbound.set(ref.to, inList);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { nodes, edges, out, inbound };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SubgraphOptions = {
|
|
42
|
+
center: string;
|
|
43
|
+
depth: number;
|
|
44
|
+
direction: "in" | "out" | "both";
|
|
45
|
+
maxNodes: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function extractSubgraph(graph: Graph, options: SubgraphOptions): {
|
|
49
|
+
nodes: NodeRow[];
|
|
50
|
+
edges: EdgeRow[];
|
|
51
|
+
} {
|
|
52
|
+
const included = new Set<string>();
|
|
53
|
+
const queue: Array<{ id: string; depth: number }> = [{ id: options.center, depth: 0 }];
|
|
54
|
+
|
|
55
|
+
while (queue.length > 0) {
|
|
56
|
+
const current = queue.shift();
|
|
57
|
+
if (!current) continue;
|
|
58
|
+
if (included.has(current.id)) continue;
|
|
59
|
+
|
|
60
|
+
included.add(current.id);
|
|
61
|
+
if (included.size >= options.maxNodes) break;
|
|
62
|
+
|
|
63
|
+
if (current.depth >= options.depth) continue;
|
|
64
|
+
|
|
65
|
+
const nextDepth = current.depth + 1;
|
|
66
|
+
const nextIds: string[] = [];
|
|
67
|
+
|
|
68
|
+
if (options.direction === "out" || options.direction === "both") {
|
|
69
|
+
const edges = graph.out.get(current.id) ?? [];
|
|
70
|
+
for (const edge of edges) {
|
|
71
|
+
nextIds.push(edge.to);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (options.direction === "in" || options.direction === "both") {
|
|
76
|
+
const edges = graph.inbound.get(current.id) ?? [];
|
|
77
|
+
for (const edge of edges) {
|
|
78
|
+
nextIds.push(edge.from);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const id of nextIds) {
|
|
83
|
+
if (!included.has(id)) {
|
|
84
|
+
queue.push({ id, depth: nextDepth });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nodes = graph.nodes.filter((node) => included.has(node.node_id));
|
|
90
|
+
const nodeSet = new Set(nodes.map((node) => node.node_id));
|
|
91
|
+
const edges = graph.edges.filter(
|
|
92
|
+
(edge) => nodeSet.has(edge.from_node_id) && nodeSet.has(edge.to_node_id)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return { nodes, edges };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type Path = {
|
|
99
|
+
node_ids: string[];
|
|
100
|
+
edge_ids: string[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type PathOptions = {
|
|
104
|
+
from: string;
|
|
105
|
+
to: string;
|
|
106
|
+
maxDepth: number;
|
|
107
|
+
maxPaths: number;
|
|
108
|
+
allowRelations?: Set<string>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export function findPaths(graph: Graph, options: PathOptions): Path[] {
|
|
112
|
+
const results: Path[] = [];
|
|
113
|
+
|
|
114
|
+
const walk = (
|
|
115
|
+
current: string,
|
|
116
|
+
target: string,
|
|
117
|
+
depth: number,
|
|
118
|
+
visited: Set<string>,
|
|
119
|
+
nodePath: string[],
|
|
120
|
+
edgePath: string[]
|
|
121
|
+
): void => {
|
|
122
|
+
if (results.length >= options.maxPaths) return;
|
|
123
|
+
if (depth > options.maxDepth) return;
|
|
124
|
+
if (current === target) {
|
|
125
|
+
results.push({ node_ids: [...nodePath], edge_ids: [...edgePath] });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const edges = graph.out.get(current) ?? [];
|
|
130
|
+
for (const edge of edges) {
|
|
131
|
+
if (options.allowRelations && !options.allowRelations.has(edge.relation_type)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (visited.has(edge.to)) continue;
|
|
135
|
+
|
|
136
|
+
visited.add(edge.to);
|
|
137
|
+
nodePath.push(edge.to);
|
|
138
|
+
edgePath.push(edge.edge_id);
|
|
139
|
+
|
|
140
|
+
walk(edge.to, target, depth + 1, visited, nodePath, edgePath);
|
|
141
|
+
|
|
142
|
+
edgePath.pop();
|
|
143
|
+
nodePath.pop();
|
|
144
|
+
visited.delete(edge.to);
|
|
145
|
+
|
|
146
|
+
if (results.length >= options.maxPaths) return;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
walk(options.from, options.to, 0, new Set([options.from]), [options.from], []);
|
|
151
|
+
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";
|
|
2
|
+
import { getTrace, getTraceArtifacts, getTraceEdges, getTraceNodes } from "../storage/queries.js";
|
|
3
|
+
import type { StorageEngine } from "../storage/engine.js";
|
|
4
|
+
import { buildGraph, extractSubgraph } from "../graph/graph.js";
|
|
5
|
+
import { explainDecision } from "../explain/explain.js";
|
|
6
|
+
|
|
7
|
+
function jsonResource(uri: string, payload: unknown) {
|
|
8
|
+
return {
|
|
9
|
+
contents: [
|
|
10
|
+
{
|
|
11
|
+
uri,
|
|
12
|
+
mimeType: "application/json",
|
|
13
|
+
text: JSON.stringify(payload, null, 2),
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseQuery(uri: string): URLSearchParams {
|
|
20
|
+
return new URL(uri).searchParams;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseNumber(value: string | null, fallback: number, min?: number, max?: number): number {
|
|
24
|
+
const parsed = value ? Number(value) : Number.NaN;
|
|
25
|
+
let out = Number.isFinite(parsed) ? parsed : fallback;
|
|
26
|
+
if (min !== undefined) out = Math.max(min, out);
|
|
27
|
+
if (max !== undefined) out = Math.min(max, out);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toTraceOutput(trace: ReturnType<typeof getTrace>) {
|
|
32
|
+
if (!trace) return null;
|
|
33
|
+
return {
|
|
34
|
+
...trace,
|
|
35
|
+
tags: JSON.parse(trace.tags_json),
|
|
36
|
+
metadata: JSON.parse(trace.metadata_json),
|
|
37
|
+
outcome: trace.outcome_json ? JSON.parse(trace.outcome_json) : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toNodeOutput(node: ReturnType<typeof getTraceNodes>[number]) {
|
|
42
|
+
return {
|
|
43
|
+
...node,
|
|
44
|
+
data: JSON.parse(node.data_json),
|
|
45
|
+
metadata: JSON.parse(node.metadata_json),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toArtifactOutput(artifact: ReturnType<typeof getTraceArtifacts>[number]) {
|
|
50
|
+
return {
|
|
51
|
+
...artifact,
|
|
52
|
+
metadata: JSON.parse(artifact.metadata_json),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerResources(
|
|
57
|
+
server: McpServer,
|
|
58
|
+
engine: StorageEngine,
|
|
59
|
+
templates: {
|
|
60
|
+
base: ResourceTemplate;
|
|
61
|
+
timeline: ResourceTemplate;
|
|
62
|
+
graph: ResourceTemplate;
|
|
63
|
+
subgraph: ResourceTemplate;
|
|
64
|
+
explain: ResourceTemplate;
|
|
65
|
+
search: ResourceTemplate;
|
|
66
|
+
similarity: ResourceTemplate;
|
|
67
|
+
risk: ResourceTemplate;
|
|
68
|
+
}
|
|
69
|
+
): void {
|
|
70
|
+
server.resource("trace", templates.base, async (uri, { trace_id }) => {
|
|
71
|
+
const trace = getTrace(engine, trace_id);
|
|
72
|
+
if (!trace) {
|
|
73
|
+
return jsonResource(uri, { ok: false, error: "Trace not found" });
|
|
74
|
+
}
|
|
75
|
+
const nodes = getTraceNodes(engine, trace_id);
|
|
76
|
+
const edges = getTraceEdges(engine, trace_id);
|
|
77
|
+
const artifacts = getTraceArtifacts(engine, trace_id);
|
|
78
|
+
|
|
79
|
+
return jsonResource(uri, {
|
|
80
|
+
trace: toTraceOutput(trace),
|
|
81
|
+
counts: {
|
|
82
|
+
nodes: nodes.length,
|
|
83
|
+
edges: edges.length,
|
|
84
|
+
artifacts: artifacts.length,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
server.resource("trace.timeline", templates.timeline, async (uri, { trace_id }) => {
|
|
90
|
+
const nodes = getTraceNodes(engine, trace_id);
|
|
91
|
+
return jsonResource(uri, { nodes: nodes.map(toNodeOutput) });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
server.resource("trace.graph", templates.graph, async (uri, { trace_id }) => {
|
|
95
|
+
const nodes = getTraceNodes(engine, trace_id);
|
|
96
|
+
const edges = getTraceEdges(engine, trace_id);
|
|
97
|
+
return jsonResource(uri, { nodes: nodes.map(toNodeOutput), edges });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
server.resource("trace.subgraph", templates.subgraph, async (uri, { trace_id }) => {
|
|
101
|
+
const params = parseQuery(uri);
|
|
102
|
+
const center = params.get("center");
|
|
103
|
+
if (!center) {
|
|
104
|
+
return jsonResource(uri, { ok: false, error: "Missing center param" });
|
|
105
|
+
}
|
|
106
|
+
const depth = parseNumber(params.get("depth"), 2, 0, 10);
|
|
107
|
+
const direction = (params.get("dir") ?? "both") as "in" | "out" | "both";
|
|
108
|
+
|
|
109
|
+
const nodes = getTraceNodes(engine, trace_id);
|
|
110
|
+
const edges = getTraceEdges(engine, trace_id);
|
|
111
|
+
const graph = buildGraph(nodes, edges);
|
|
112
|
+
const subgraph = extractSubgraph(graph, {
|
|
113
|
+
center,
|
|
114
|
+
depth,
|
|
115
|
+
direction,
|
|
116
|
+
maxNodes: 250,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return jsonResource(uri, {
|
|
120
|
+
nodes: subgraph.nodes.map(toNodeOutput),
|
|
121
|
+
edges: subgraph.edges,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
server.resource("trace.explain", templates.explain, async (uri, { trace_id }) => {
|
|
126
|
+
const params = parseQuery(uri);
|
|
127
|
+
const decision = params.get("decision");
|
|
128
|
+
if (!decision) {
|
|
129
|
+
return jsonResource(uri, { ok: false, error: "Missing decision param" });
|
|
130
|
+
}
|
|
131
|
+
const depth = parseNumber(params.get("depth"), 4, 1, 10);
|
|
132
|
+
|
|
133
|
+
const nodes = getTraceNodes(engine, trace_id);
|
|
134
|
+
const edges = getTraceEdges(engine, trace_id);
|
|
135
|
+
const artifacts = getTraceArtifacts(engine, trace_id);
|
|
136
|
+
const explanation = explainDecision(decision, nodes, edges, artifacts, depth);
|
|
137
|
+
|
|
138
|
+
return jsonResource(uri, {
|
|
139
|
+
...explanation,
|
|
140
|
+
decision_node: toNodeOutput(explanation.decision_node),
|
|
141
|
+
assumptions: explanation.assumptions.map(toNodeOutput),
|
|
142
|
+
evidence: explanation.evidence.map(toNodeOutput),
|
|
143
|
+
alternatives: explanation.alternatives.map(toNodeOutput),
|
|
144
|
+
checks: explanation.checks.map(toNodeOutput),
|
|
145
|
+
outcome: explanation.outcome ? toNodeOutput(explanation.outcome) : null,
|
|
146
|
+
artifacts: explanation.artifacts.map(toArtifactOutput),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
server.resource("trace.search", templates.search, async (uri) => {
|
|
151
|
+
const params = parseQuery(uri);
|
|
152
|
+
const text = params.get("text") ?? undefined;
|
|
153
|
+
const traceId = params.get("trace_id") ?? undefined;
|
|
154
|
+
const type = params.get("type") ?? undefined;
|
|
155
|
+
|
|
156
|
+
const nodes = engine.queryAll(
|
|
157
|
+
`SELECT * FROM nodes WHERE
|
|
158
|
+
(? IS NULL OR trace_id = ?)
|
|
159
|
+
AND (? IS NULL OR type = ?)
|
|
160
|
+
AND (? IS NULL OR summary LIKE ?)
|
|
161
|
+
ORDER BY created_at DESC LIMIT 50;`,
|
|
162
|
+
[traceId, traceId, type, type, text, text ? `%${text}%` : null]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return jsonResource(uri, { nodes: nodes.map(toNodeOutput) });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
server.resource("trace.similarity", templates.similarity, async (uri, { trace_id }) => {
|
|
169
|
+
const params = parseQuery(uri);
|
|
170
|
+
const decision = params.get("decision");
|
|
171
|
+
if (!decision) {
|
|
172
|
+
return jsonResource(uri, { ok: false, error: "Missing decision param" });
|
|
173
|
+
}
|
|
174
|
+
const scope = (params.get("scope") ?? "all") as "trace" | "all";
|
|
175
|
+
const limit = parseNumber(params.get("limit"), 10, 1, 50);
|
|
176
|
+
const depth = parseNumber(params.get("depth"), 4, 1, 10);
|
|
177
|
+
const maxTraces = parseNumber(params.get("max_traces"), 200, 1, 1000);
|
|
178
|
+
|
|
179
|
+
const { findSimilarDecisions } = await import("../similarity/similarity.js");
|
|
180
|
+
const matches = findSimilarDecisions(engine, decision, {
|
|
181
|
+
scope,
|
|
182
|
+
limit,
|
|
183
|
+
depth,
|
|
184
|
+
maxTraces,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return jsonResource(uri, {
|
|
188
|
+
trace_id,
|
|
189
|
+
matches: matches.map((match) => ({
|
|
190
|
+
node: toNodeOutput(match.node),
|
|
191
|
+
trace: toTraceOutput(match.trace),
|
|
192
|
+
score: match.score,
|
|
193
|
+
reasons: match.reasons,
|
|
194
|
+
})),
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
server.resource("trace.risk", templates.risk, async (uri) => {
|
|
199
|
+
const params = parseQuery(uri);
|
|
200
|
+
const decision = params.get("decision");
|
|
201
|
+
if (!decision) {
|
|
202
|
+
return jsonResource(uri, { ok: false, error: "Missing decision param" });
|
|
203
|
+
}
|
|
204
|
+
const scope = (params.get("scope") ?? "all") as "trace" | "all";
|
|
205
|
+
const limit = parseNumber(params.get("limit"), 10, 1, 50);
|
|
206
|
+
const depth = parseNumber(params.get("depth"), 4, 1, 10);
|
|
207
|
+
const threshold = parseNumber(params.get("threshold"), 0.6, 0, 1);
|
|
208
|
+
const maxTraces = parseNumber(params.get("max_traces"), 200, 1, 1000);
|
|
209
|
+
|
|
210
|
+
const { assessDecisionRisk } = await import("../similarity/similarity.js");
|
|
211
|
+
const assessment = assessDecisionRisk(engine, decision, {
|
|
212
|
+
scope,
|
|
213
|
+
limit,
|
|
214
|
+
depth,
|
|
215
|
+
threshold,
|
|
216
|
+
maxTraces,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return jsonResource(uri, {
|
|
220
|
+
assessment: {
|
|
221
|
+
decision: toNodeOutput(assessment.decision),
|
|
222
|
+
novelty: assessment.novelty,
|
|
223
|
+
similar_failures: assessment.similar_failures.map((match) => ({
|
|
224
|
+
node: toNodeOutput(match.node),
|
|
225
|
+
trace: toTraceOutput(match.trace),
|
|
226
|
+
score: match.score,
|
|
227
|
+
reasons: match.reasons,
|
|
228
|
+
})),
|
|
229
|
+
signature_failed_match: assessment.signature_failed_match,
|
|
230
|
+
warnings: assessment.warnings,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|