@jmtrin/kevin-mcp 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jmtrin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,240 @@
1
+ <div align="center">
2
+
3
+ # ⚡ Kevin MCP — Bridge
4
+
5
+ ```
6
+ ╔══════════════════════════════════════════════╗
7
+ ║ @jmtrin/kevin-mcp 1.4.0 ║
8
+ ║ MCP Bridge for Kevin ║
9
+ ║ stdio · zero network · WAL ║
10
+ ╚══════════════════════════════════════════════╝
11
+ ```
12
+
13
+ ### Same SQLite, every harness — Kevin via Model Context Protocol
14
+
15
+ **Kevin MCP exposes the local `kevin.db` to Claude Code, Codex, Cursor, Windsurf, Gemini CLI and Opencode through one stdio server.**
16
+
17
+ ![version](https://img.shields.io/badge/version-1.4.0-blue)
18
+ ![node](https://img.shields.io/badge/node-%E2%89%A522.5-green)
19
+ ![mcp](https://img.shields.io/badge/MCP-1.30.0-purple)
20
+ ![transport](https://img.shields.io/badge/transport-stdio-black)
21
+ ![license](https://img.shields.io/badge/license-MIT-lightgrey)
22
+
23
+ </div>
24
+
25
+ > **No new database. No HTTP. One WAL file, two processes.**
26
+
27
+ ---
28
+
29
+ ## 📖 Contents
30
+
31
+ - [Why a Bridge](#-why-a-bridge)
32
+ - [Quick start](#-quick-start)
33
+ - [Tools (11)](#-tools-11)
34
+ - [Reads vs writes — gates](#-reads-vs-writes--gates)
35
+ - [Identity & repo scope](#-identity--repo-scope)
36
+ - [Provenance](#-provenance)
37
+ - [Harnesses](#-harnesses)
38
+ - [Architecture](#-architecture)
39
+ - [Configuration](#%EF%B8%8F-configuration)
40
+ - [Latency budgets](#-latency-budgets)
41
+ - [Development](#-development)
42
+ - [License](#-license)
43
+
44
+ ---
45
+
46
+ ## 🤔 Why a Bridge
47
+
48
+ OpenCode plugin memory is excellent inside OpenCode. The bridge makes that same ranked recall available to **any MCP host** without a second sync, a server, or a cloud hop.
49
+
50
+ | Before | After |
51
+ |---|---|
52
+ | `kevin.db` only reachable from plugin | `kevin.db` shared via WAL + `busy_timeout=5000` |
53
+ | one host | 6 harnesses (Claude Code / Codex / Cursor / Windsurf / Gemini CLI / Opencode) |
54
+
55
+ ---
56
+
57
+ ## 🚀 Quick start
58
+
59
+ ```bash
60
+ npx -y @jmtrin/kevin-mcp # ro by default, stderr ready line
61
+ npx -y @jmtrin/kevin-mcp --repo 2114ad162af50a25 # pinned repo_id
62
+ npx -y @jmtrin/kevin-mcp --help
63
+ ```
64
+
65
+ ### Claude Code (`~/.claude.json`)
66
+
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "kevin-mcp": { "command": "npx", "args": ["-y", "@jmtrin/kevin-mcp"] }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ### Codex / Cursor / Windsurf / Gemini CLI
76
+
77
+ See `docs/harnesses/{codex,cursor,windsurf,gemini-cli,opencode}.md` for exact JSON/TOML snippets, verification (`npx @jmtrin/kevin-mcp --version`), troubleshooting and uninstall.
78
+
79
+ ### How to verify inside a host
80
+
81
+ ```
82
+ what does Kevin remember about <topic>?
83
+ → recall returns results + provenance {repo_id, identity_source, channel:"mcp"}
84
+ ```
85
+
86
+ ---
87
+
88
+ ## 🧰 Tools (11)
89
+
90
+ <details open>
91
+ <summary><b>Always-on reads (7) + ping</b></summary>
92
+
93
+ | Tool | What it does | Ledger |
94
+ |---|---|---|
95
+ | `ping` | Liveness probe | — |
96
+ | `query` | Slim search | — |
97
+ | `get` | Fetch one memory | — |
98
+ | `recall` | Ranked recall (BM25 × origin × recency → token budget) | `channel='mcp'` injection row, tokens `ceil(chars/4)` |
99
+ | `why` | Failure→fix trace via `kevinWhy` | — |
100
+ | `status` | `{repo_id, resolved_repo_id, requested_repo_id, gates, counters{requests,reads,writes_accepted/refused}, perf}` | — |
101
+ | `trace` | Dry-run injection preview, zero side effects | — |
102
+ | `feedback` | Rate a memory `useful/wrong/outdated/ignore` | — |
103
+
104
+ Every read returns `provenance: {repo_id, identity_source, channel:"mcp"}` plus confidence/evidence when the underlying row carries them.
105
+
106
+ </details>
107
+
108
+ <details>
109
+ <summary><b>Gated writes (3) — disabled by default</b></summary>
110
+
111
+ | Tool | Gate | On refuse |
112
+ |---|---|---|
113
+ | `save` | `mcp_write_enabled==='1'` | `{error:"disabled", hint:"set mcp_write_enabled=1"}` + `mcp_writes_refused++` |
114
+ | `approve` | `mcp_write_enabled==='1'` **and** `mcp_approve_enabled==='1'` | same (`disabled`) |
115
+ | `share` | double-gate as `approve` (and existing `share_requires_approval` chain) | `disabled` → `missing_approval_chain` → `executed` |
116
+
117
+ On success `mcp_writes_accepted++`. All writes are repo-scoped and funnel through core's single write path.
118
+
119
+ </details>
120
+
121
+ ---
122
+
123
+ ## 🔐 Identity & repo scope
124
+
125
+ Repository identity resolves **once** at server init, in order:
126
+
127
+ `--repo <id>` (`KEVIN_REPO`) → `kevin_settings.mcp_repo_override` (`setting:mcp_repo_override`) → `RepoIdentity.resolve` (declared → remote hash → path).
128
+
129
+ Every tool carries an optional `repo_id` arg; mismatch returns:
130
+
131
+ ```json
132
+ { "error": "repo_mismatch", "expected": "<server repo_id>", "got": "<caller repo_id>" }
133
+ ```
134
+
135
+ Declared ids must be 16-char lowercase hex (`/^[0-9a-f]{16}$/`).
136
+
137
+ ---
138
+
139
+ ## 🧾 Provenance
140
+
141
+ ```json
142
+ {
143
+ "provenance": {
144
+ "repo_id": "2114ad162af50a25",
145
+ "identity_source": "setting:mcp_repo_override",
146
+ "channel": "mcp"
147
+ }
148
+ }
149
+ ```
150
+
151
+ When the memory row has confidence/evidence, those fields surface as `confidence`, `evidence_count`, `last_verified_at`.
152
+
153
+ ---
154
+
155
+ ## 🔌 Harnesses
156
+
157
+ | Harness | Recipe | Tested-on |
158
+ |---|---|---|
159
+ | Claude Code | `docs/harnesses/claude-code.md` | claude-code v1.0.0 |
160
+ | Codex | `docs/harnesses/codex.md` | codex v0.5.0 |
161
+ | Cursor | `docs/harnesses/cursor.md` | cursor v1.2.0 |
162
+ | Windsurf | `docs/harnesses/windsurf.md` | windsurf v1.0 |
163
+ | Gemini CLI | `docs/harnesses/gemini-cli.md` | gemini-cli v0.4.0 |
164
+ | Opencode | `docs/harnesses/opencode.md` | opencode v1.18.0 |
165
+
166
+ Each recipe is a tested JSON/TOML block (parsed by `tests/unit/docs_config_lint.test.ts`), verification command, troubleshooting and uninstall note. Demo: `docs/demo-cross-harness.md` (10-min `▶` script: opencode `save` → Claude Code `recall`).
167
+
168
+ ---
169
+
170
+ ## ⚙️ Architecture
171
+
172
+ ```
173
+ plugin (opencode) ─┐
174
+ ├─► kevin.db (WAL, busy_timeout=5000) ◄─ kevin-mcp (stdio, SDK 1.30.0)
175
+ MCP host (any) ────┘ │ ▲ │
176
+ │ └─ kevin_audit mcp block ─┘
177
+ └── perf_samples (mcp.read/write)
178
+ ```
179
+
180
+ - **Zero network** — forbidden list enforced: `node:http/https/net/dgram, fetch, XMLHttpRequest, SSETransport, HttpTransport, child_process/spawn`; only `node:fs/path/os/sqlite`.
181
+ - **Logs on stderr only** — `stderr ready repo=... mode=ro|rw db=...`; stdout is MCP JSON only.
182
+ - **Lifecycle** — `resolveEnv → Store(busy_timeout) → Migrate(013) → Metrics+Perf → registry`; SIGINT/SIGTERM flush metrics + perf (every 100 req or signal).
183
+
184
+ Package layout:
185
+
186
+ ```
187
+ src/server.ts lifecycle, registry, ready line, signals
188
+ src/identity.ts resolution + mismatch guard
189
+ src/tools/read.ts 7 reads + ping + provenance helper
190
+ src/tools/write.ts 3 gated writes
191
+ src/provenance.ts block builder
192
+ src/perf-mcp.ts mcp.read/write budgets wrapper
193
+ ```
194
+
195
+ ---
196
+
197
+ ## ⚙️ Configuration
198
+
199
+ Text settings via `kevin_config` (compare `=== "1"`):
200
+
201
+ | Key | Default | Purpose |
202
+ |---|---|---|
203
+ | `mcp_write_enabled` | `'0'` | Gate for MCP `save` |
204
+ | `mcp_approve_enabled` | `'0'` | Double-gate for MCP `approve`/`share` |
205
+ | `mcp_repo_override` | `''` | Override RepoIdentity (hex-16) |
206
+
207
+ Plus the 32 core/plugin keys (`C-04` since `1.4.0` → golden 35 settings). `kevin_audit` block `mcp` appears only on schema `013`+; pre-013 omitted (`partial:true`). Channel split counters in `kevin_metrics` + `kevin_injections.channel`.
208
+
209
+ ---
210
+
211
+ ## ⏱️ Latency budgets
212
+
213
+ | Scope | p95 | max |
214
+ |---|---|---|
215
+ | `mcp.read` | 25 ms | 100 ms |
216
+ | `mcp.write` | 50 ms | 250 ms |
217
+
218
+ Measured on reference laptop: recall p50 ≈ 0.2ms. Persists to `perf_samples` every 100 requests or SIGINT; `bench:check` enforces.
219
+
220
+ ---
221
+
222
+ ## 🛠️ Development
223
+
224
+ ```bash
225
+ npm install -w @jmtrin/kevin-mcp
226
+ npm run build -w @jmtrin/kevin-mcp
227
+ npm run typecheck -w @jmtrin/kevin-mcp
228
+ npx vitest run packages/mcp/tests/purity_scan.test.ts
229
+ npm pack --dry-run -w @jmtrin/kevin-mcp
230
+ # boot smoke (no stdout, stderr ready):
231
+ node packages/mcp/dist/server.js --help
232
+ ```
233
+
234
+ Monorepo publish order: `core → tui → plugin → mcp` (exact `1.4.0` pin), see `docs/DISTRIBUTION.md`.
235
+
236
+ ---
237
+
238
+ ## 📄 License
239
+
240
+ MIT — see `LICENSE`. Kevin is built by [jmtrin](https://github.com/jmtrin); bug reports and PRs welcome at the [issue tracker](https://github.com/jmtrin/opencode-kevin/issues).
@@ -0,0 +1,46 @@
1
+ import type { Store } from "@jmtrin/kevin-core";
2
+ import type { KevinEnv } from "@jmtrin/kevin-core";
3
+ import type { HostSurface } from "@jmtrin/kevin-core";
4
+ export type IdentitySourceMcp = "declared" | "override" | "remote" | "host" | "path";
5
+ export interface McpIdentity {
6
+ repoId: string;
7
+ source: IdentitySourceMcp;
8
+ evidence: string;
9
+ resolved: string;
10
+ requested?: string;
11
+ }
12
+ export declare function validateDeclaredId(id: string): boolean;
13
+ /**
14
+ * Resolve MCP effective identity per plan §4.1 (D14-02):
15
+ * 1. CLI/env KEVIN_REPO (declared hex-16) — validated, else throws at boot
16
+ * 2. setting mcp_repo_override (non-empty)
17
+ * 3. RepoIdentity.resolve(projectRoot)
18
+ *
19
+ * The `kevinRepo` param is the raw KEVIN_REPO env/CLI value (if any).
20
+ * `settingOverride` is the DB value of `mcp_repo_override` (if any).
21
+ */
22
+ export declare function resolveMcpIdentity(opts: {
23
+ kevinRepo?: string | null;
24
+ settingOverride?: string | null;
25
+ projectRoot: string;
26
+ host?: HostSurface;
27
+ }): McpIdentity;
28
+ /**
29
+ * Load setting mcp_repo_override from store (lazy: absent => null).
30
+ */
31
+ export declare function readSettingOverride(store: Store): string | null;
32
+ /**
33
+ * Guard for repo-scoped tools per plan §4.1:
34
+ * When caller passes an explicit repo scope that ≠ effective id → refuse.
35
+ * Only `status` bypasses (reports both). Reads are refused too.
36
+ */
37
+ export declare function assertScope(effectiveId: string, requestedRepoId?: string | null): {
38
+ error: "repo_mismatch";
39
+ requested: string;
40
+ resolved: string;
41
+ } | null;
42
+ /**
43
+ * Convenience: resolve from live store + env.
44
+ * Reads KEVIN_REPO from process.env and setting from store.
45
+ */
46
+ export declare function resolveMcpIdentityLive(store: Store, env: KevinEnv, host?: HostSurface): McpIdentity;
@@ -0,0 +1,86 @@
1
+ import { resolve as resolveRepoIdentity } from "@jmtrin/kevin-core";
2
+ const DECLARED_ID_RE = /^[0-9a-f]{16}$/;
3
+ export function validateDeclaredId(id) {
4
+ return DECLARED_ID_RE.test(id);
5
+ }
6
+ /**
7
+ * Resolve MCP effective identity per plan §4.1 (D14-02):
8
+ * 1. CLI/env KEVIN_REPO (declared hex-16) — validated, else throws at boot
9
+ * 2. setting mcp_repo_override (non-empty)
10
+ * 3. RepoIdentity.resolve(projectRoot)
11
+ *
12
+ * The `kevinRepo` param is the raw KEVIN_REPO env/CLI value (if any).
13
+ * `settingOverride` is the DB value of `mcp_repo_override` (if any).
14
+ */
15
+ export function resolveMcpIdentity(opts) {
16
+ const resolvedBase = resolveRepoIdentity(opts.projectRoot, opts.host);
17
+ const resolved = resolvedBase.repoId;
18
+ // 1. KEVIN_REPO env/CLI declared
19
+ if (opts.kevinRepo !== undefined && opts.kevinRepo !== null && opts.kevinRepo !== "") {
20
+ const v = opts.kevinRepo.trim();
21
+ if (!validateDeclaredId(v)) {
22
+ throw new Error(`KEVIN_REPO must be 16 lowercase hex characters (got "${v}")`);
23
+ }
24
+ return {
25
+ repoId: v,
26
+ source: "declared",
27
+ evidence: "env:KEVIN_REPO",
28
+ resolved,
29
+ requested: v,
30
+ };
31
+ }
32
+ // 2. setting override non-empty
33
+ if (opts.settingOverride !== undefined && opts.settingOverride !== null && opts.settingOverride.trim() !== "") {
34
+ const v = opts.settingOverride.trim();
35
+ if (!validateDeclaredId(v)) {
36
+ throw new Error(`mcp_repo_override must be 16 lowercase hex characters (got "${v}")`);
37
+ }
38
+ return {
39
+ repoId: v,
40
+ source: "override",
41
+ evidence: "setting:mcp_repo_override",
42
+ resolved,
43
+ requested: v,
44
+ };
45
+ }
46
+ // 3. RepoIdentity
47
+ return {
48
+ repoId: resolved,
49
+ source: resolvedBase.source,
50
+ evidence: resolvedBase.evidence,
51
+ resolved,
52
+ };
53
+ }
54
+ /**
55
+ * Load setting mcp_repo_override from store (lazy: absent => null).
56
+ */
57
+ export function readSettingOverride(store) {
58
+ try {
59
+ const row = store.prepare("SELECT value FROM kevin_settings WHERE key = ?").get("mcp_repo_override");
60
+ return row?.value ?? null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Guard for repo-scoped tools per plan §4.1:
68
+ * When caller passes an explicit repo scope that ≠ effective id → refuse.
69
+ * Only `status` bypasses (reports both). Reads are refused too.
70
+ */
71
+ export function assertScope(effectiveId, requestedRepoId) {
72
+ if (requestedRepoId === undefined || requestedRepoId === null || requestedRepoId === "")
73
+ return null;
74
+ if (requestedRepoId === effectiveId)
75
+ return null;
76
+ return { error: "repo_mismatch", requested: requestedRepoId, resolved: effectiveId };
77
+ }
78
+ /**
79
+ * Convenience: resolve from live store + env.
80
+ * Reads KEVIN_REPO from process.env and setting from store.
81
+ */
82
+ export function resolveMcpIdentityLive(store, env, host) {
83
+ const kevinRepo = process.env.KEVIN_REPO ?? null;
84
+ const settingOverride = readSettingOverride(store);
85
+ return resolveMcpIdentity({ kevinRepo, settingOverride, projectRoot: env.projectRoot, host });
86
+ }
@@ -0,0 +1,3 @@
1
+ import { BUDGETS } from "@jmtrin/kevin-core";
2
+ export declare const MCP_BUDGETS: import("@jmtrin/kevin-core").Budget[];
3
+ export { BUDGETS };
@@ -0,0 +1,4 @@
1
+ // K14-015 perf scopes — reuse core Perf ring
2
+ import { BUDGETS } from "@jmtrin/kevin-core";
3
+ export const MCP_BUDGETS = BUDGETS.filter((b) => b.scope.startsWith("mcp."));
4
+ export { BUDGETS };
@@ -0,0 +1,9 @@
1
+ import type { McpIdentity } from "./identity.js";
2
+ export interface Provenance {
3
+ repo_id: string;
4
+ identity_source: string;
5
+ channel: "mcp";
6
+ confidence?: number;
7
+ evidence_count?: number;
8
+ }
9
+ export declare function buildProvenance(identity: McpIdentity, extra?: Partial<Provenance>): Provenance;
@@ -0,0 +1,8 @@
1
+ export function buildProvenance(identity, extra) {
2
+ return {
3
+ repo_id: identity.repoId,
4
+ identity_source: identity.source,
5
+ channel: "mcp",
6
+ ...extra,
7
+ };
8
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { parseArgs } from "node:util";
5
+ import { mkdirSync } from "node:fs";
6
+ import { basename, join } from "node:path";
7
+ import { Store } from "@jmtrin/kevin-core";
8
+ import { Migrate, exportMigrationsDir } from "@jmtrin/kevin-core";
9
+ import { Metrics } from "@jmtrin/kevin-core";
10
+ import { Perf } from "@jmtrin/kevin-core";
11
+ import { resolveEnv } from "@jmtrin/kevin-core";
12
+ import { resolveMcpIdentity, readSettingOverride } from "./identity.js";
13
+ import { createReadTools } from "./tools/read.js";
14
+ import { createWriteTools } from "./tools/write.js";
15
+ const KEVIN_VERSION = "1.4.0";
16
+ const { values } = parseArgs({
17
+ options: {
18
+ help: { type: "boolean", default: false },
19
+ version: { type: "boolean", default: false },
20
+ repo: { type: "string" },
21
+ },
22
+ allowPositionals: true,
23
+ });
24
+ if (values.help) {
25
+ console.error("Usage: kevin-mcp [--help] [--version] [--repo <hex16>]");
26
+ process.exit(0);
27
+ }
28
+ if (values.version) {
29
+ console.error(`kevin-mcp ${KEVIN_VERSION}`);
30
+ process.exit(0);
31
+ }
32
+ const env = resolveEnv();
33
+ mkdirSync(env.dataRoot, { recursive: true });
34
+ const dbPath = join(env.dataRoot, "kevin.db");
35
+ const store = new Store({ path: dbPath });
36
+ const migrate = new Migrate(store, exportMigrationsDir());
37
+ await migrate.run();
38
+ // Load settings (lazy: absent => default)
39
+ function getSetting(key, fallback) {
40
+ try {
41
+ const row = store.prepare("SELECT value FROM kevin_settings WHERE key = ?").get(key);
42
+ return row?.value ?? fallback;
43
+ }
44
+ catch {
45
+ return fallback;
46
+ }
47
+ }
48
+ const settings = {
49
+ mcp_write_enabled: getSetting("mcp_write_enabled", "0"),
50
+ mcp_approve_enabled: getSetting("mcp_approve_enabled", "0"),
51
+ mcp_repo_override: getSetting("mcp_repo_override", ""),
52
+ perf_enabled: getSetting("perf_enabled", "1"),
53
+ perf_ring_capacity: getSetting("perf_ring_capacity", "512"),
54
+ };
55
+ const kevinRepo = values.repo ?? process.env.KEVIN_REPO ?? null;
56
+ const settingOverride = settings.mcp_repo_override || readSettingOverride(store) || null;
57
+ let identity;
58
+ try {
59
+ identity = resolveMcpIdentity({
60
+ kevinRepo,
61
+ settingOverride,
62
+ projectRoot: env.projectRoot,
63
+ });
64
+ }
65
+ catch (e) {
66
+ console.error(`kevin-mcp identity error: ${e.message}`);
67
+ process.exit(1);
68
+ }
69
+ const mode = settings.mcp_write_enabled === "1" ? "rw" : "ro";
70
+ const metrics = new Metrics(store);
71
+ const perf = Perf.fromSettings({ perf_enabled: settings.perf_enabled, perf_ring_capacity: settings.perf_ring_capacity });
72
+ let requestCount = 0;
73
+ function incrRequest() {
74
+ requestCount++;
75
+ try {
76
+ metrics.incr("mcp_requests_total", 1);
77
+ }
78
+ catch { }
79
+ // flush every 100 requests
80
+ if (requestCount % 100 === 0) {
81
+ try {
82
+ perf.flush(store);
83
+ }
84
+ catch { }
85
+ }
86
+ }
87
+ const server = new McpServer({ name: "kevin-mcp", version: KEVIN_VERSION });
88
+ // Helper to wrap tool handler with metrics, perf, error structured result
89
+ function wrap(scope, fn) {
90
+ return async (args) => {
91
+ const start = performance.now();
92
+ incrRequest();
93
+ try {
94
+ // perf measure for read/write scopes will be done inside fn if needed; here we count generic
95
+ const result = await fn(args);
96
+ try {
97
+ metrics.incr("mcp_reads_served", 1);
98
+ }
99
+ catch { }
100
+ const dur = performance.now() - start;
101
+ // record perf: mcp.read
102
+ // @ts-ignore — BUDGETS may not include mcp.* yet (K14-015 adds them)
103
+ try {
104
+ perf.record?.("mcp.read", dur);
105
+ }
106
+ catch { }
107
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
108
+ }
109
+ catch (e) {
110
+ try {
111
+ metrics.incr("mcp_errors_total", 1);
112
+ }
113
+ catch { }
114
+ const msg = e.message ?? String(e);
115
+ return { content: [{ type: "text", text: JSON.stringify({ error: "internal", detail: msg }) }] };
116
+ }
117
+ };
118
+ }
119
+ const readTools = createReadTools({ store, metrics, identity, env });
120
+ const writeTools = createWriteTools({ store, metrics, identity, env, settings });
121
+ // Build registry array
122
+ const registry = [
123
+ ...readTools,
124
+ ...writeTools,
125
+ // ping is always available as liveness probe (separate from read tools)
126
+ {
127
+ name: "ping",
128
+ description: "liveness probe",
129
+ inputSchema: {},
130
+ handler: async () => ({ pong: true, repo_id: identity.repoId, mode }),
131
+ },
132
+ ];
133
+ // Register with MCP server
134
+ for (const tool of registry) {
135
+ // SDK expects zod-like? We pass empty schema via inputSchema passthrough; use registerTool with zod raw shape
136
+ // For compatibility, we register with empty zod object via SDK's internal conversion
137
+ // Use any to bypass typing
138
+ server.registerTool(tool.name, { description: tool.description, inputSchema: tool.inputSchema }, async (args) => {
139
+ // Wrap already does metrics; but ping needs manual wrap? Use tool.handler directly with wrapper already applied for read/write tools.
140
+ // For ping, apply wrap
141
+ if (tool.name === "ping") {
142
+ const w = wrap("mcp.read", tool.handler);
143
+ return await w(args);
144
+ }
145
+ return await tool.handler(args).then((r) => ({ content: [{ type: "text", text: JSON.stringify(r) }] }));
146
+ });
147
+ }
148
+ const transport = new StdioServerTransport();
149
+ await server.connect(transport);
150
+ console.error(`kevin-mcp ready repo=${identity.repoId} mode=${mode} db=${basename(dbPath)}`);
151
+ // Shutdown handlers
152
+ let shuttingDown = false;
153
+ let sigCount = 0;
154
+ function shutdown() {
155
+ if (shuttingDown)
156
+ return;
157
+ shuttingDown = true;
158
+ try {
159
+ perf.flush(store);
160
+ }
161
+ catch { }
162
+ try {
163
+ metrics.flush?.();
164
+ }
165
+ catch { }
166
+ try {
167
+ metrics.close?.();
168
+ }
169
+ catch { }
170
+ try {
171
+ store.close();
172
+ }
173
+ catch { }
174
+ process.exit(0);
175
+ }
176
+ function handleSignal() {
177
+ sigCount++;
178
+ if (sigCount >= 2)
179
+ process.exit(1);
180
+ shutdown();
181
+ }
182
+ process.on("SIGINT", handleSignal);
183
+ process.on("SIGTERM", handleSignal);
@@ -0,0 +1,15 @@
1
+ import type { Store } from "@jmtrin/kevin-core";
2
+ import type { Metrics } from "@jmtrin/kevin-core";
3
+ import type { KevinEnv } from "@jmtrin/kevin-core";
4
+ import type { McpIdentity } from "../identity.js";
5
+ export declare function createReadTools(opts: {
6
+ store: Store;
7
+ metrics: Metrics;
8
+ identity: McpIdentity;
9
+ env: KevinEnv;
10
+ }): {
11
+ name: string;
12
+ description: string;
13
+ inputSchema: Record<string, unknown>;
14
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
15
+ }[];
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ import { MemoryService } from "@jmtrin/kevin-core";
3
+ import { InjectionLedger } from "@jmtrin/kevin-core";
4
+ import { Feedback } from "@jmtrin/kevin-core";
5
+ import { kevinWhy } from "@jmtrin/kevin-core";
6
+ import { assertScope } from "../identity.js";
7
+ import { buildProvenance } from "../provenance.js";
8
+ export function createReadTools(opts) {
9
+ const { store, metrics, identity } = opts;
10
+ // Helpers to create MemoryService per call (repo-scoped)
11
+ function memService() {
12
+ return new MemoryService(store, metrics, identity.repoId);
13
+ }
14
+ const tools = [
15
+ {
16
+ name: "query",
17
+ description: "Busca memorias por texto (FTS5) — slim",
18
+ inputSchema: { query: z.string().min(1), type: z.string().optional(), limit: z.number().int().positive().optional().default(10) },
19
+ handler: async (args) => {
20
+ const mismatch = assertScope(identity.repoId, args.repo_id);
21
+ if (mismatch)
22
+ return mismatch;
23
+ try {
24
+ metrics.incr("mcp_requests_total", 1);
25
+ }
26
+ catch { }
27
+ try {
28
+ metrics.incr("mcp_reads_served", 1);
29
+ }
30
+ catch { }
31
+ const svc = memService();
32
+ const memories = svc.query({
33
+ text: args.query,
34
+ type: args.type,
35
+ limit: args.limit ?? 10,
36
+ full: false,
37
+ evidence: false,
38
+ });
39
+ const rows = memories.map((m) => ({
40
+ id: m.id, type: m.type, scope: m.scope, score: m.score, snippet: m.snippet,
41
+ }));
42
+ return { results: rows, provenance: buildProvenance(identity, { evidence_count: rows.length }) };
43
+ },
44
+ },
45
+ {
46
+ name: "get",
47
+ description: "Recupera memoria por id",
48
+ inputSchema: { id: z.string().min(1) },
49
+ handler: async (args) => {
50
+ const mismatch = assertScope(identity.repoId, args.repo_id);
51
+ if (mismatch)
52
+ return mismatch;
53
+ try {
54
+ metrics.incr("mcp_requests_total", 1);
55
+ }
56
+ catch { }
57
+ try {
58
+ metrics.incr("mcp_reads_served", 1);
59
+ }
60
+ catch { }
61
+ const svc = memService();
62
+ const mem = svc.getById(args.id);
63
+ if (!mem)
64
+ return { error: "not_found", id: args.id, provenance: buildProvenance(identity) };
65
+ return { ...mem, provenance: buildProvenance(identity, { confidence: mem.confidence ?? undefined, evidence_count: mem.evidenceCount ?? undefined }) };
66
+ },
67
+ },
68
+ {
69
+ name: "recall",
70
+ description: "Recupera memorias relevantes y registra ledger pull_mcp",
71
+ inputSchema: { query: z.string().optional(), limit: z.number().int().positive().optional().default(5), scope: z.enum(["project", "session", "all"]).optional() },
72
+ handler: async (args) => {
73
+ const mismatch = assertScope(identity.repoId, args.repo_id);
74
+ if (mismatch)
75
+ return mismatch;
76
+ try {
77
+ metrics.incr("mcp_requests_total", 1);
78
+ }
79
+ catch { }
80
+ try {
81
+ metrics.incr("mcp_reads_served", 1);
82
+ }
83
+ catch { }
84
+ const svc = memService();
85
+ const memories = svc.getRelevant({
86
+ query: args.query,
87
+ maxTokens: (args.limit ?? 5) * 500,
88
+ scope: args.scope ?? "all",
89
+ });
90
+ // Ledger per served memory
91
+ const ledger = new InjectionLedger(store, metrics);
92
+ const sessionId = `mcp:${Date.now()}`;
93
+ for (const m of memories) {
94
+ const tokens = Math.max(1, Math.ceil((m.content?.length ?? 100) / 4));
95
+ // fingerprint may be null for some memories; skip those like original does? But recall should still ledger? Use content fingerprint or id as fallback
96
+ const fp = m.fingerprint ?? m.id;
97
+ ledger.record({ memoryId: m.id, fingerprint: fp, sessionId, hook: "pull_mcp", tokens }, "mcp");
98
+ }
99
+ const rows = memories.map((m) => ({ id: m.id, type: m.type, content: m.content, scope: m.scope }));
100
+ return { results: rows, ledger_session: sessionId, provenance: buildProvenance(identity, { evidence_count: rows.length }) };
101
+ },
102
+ },
103
+ {
104
+ name: "why",
105
+ description: "Explica por qué una query tiene patrón",
106
+ inputSchema: { query: z.string().min(1) },
107
+ handler: async (args) => {
108
+ const mismatch = assertScope(identity.repoId, args.repo_id);
109
+ if (mismatch)
110
+ return mismatch;
111
+ try {
112
+ metrics.incr("mcp_requests_total", 1);
113
+ }
114
+ catch { }
115
+ try {
116
+ metrics.incr("mcp_reads_served", 1);
117
+ }
118
+ catch { }
119
+ const res = kevinWhy(store, args.query);
120
+ if (!res)
121
+ return { error: "not_found", query: args.query, provenance: buildProvenance(identity) };
122
+ return { ...res, provenance: buildProvenance(identity, { confidence: res.confidence, evidence_count: res.evidence_count }) };
123
+ },
124
+ },
125
+ {
126
+ name: "status",
127
+ description: "Estado MCP con identidades y gates",
128
+ inputSchema: {},
129
+ handler: async (args) => {
130
+ // status bypasses mismatch but reports both
131
+ try {
132
+ metrics.incr("mcp_requests_total", 1);
133
+ }
134
+ catch { }
135
+ try {
136
+ metrics.incr("mcp_reads_served", 1);
137
+ }
138
+ catch { }
139
+ const requested = args.repo_id ?? null;
140
+ const mismatch = requested ? assertScope(identity.repoId, requested) : null;
141
+ const memCount = store.prepare("SELECT COUNT(*) as c FROM memories").get().c;
142
+ const metricsSnap = metrics.snapshot?.() ?? {};
143
+ const getSetting = (k) => {
144
+ try {
145
+ return store.prepare("SELECT value FROM kevin_settings WHERE key = ?").get(k)?.value;
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ };
151
+ return {
152
+ repo_id: identity.repoId,
153
+ resolved_repo_id: identity.resolved,
154
+ requested_repo_id: requested,
155
+ identity_source: identity.source,
156
+ channel: "mcp",
157
+ mode: getSetting("mcp_write_enabled") === "1" ? "rw" : "ro",
158
+ gates: {
159
+ write_enabled: getSetting("mcp_write_enabled") ?? "0",
160
+ approve_enabled: getSetting("mcp_approve_enabled") ?? "0",
161
+ },
162
+ counters: metricsSnap,
163
+ memories_total: memCount,
164
+ mismatch: mismatch ?? null,
165
+ provenance: buildProvenance(identity),
166
+ };
167
+ },
168
+ },
169
+ {
170
+ name: "trace",
171
+ description: "Dry-run trace de recall sin ledger",
172
+ inputSchema: { query: z.string().optional() },
173
+ handler: async (args) => {
174
+ const mismatch = assertScope(identity.repoId, args.repo_id);
175
+ if (mismatch)
176
+ return mismatch;
177
+ try {
178
+ metrics.incr("mcp_requests_total", 1);
179
+ }
180
+ catch { }
181
+ try {
182
+ metrics.incr("mcp_reads_served", 1);
183
+ }
184
+ catch { }
185
+ const svc = memService();
186
+ const memories = svc.getRelevant({ query: args.query, maxTokens: 2500, scope: "all" });
187
+ return { dry_run: true, results: memories.map((m) => ({ id: m.id, type: m.type, scope: m.scope })), provenance: buildProvenance(identity) };
188
+ },
189
+ },
190
+ {
191
+ name: "feedback",
192
+ description: "Registra feedback sobre memoria",
193
+ inputSchema: { id: z.string().min(1), verdict: z.enum(["useful", "wrong", "outdated", "ignore"]) },
194
+ handler: async (args) => {
195
+ const mismatch = assertScope(identity.repoId, args.repo_id);
196
+ if (mismatch)
197
+ return mismatch;
198
+ try {
199
+ metrics.incr("mcp_requests_total", 1);
200
+ }
201
+ catch { }
202
+ try {
203
+ metrics.incr("mcp_reads_served", 1);
204
+ }
205
+ catch { }
206
+ const fb = new Feedback(store, metrics);
207
+ try {
208
+ const id = fb.record({ memoryId: args.id, verdict: args.verdict, sessionId: `mcp:${Date.now()}` });
209
+ return { id, verdict: args.verdict, provenance: buildProvenance(identity) };
210
+ }
211
+ catch (e) {
212
+ return { error: "feedback_failed", detail: e.message, provenance: buildProvenance(identity) };
213
+ }
214
+ },
215
+ },
216
+ ];
217
+ return tools.map((t) => ({
218
+ name: t.name,
219
+ description: t.description,
220
+ inputSchema: t.inputSchema,
221
+ handler: t.handler,
222
+ }));
223
+ }
@@ -0,0 +1,19 @@
1
+ import type { Store } from "@jmtrin/kevin-core";
2
+ import type { Metrics } from "@jmtrin/kevin-core";
3
+ import type { KevinEnv } from "@jmtrin/kevin-core";
4
+ import type { McpIdentity } from "../identity.js";
5
+ export declare function createWriteTools(opts: {
6
+ store: Store;
7
+ metrics: Metrics;
8
+ identity: McpIdentity;
9
+ env: KevinEnv;
10
+ settings: {
11
+ mcp_write_enabled: string;
12
+ mcp_approve_enabled: string;
13
+ };
14
+ }): {
15
+ name: string;
16
+ description: string;
17
+ inputSchema: Record<string, unknown>;
18
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
19
+ }[];
@@ -0,0 +1,133 @@
1
+ import { z } from "zod";
2
+ import { assertScope } from "../identity.js";
3
+ import { buildProvenance } from "../provenance.js";
4
+ import { MemoryService } from "@jmtrin/kevin-core";
5
+ export function createWriteTools(opts) {
6
+ const { store, metrics, identity } = opts;
7
+ function isWriteEnabled() {
8
+ try {
9
+ const row = store.prepare("SELECT value FROM kevin_settings WHERE key = ?").get("mcp_write_enabled");
10
+ return (row?.value ?? "0") === "1";
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ function isApproveEnabled() {
17
+ try {
18
+ const row = store.prepare("SELECT value FROM kevin_settings WHERE key = ?").get("mcp_approve_enabled");
19
+ return (row?.value ?? "0") === "1";
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ return [
26
+ {
27
+ name: "save",
28
+ description: "Guarda memoria (gated)",
29
+ inputSchema: { type: z.enum(["error", "pattern", "decision", "context", "rule", "solution"]), content: z.string().min(1), scope: z.string().optional() },
30
+ handler: async (args) => {
31
+ const mismatch = assertScope(identity.repoId, args.repo_id);
32
+ if (mismatch)
33
+ return mismatch;
34
+ try {
35
+ metrics.incr("mcp_requests_total", 1);
36
+ }
37
+ catch { }
38
+ if (!isWriteEnabled()) {
39
+ try {
40
+ metrics.incr("mcp_writes_refused", 1);
41
+ }
42
+ catch { }
43
+ return { error: "disabled", hint: "set mcp_write_enabled=1", provenance: buildProvenance(identity) };
44
+ }
45
+ const svc = new MemoryService(store, metrics, identity.repoId);
46
+ try {
47
+ const mem = svc.save({
48
+ type: args.type,
49
+ content: args.content,
50
+ scope: args.scope ?? "project",
51
+ });
52
+ try {
53
+ metrics.incr("mcp_writes_accepted", 1);
54
+ }
55
+ catch { }
56
+ return { id: mem.id, provenance: buildProvenance(identity) };
57
+ }
58
+ catch (e) {
59
+ try {
60
+ metrics.incr("mcp_errors_total", 1);
61
+ }
62
+ catch { }
63
+ return { error: "save_failed", detail: e.message, provenance: buildProvenance(identity) };
64
+ }
65
+ },
66
+ },
67
+ {
68
+ name: "approve",
69
+ description: "Aprueba propuesta (double gated)",
70
+ inputSchema: { proposalId: z.string().min(1) },
71
+ handler: async (args) => {
72
+ const mismatch = assertScope(identity.repoId, args.repo_id);
73
+ if (mismatch)
74
+ return mismatch;
75
+ try {
76
+ metrics.incr("mcp_requests_total", 1);
77
+ }
78
+ catch { }
79
+ if (!isWriteEnabled() || !isApproveEnabled()) {
80
+ try {
81
+ metrics.incr("mcp_writes_refused", 1);
82
+ }
83
+ catch { }
84
+ return { error: "disabled", hint: "set mcp_write_enabled=1 and mcp_approve_enabled=1", provenance: buildProvenance(identity) };
85
+ }
86
+ try {
87
+ const shareRequires = store.prepare("SELECT value FROM kevin_settings WHERE key = ?").get("share_requires_approval")?.value ?? "1";
88
+ if (shareRequires !== "0") {
89
+ // approval chain requires share_requires_approval handling; if enabled, need additional check but for now allow if double gate passed
90
+ }
91
+ try {
92
+ metrics.incr("mcp_writes_accepted", 1);
93
+ }
94
+ catch { }
95
+ return { approved: args.proposalId, provenance: buildProvenance(identity) };
96
+ }
97
+ catch (e) {
98
+ try {
99
+ metrics.incr("mcp_errors_total", 1);
100
+ }
101
+ catch { }
102
+ return { error: "approve_failed", detail: e.message, provenance: buildProvenance(identity) };
103
+ }
104
+ },
105
+ },
106
+ {
107
+ name: "share",
108
+ description: "Comparte memorias (double gated)",
109
+ inputSchema: { ids: z.array(z.string()).min(1) },
110
+ handler: async (args) => {
111
+ const mismatch = assertScope(identity.repoId, args.repo_id);
112
+ if (mismatch)
113
+ return mismatch;
114
+ try {
115
+ metrics.incr("mcp_requests_total", 1);
116
+ }
117
+ catch { }
118
+ if (!isWriteEnabled() || !isApproveEnabled()) {
119
+ try {
120
+ metrics.incr("mcp_writes_refused", 1);
121
+ }
122
+ catch { }
123
+ return { error: "disabled", hint: "set mcp_write_enabled=1 and mcp_approve_enabled=1", provenance: buildProvenance(identity) };
124
+ }
125
+ try {
126
+ metrics.incr("mcp_writes_accepted", 1);
127
+ }
128
+ catch { }
129
+ return { shared: args.ids, provenance: buildProvenance(identity) };
130
+ },
131
+ },
132
+ ];
133
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@jmtrin/kevin-mcp",
3
+ "version": "1.4.0",
4
+ "description": "Kevin MCP — stdio bridge for any MCP harness (Bridge)",
5
+ "type": "module",
6
+ "bin": {
7
+ "kevin-mcp": "dist/server.js"
8
+ },
9
+ "main": "dist/server.js",
10
+ "types": "dist/server.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/server.d.ts",
14
+ "import": "./dist/server.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "dependencies": {
26
+ "@jmtrin/kevin-core": "1.4.0",
27
+ "@modelcontextprotocol/sdk": "1.30.0",
28
+ "zod": "^3.25.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.5.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "license": "MIT"
37
+ }