klyro 1.0.4 → 1.0.6

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.
Files changed (80) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +22 -2
  5. package/dist/agent/orchestrator.js +30 -4
  6. package/dist/agent/runtime.d.ts +5 -0
  7. package/dist/agent/runtime.js +174 -51
  8. package/dist/checkpoints/store.d.ts +9 -0
  9. package/dist/checkpoints/store.js +20 -0
  10. package/dist/cli/auth.js +11 -3
  11. package/dist/cli/completion.js +63 -10
  12. package/dist/cli/config.d.ts +4 -4
  13. package/dist/cli/doctor.js +13 -0
  14. package/dist/cli/eval.d.ts +15 -1
  15. package/dist/cli/eval.js +34 -2
  16. package/dist/cli/hooks.d.ts +54 -5
  17. package/dist/cli/hooks.js +85 -6
  18. package/dist/cli/init.d.ts +6 -0
  19. package/dist/cli/init.js +60 -0
  20. package/dist/cli/repl.js +261 -34
  21. package/dist/cli/run.d.ts +7 -1
  22. package/dist/cli/run.js +97 -41
  23. package/dist/cli/slash/custom.d.ts +25 -0
  24. package/dist/cli/slash/custom.js +166 -0
  25. package/dist/cli/slash/parser.d.ts +16 -2
  26. package/dist/cli/slash/parser.js +67 -18
  27. package/dist/cli/update.d.ts +8 -4
  28. package/dist/cli/update.js +50 -7
  29. package/dist/context/accounting.d.ts +8 -0
  30. package/dist/context/accounting.js +18 -1
  31. package/dist/context/compaction.d.ts +1 -0
  32. package/dist/context/compaction.js +2 -1
  33. package/dist/context/memory.js +18 -1
  34. package/dist/eval/harness.d.ts +21 -3
  35. package/dist/eval/harness.js +31 -3
  36. package/dist/eval/judge.d.ts +32 -0
  37. package/dist/eval/judge.js +63 -0
  38. package/dist/eval/tasks.js +134 -0
  39. package/dist/index.js +225 -132
  40. package/dist/mcp/client.d.ts +15 -0
  41. package/dist/mcp/client.js +42 -2
  42. package/dist/mcp/config.d.ts +10 -1
  43. package/dist/mcp/config.js +64 -1
  44. package/dist/mcp/registry.d.ts +13 -0
  45. package/dist/mcp/registry.js +47 -5
  46. package/dist/mcp/remote.d.ts +29 -0
  47. package/dist/mcp/remote.js +153 -0
  48. package/dist/mcp/serve.d.ts +23 -0
  49. package/dist/mcp/serve.js +111 -0
  50. package/dist/policy/approval.d.ts +15 -1
  51. package/dist/policy/approval.js +8 -0
  52. package/dist/policy/engine.d.ts +11 -1
  53. package/dist/policy/engine.js +14 -1
  54. package/dist/policy/secret-redactor.js +4 -1
  55. package/dist/providers/endpoints.d.ts +43 -0
  56. package/dist/providers/endpoints.js +104 -0
  57. package/dist/providers.js +13 -10
  58. package/dist/shared/error-map.d.ts +19 -0
  59. package/dist/shared/error-map.js +58 -0
  60. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  61. package/dist/tools/lsp/diagnostics.js +88 -9
  62. package/dist/tools/normalize.d.ts +3 -0
  63. package/dist/tools/normalize.js +8 -5
  64. package/dist/tools/search/dependencies.d.ts +2 -2
  65. package/dist/tools/shell/background.d.ts +6 -0
  66. package/dist/tools/shell/background.js +17 -0
  67. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  68. package/dist/tools/symbols/find-symbol.js +8 -6
  69. package/dist/tools/types.d.ts +8 -1
  70. package/dist/tui/app.d.ts +2 -0
  71. package/dist/tui/app.js +454 -53
  72. package/dist/tui/app.test.js +66 -3
  73. package/dist/tui/approval.js +53 -1
  74. package/dist/tui/markdown.js +9 -0
  75. package/dist/tui/mouse.d.ts +26 -1
  76. package/dist/tui/mouse.js +104 -6
  77. package/dist/tui/scroll-flow.test.js +3 -1
  78. package/dist/tui/tokens.d.ts +6 -6
  79. package/dist/tui/tokens.js +9 -6
  80. package/package.json +1 -1
@@ -7,9 +7,11 @@ export declare const McpServerPolicySchema: z.ZodObject<{
7
7
  }, z.core.$strip>;
8
8
  export type McpServerPolicy = z.infer<typeof McpServerPolicySchema>;
9
9
  export declare const McpServerSpecSchema: z.ZodObject<{
10
- command: z.ZodString;
10
+ command: z.ZodOptional<z.ZodString>;
11
11
  args: z.ZodOptional<z.ZodArray<z.ZodString>>;
12
12
  env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
+ url: z.ZodOptional<z.ZodString>;
14
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
15
  disabled: z.ZodOptional<z.ZodBoolean>;
14
16
  policy: z.ZodOptional<z.ZodObject<{
15
17
  allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -38,3 +40,10 @@ export declare function expandEnv(value: string): string;
38
40
  export declare function loadMcpServers(cwd: string): McpServersConfig;
39
41
  /** Servers eligible for connection (configured and not disabled). */
40
42
  export declare function enabledServers(cfg: McpServersConfig): Record<string, McpServerSpec>;
43
+ /** Server names are bounded: the registry builds `mcp__<server>__<tool>` (≤64 chars). */
44
+ export declare const MCP_NAME_RE: RegExp;
45
+ export declare function projectMcpPath(cwd: string): string;
46
+ /** Add (or reject duplicates of) a project-level MCP server. Throws on invalid input. */
47
+ export declare function addProjectServer(cwd: string, name: string, spec: unknown): void;
48
+ /** Remove a project-level MCP server. Returns false when absent. */
49
+ export declare function removeProjectServer(cwd: string, name: string): boolean;
@@ -19,11 +19,23 @@ export const McpServerPolicySchema = z.object({
19
19
  timeoutMs: z.number().int().positive().optional(),
20
20
  });
21
21
  export const McpServerSpecSchema = z.object({
22
- command: z.string().min(1),
22
+ /** stdio transport: command to spawn. Required unless `url` is set. */
23
+ command: z.string().min(1).optional(),
23
24
  args: z.array(z.string()).optional(),
24
25
  env: z.record(z.string(), z.string()).optional(),
26
+ /** Remote transport: Streamable-HTTP JSON-RPC endpoint. Required unless `command` is set. */
27
+ url: z.string().url().optional(),
28
+ /** Extra HTTP headers for remote transport (`${env:VAR}` expanded). */
29
+ headers: z.record(z.string(), z.string()).optional(),
25
30
  disabled: z.boolean().optional(),
26
31
  policy: McpServerPolicySchema.optional(),
32
+ }).superRefine((s, ctx) => {
33
+ if (!s.command && !s.url) {
34
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server needs either "command" (stdio) or "url" (remote HTTP)' });
35
+ }
36
+ if (s.command && s.url) {
37
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server takes either "command" or "url", not both' });
38
+ }
27
39
  });
28
40
  /** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
29
41
  export const MAX_MCP_TIMEOUT_MS = 600_000;
@@ -82,6 +94,12 @@ export function loadMcpServers(cwd) {
82
94
  env[k] = expandEnv(v);
83
95
  spec.env = env;
84
96
  }
97
+ if (spec.headers) {
98
+ const headers = {};
99
+ for (const [k, v] of Object.entries(spec.headers))
100
+ headers[k] = expandEnv(v);
101
+ spec.headers = headers;
102
+ }
85
103
  servers[name] = spec;
86
104
  sources[name] = label;
87
105
  }
@@ -97,3 +115,48 @@ export function enabledServers(cfg) {
97
115
  }
98
116
  return out;
99
117
  }
118
+ /** Server names are bounded: the registry builds `mcp__<server>__<tool>` (≤64 chars). */
119
+ export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,20}$/;
120
+ export function projectMcpPath(cwd) {
121
+ return path.join(cwd, '.mcp.json');
122
+ }
123
+ function readProjectDoc(cwd) {
124
+ const raw = readJsonFile(projectMcpPath(cwd));
125
+ const doc = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {});
126
+ const servers = normalizeRaw(doc);
127
+ return { doc, servers: { ...servers } };
128
+ }
129
+ function writeProjectDoc(cwd, doc, servers) {
130
+ const next = { ...doc };
131
+ if ('mcpServers' in next)
132
+ next['mcpServers'] = servers;
133
+ else
134
+ next['servers'] = servers;
135
+ if (Object.keys(next).length === 0)
136
+ next['mcpServers'] = servers;
137
+ const tmp = `${projectMcpPath(cwd)}.tmp-${process.pid}`;
138
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', 'utf-8');
139
+ fs.renameSync(tmp, projectMcpPath(cwd));
140
+ }
141
+ /** Add (or reject duplicates of) a project-level MCP server. Throws on invalid input. */
142
+ export function addProjectServer(cwd, name, spec) {
143
+ if (!MCP_NAME_RE.test(name))
144
+ throw new Error(`invalid server name "${name}" (want 1-20 chars of A-Za-z0-9_-)`);
145
+ const parsed = McpServerSpecSchema.safeParse(spec);
146
+ if (!parsed.success)
147
+ throw new Error(`invalid server spec: ${parsed.error.issues.map((i) => i.message).join('; ')}`);
148
+ const { doc, servers } = readProjectDoc(cwd);
149
+ if (name in servers)
150
+ throw new Error(`server "${name}" already configured in ${projectMcpPath(cwd)} (remove it first)`);
151
+ servers[name] = parsed.data;
152
+ writeProjectDoc(cwd, doc, servers);
153
+ }
154
+ /** Remove a project-level MCP server. Returns false when absent. */
155
+ export function removeProjectServer(cwd, name) {
156
+ const { doc, servers } = readProjectDoc(cwd);
157
+ if (!(name in servers))
158
+ return false;
159
+ delete servers[name];
160
+ writeProjectDoc(cwd, doc, servers);
161
+ return true;
162
+ }
@@ -30,6 +30,11 @@ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
30
30
  * behind a plausible-looking name.
31
31
  */
32
32
  export declare function sanitizeMcpName(server: string, tool: string): string;
33
+ /**
34
+ * Construct the right client for a spec: remote HTTP when `url` is set,
35
+ * else stdio. Used by registration, probe, and prompt runs.
36
+ */
37
+ export declare function makeMcpClient(name: string, spec: McpServerSpec): McpClientLike;
33
38
  export declare function registerMcpServers(cfg: {
34
39
  servers: Record<string, McpServerSpec>;
35
40
  }, opts: RegisterMcpOpts): Promise<McpRegisterResult>;
@@ -52,3 +57,11 @@ export declare function loadAndRegisterMcp(opts: {
52
57
  source: 'global' | 'project';
53
58
  }) => Promise<boolean>;
54
59
  }): Promise<McpRegisterResult>;
60
+ /**
61
+ * Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh stdio
62
+ * connection per invocation (no client lifecycle to manage), redacted
63
+ * output. Typing the prompt name is explicit consent, so project servers
64
+ * connect without the registration-time approval gate. Throws McpError /
65
+ * Error on unknown server, disabled server, or prompt failure.
66
+ */
67
+ export declare function runMcpPrompt(cwd: string, server: string, prompt: string, args: Record<string, string>): Promise<string>;
@@ -11,10 +11,12 @@
11
11
  * 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
12
12
  * runtime loop prompts before executing.
13
13
  *
14
- * Permission class: no code in src consumes `Tool.permission` (it is
15
- * write-only metadata today), so we pick the most restrictive class,
16
- * 'admin' — the same class as `spawn_agent`, since MCP tools execute
17
- * arbitrary external side effects (read/write/network) outside our control.
14
+ * Permission class: the runtime passes each tool's `permission` into
15
+ * `PolicyEngine.evaluate`, and `execute`/`admin` tools with no explicit
16
+ * allow rule fall through to ask (interactive) or deny (headless). We
17
+ * pick the most restrictive class, 'admin' — the same class as
18
+ * `spawn_agent`, since MCP tools execute arbitrary external side effects
19
+ * (read/write/network) outside our control.
18
20
  *
19
21
  * Debug capture: when `KLYRO_MCP_DEBUG=1` is set, every MCP tool success
20
22
  * AND error ALSO writes the UNREDACTED raw JSON payload (pre-redaction,
@@ -28,6 +30,7 @@ import * as fs from 'node:fs';
28
30
  import * as os from 'node:os';
29
31
  import * as path from 'node:path';
30
32
  import { McpClient, McpError } from './client.js';
33
+ import { RemoteMcpClient } from './remote.js';
31
34
  import { loadMcpServers } from './config.js';
32
35
  import { evaluateMcpPolicy } from './policy.js';
33
36
  import { jsonSchemaToZod } from './schema.js';
@@ -139,9 +142,20 @@ async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
139
142
  return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
140
143
  }
141
144
  }
145
+ /**
146
+ * Construct the right client for a spec: remote HTTP when `url` is set,
147
+ * else stdio. Used by registration, probe, and prompt runs.
148
+ */
149
+ export function makeMcpClient(name, spec) {
150
+ if (spec.url)
151
+ return new RemoteMcpClient(name, spec);
152
+ if (!spec.command)
153
+ throw new McpError(`mcp server "${name}" has neither command nor url`, 'INVALID_SPEC');
154
+ return new McpClient(name, spec);
155
+ }
142
156
  export async function registerMcpServers(cfg, opts) {
143
157
  const { registry, policy } = opts;
144
- const factory = opts.clientFactory ?? ((name, spec) => new McpClient(name, spec));
158
+ const factory = opts.clientFactory ?? makeMcpClient;
145
159
  const registered = [];
146
160
  const errors = [];
147
161
  const skipped = [];
@@ -289,3 +303,31 @@ export async function loadAndRegisterMcp(opts) {
289
303
  };
290
304
  }
291
305
  }
306
+ /**
307
+ * Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh stdio
308
+ * connection per invocation (no client lifecycle to manage), redacted
309
+ * output. Typing the prompt name is explicit consent, so project servers
310
+ * connect without the registration-time approval gate. Throws McpError /
311
+ * Error on unknown server, disabled server, or prompt failure.
312
+ */
313
+ export async function runMcpPrompt(cwd, server, prompt, args) {
314
+ const cfg = loadMcpServers(cwd);
315
+ const spec = cfg.servers[server];
316
+ if (!spec)
317
+ throw new Error(`mcp server not found: ${server}`);
318
+ if (spec.disabled)
319
+ throw new Error(`mcp server disabled: ${server}`);
320
+ const client = makeMcpClient(server, spec);
321
+ if (!client.promptsGet)
322
+ throw new Error(`mcp server "${server}" does not support prompts`);
323
+ try {
324
+ const withConnect = client;
325
+ if (typeof withConnect.connect === 'function')
326
+ await withConnect.connect();
327
+ const text = await client.promptsGet(prompt, args);
328
+ return redact(text).slice(0, 8000);
329
+ }
330
+ finally {
331
+ await client.close().catch(() => undefined);
332
+ }
333
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Remote MCP transport — JSON-RPC 2.0 over HTTP POST (Streamable HTTP).
3
+ *
4
+ * Covers servers that speak plain request/response JSON-RPC at an HTTPS
5
+ * endpoint (the common self-hosted shape). Pure SSE-stream servers that
6
+ * require a persistent GET event stream are NOT supported — `probe` reports
7
+ * that explicitly. Custom headers (e.g. Authorization) come from the spec
8
+ * with `${env:VAR}` expansion applied at load time.
9
+ */
10
+ import type { McpServerSpec } from './config.js';
11
+ import { type McpClientLike, type McpToolDef, type McpCallResult, type McpResource, type McpPromptDef } from './client.js';
12
+ /** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
13
+ export declare function parseSseBody(text: string): unknown[];
14
+ export declare class RemoteMcpClient implements McpClientLike {
15
+ readonly name: string;
16
+ private readonly spec;
17
+ private nextId;
18
+ private readonly headers;
19
+ private readonly timeoutMs;
20
+ constructor(name: string, spec: McpServerSpec);
21
+ connect(): Promise<void>;
22
+ listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
23
+ callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
24
+ listResources(signal?: AbortSignal): Promise<McpResource[]>;
25
+ promptsList(signal?: AbortSignal): Promise<McpPromptDef[]>;
26
+ promptsGet(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
27
+ close(): Promise<void>;
28
+ private request;
29
+ }
@@ -0,0 +1,153 @@
1
+ import { McpError } from './client.js';
2
+ const PROTOCOL_VERSION = '2024-11-05';
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ /** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
5
+ export function parseSseBody(text) {
6
+ const out = [];
7
+ for (const line of text.split('\n')) {
8
+ const t = line.trim();
9
+ if (!t.startsWith('data:'))
10
+ continue;
11
+ const payload = t.slice(5).trim();
12
+ if (payload === '[DONE]')
13
+ continue;
14
+ try {
15
+ out.push(JSON.parse(payload));
16
+ }
17
+ catch { /* ignore malformed lines */ }
18
+ }
19
+ return out;
20
+ }
21
+ export class RemoteMcpClient {
22
+ name;
23
+ spec;
24
+ nextId = 1;
25
+ headers;
26
+ timeoutMs;
27
+ constructor(name, spec) {
28
+ this.name = name;
29
+ this.spec = spec;
30
+ if (!spec.url)
31
+ throw new McpError(`mcp server "${name}" has no url`, 'INVALID_SPEC');
32
+ this.headers = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...(spec.headers ?? {}) };
33
+ this.timeoutMs = spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
+ }
35
+ async connect() {
36
+ // Handshake doubles as reachability probe; failures surface typed errors.
37
+ await this.request('initialize', {
38
+ protocolVersion: PROTOCOL_VERSION,
39
+ capabilities: {},
40
+ clientInfo: { name: 'klyro', version: 'remote' },
41
+ });
42
+ // Best-effort initialized notification (no id → no response expected).
43
+ try {
44
+ await fetch(this.spec.url, {
45
+ method: 'POST',
46
+ headers: this.headers,
47
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
48
+ signal: AbortSignal.timeout(5000),
49
+ });
50
+ }
51
+ catch { /* notification is best-effort */ }
52
+ }
53
+ async listTools(signal) {
54
+ const res = (await this.request('tools/list', {}, this.timeoutMs, signal));
55
+ return Array.isArray(res.tools) ? res.tools : [];
56
+ }
57
+ async callTool(name, args, signal) {
58
+ const res = (await this.request('tools/call', { name, arguments: args ?? {} }, this.timeoutMs, signal));
59
+ const blocks = Array.isArray(res.content) ? res.content : [];
60
+ const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
61
+ return { text, isError: res.isError === true, raw: res };
62
+ }
63
+ async listResources(signal) {
64
+ const res = (await this.request('resources/list', {}, this.timeoutMs, signal));
65
+ return Array.isArray(res.resources) ? res.resources : [];
66
+ }
67
+ async promptsList(signal) {
68
+ try {
69
+ const res = (await this.request('prompts/list', {}, this.timeoutMs, signal));
70
+ return Array.isArray(res.prompts) ? res.prompts : [];
71
+ }
72
+ catch (err) {
73
+ if (err instanceof McpError && err.code === 'SERVER_ERROR' && err.details?.code === -32601)
74
+ return [];
75
+ throw err;
76
+ }
77
+ }
78
+ async promptsGet(name, args, signal) {
79
+ const res = (await this.request('prompts/get', { name, arguments: args ?? {} }, this.timeoutMs, signal));
80
+ const parts = [];
81
+ if (typeof res.description === 'string' && res.description)
82
+ parts.push(res.description);
83
+ for (const m of Array.isArray(res.messages) ? res.messages : []) {
84
+ const c = m.content;
85
+ if (c && typeof c.text === 'string')
86
+ parts.push(c.text);
87
+ else if (c !== undefined)
88
+ parts.push(JSON.stringify(c));
89
+ }
90
+ return parts.join('\n\n');
91
+ }
92
+ async close() {
93
+ // Stateless HTTP — nothing to tear down.
94
+ }
95
+ async request(method, params, timeoutMs = this.timeoutMs, signal) {
96
+ if (signal?.aborted)
97
+ throw new McpError(`mcp call "${method}" aborted`, 'ABORTED');
98
+ const ctrl = new AbortController();
99
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
100
+ const onAbort = () => ctrl.abort();
101
+ signal?.addEventListener('abort', onAbort, { once: true });
102
+ const id = this.nextId++;
103
+ try {
104
+ const res = await fetch(this.spec.url, {
105
+ method: 'POST',
106
+ headers: this.headers,
107
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
108
+ signal: ctrl.signal,
109
+ });
110
+ const ctype = res.headers.get('content-type') ?? '';
111
+ const text = await res.text();
112
+ if (!res.ok)
113
+ throw new McpError(`mcp server "${this.name}" HTTP ${res.status}: ${text.slice(0, 300)}`, 'HTTP_ERROR', { status: res.status });
114
+ let body;
115
+ if (ctype.includes('text/event-stream')) {
116
+ const events = parseSseBody(text);
117
+ const last = [...events].reverse().find((e) => typeof e === 'object' && e !== null && 'result' in e);
118
+ const errs = [...events].reverse().find((e) => typeof e === 'object' && e !== null && 'error' in e);
119
+ if (!last && errs?.error)
120
+ throw new McpError(`mcp server "${this.name}" error: ${errs.error.message ?? 'unknown'}`, 'SERVER_ERROR', errs.error);
121
+ if (!last)
122
+ throw new McpError(`mcp server "${this.name}" empty SSE stream`, 'PROTOCOL_ERROR');
123
+ body = last.result;
124
+ }
125
+ else {
126
+ try {
127
+ body = JSON.parse(text);
128
+ }
129
+ catch {
130
+ throw new McpError(`mcp server "${this.name}" returned non-JSON`, 'PROTOCOL_ERROR');
131
+ }
132
+ const env = body;
133
+ if (env && typeof env === 'object' && 'error' in env && env.error) {
134
+ throw new McpError(`mcp server "${this.name}" error: ${env.error.message ?? 'unknown'}`, 'SERVER_ERROR', env.error);
135
+ }
136
+ body = env && typeof env === 'object' && 'result' in env ? env.result : body;
137
+ }
138
+ return body;
139
+ }
140
+ catch (err) {
141
+ if (err instanceof McpError)
142
+ throw err;
143
+ if (err instanceof Error && err.name === 'AbortError') {
144
+ throw new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT');
145
+ }
146
+ throw new McpError(`mcp call "${method}" failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR');
147
+ }
148
+ finally {
149
+ clearTimeout(timer);
150
+ signal?.removeEventListener('abort', onAbort);
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,23 @@
1
+ import { type ToolRegistry } from '../tools/registry.js';
2
+ import type { ToolContext } from '../tools/types.js';
3
+ import { PolicyEngine } from '../policy/engine.js';
4
+ interface JsonRpcRequest {
5
+ jsonrpc?: string;
6
+ id?: string | number | null;
7
+ method?: string;
8
+ params?: {
9
+ name?: string;
10
+ arguments?: unknown;
11
+ };
12
+ }
13
+ export interface ServeDeps {
14
+ registry?: ToolRegistry;
15
+ policy?: PolicyEngine;
16
+ ctx?: ToolContext;
17
+ }
18
+ export declare function makeServeDeps(cwd: string, overrides?: ServeDeps): Required<ServeDeps>;
19
+ /** Pure request handler — unit-tested without stdio. Returns null for notifications. */
20
+ export declare function handleMcpRequest(deps: Required<ServeDeps>, msg: JsonRpcRequest): Promise<Record<string, unknown> | null>;
21
+ /** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
22
+ export declare function serveStdio(cwd: string): Promise<number>;
23
+ export {};
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `klyro mcp serve` — expose the builtin tool registry as a minimal MCP
3
+ * server over stdio (newline-delimited JSON-RPC 2.0).
4
+ *
5
+ * Supported methods: `initialize`, `ping`, `tools/list`, `tools/call`.
6
+ * Every call is gated by the PolicyEngine (default config + builtin rules)
7
+ * in headless mode: `allow` runs, `deny`/`ask` are refused as tool errors
8
+ * (serve cannot prompt, so nothing privileged runs without an explicit
9
+ * allow rule) — serving never bypasses policy.
10
+ */
11
+ import * as readline from 'node:readline';
12
+ import { builtinRegistry } from '../tools/registry.js';
13
+ import { PolicyEngine, builtinRules, DEFAULT_POLICY_CONFIG } from '../policy/engine.js';
14
+ import { readVersion } from '../version.js';
15
+ export function makeServeDeps(cwd, overrides = {}) {
16
+ return {
17
+ registry: overrides.registry ?? builtinRegistry(),
18
+ policy: overrides.policy ?? new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG),
19
+ ctx: overrides.ctx ?? { cwd, env: process.env, nonInteractive: true },
20
+ };
21
+ }
22
+ /** Pure request handler — unit-tested without stdio. Returns null for notifications. */
23
+ export async function handleMcpRequest(deps, msg) {
24
+ const id = msg.id ?? null;
25
+ if (msg.method === undefined || typeof msg.method !== 'string') {
26
+ if (id === null || id === undefined)
27
+ return null;
28
+ return { jsonrpc: '2.0', id, error: { code: -32600, message: 'Invalid Request' } };
29
+ }
30
+ if (id === null || id === undefined)
31
+ return null; // notification — no response
32
+ if (msg.method === 'initialize') {
33
+ return {
34
+ jsonrpc: '2.0', id,
35
+ result: {
36
+ protocolVersion: '2024-11-05',
37
+ capabilities: { tools: {} },
38
+ serverInfo: { name: 'klyro', version: readVersion() },
39
+ },
40
+ };
41
+ }
42
+ if (msg.method === 'ping')
43
+ return { jsonrpc: '2.0', id, result: {} };
44
+ if (msg.method === 'tools/list') {
45
+ const schemas = deps.registry.jsonSchemas();
46
+ return {
47
+ jsonrpc: '2.0', id,
48
+ result: {
49
+ tools: deps.registry.list().map((t) => ({
50
+ name: t.name,
51
+ description: t.description,
52
+ inputSchema: schemas[t.name] ?? { type: 'object' },
53
+ })),
54
+ },
55
+ };
56
+ }
57
+ if (msg.method === 'tools/call') {
58
+ const name = msg.params?.name;
59
+ const args = msg.params?.arguments ?? {};
60
+ if (typeof name !== 'string' || !deps.registry.get(name)) {
61
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${String(name)}` } };
62
+ }
63
+ const decision = await deps.policy.evaluate({ name, input: (args ?? {}), permission: deps.registry.get(name)?.permission }, { cwd: deps.ctx.cwd, nonInteractive: true });
64
+ if (decision.action !== 'allow') {
65
+ return {
66
+ jsonrpc: '2.0', id,
67
+ result: {
68
+ content: [{ type: 'text', text: `POLICY_DENIED: ${decision.action === 'deny' ? decision.reason ?? 'denied' : 'approval required (non-interactive serve cannot prompt)'}` }],
69
+ isError: true,
70
+ },
71
+ };
72
+ }
73
+ const res = await deps.registry.execute(name, args, deps.ctx);
74
+ if (!res.ok) {
75
+ const e = res.error;
76
+ return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `${e.code ?? 'ERROR'}: ${e.message ?? ''}` }], isError: true } };
77
+ }
78
+ return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: typeof res.value === 'string' ? res.value : JSON.stringify(res.value) }] } };
79
+ }
80
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
81
+ }
82
+ /** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
83
+ export async function serveStdio(cwd) {
84
+ const deps = makeServeDeps(cwd);
85
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
86
+ for await (const line of rl) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed)
89
+ continue;
90
+ let msg;
91
+ try {
92
+ msg = JSON.parse(trimmed);
93
+ }
94
+ catch {
95
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n');
96
+ continue;
97
+ }
98
+ try {
99
+ const res = await handleMcpRequest(deps, msg);
100
+ if (res)
101
+ process.stdout.write(JSON.stringify(res) + '\n');
102
+ }
103
+ catch (err) {
104
+ const id = msg.id ?? null;
105
+ if (id !== null && id !== undefined) {
106
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } }) + '\n');
107
+ }
108
+ }
109
+ }
110
+ return 0;
111
+ }
@@ -11,7 +11,17 @@ export type ApprovalChoice =
11
11
  /** Yes, and auto-allow this pattern for the rest of the session. */
12
12
  | 'always'
13
13
  /** Yes, session-allow AND persist the pattern to settings (survives restarts). */
14
- | 'always-persist';
14
+ | 'always-persist'
15
+ /**
16
+ * Yes, but with edited input. Produced by the TUI modal (`e`): the
17
+ * runtime re-validates + re-evaluates policy on `editedInput` (bounded
18
+ * re-prompt loop) instead of running the original call. The stdin prompt
19
+ * has no editor — `e` there stays `deny`.
20
+ */
21
+ | {
22
+ kind: 'edit';
23
+ editedInput: Record<string, unknown>;
24
+ };
15
25
  export interface ApprovalRequest {
16
26
  toolName: string;
17
27
  reason: string;
@@ -37,6 +47,10 @@ export declare function sanitizeForPrompt(s: string): string;
37
47
  * so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
38
48
  * persist to settings. Case MUST be checked before lowercasing — the old TUI
39
49
  * code lowercased first, so `A` silently became session-only `always`.
50
+ *
51
+ * NOTE: `e` maps to `deny` here, but the TUI modal intercepts `e` BEFORE
52
+ * this mapper to open inline edit mode (edit-and-retry). Only callers
53
+ * without an editor (stdin prompt) should rely on the `e`→`deny` mapping.
40
54
  */
41
55
  export declare function approvalChoiceForKey(inputStr: string): ApprovalChoice | 'expand' | 'explain' | null;
42
56
  /** Default prompt backed by readline on stdin/stdout. */
@@ -30,6 +30,10 @@ export function sanitizeForPrompt(s) {
30
30
  * so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
31
31
  * persist to settings. Case MUST be checked before lowercasing — the old TUI
32
32
  * code lowercased first, so `A` silently became session-only `always`.
33
+ *
34
+ * NOTE: `e` maps to `deny` here, but the TUI modal intercepts `e` BEFORE
35
+ * this mapper to open inline edit mode (edit-and-retry). Only callers
36
+ * without an editor (stdin prompt) should rely on the `e`→`deny` mapping.
33
37
  */
34
38
  export function approvalChoiceForKey(inputStr) {
35
39
  if (inputStr === 'A')
@@ -101,6 +105,10 @@ export class PatternApprovalCache {
101
105
  if (key && this.session.has(key))
102
106
  return 'allow';
103
107
  const choice = await this.inner.ask(req);
108
+ // Edited input is a new call, not an approval of the pattern — pass
109
+ // through without caching so the re-evaluated call prompts on its own.
110
+ if (typeof choice === 'object')
111
+ return choice;
104
112
  if ((choice === 'always' || choice === 'always-persist') && key) {
105
113
  this.session.add(key);
106
114
  }
@@ -20,6 +20,13 @@ export interface ToolCallLike {
20
20
  name: string;
21
21
  /** Parsed tool input. */
22
22
  input: Record<string, unknown>;
23
+ /**
24
+ * Tool risk class from the registry (`read|edit|execute|admin`).
25
+ * The runtime always passes this; when present, `execute`/`admin`
26
+ * tools fall through to ask/deny instead of the legacy default-allow
27
+ * (see evaluate). Omitted in unit tests → legacy default-allow.
28
+ */
29
+ permission?: 'read' | 'edit' | 'execute' | 'admin';
23
30
  }
24
31
  export interface PolicyContext {
25
32
  cwd: string;
@@ -55,7 +62,10 @@ export interface PolicyRule {
55
62
  export declare const DEFAULT_POLICY_CONFIG: PolicyConfig;
56
63
  /**
57
64
  * Compose multiple rules. The first rule to return a Decision wins.
58
- * If none return a Decision, the default is `allow`.
65
+ * If none return a Decision, privileged tools (`execute`/`admin`, when
66
+ * the caller passes `permission`) fall through to ask (interactive) or
67
+ * deny (headless) instead of allow; everything else defaults to `allow`.
68
+ * `auto` mode keeps the legacy allow-everything behavior.
59
69
  */
60
70
  export declare class PolicyEngine {
61
71
  private readonly rules;
@@ -44,7 +44,10 @@ export const DEFAULT_POLICY_CONFIG = {
44
44
  };
45
45
  /**
46
46
  * Compose multiple rules. The first rule to return a Decision wins.
47
- * If none return a Decision, the default is `allow`.
47
+ * If none return a Decision, privileged tools (`execute`/`admin`, when
48
+ * the caller passes `permission`) fall through to ask (interactive) or
49
+ * deny (headless) instead of allow; everything else defaults to `allow`.
50
+ * `auto` mode keeps the legacy allow-everything behavior.
48
51
  */
49
52
  export class PolicyEngine {
50
53
  rules;
@@ -116,6 +119,16 @@ export class PolicyEngine {
116
119
  if (d)
117
120
  return d;
118
121
  }
122
+ // Privileged-class default: an `execute`/`admin` tool that no rule
123
+ // explicitly allowed must not run silently. Interactive sessions get
124
+ // an approval prompt; headless sessions get a denial naming the
125
+ // escape hatch (an explicit `tool`/`tool(glob)` allow rule).
126
+ if (ctx.config.mode !== 'auto' && (call.permission === 'execute' || call.permission === 'admin')) {
127
+ if (ctx.nonInteractive) {
128
+ return { action: 'deny', reason: `${call.name} is a privileged ${call.permission} tool — pre-approve with an allow rule (e.g. "${call.name}")` };
129
+ }
130
+ return { action: 'ask', reason: `${call.name} is a privileged ${call.permission} tool and needs approval` };
131
+ }
119
132
  return { action: 'allow' };
120
133
  }
121
134
  evaluateGlobRules(call) {
@@ -12,7 +12,10 @@ import { Transform } from 'node:stream';
12
12
  const PATTERNS = [
13
13
  { name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
14
14
  { name: 'aws-secret', re: /(?:aws_secret_access_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi },
15
- { name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
15
+ // Long base64-ish runs must BOTH contain a +/= (excludes pure-hex SHAs
16
+ // and hashes) AND a digit (excludes letter-only words/sentences that
17
+ // happen to be long). Genuine secrets mix classes; prose rarely does.
18
+ { name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])(?=[A-Za-z0-9/+=]*[0-9])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
16
19
  { name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
17
20
  { name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
18
21
  { name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },