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/src/schemas.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { z } from "zod";
2
+
3
+ export const nodeTypeEnum = z.enum([
4
+ "Intent",
5
+ "Assumption",
6
+ "Observation",
7
+ "Option",
8
+ "Decision",
9
+ "Action",
10
+ "Verification",
11
+ "Outcome",
12
+ "HumanOverride",
13
+ ]);
14
+
15
+ export const relationTypeEnum = z.enum([
16
+ "depends_on",
17
+ "justified_by",
18
+ "validated_by",
19
+ "contradicted_by",
20
+ "causes",
21
+ "supersedes",
22
+ "derived_from",
23
+ ]);
24
+
25
+ export const redactionLevelEnum = z.enum(["internal", "scrubbed", "public"]);
26
+
27
+ const jsonRecord = z.record(z.string(), z.unknown());
28
+
29
+ export const traceStartInputSchema = z.object({
30
+ trace_id: z.string().optional(),
31
+ workflow_id: z.string().optional(),
32
+ actor: z.string().optional(),
33
+ intent: z.string(),
34
+ tags: z.array(z.string()).optional().default([]),
35
+ metadata: jsonRecord.optional().default({}),
36
+ started_at: z.number().int().optional(),
37
+ });
38
+
39
+ export const traceStartOutputSchema = z.object({
40
+ ok: z.literal(true),
41
+ trace_id: z.string(),
42
+ });
43
+
44
+ export const traceAddNodeInputSchema = z.object({
45
+ trace_id: z.string(),
46
+ node_id: z.string().optional(),
47
+ type: nodeTypeEnum,
48
+ summary: z.string(),
49
+ data: jsonRecord.optional().default({}),
50
+ confidence: z.number().min(0).max(1).optional(),
51
+ metadata: jsonRecord.optional().default({}),
52
+ created_at: z.number().int().optional(),
53
+ });
54
+
55
+ export const traceAddNodeOutputSchema = z.object({
56
+ ok: z.literal(true),
57
+ node_id: z.string(),
58
+ });
59
+
60
+ export const traceAddEdgeInputSchema = z.object({
61
+ trace_id: z.string(),
62
+ edge_id: z.string().optional(),
63
+ from_node_id: z.string(),
64
+ to_node_id: z.string(),
65
+ relation_type: relationTypeEnum,
66
+ data: jsonRecord.optional().default({}),
67
+ created_at: z.number().int().optional(),
68
+ });
69
+
70
+ export const traceAddEdgeOutputSchema = z.object({
71
+ ok: z.literal(true),
72
+ edge_id: z.string(),
73
+ });
74
+
75
+ export const traceAttachArtifactInputSchema = z.object({
76
+ trace_id: z.string(),
77
+ artifact_id: z.string().optional(),
78
+ node_id: z.string().optional(),
79
+ artifact_type: z.string(),
80
+ content: z.string(),
81
+ redaction_level: redactionLevelEnum.optional().default("internal"),
82
+ metadata: jsonRecord.optional().default({}),
83
+ created_at: z.number().int().optional(),
84
+ });
85
+
86
+ export const traceAttachArtifactOutputSchema = z.object({
87
+ ok: z.literal(true),
88
+ artifact_id: z.string(),
89
+ sha256: z.string(),
90
+ redaction_level: redactionLevelEnum,
91
+ });
92
+
93
+ export const traceFinishInputSchema = z.object({
94
+ trace_id: z.string(),
95
+ status: z.enum(["success", "fail", "unknown"]).optional(),
96
+ outcome: jsonRecord.optional(),
97
+ finished_at: z.number().int().optional(),
98
+ });
99
+
100
+ export const traceFinishOutputSchema = z.object({
101
+ ok: z.literal(true),
102
+ trace_id: z.string(),
103
+ });
104
+
105
+ export const traceQueryInputSchema = z.object({
106
+ trace_id: z.string().optional(),
107
+ type: nodeTypeEnum.optional(),
108
+ text: z.string().optional(),
109
+ tags: z.array(z.string()).optional(),
110
+ limit: z.number().int().min(1).max(500).optional().default(50),
111
+ offset: z.number().int().min(0).optional().default(0),
112
+ });
113
+
114
+ export const traceQueryOutputSchema = z.object({
115
+ ok: z.literal(true),
116
+ nodes: z.array(z.record(z.string(), z.unknown())),
117
+ });
118
+
119
+ export const traceGetSubgraphInputSchema = z.object({
120
+ trace_id: z.string(),
121
+ center: z.string(),
122
+ depth: z.number().int().min(0).max(10).optional().default(2),
123
+ direction: z.enum(["in", "out", "both"]).optional().default("both"),
124
+ max_nodes: z.number().int().min(1).max(2000).optional().default(250),
125
+ });
126
+
127
+ export const traceGetSubgraphOutputSchema = z.object({
128
+ ok: z.literal(true),
129
+ nodes: z.array(z.record(z.string(), z.unknown())),
130
+ edges: z.array(z.record(z.string(), z.unknown())),
131
+ });
132
+
133
+ export const traceFindPathsInputSchema = z.object({
134
+ trace_id: z.string(),
135
+ from: z.string(),
136
+ to: z.string(),
137
+ max_depth: z.number().int().min(1).max(10).optional().default(4),
138
+ max_paths: z.number().int().min(1).max(50).optional().default(10),
139
+ allow_relations: z.array(relationTypeEnum).optional(),
140
+ });
141
+
142
+ export const traceFindPathsOutputSchema = z.object({
143
+ ok: z.literal(true),
144
+ paths: z.array(
145
+ z.object({
146
+ node_ids: z.array(z.string()),
147
+ edge_ids: z.array(z.string()),
148
+ })
149
+ ),
150
+ });
151
+
152
+ export const traceExplainDecisionInputSchema = z.object({
153
+ trace_id: z.string(),
154
+ decision_node_id: z.string(),
155
+ depth: z.number().int().min(1).max(10).optional().default(4),
156
+ });
157
+
158
+ export const traceExplainDecisionOutputSchema = z.object({
159
+ ok: z.literal(true),
160
+ explanation: z.record(z.string(), z.unknown()),
161
+ });
162
+
163
+ export const traceSimilarityInputSchema = z.object({
164
+ trace_id: z.string().optional(),
165
+ decision_node_id: z.string(),
166
+ scope: z.enum(["trace", "all"]).optional().default("all"),
167
+ limit: z.number().int().min(1).max(50).optional().default(10),
168
+ depth: z.number().int().min(1).max(10).optional().default(4),
169
+ max_traces: z.number().int().min(1).max(1000).optional().default(200),
170
+ });
171
+
172
+ export const traceSimilarityOutputSchema = z.object({
173
+ ok: z.literal(true),
174
+ matches: z.array(
175
+ z.object({
176
+ node: z.record(z.string(), z.unknown()),
177
+ trace: z.record(z.string(), z.unknown()),
178
+ score: z.number(),
179
+ reasons: z.object({
180
+ text: z.number(),
181
+ tags: z.number(),
182
+ signature: z.boolean(),
183
+ }),
184
+ })
185
+ ),
186
+ });
187
+
188
+ export const traceRiskCheckInputSchema = z.object({
189
+ decision_node_id: z.string(),
190
+ scope: z.enum(["trace", "all"]).optional().default("all"),
191
+ limit: z.number().int().min(1).max(50).optional().default(10),
192
+ depth: z.number().int().min(1).max(10).optional().default(4),
193
+ threshold: z.number().min(0).max(1).optional().default(0.6),
194
+ max_traces: z.number().int().min(1).max(1000).optional().default(200),
195
+ });
196
+
197
+ export const traceRiskCheckOutputSchema = z.object({
198
+ ok: z.literal(true),
199
+ assessment: z.object({
200
+ decision: z.record(z.string(), z.unknown()),
201
+ novelty: z.boolean(),
202
+ similar_failures: z.array(
203
+ z.object({
204
+ node: z.record(z.string(), z.unknown()),
205
+ trace: z.record(z.string(), z.unknown()),
206
+ score: z.number(),
207
+ reasons: z.object({
208
+ text: z.number(),
209
+ tags: z.number(),
210
+ signature: z.boolean(),
211
+ }),
212
+ })
213
+ ),
214
+ signature_failed_match: z.boolean(),
215
+ warnings: z.array(z.string()),
216
+ }),
217
+ });
218
+
219
+ export const errorOutputSchema = z.object({
220
+ ok: z.literal(false),
221
+ error: z.object({
222
+ code: z.string(),
223
+ message: z.string(),
224
+ details: z.unknown().optional(),
225
+ }),
226
+ });
227
+
228
+ export type TraceStartInput = z.infer<typeof traceStartInputSchema>;
229
+ export type TraceAddNodeInput = z.infer<typeof traceAddNodeInputSchema>;
230
+ export type TraceAddEdgeInput = z.infer<typeof traceAddEdgeInputSchema>;
231
+ export type TraceAttachArtifactInput = z.infer<typeof traceAttachArtifactInputSchema>;
232
+ export type TraceFinishInput = z.infer<typeof traceFinishInputSchema>;
233
+ export type TraceQueryInput = z.infer<typeof traceQueryInputSchema>;
234
+ export type TraceGetSubgraphInput = z.infer<typeof traceGetSubgraphInputSchema>;
235
+ export type TraceFindPathsInput = z.infer<typeof traceFindPathsInputSchema>;
236
+ export type TraceExplainDecisionInput = z.infer<typeof traceExplainDecisionInputSchema>;
237
+ export type TraceSimilarityInput = z.infer<typeof traceSimilarityInputSchema>;
238
+ export type TraceRiskCheckInput = z.infer<typeof traceRiskCheckInputSchema>;
package/src/server.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
3
+
4
+ import { initStorage } from "./storage/db.js";
5
+ import { registerTools } from "./mcp/tools.js";
6
+ import { registerResources } from "./mcp/resources.js";
7
+
8
+ async function main() {
9
+ const engine = await initStorage({
10
+ onLog: (msg) => console.error(`[ctx] ${msg}`),
11
+ });
12
+
13
+ const server = new McpServer({
14
+ name: "ctx",
15
+ version: "0.1.0",
16
+ });
17
+
18
+ registerTools(server, engine);
19
+
20
+ registerResources(server, engine, {
21
+ base: new ResourceTemplate("trace://{trace_id}", { list: undefined }),
22
+ timeline: new ResourceTemplate("trace://{trace_id}/timeline", { list: undefined }),
23
+ graph: new ResourceTemplate("trace://{trace_id}/graph", { list: undefined }),
24
+ subgraph: new ResourceTemplate("trace://{trace_id}/subgraph", { list: undefined }),
25
+ explain: new ResourceTemplate("trace://{trace_id}/explain", { list: undefined }),
26
+ search: new ResourceTemplate("trace://search", { list: undefined }),
27
+ similarity: new ResourceTemplate("trace://{trace_id}/similarity", { list: undefined }),
28
+ risk: new ResourceTemplate("trace://{trace_id}/risk", { list: undefined }),
29
+ });
30
+
31
+ const transport = new StdioServerTransport();
32
+ await server.connect(transport);
33
+ }
34
+
35
+ main().catch((err) => {
36
+ console.error("[ctx] Failed to start server", err);
37
+ process.exit(1);
38
+ });
@@ -0,0 +1,299 @@
1
+ import type { StorageEngine } from "../storage/engine.js";
2
+ import {
3
+ getAllTraces,
4
+ getNode,
5
+ getTrace,
6
+ getTraceEdges,
7
+ getTraceNodes,
8
+ searchNodes,
9
+ type NodeRow,
10
+ type TraceRow,
11
+ } from "../storage/queries.js";
12
+ import { explainDecision } from "../explain/explain.js";
13
+ import { sha256 } from "../util/hashing.js";
14
+
15
+ export type SimilarDecision = {
16
+ node: NodeRow;
17
+ trace: TraceRow;
18
+ score: number;
19
+ reasons: {
20
+ text: number;
21
+ tags: number;
22
+ signature: boolean;
23
+ };
24
+ };
25
+
26
+ export type SimilarityOptions = {
27
+ scope: "trace" | "all";
28
+ limit: number;
29
+ depth: number;
30
+ maxTraces: number;
31
+ };
32
+
33
+ export type RiskAssessment = {
34
+ decision: NodeRow;
35
+ novelty: boolean;
36
+ similar_failures: SimilarDecision[];
37
+ signature_failed_match: boolean;
38
+ warnings: string[];
39
+ };
40
+
41
+ type TraceContext = {
42
+ nodes: NodeRow[];
43
+ edges: ReturnType<typeof getTraceEdges>;
44
+ signatureByDecision: Map<string, string>;
45
+ };
46
+
47
+ export function findSimilarDecisions(
48
+ engine: StorageEngine,
49
+ decisionNodeId: string,
50
+ options: SimilarityOptions
51
+ ): SimilarDecision[] {
52
+ const decision = getNode(engine, decisionNodeId);
53
+ if (!decision) {
54
+ throw new Error(`Decision node ${decisionNodeId} not found`);
55
+ }
56
+
57
+ const baseTrace = getTrace(engine, decision.trace_id);
58
+ if (!baseTrace) {
59
+ throw new Error(`Trace ${decision.trace_id} not found`);
60
+ }
61
+
62
+ const baseContext = getTraceContext(engine, decision.trace_id, options.depth);
63
+ const baseSignature = baseContext.signatureByDecision.get(decisionNodeId);
64
+ if (!baseSignature) {
65
+ throw new Error(`Signature for ${decisionNodeId} not found`);
66
+ }
67
+
68
+ const baseTokens = tokenize(decision.summary);
69
+ const baseTags = parseTags(baseTrace.tags_json);
70
+
71
+ const candidates = searchNodes(engine, {
72
+ traceId: options.scope === "trace" ? decision.trace_id : undefined,
73
+ type: "Decision",
74
+ text: baseTokens.join(" "),
75
+ limit: Math.max(options.limit * 3, 30),
76
+ offset: 0,
77
+ }).filter((node) => node.node_id !== decisionNodeId);
78
+
79
+ const traceMap = new Map<string, TraceRow>();
80
+ if (options.scope === "all") {
81
+ const traces = getAllTraces(engine).slice(0, options.maxTraces);
82
+ for (const trace of traces) {
83
+ traceMap.set(trace.trace_id, trace);
84
+ }
85
+ } else {
86
+ traceMap.set(baseTrace.trace_id, baseTrace);
87
+ }
88
+
89
+ const scored = candidates
90
+ .map((node) => {
91
+ const trace = traceMap.get(node.trace_id) ?? getTrace(engine, node.trace_id);
92
+ if (!trace) return null;
93
+
94
+ const context = getTraceContext(engine, node.trace_id, options.depth);
95
+
96
+ const signature = context.signatureByDecision.get(node.node_id);
97
+ const signatureMatch = signature === baseSignature;
98
+
99
+ const textScore = tokenSimilarity(baseTokens, tokenize(node.summary));
100
+ const tagScore = tagOverlap(baseTags, parseTags(trace.tags_json));
101
+
102
+ const score = 0.5 * (signatureMatch ? 1 : 0) + 0.4 * textScore + 0.1 * tagScore;
103
+
104
+ return {
105
+ node,
106
+ trace,
107
+ score,
108
+ reasons: {
109
+ text: textScore,
110
+ tags: tagScore,
111
+ signature: signatureMatch,
112
+ },
113
+ } as SimilarDecision;
114
+ })
115
+ .filter((item): item is SimilarDecision => Boolean(item));
116
+
117
+ return scored.sort((a, b) => b.score - a.score).slice(0, options.limit);
118
+ }
119
+
120
+ export function assessDecisionRisk(
121
+ engine: StorageEngine,
122
+ decisionNodeId: string,
123
+ options: SimilarityOptions & { threshold: number }
124
+ ): RiskAssessment {
125
+ const decision = getNode(engine, decisionNodeId);
126
+ if (!decision) {
127
+ throw new Error(`Decision node ${decisionNodeId} not found`);
128
+ }
129
+
130
+ const similar = findSimilarDecisions(engine, decisionNodeId, options);
131
+ const matching = similar.filter((item) => item.score >= options.threshold);
132
+
133
+ const similarFailures = matching.filter((item) => item.trace.status === "fail");
134
+
135
+ const signatureFailedMatch = hasFailedSignatureMatch(
136
+ engine,
137
+ decisionNodeId,
138
+ options.depth,
139
+ options.maxTraces
140
+ );
141
+
142
+ const novelty = matching.length === 0;
143
+ const warnings: string[] = [];
144
+ if (novelty) warnings.push("no_similar_decisions");
145
+ if (similarFailures.length > 0) warnings.push("similar_decisions_failed");
146
+ if (signatureFailedMatch) warnings.push("signature_match_failed_trace");
147
+
148
+ return {
149
+ decision,
150
+ novelty,
151
+ similar_failures: similarFailures,
152
+ signature_failed_match: signatureFailedMatch,
153
+ warnings,
154
+ };
155
+ }
156
+
157
+ function loadTraceContext(engine: StorageEngine, traceId: string, depth: number): TraceContext {
158
+ const nodes = getTraceNodes(engine, traceId);
159
+ const edges = getTraceEdges(engine, traceId);
160
+ const signatureByDecision = new Map<string, string>();
161
+
162
+ for (const node of nodes) {
163
+ if (node.type !== "Decision") continue;
164
+ const signature = computeDecisionSignature(node.node_id, nodes, edges, depth);
165
+ signatureByDecision.set(node.node_id, signature);
166
+ }
167
+
168
+ return { nodes, edges, signatureByDecision };
169
+ }
170
+
171
+ const traceContextCache = new WeakMap<StorageEngine, Map<string, Map<number, TraceContext>>>();
172
+
173
+ export function invalidateTraceContext(engine: StorageEngine, traceId?: string): void {
174
+ const engineCache = traceContextCache.get(engine);
175
+ if (!engineCache) return;
176
+ if (!traceId) {
177
+ traceContextCache.delete(engine);
178
+ return;
179
+ }
180
+ engineCache.delete(traceId);
181
+ if (engineCache.size === 0) {
182
+ traceContextCache.delete(engine);
183
+ }
184
+ }
185
+
186
+ function getTraceContext(engine: StorageEngine, traceId: string, depth: number): TraceContext {
187
+ let engineCache = traceContextCache.get(engine);
188
+ if (!engineCache) {
189
+ engineCache = new Map();
190
+ traceContextCache.set(engine, engineCache);
191
+ }
192
+
193
+ let depthCache = engineCache.get(traceId);
194
+ if (!depthCache) {
195
+ depthCache = new Map();
196
+ engineCache.set(traceId, depthCache);
197
+ }
198
+
199
+ const cached = depthCache.get(depth);
200
+ if (cached) return cached;
201
+
202
+ const context = loadTraceContext(engine, traceId, depth);
203
+ depthCache.set(depth, context);
204
+ return context;
205
+ }
206
+
207
+ function computeDecisionSignature(
208
+ decisionNodeId: string,
209
+ nodes: NodeRow[],
210
+ edges: ReturnType<typeof getTraceEdges>,
211
+ depth: number
212
+ ): string {
213
+ const explanation = explainDecision(decisionNodeId, nodes, edges, [], depth);
214
+ const parts = [
215
+ explanation.decision_node.summary,
216
+ ...explanation.assumptions.map((node) => node.summary),
217
+ ...explanation.evidence.map((node) => node.summary),
218
+ ...explanation.alternatives.map((node) => node.summary),
219
+ ...explanation.checks.map((node) => node.summary),
220
+ ]
221
+ .map(normalizeText)
222
+ .filter(Boolean)
223
+ .sort();
224
+
225
+ return sha256(parts.join("|"));
226
+ }
227
+
228
+ function hasFailedSignatureMatch(
229
+ engine: StorageEngine,
230
+ decisionNodeId: string,
231
+ depth: number,
232
+ maxTraces: number
233
+ ): boolean {
234
+ const decision = getNode(engine, decisionNodeId);
235
+ if (!decision) return false;
236
+
237
+ const baseContext = getTraceContext(engine, decision.trace_id, depth);
238
+ const baseSignature = baseContext.signatureByDecision.get(decisionNodeId);
239
+ if (!baseSignature) return false;
240
+
241
+ const failedTraces = getAllTraces(engine)
242
+ .filter((trace) => trace.status === "fail")
243
+ .slice(0, maxTraces);
244
+ for (const trace of failedTraces) {
245
+ const context = getTraceContext(engine, trace.trace_id, depth);
246
+ for (const signature of context.signatureByDecision.values()) {
247
+ if (signature === baseSignature) return true;
248
+ }
249
+ }
250
+
251
+ return false;
252
+ }
253
+
254
+ function parseTags(tagsJson: string | null): string[] {
255
+ if (!tagsJson) return [];
256
+ try {
257
+ const parsed = JSON.parse(tagsJson);
258
+ return Array.isArray(parsed) ? parsed : [];
259
+ } catch {
260
+ return [];
261
+ }
262
+ }
263
+
264
+ function tokenize(text: string): string[] {
265
+ return normalizeText(text)
266
+ .split(" ")
267
+ .filter(Boolean);
268
+ }
269
+
270
+ function normalizeText(text: string): string {
271
+ return text
272
+ .toLowerCase()
273
+ .replace(/[^a-z0-9\s]/g, " ")
274
+ .replace(/\s+/g, " ")
275
+ .trim();
276
+ }
277
+
278
+ function tokenSimilarity(a: string[], b: string[]): number {
279
+ if (a.length === 0 || b.length === 0) return 0;
280
+ const setA = new Set(a);
281
+ const setB = new Set(b);
282
+ let intersection = 0;
283
+ for (const token of setA) {
284
+ if (setB.has(token)) intersection += 1;
285
+ }
286
+ const union = new Set([...setA, ...setB]).size;
287
+ return union === 0 ? 0 : intersection / union;
288
+ }
289
+
290
+ function tagOverlap(a: string[], b: string[]): number {
291
+ if (a.length === 0 || b.length === 0) return 0;
292
+ const setA = new Set(a);
293
+ let intersection = 0;
294
+ for (const tag of b) {
295
+ if (setA.has(tag)) intersection += 1;
296
+ }
297
+ const denom = Math.max(a.length, b.length);
298
+ return denom === 0 ? 0 : intersection / denom;
299
+ }
@@ -0,0 +1,17 @@
1
+ import { SqlJsEngine, type SqlJsEngineOptions } from "./sqljsEngine.js";
2
+
3
+ let engine: SqlJsEngine | null = null;
4
+
5
+ export async function initStorage(opts?: SqlJsEngineOptions): Promise<SqlJsEngine> {
6
+ if (engine) return engine;
7
+ engine = new SqlJsEngine(opts);
8
+ await engine.init();
9
+ return engine;
10
+ }
11
+
12
+ export function getStorage(): SqlJsEngine {
13
+ if (!engine) {
14
+ throw new Error("Storage engine not initialized");
15
+ }
16
+ return engine;
17
+ }
@@ -0,0 +1,14 @@
1
+ export type TxFn<T> = () => T;
2
+
3
+ export interface StorageEngine {
4
+ init(): Promise<void>;
5
+
6
+ transaction<T>(fn: TxFn<T>): T;
7
+
8
+ exec(sql: string, params?: unknown[]): void;
9
+ queryAll<T = unknown>(sql: string, params?: unknown[]): T[];
10
+ queryOne<T = unknown>(sql: string, params?: unknown[]): T | null;
11
+
12
+ flushIfDirty(): Promise<void>;
13
+ flushNow(): Promise<void>;
14
+ }
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export type Migration = {
6
+ version: number;
7
+ sql: string;
8
+ optional?: boolean;
9
+ };
10
+
11
+ function readMigrationFile(fileName: string): string {
12
+ const baseDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../migrations");
13
+ return fs.readFileSync(path.join(baseDir, fileName), "utf8");
14
+ }
15
+
16
+ export const defaultMigrations: Migration[] = [
17
+ { version: 1, sql: readMigrationFile("001_init.sql") },
18
+ { version: 2, sql: readMigrationFile("002_fts.sql"), optional: true },
19
+ ];