@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 +21 -0
- package/README.md +240 -0
- package/dist/identity.d.ts +46 -0
- package/dist/identity.js +86 -0
- package/dist/perf-mcp.d.ts +3 -0
- package/dist/perf-mcp.js +4 -0
- package/dist/provenance.d.ts +9 -0
- package/dist/provenance.js +8 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +183 -0
- package/dist/tools/read.d.ts +15 -0
- package/dist/tools/read.js +223 -0
- package/dist/tools/write.d.ts +19 -0
- package/dist/tools/write.js +133 -0
- package/package.json +37 -0
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
|
+

|
|
18
|
+

|
|
19
|
+

|
|
20
|
+

|
|
21
|
+

|
|
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;
|
package/dist/identity.js
ADDED
|
@@ -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
|
+
}
|
package/dist/perf-mcp.js
ADDED
|
@@ -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;
|
package/dist/server.d.ts
ADDED
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
|
+
}
|