|
1 | | -// kern MCP server — stdio transport. |
| 1 | +// kern MCP server — stdio transport + embedded local HTTP server. |
2 | 2 | // |
3 | | -// Started by MCP clients (Claude Code, Cursor, etc.): |
4 | | -// { "command": "npx", "args": ["kern", "proxy"] } |
| 3 | +// Tools: |
| 4 | +// kern_status — vault health (secret count, recipient count) |
| 5 | +// kern_list — list secret names (no values) |
| 6 | +// kern_add — add a secret via URL-mode elicitation (browser form) |
| 7 | +// kern_rotate — rotate a secret via URL-mode elicitation |
| 8 | +// kern_remove — remove a secret |
| 9 | +// kern_recipients — list recipients (public keys) |
5 | 10 | // |
6 | | -// Implements the MCP JSON-RPC 2.0 protocol over stdin/stdout. |
| 11 | +// Credential capture uses MCP URL-mode elicitation: |
| 12 | +// 1. kern returns a localhost URL |
| 13 | +// 2. MCP client opens the user's browser |
| 14 | +// 3. User pastes credential into kern's local form |
| 15 | +// 4. Value goes vault → encrypted. Never through the LLM. |
7 | 16 |
|
8 | | -import { createProxyTools, loadConfig, type ProxyConfig } from "./proxy.js"; |
| 17 | +import { loadFromHost } from "./identity.js"; |
| 18 | +import { Vault } from "./vault.js"; |
| 19 | +import { startLocalServer, type LocalServer } from "./serve.js"; |
9 | 20 |
|
10 | | -interface JsonRpcRequest { |
11 | | - jsonrpc: "2.0"; |
12 | | - id?: string | number; |
13 | | - method: string; |
14 | | - params?: any; |
| 21 | +interface Tool { |
| 22 | + name: string; |
| 23 | + description: string; |
| 24 | + inputSchema: object; |
| 25 | + handler: (args: any, respond: RespondFn) => Promise<void>; |
15 | 26 | } |
16 | 27 |
|
17 | | -function respond(id: string | number | undefined, result: any) { |
18 | | - const msg = JSON.stringify({ jsonrpc: "2.0", id, result }); |
19 | | - process.stdout.write(msg + "\n"); |
20 | | -} |
| 28 | +type RespondFn = (id: string | number | undefined, result: any) => void; |
21 | 29 |
|
22 | | -function respondError(id: string | number | undefined, code: number, message: string) { |
23 | | - const msg = JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }); |
24 | | - process.stdout.write(msg + "\n"); |
25 | | -} |
| 30 | +export async function startMcpServer() { |
| 31 | + const identity = await loadFromHost(); |
| 32 | + const dir = process.env.KERN_VAULT_DIR ?? process.env.KORN_VAULT_DIR; |
| 33 | + const vault = new Vault({ identity, ...(dir ? { dir } : {}) }); |
| 34 | + |
| 35 | + let server: LocalServer | null = null; |
| 36 | + |
| 37 | + function ensureServer(): LocalServer { |
| 38 | + if (!server) { |
| 39 | + server = startLocalServer({ |
| 40 | + vault, |
| 41 | + onAdd: (name) => process.stderr.write(`[kern] encrypted: ${name}\n`), |
| 42 | + }); |
| 43 | + process.stderr.write(`[kern] local server at ${server.url}\n`); |
| 44 | + } |
| 45 | + return server; |
| 46 | + } |
26 | 47 |
|
27 | | -export async function startMcpServer(config?: ProxyConfig) { |
28 | | - const cfg = config ?? loadConfig(); |
29 | | - const tools = await createProxyTools(cfg); |
| 48 | + const tools: Tool[] = [ |
| 49 | + { |
| 50 | + name: "kern_status", |
| 51 | + description: "Vault health: how many secrets, how many recipients, vault directory.", |
| 52 | + inputSchema: { type: "object", properties: {} }, |
| 53 | + handler: async (_args, respond) => { |
| 54 | + const secrets = vault.list(); |
| 55 | + let recipients: string[] = []; |
| 56 | + try { |
| 57 | + const { readFileSync } = await import("fs"); |
| 58 | + const { join } = await import("path"); |
| 59 | + const r = readFileSync(join(vault.dir, ".recipients"), "utf8"); |
| 60 | + recipients = r.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")); |
| 61 | + } catch {} |
| 62 | + respond(undefined, { |
| 63 | + content: [{ type: "text", text: JSON.stringify({ |
| 64 | + secrets: secrets.length, |
| 65 | + recipients: recipients.length, |
| 66 | + names: secrets, |
| 67 | + dir: vault.dir, |
| 68 | + }, null, 2) }], |
| 69 | + }); |
| 70 | + }, |
| 71 | + }, |
| 72 | + { |
| 73 | + name: "kern_list", |
| 74 | + description: "List all secret names in the vault. Returns names only — never values.", |
| 75 | + inputSchema: { type: "object", properties: {} }, |
| 76 | + handler: async (_args, respond) => { |
| 77 | + const secrets = vault.list(); |
| 78 | + respond(undefined, { |
| 79 | + content: [{ type: "text", text: secrets.length |
| 80 | + ? `${secrets.length} secrets: ${secrets.join(", ")}` |
| 81 | + : "Vault is empty. Use kern_add to add secrets." }], |
| 82 | + }); |
| 83 | + }, |
| 84 | + }, |
| 85 | + { |
| 86 | + name: "kern_add", |
| 87 | + description: "Add a secret to the vault. Opens a secure browser form where the user pastes the credential. The value never passes through the LLM.", |
| 88 | + inputSchema: { |
| 89 | + type: "object", |
| 90 | + required: ["name"], |
| 91 | + properties: { |
| 92 | + name: { type: "string", description: "Secret name (e.g. github_token, openai_key)" }, |
| 93 | + }, |
| 94 | + }, |
| 95 | + handler: async (args, respond) => { |
| 96 | + const name = args.name as string; |
| 97 | + const srv = ensureServer(); |
| 98 | + const url = `${srv.url}/add?name=${encodeURIComponent(name)}`; |
| 99 | + |
| 100 | + respond(undefined, { |
| 101 | + content: [{ type: "text", text: `Opening secure form for "${name}". Paste your credential in the browser — it goes directly to the vault, never through this conversation.` }], |
| 102 | + _meta: { |
| 103 | + elicitation: { |
| 104 | + mode: "url", |
| 105 | + url, |
| 106 | + message: `Paste your ${name} credential in the browser form.`, |
| 107 | + elicitationId: `kern-add-${name}-${Date.now()}`, |
| 108 | + }, |
| 109 | + }, |
| 110 | + }); |
30 | 111 |
|
31 | | - const toolDefs = Object.entries(tools).map(([name, def]) => ({ |
32 | | - name, |
33 | | - description: def.description, |
34 | | - inputSchema: def.inputSchema, |
| 112 | + // Wait for the user to submit the form |
| 113 | + try { |
| 114 | + await fetch(`${srv.url}/api/wait?name=${encodeURIComponent(name)}`); |
| 115 | + } catch {} |
| 116 | + }, |
| 117 | + }, |
| 118 | + { |
| 119 | + name: "kern_rotate", |
| 120 | + description: "Rotate a secret — opens browser form for the new value. The old value is replaced.", |
| 121 | + inputSchema: { |
| 122 | + type: "object", |
| 123 | + required: ["name"], |
| 124 | + properties: { |
| 125 | + name: { type: "string", description: "Secret name to rotate" }, |
| 126 | + }, |
| 127 | + }, |
| 128 | + handler: async (args, respond) => { |
| 129 | + const name = args.name as string; |
| 130 | + const secrets = vault.list(); |
| 131 | + if (!secrets.includes(name)) { |
| 132 | + respond(undefined, { |
| 133 | + content: [{ type: "text", text: `Secret "${name}" not found. Available: ${secrets.join(", ")}` }], |
| 134 | + }); |
| 135 | + return; |
| 136 | + } |
| 137 | + const srv = ensureServer(); |
| 138 | + const url = `${srv.url}/add?name=${encodeURIComponent(name)}`; |
| 139 | + |
| 140 | + respond(undefined, { |
| 141 | + content: [{ type: "text", text: `Opening secure form to rotate "${name}".` }], |
| 142 | + _meta: { |
| 143 | + elicitation: { |
| 144 | + mode: "url", |
| 145 | + url, |
| 146 | + message: `Paste the new value for ${name}.`, |
| 147 | + elicitationId: `kern-rotate-${name}-${Date.now()}`, |
| 148 | + }, |
| 149 | + }, |
| 150 | + }); |
| 151 | + |
| 152 | + try { |
| 153 | + await fetch(`${srv.url}/api/wait?name=${encodeURIComponent(name)}`); |
| 154 | + } catch {} |
| 155 | + }, |
| 156 | + }, |
| 157 | + { |
| 158 | + name: "kern_remove", |
| 159 | + description: "Remove a secret from the vault.", |
| 160 | + inputSchema: { |
| 161 | + type: "object", |
| 162 | + required: ["name"], |
| 163 | + properties: { |
| 164 | + name: { type: "string", description: "Secret name to remove" }, |
| 165 | + }, |
| 166 | + }, |
| 167 | + handler: async (args, respond) => { |
| 168 | + const name = args.name as string; |
| 169 | + try { |
| 170 | + vault.delete(name); |
| 171 | + respond(undefined, { |
| 172 | + content: [{ type: "text", text: `Removed "${name}" from vault.` }], |
| 173 | + }); |
| 174 | + } catch (e: any) { |
| 175 | + respond(undefined, { |
| 176 | + content: [{ type: "text", text: `Error: ${e.message}` }], |
| 177 | + isError: true, |
| 178 | + }); |
| 179 | + } |
| 180 | + }, |
| 181 | + }, |
| 182 | + { |
| 183 | + name: "kern_recipients", |
| 184 | + description: "List vault recipients (public keys that can decrypt). No secret values exposed.", |
| 185 | + inputSchema: { type: "object", properties: {} }, |
| 186 | + handler: async (_args, respond) => { |
| 187 | + try { |
| 188 | + const { readFileSync } = await import("fs"); |
| 189 | + const { join } = await import("path"); |
| 190 | + const raw = readFileSync(join(vault.dir, ".recipients"), "utf8"); |
| 191 | + const keys = raw.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")); |
| 192 | + respond(undefined, { |
| 193 | + content: [{ type: "text", text: `${keys.length} recipients:\n${keys.map(k => ` ${k}`).join("\n")}` }], |
| 194 | + }); |
| 195 | + } catch { |
| 196 | + respond(undefined, { |
| 197 | + content: [{ type: "text", text: "No .recipients file found." }], |
| 198 | + }); |
| 199 | + } |
| 200 | + }, |
| 201 | + }, |
| 202 | + ]; |
| 203 | + |
| 204 | + // MCP stdio transport |
| 205 | + const toolDefs = tools.map(t => ({ |
| 206 | + name: t.name, |
| 207 | + description: t.description, |
| 208 | + inputSchema: t.inputSchema, |
35 | 209 | })); |
36 | 210 |
|
37 | | - let buffer = ""; |
| 211 | + function respond(id: string | number | undefined, result: any) { |
| 212 | + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n"); |
| 213 | + } |
| 214 | + |
| 215 | + function respondError(id: string | number | undefined, code: number, message: string) { |
| 216 | + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n"); |
| 217 | + } |
38 | 218 |
|
| 219 | + let buffer = ""; |
39 | 220 | process.stdin.setEncoding("utf8"); |
40 | 221 | process.stdin.on("data", (chunk: string) => { |
41 | 222 | buffer += chunk; |
42 | | - let newlineIdx: number; |
43 | | - while ((newlineIdx = buffer.indexOf("\n")) !== -1) { |
44 | | - const line = buffer.slice(0, newlineIdx).trim(); |
45 | | - buffer = buffer.slice(newlineIdx + 1); |
| 223 | + let idx: number; |
| 224 | + while ((idx = buffer.indexOf("\n")) !== -1) { |
| 225 | + const line = buffer.slice(0, idx).trim(); |
| 226 | + buffer = buffer.slice(idx + 1); |
46 | 227 | if (line) handleMessage(line); |
47 | 228 | } |
48 | 229 | }); |
49 | 230 |
|
50 | 231 | function handleMessage(line: string) { |
51 | | - let req: JsonRpcRequest; |
52 | | - try { |
53 | | - req = JSON.parse(line); |
54 | | - } catch { |
55 | | - respondError(undefined, -32700, "Parse error"); |
56 | | - return; |
57 | | - } |
| 232 | + let req: { jsonrpc: string; id?: string | number; method: string; params?: any }; |
| 233 | + try { req = JSON.parse(line); } catch { respondError(undefined, -32700, "Parse error"); return; } |
58 | 234 |
|
59 | 235 | switch (req.method) { |
60 | 236 | case "initialize": |
61 | 237 | respond(req.id, { |
62 | 238 | protocolVersion: "2024-11-05", |
63 | | - capabilities: { tools: {} }, |
64 | | - serverInfo: { name: "kern", version: "0.1.0" }, |
| 239 | + capabilities: { tools: {}, elicitation: { url: {} } }, |
| 240 | + serverInfo: { name: "kern", version: "0.2.0" }, |
65 | 241 | }); |
66 | 242 | break; |
67 | | - |
68 | 243 | case "notifications/initialized": |
69 | 244 | case "notifications/cancelled": |
70 | 245 | break; |
71 | | - |
72 | 246 | case "ping": |
73 | 247 | respond(req.id, {}); |
74 | 248 | break; |
75 | | - |
76 | 249 | case "tools/list": |
77 | 250 | respond(req.id, { tools: toolDefs }); |
78 | 251 | break; |
79 | | - |
80 | 252 | case "tools/call": { |
81 | 253 | const name = req.params?.name; |
82 | 254 | const args = req.params?.arguments ?? {}; |
83 | | - const tool = tools[name as keyof typeof tools]; |
84 | | - if (!tool) { |
85 | | - respondError(req.id, -32602, `Unknown tool: ${name}`); |
86 | | - return; |
87 | | - } |
88 | | - (tool.handler as any)(args) |
89 | | - .then((result: any) => { |
90 | | - respond(req.id, { |
91 | | - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], |
92 | | - }); |
93 | | - }) |
94 | | - .catch((err: any) => { |
95 | | - respond(req.id, { |
96 | | - content: [{ type: "text", text: `Error: ${err.message}` }], |
97 | | - isError: true, |
98 | | - }); |
99 | | - }); |
| 255 | + const tool = tools.find(t => t.name === name); |
| 256 | + if (!tool) { respondError(req.id, -32602, `Unknown tool: ${name}`); return; } |
| 257 | + tool.handler(args, (_, result) => respond(req.id, result)) |
| 258 | + .catch((err: any) => respond(req.id, { |
| 259 | + content: [{ type: "text", text: `Error: ${err.message}` }], |
| 260 | + isError: true, |
| 261 | + })); |
100 | 262 | break; |
101 | 263 | } |
102 | | - |
103 | 264 | default: |
104 | 265 | respondError(req.id, -32601, `Method not found: ${req.method}`); |
105 | 266 | } |
106 | 267 | } |
107 | 268 |
|
108 | | - process.stderr.write("[kern] MCP proxy ready\n"); |
| 269 | + process.on("exit", () => server?.close()); |
| 270 | + process.stderr.write("[kern] MCP server ready\n"); |
109 | 271 | } |
0 commit comments