Skip to content

Commit 0ff7a65

Browse files
committed
feat: replace proxy with local form server + vault MCP tools
- kern serve: local HTTP server with credential capture form - kern mcp: MCP server with kern_add (URL-mode elicitation), kern_list, kern_status, kern_rotate, kern_remove, kern_recipients - Credential values flow: browser form → kern → vault. Never through LLM. - Removed proxy (kern_fetch URL-matching approach) - 16 tests passing
1 parent a36900a commit 0ff7a65

7 files changed

Lines changed: 444 additions & 304 deletions

File tree

bin/kern.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ async function main() {
3434
case "secret":
3535
case "secrets": return cmdSecret(sub, args.slice(2));
3636
case "recipients": return cmdRecipients();
37-
case "proxy":
38-
case "mcp": return cmdProxy(sub);
37+
case "mcp": return cmdMcp();
38+
case "serve": return cmdServe();
3939
default:
4040
console.error(`unknown command: ${cmd}`);
4141
help();
@@ -45,7 +45,8 @@ async function main() {
4545

4646
function help() {
4747
process.stdout.write(`kern v0.2 — the agent credential manager\n\n` +
48-
` kern proxy start MCP proxy (agents connect via MCP)\n` +
48+
` kern mcp start MCP server (for Claude Code, Cursor, etc.)\n` +
49+
` kern serve start local form server only\n\n` +
4950
` kern identity init [--save PATH]\n` +
5051
` kern identity pubkey\n\n` +
5152
` kern secret add NAME\n` +
@@ -58,11 +59,24 @@ function help() {
5859
`env: KERN_AGE_KEY (private key) KERN_VAULT_DIR (default ./secrets)\n`);
5960
}
6061

61-
async function cmdProxy(configPath?: string) {
62+
async function cmdMcp() {
6263
const { startMcpServer } = await import("../src/mcp.js");
63-
const { loadConfig } = await import("../src/proxy.js");
64-
const config = loadConfig(configPath);
65-
await startMcpServer(config);
64+
await startMcpServer();
65+
}
66+
67+
async function cmdServe() {
68+
const { loadFromHost } = await import("../src/identity.js");
69+
const { Vault } = await import("../src/vault.js");
70+
const { startLocalServer } = await import("../src/serve.js");
71+
const identity = await loadFromHost();
72+
const dir = process.env.KERN_VAULT_DIR ?? process.env.KORN_VAULT_DIR;
73+
const vault = new Vault({ identity, ...(dir ? { dir } : {}) });
74+
const srv = startLocalServer({
75+
vault,
76+
onAdd: (name) => console.log(`✓ encrypted: ${name}`),
77+
});
78+
console.log(`kern form server at ${srv.url}`);
79+
console.log(`Open ${srv.url}/add?name=YOUR_SECRET to add a credential`);
6680
}
6781

6882
async function cmdIdentity(sub: string | undefined, rest: string[]) {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
".": "./src/index.ts",
1111
"./vault": "./src/vault.ts",
1212
"./identity": "./src/identity.ts",
13-
"./proxy": "./src/proxy.ts",
13+
"./serve": "./src/serve.ts",
1414
"./mcp": "./src/mcp.ts"
1515
},
1616
"bin": {

src/mcp.ts

Lines changed: 224 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,271 @@
1-
// kern MCP server — stdio transport.
1+
// kern MCP server — stdio transport + embedded local HTTP server.
22
//
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)
510
//
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.
716

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";
920

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>;
1526
}
1627

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;
2129

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+
}
2647

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+
});
30111

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,
35209
}));
36210

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+
}
38218

219+
let buffer = "";
39220
process.stdin.setEncoding("utf8");
40221
process.stdin.on("data", (chunk: string) => {
41222
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);
46227
if (line) handleMessage(line);
47228
}
48229
});
49230

50231
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; }
58234

59235
switch (req.method) {
60236
case "initialize":
61237
respond(req.id, {
62238
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" },
65241
});
66242
break;
67-
68243
case "notifications/initialized":
69244
case "notifications/cancelled":
70245
break;
71-
72246
case "ping":
73247
respond(req.id, {});
74248
break;
75-
76249
case "tools/list":
77250
respond(req.id, { tools: toolDefs });
78251
break;
79-
80252
case "tools/call": {
81253
const name = req.params?.name;
82254
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+
}));
100262
break;
101263
}
102-
103264
default:
104265
respondError(req.id, -32601, `Method not found: ${req.method}`);
105266
}
106267
}
107268

108-
process.stderr.write("[kern] MCP proxy ready\n");
269+
process.on("exit", () => server?.close());
270+
process.stderr.write("[kern] MCP server ready\n");
109271
}

0 commit comments

Comments
 (0)