context-vault 2.0.1

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.
@@ -0,0 +1,44 @@
1
+ # Server (Coordinator)
2
+
3
+ The MCP server entry point. This is the only place where layers cross. It wires together Capture, Index, and Retrieve into MCP tool handlers.
4
+
5
+ ## Coordinator Pattern
6
+
7
+ Each tool handler orchestrates layers sequentially:
8
+
9
+ ```
10
+ save_context → writeEntry(ctx, data) [Capture Layer]
11
+ → indexEntry(ctx, entry) [Index Layer]
12
+
13
+ get_context → hybridSearch(ctx, query) [Retrieve Layer]
14
+ ```
15
+
16
+ No layer calls another layer directly — the server coordinates all cross-layer operations.
17
+
18
+ ## Auto-Reindex
19
+
20
+ On the first tool call per session, the server runs a full reindex to ensure the search index matches the files on disk. This is transparent to the agent — no manual reindex tool needed.
21
+
22
+ ## Context Object (`ctx`)
23
+
24
+ Bundles shared state passed to all layers:
25
+
26
+ ```js
27
+ { db, config, stmts, embed, insertVec, deleteVec }
28
+ ```
29
+
30
+ ## MCP Tools
31
+
32
+ | Tool | Layers Used | Description |
33
+ |------|-------------|-------------|
34
+ | `get_context` | Retrieve | Hybrid FTS5 + vector search |
35
+ | `save_context` | Capture → Index | Write-through knowledge capture |
36
+ | `context_status` | Core (status) | Diagnostics |
37
+
38
+ ## Dependency Rule
39
+
40
+ ```
41
+ server/ → core/, capture/, index/, retrieve/ (all layers)
42
+ ```
43
+
44
+ This is the only module allowed to import across layer boundaries.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * helpers.js — Shared MCP response helpers and validation
3
+ */
4
+
5
+ // ─── MCP Response Helpers ────────────────────────────────────────────────────
6
+
7
+ export function ok(text) {
8
+ return { content: [{ type: "text", text }] };
9
+ }
10
+
11
+ export function err(text, code = "UNKNOWN") {
12
+ return { content: [{ type: "text", text }], isError: true, code };
13
+ }
14
+
15
+ // ─── Validation Helpers ──────────────────────────────────────────────────────
16
+
17
+ export function ensureVaultExists(config) {
18
+ if (!config.vaultDirExists) {
19
+ return err(`Vault directory not found: ${config.vaultDir}. Run context_status for diagnostics.`, "VAULT_NOT_FOUND");
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function ensureValidKind(kind) {
25
+ if (!/^[a-z][a-z0-9_-]*$/.test(kind)) {
26
+ return err("Required: kind (lowercase alphanumeric, e.g. 'insight', 'reference')", "INVALID_KIND");
27
+ }
28
+ return null;
29
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
11
+
12
+ import { resolveConfig } from "../core/config.js";
13
+ import { embed } from "../index/embed.js";
14
+ import { initDatabase, prepareStatements, insertVec, deleteVec } from "../index/db.js";
15
+ import { registerTools } from "./tools.js";
16
+
17
+ // ─── Config Resolution ──────────────────────────────────────────────────────
18
+
19
+ const config = resolveConfig();
20
+
21
+ // Create directories
22
+ mkdirSync(config.dataDir, { recursive: true });
23
+ mkdirSync(config.vaultDir, { recursive: true });
24
+
25
+ // Write .context-mcp marker (always update to reflect current version)
26
+ const markerPath = join(config.vaultDir, ".context-mcp");
27
+ const markerData = existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, "utf-8")) : {};
28
+ writeFileSync(markerPath, JSON.stringify({ created: markerData.created || new Date().toISOString(), version: pkg.version }, null, 2) + "\n");
29
+
30
+ // Update existence flag after directory creation
31
+ config.vaultDirExists = existsSync(config.vaultDir);
32
+
33
+ // Startup diagnostics
34
+ console.error(`[context-mcp] Vault: ${config.vaultDir}`);
35
+ console.error(`[context-mcp] Database: ${config.dbPath}`);
36
+ console.error(`[context-mcp] Dev dir: ${config.devDir}`);
37
+ if (!config.vaultDirExists) {
38
+ console.error(`[context-mcp] WARNING: Vault directory not found!`);
39
+ }
40
+
41
+ // ─── Database Init ───────────────────────────────────────────────────────────
42
+
43
+ let db, stmts;
44
+ try {
45
+ db = initDatabase(config.dbPath);
46
+ stmts = prepareStatements(db);
47
+ } catch (e) {
48
+ console.error(`[context-mcp] Database init failed: ${e.message}`);
49
+ console.error(`[context-mcp] DB path: ${config.dbPath}`);
50
+ console.error(`[context-mcp] Try deleting the DB file and restarting: rm "${config.dbPath}"`);
51
+ process.exit(1);
52
+ }
53
+
54
+ const ctx = {
55
+ db,
56
+ config,
57
+ stmts,
58
+ embed,
59
+ insertVec: (rowid, embedding) => insertVec(stmts, rowid, embedding),
60
+ deleteVec: (rowid) => deleteVec(stmts, rowid),
61
+ };
62
+
63
+ // ─── MCP Server ──────────────────────────────────────────────────────────────
64
+
65
+ const server = new McpServer(
66
+ { name: "context-mcp", version: pkg.version },
67
+ { capabilities: { tools: {} } }
68
+ );
69
+
70
+ registerTools(server, ctx);
71
+
72
+ // ─── Graceful Shutdown ───────────────────────────────────────────────────────
73
+
74
+ function shutdown() {
75
+ try { db.close(); } catch {}
76
+ process.exit(0);
77
+ }
78
+ process.on("SIGINT", shutdown);
79
+ process.on("SIGTERM", shutdown);
80
+
81
+ const transport = new StdioServerTransport();
82
+ await server.connect(transport);
@@ -0,0 +1,211 @@
1
+ /**
2
+ * tools.js — MCP tool registrations
3
+ *
4
+ * Three tools: save_context (write), get_context (read), context_status (diag).
5
+ * Auto-reindex runs transparently on first tool call per session.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import { existsSync } from "node:fs";
10
+
11
+ import { captureAndIndex } from "../capture/index.js";
12
+ import { hybridSearch } from "../retrieve/index.js";
13
+ import { reindex, indexEntry } from "../index/index.js";
14
+ import { gatherVaultStatus } from "../core/status.js";
15
+ import { categoryFor } from "../core/categories.js";
16
+ import { normalizeKind } from "../core/files.js";
17
+ import { ok, err, ensureVaultExists, ensureValidKind } from "./helpers.js";
18
+
19
+ /**
20
+ * Register all MCP tools on the server.
21
+ *
22
+ * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
23
+ * @param {{ db, config, stmts, embed, insertVec, deleteVec }} ctx
24
+ */
25
+ export function registerTools(server, ctx) {
26
+ const { config } = ctx;
27
+
28
+ // ─── Auto-Reindex (runs once per session, on first tool call) ──────────────
29
+
30
+ let reindexDone = false;
31
+ let reindexPromise = null;
32
+ let reindexAttempts = 0;
33
+ let reindexFailed = false;
34
+ const MAX_REINDEX_ATTEMPTS = 2;
35
+
36
+ async function ensureIndexed() {
37
+ if (reindexDone) return;
38
+ if (reindexPromise) return reindexPromise;
39
+ reindexPromise = reindex(ctx, { fullSync: true })
40
+ .then((stats) => {
41
+ reindexDone = true;
42
+ const total = stats.added + stats.updated + stats.removed;
43
+ if (total > 0) {
44
+ console.error(`[context-mcp] Auto-reindex: +${stats.added} ~${stats.updated} -${stats.removed} (${stats.unchanged} unchanged)`);
45
+ }
46
+ })
47
+ .catch((e) => {
48
+ reindexAttempts++;
49
+ console.error(`[context-mcp] Auto-reindex failed (attempt ${reindexAttempts}/${MAX_REINDEX_ATTEMPTS}): ${e.message}`);
50
+ if (reindexAttempts >= MAX_REINDEX_ATTEMPTS) {
51
+ console.error(`[context-mcp] Giving up on auto-reindex. Run \`context-mcp reindex\` manually to diagnose.`);
52
+ reindexDone = true;
53
+ reindexFailed = true;
54
+ } else {
55
+ reindexPromise = null; // Allow retry on next tool call
56
+ }
57
+ });
58
+ return reindexPromise;
59
+ }
60
+
61
+ // ─── get_context (read) ────────────────────────────────────────────────────
62
+
63
+ server.tool(
64
+ "get_context",
65
+ "Search your knowledge vault. Returns entries ranked by relevance using hybrid full-text + semantic search. Use this to find insights, decisions, patterns, or any saved context.",
66
+ {
67
+ query: z.string().describe("Search query (natural language or keywords)"),
68
+ kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
69
+ category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
70
+ tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
71
+ since: z.string().optional().describe("ISO date, return entries created after this"),
72
+ until: z.string().optional().describe("ISO date, return entries created before this"),
73
+ limit: z.number().optional().describe("Max results to return (default 10)"),
74
+ },
75
+ async ({ query, kind, category, tags, since, until, limit }) => {
76
+ if (!query?.trim()) return err("Required: query (non-empty string)", "INVALID_INPUT");
77
+ await ensureIndexed();
78
+
79
+ const kindFilter = kind ? normalizeKind(kind) : null;
80
+ const sorted = await hybridSearch(ctx, query, {
81
+ kindFilter,
82
+ categoryFilter: category || null,
83
+ since: since || null,
84
+ until: until || null,
85
+ limit: limit || 10,
86
+ });
87
+
88
+ // Post-filter by tags if provided
89
+ const filtered = tags?.length
90
+ ? sorted.filter((r) => {
91
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
92
+ return tags.some((t) => entryTags.includes(t));
93
+ })
94
+ : sorted;
95
+
96
+ if (!filtered.length) return ok("No results found for: " + query);
97
+
98
+ const lines = [];
99
+ if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-mcp reindex\` to fix.\n`);
100
+ lines.push(`## Results for "${query}" (${filtered.length} matches)\n`);
101
+ for (const r of filtered) {
102
+ const meta = r.meta ? JSON.parse(r.meta) : {};
103
+ lines.push(`### ${r.title || "(untitled)"} [${r.kind}/${r.category}]`);
104
+ lines.push(`Score: ${r.score.toFixed(3)} | Tags: ${r.tags || "none"} | File: ${r.file_path || "n/a"}`);
105
+ lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
106
+ lines.push("");
107
+ }
108
+ return ok(lines.join("\n"));
109
+ }
110
+ );
111
+
112
+ // ─── save_context (write) ──────────────────────────────────────────────────
113
+
114
+ server.tool(
115
+ "save_context",
116
+ "Save knowledge to your vault. Creates a .md file and indexes it for search. Use for any kind of context: insights, decisions, patterns, references, or any custom kind.",
117
+ {
118
+ kind: z.string().describe("Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind)"),
119
+ title: z.string().optional().describe("Entry title (optional for insights)"),
120
+ body: z.string().describe("Main content"),
121
+ tags: z.array(z.string()).optional().describe("Tags for categorization and search"),
122
+ meta: z.any().optional().describe("Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })"),
123
+ folder: z.string().optional().describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
124
+ source: z.string().optional().describe("Where this knowledge came from"),
125
+ identity_key: z.string().optional().describe("Required for entity kinds (contact, project, tool, source). The unique identifier for this entity."),
126
+ expires_at: z.string().optional().describe("ISO date for TTL expiry"),
127
+ },
128
+ async ({ kind, title, body, tags, meta, folder, source, identity_key, expires_at }) => {
129
+ const vaultErr = ensureVaultExists(config);
130
+ if (vaultErr) return vaultErr;
131
+ const kindErr = ensureValidKind(kind);
132
+ if (kindErr) return kindErr;
133
+ if (!body?.trim()) return err("Required: body (non-empty string)", "INVALID_INPUT");
134
+
135
+ // Validate: entity kinds require identity_key
136
+ if (categoryFor(kind) === "entity" && !identity_key) {
137
+ return err(`Entity kind "${kind}" requires identity_key`, "MISSING_IDENTITY_KEY");
138
+ }
139
+
140
+ await ensureIndexed();
141
+
142
+ const mergedMeta = { ...(meta || {}) };
143
+ if (folder) mergedMeta.folder = folder;
144
+ const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
145
+
146
+ const entry = await captureAndIndex(ctx, { kind, title, body, meta: finalMeta, tags, source, folder, identity_key, expires_at }, indexEntry);
147
+ return ok(`Saved ${kind} ${entry.id}\nFile: ${entry.filePath}${title ? "\nTitle: " + title : ""}`);
148
+ }
149
+ );
150
+
151
+ // ─── context_status (diagnostics) ──────────────────────────────────────────
152
+
153
+ server.tool(
154
+ "context_status",
155
+ "Show vault health: resolved config, file counts per kind, database size, and any issues. Use to verify setup or troubleshoot.",
156
+ {},
157
+ () => {
158
+ const status = gatherVaultStatus(ctx);
159
+
160
+ const lines = [
161
+ `## Vault Status`,
162
+ ``,
163
+ `Vault: ${config.vaultDir} (exists: ${config.vaultDirExists}, ${status.fileCount} files)`,
164
+ `Database: ${config.dbPath} (exists: ${existsSync(config.dbPath)}, ${status.dbSize})`,
165
+ `Dev dir: ${config.devDir}`,
166
+ `Data dir: ${config.dataDir}`,
167
+ `Config: ${config.configPath}`,
168
+ `Resolved via: ${status.resolvedFrom}`,
169
+ `Schema: v5 (categories)`,
170
+ ``,
171
+ `### Indexed`,
172
+ ];
173
+
174
+ if (status.kindCounts.length) {
175
+ for (const { kind, c } of status.kindCounts) lines.push(`- ${c} ${kind}s`);
176
+ } else {
177
+ lines.push(`- (empty)`);
178
+ }
179
+
180
+ if (status.categoryCounts.length) {
181
+ lines.push(``);
182
+ lines.push(`### Categories`);
183
+ for (const { category, c } of status.categoryCounts) lines.push(`- ${category}: ${c}`);
184
+ }
185
+
186
+ if (status.subdirs.length) {
187
+ lines.push(``);
188
+ lines.push(`### Disk Directories`);
189
+ for (const { name, count } of status.subdirs) lines.push(`- ${name}/: ${count} files`);
190
+ }
191
+
192
+ if (status.stalePaths) {
193
+ lines.push(``);
194
+ lines.push(`### Stale Paths Detected`);
195
+ lines.push(`DB contains ${status.staleCount} paths not matching current vault dir.`);
196
+ lines.push(`Auto-reindex will fix this on next search or save.`);
197
+ }
198
+
199
+ if (status.embeddingStatus) {
200
+ const { indexed, total, missing } = status.embeddingStatus;
201
+ if (missing > 0) {
202
+ lines.push(``);
203
+ lines.push(`### Embeddings`);
204
+ lines.push(`${indexed}/${total} entries have embeddings (${missing} missing)`);
205
+ }
206
+ }
207
+
208
+ return ok(lines.join("\n"));
209
+ }
210
+ );
211
+ }
@@ -0,0 +1,36 @@
1
+ -- Context MCP UI Launcher
2
+ -- Starts the server if not running, then opens the dashboard
3
+
4
+ on run
5
+ -- Dynamic node path
6
+ set nodePath to do shell script "which node"
7
+
8
+ -- Dynamic serve.js path (relative to this script's bundle)
9
+ set scriptDir to do shell script "dirname " & quoted form of (POSIX path of (path to me))
10
+ set serverScript to scriptDir & "/serve.js"
11
+ set serverPort to "3141"
12
+ set serverURL to "http://localhost:" & serverPort
13
+
14
+ -- Check if server is already running on port
15
+ set isRunning to false
16
+ try
17
+ do shell script "lsof -ti:" & serverPort
18
+ set isRunning to true
19
+ end try
20
+
21
+ -- Start server via nohup so it survives after this app exits
22
+ if not isRunning then
23
+ do shell script "nohup " & quoted form of nodePath & " " & quoted form of serverScript & " > /tmp/context-mcp.log 2>&1 &"
24
+ -- Wait for server to be ready
25
+ repeat 10 times
26
+ delay 0.5
27
+ try
28
+ do shell script "curl -s -o /dev/null -w '%{http_code}' " & serverURL & "/api/discover"
29
+ exit repeat
30
+ end try
31
+ end repeat
32
+ end if
33
+
34
+ -- Open in default browser
35
+ open location serverURL
36
+ end run