klyro 1.0.0 → 1.0.1
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/dist/agent/anthropic-adapter.d.ts +36 -0
- package/dist/agent/anthropic-adapter.js +68 -16
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +46 -5
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +106 -5
- package/dist/agent/orchestrator.js +378 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +2 -2
- package/dist/agent/runtime.d.ts +50 -8
- package/dist/agent/runtime.js +196 -33
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.js +30 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/config.d.ts +9 -3
- package/dist/cli/config.js +64 -3
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +155 -28
- package/dist/cli/run.d.ts +7 -11
- package/dist/cli/run.js +68 -17
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +28 -0
- package/dist/index.js +89 -4
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -1
- package/dist/mcp/registry.js +78 -6
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/engine.js +88 -8
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +17 -0
- package/dist/providers/model-info.js +35 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +71 -0
- package/dist/tools/fs/edit-file.js +65 -0
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +66 -0
- package/dist/tools/fs/write-file.js +67 -0
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +1 -0
- package/dist/tools/shell/shell-exec.js +83 -11
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +55 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +23 -12
- package/dist/verification/engine.js +3 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +33 -0
- package/dist/verification/scoped.js +28 -6
- package/package.json +1 -1
package/dist/mcp/client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* failures and server errors surface as typed `McpError`s — never throws
|
|
8
8
|
* raw across the boundary.
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from 'node:child_process';
|
|
10
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
11
11
|
export class McpError extends Error {
|
|
12
12
|
code;
|
|
13
13
|
details;
|
|
@@ -21,6 +21,10 @@ export class McpError extends Error {
|
|
|
21
21
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
22
22
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
23
|
const MAX_STDERR_BYTES = 8192;
|
|
24
|
+
/** Upper bound on spawn + initialize handshake; partial children are killed. */
|
|
25
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
26
|
+
/** Grace period between SIGTERM and SIGKILL in close(). */
|
|
27
|
+
const CLOSE_SIGKILL_AFTER_MS = 2000;
|
|
24
28
|
export class McpClient {
|
|
25
29
|
name;
|
|
26
30
|
spec;
|
|
@@ -54,6 +58,12 @@ export class McpClient {
|
|
|
54
58
|
return;
|
|
55
59
|
}
|
|
56
60
|
this.child = child;
|
|
61
|
+
// Swallow async stream errors (e.g. EPIPE on write-after-exit):
|
|
62
|
+
// request promises already surface failures via write callbacks and
|
|
63
|
+
// the exit handler, so these must never become uncaught exceptions.
|
|
64
|
+
child.stdin?.on('error', () => { });
|
|
65
|
+
child.stdout?.on('error', () => { });
|
|
66
|
+
child.stderr?.on('error', () => { });
|
|
57
67
|
child.on('error', (err) => {
|
|
58
68
|
this.failAll(new McpError(`mcp server "${this.name}" process error: ${err.message}`, 'SPAWN_FAILED'));
|
|
59
69
|
if (!this.connected)
|
|
@@ -73,13 +83,29 @@ export class McpClient {
|
|
|
73
83
|
resolve();
|
|
74
84
|
});
|
|
75
85
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
let connectTimer;
|
|
87
|
+
try {
|
|
88
|
+
await Promise.race([
|
|
89
|
+
(async () => {
|
|
90
|
+
await this.request('initialize', {
|
|
91
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
92
|
+
capabilities: {},
|
|
93
|
+
clientInfo: { name: 'klyro', version: '1.0.1' },
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
this.notify('notifications/initialized', {});
|
|
96
|
+
this.connected = true;
|
|
97
|
+
})(),
|
|
98
|
+
new Promise((_resolve, reject) => {
|
|
99
|
+
connectTimer = setTimeout(() => {
|
|
100
|
+
reject(new McpError(`mcp server "${this.name}" connect timed out after ${CONNECT_TIMEOUT_MS}ms`, 'TIMEOUT'));
|
|
101
|
+
}, CONNECT_TIMEOUT_MS);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (connectTimer)
|
|
107
|
+
clearTimeout(connectTimer);
|
|
108
|
+
}
|
|
83
109
|
}
|
|
84
110
|
catch (err) {
|
|
85
111
|
await this.close();
|
|
@@ -87,9 +113,9 @@ export class McpClient {
|
|
|
87
113
|
}
|
|
88
114
|
}
|
|
89
115
|
connected = false;
|
|
90
|
-
async listTools() {
|
|
116
|
+
async listTools(signal) {
|
|
91
117
|
this.assertLive();
|
|
92
|
-
const res = (await this.request('tools/list', {}, this.timeout()));
|
|
118
|
+
const res = (await this.request('tools/list', {}, this.timeout(), signal));
|
|
93
119
|
return Array.isArray(res.tools) ? res.tools : [];
|
|
94
120
|
}
|
|
95
121
|
async callTool(name, args, signal) {
|
|
@@ -99,9 +125,9 @@ export class McpClient {
|
|
|
99
125
|
const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
|
|
100
126
|
return { text, isError: res.isError === true, raw: res };
|
|
101
127
|
}
|
|
102
|
-
async listResources() {
|
|
128
|
+
async listResources(signal) {
|
|
103
129
|
this.assertLive();
|
|
104
|
-
const res = (await this.request('resources/list', {}, this.timeout()));
|
|
130
|
+
const res = (await this.request('resources/list', {}, this.timeout(), signal));
|
|
105
131
|
return Array.isArray(res.resources) ? res.resources : [];
|
|
106
132
|
}
|
|
107
133
|
async readResource(uri, signal) {
|
|
@@ -111,16 +137,49 @@ export class McpClient {
|
|
|
111
137
|
return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
|
|
112
138
|
}
|
|
113
139
|
async close() {
|
|
140
|
+
// Idempotent vs the exit handler: closed is set FIRST (the on('exit')
|
|
141
|
+
// callback checks it before failAll), and a second close() with no child
|
|
142
|
+
// left is a no-op.
|
|
143
|
+
if (this.closed && this.child === null)
|
|
144
|
+
return;
|
|
114
145
|
this.closed = true;
|
|
115
146
|
this.failAll(new McpError(`mcp server "${this.name}" closed`, 'CLOSED'));
|
|
116
147
|
const child = this.child;
|
|
117
148
|
this.child = null;
|
|
118
|
-
if (
|
|
149
|
+
if (!child)
|
|
150
|
+
return;
|
|
151
|
+
// Already reaped — no escalation timer needed.
|
|
152
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
153
|
+
return;
|
|
154
|
+
if (process.platform === 'win32') {
|
|
155
|
+
try {
|
|
156
|
+
if (child.pid !== undefined) {
|
|
157
|
+
execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* ignore — fall through to kill() */ }
|
|
119
161
|
try {
|
|
120
162
|
child.kill();
|
|
121
163
|
}
|
|
122
164
|
catch { /* ignore */ }
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
child.kill('SIGTERM');
|
|
123
169
|
}
|
|
170
|
+
catch { /* already gone */ }
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
try {
|
|
173
|
+
child.kill('SIGKILL');
|
|
174
|
+
}
|
|
175
|
+
catch { /* already gone */ }
|
|
176
|
+
}, CLOSE_SIGKILL_AFTER_MS);
|
|
177
|
+
timer.unref?.();
|
|
178
|
+
child.once('exit', () => clearTimeout(timer));
|
|
179
|
+
}
|
|
180
|
+
/** Test hook: number of in-flight JSON-RPC requests. */
|
|
181
|
+
pendingCount() {
|
|
182
|
+
return this.pending.size;
|
|
124
183
|
}
|
|
125
184
|
/* ---------------- internals ---------------- */
|
|
126
185
|
timeout() {
|
|
@@ -154,7 +213,8 @@ export class McpClient {
|
|
|
154
213
|
continue;
|
|
155
214
|
this.pending.delete(msg.id);
|
|
156
215
|
clearTimeout(pend.timer);
|
|
157
|
-
pend.signal
|
|
216
|
+
if (pend.signal && pend.onAbort)
|
|
217
|
+
pend.signal.removeEventListener('abort', pend.onAbort);
|
|
158
218
|
if (msg.error) {
|
|
159
219
|
pend.reject(new McpError(`mcp server "${this.name}" error: ${msg.error.message ?? 'unknown'}`, 'SERVER_ERROR', msg.error));
|
|
160
220
|
}
|
|
@@ -181,6 +241,9 @@ export class McpClient {
|
|
|
181
241
|
}
|
|
182
242
|
const id = this.nextId++;
|
|
183
243
|
const timer = setTimeout(() => {
|
|
244
|
+
const p = this.pending.get(id);
|
|
245
|
+
if (p?.signal && p.onAbort)
|
|
246
|
+
p.signal.removeEventListener('abort', p.onAbort);
|
|
184
247
|
this.pending.delete(id);
|
|
185
248
|
reject(new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT'));
|
|
186
249
|
}, timeoutMs);
|
|
@@ -202,6 +265,8 @@ export class McpClient {
|
|
|
202
265
|
if (err) {
|
|
203
266
|
this.pending.delete(id);
|
|
204
267
|
clearTimeout(timer);
|
|
268
|
+
if (signal && pend.onAbort)
|
|
269
|
+
signal.removeEventListener('abort', pend.onAbort);
|
|
205
270
|
reject(new McpError(`mcp call "${method}" write failed: ${err.message}`, 'IO_ERROR'));
|
|
206
271
|
}
|
|
207
272
|
});
|
|
@@ -209,6 +274,8 @@ export class McpClient {
|
|
|
209
274
|
catch (err) {
|
|
210
275
|
this.pending.delete(id);
|
|
211
276
|
clearTimeout(timer);
|
|
277
|
+
if (signal && pend.onAbort)
|
|
278
|
+
signal.removeEventListener('abort', pend.onAbort);
|
|
212
279
|
reject(new McpError(`mcp call "${method}" write failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR'));
|
|
213
280
|
}
|
|
214
281
|
});
|
|
@@ -219,6 +286,8 @@ export class McpClient {
|
|
|
219
286
|
for (const [id, pend] of [...this.pending]) {
|
|
220
287
|
this.pending.delete(id);
|
|
221
288
|
clearTimeout(pend.timer);
|
|
289
|
+
if (pend.signal && pend.onAbort)
|
|
290
|
+
pend.signal.removeEventListener('abort', pend.onAbort);
|
|
222
291
|
pend.reject(err);
|
|
223
292
|
}
|
|
224
293
|
}
|
package/dist/mcp/config.d.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface McpServersConfig {
|
|
|
24
24
|
/** Where each server entry came from (for doctor/diagnostics). */
|
|
25
25
|
sources: Record<string, string>;
|
|
26
26
|
}
|
|
27
|
+
/** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
|
|
28
|
+
export declare const MAX_MCP_TIMEOUT_MS = 600000;
|
|
29
|
+
/**
|
|
30
|
+
* Expand `${env:VAR}` references from `process.env`.
|
|
31
|
+
*
|
|
32
|
+
* A reference to an UNSET or empty variable expands to `''` (silent empty
|
|
33
|
+
* expansion): the entry is kept with the empty value rather than rejected,
|
|
34
|
+
* so callers always see the effective spec. Pure — exported for unit tests.
|
|
35
|
+
*/
|
|
36
|
+
export declare function expandEnv(value: string): string;
|
|
27
37
|
/** Load + merge global and project MCP server specs. Invalid entries are skipped. */
|
|
28
38
|
export declare function loadMcpServers(cwd: string): McpServersConfig;
|
|
29
39
|
/** Servers eligible for connection (configured and not disabled). */
|
package/dist/mcp/config.js
CHANGED
|
@@ -25,7 +25,16 @@ export const McpServerSpecSchema = z.object({
|
|
|
25
25
|
disabled: z.boolean().optional(),
|
|
26
26
|
policy: McpServerPolicySchema.optional(),
|
|
27
27
|
});
|
|
28
|
-
|
|
28
|
+
/** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
|
|
29
|
+
export const MAX_MCP_TIMEOUT_MS = 600_000;
|
|
30
|
+
/**
|
|
31
|
+
* Expand `${env:VAR}` references from `process.env`.
|
|
32
|
+
*
|
|
33
|
+
* A reference to an UNSET or empty variable expands to `''` (silent empty
|
|
34
|
+
* expansion): the entry is kept with the empty value rather than rejected,
|
|
35
|
+
* so callers always see the effective spec. Pure — exported for unit tests.
|
|
36
|
+
*/
|
|
37
|
+
export function expandEnv(value) {
|
|
29
38
|
return value.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => process.env[name] ?? '');
|
|
30
39
|
}
|
|
31
40
|
function normalizeRaw(raw) {
|
|
@@ -55,10 +64,18 @@ export function loadMcpServers(cwd) {
|
|
|
55
64
|
for (const [filePath, label] of [[globalPath, 'global'], [projectPath, 'project']]) {
|
|
56
65
|
const raw = normalizeRaw(readJsonFile(filePath));
|
|
57
66
|
for (const [name, specRaw] of Object.entries(raw)) {
|
|
67
|
+
// Empty server names can never produce a valid tool name — skip
|
|
68
|
+
// silently (shape stays `{servers, sources}`; registry reports
|
|
69
|
+
// empty TOOL names as errors at registration time).
|
|
70
|
+
if (name === '')
|
|
71
|
+
continue;
|
|
58
72
|
const parsed = McpServerSpecSchema.safeParse(specRaw);
|
|
59
73
|
if (!parsed.success)
|
|
60
74
|
continue;
|
|
61
75
|
const spec = parsed.data;
|
|
76
|
+
if (spec.policy?.timeoutMs !== undefined && spec.policy.timeoutMs > MAX_MCP_TIMEOUT_MS) {
|
|
77
|
+
spec.policy.timeoutMs = MAX_MCP_TIMEOUT_MS;
|
|
78
|
+
}
|
|
62
79
|
if (spec.env) {
|
|
63
80
|
const env = {};
|
|
64
81
|
for (const [k, v] of Object.entries(spec.env))
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -37,7 +37,16 @@ export interface RegisterMcpOpts {
|
|
|
37
37
|
policy?: PolicyEngine;
|
|
38
38
|
clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
|
|
39
39
|
}
|
|
40
|
-
/**
|
|
40
|
+
/** Success values are redacted FIRST, then truncated to this many chars. */
|
|
41
|
+
export declare const MCP_SUCCESS_MAX_CHARS = 12000;
|
|
42
|
+
/**
|
|
43
|
+
* `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64.
|
|
44
|
+
*
|
|
45
|
+
* No `'server'`/`'tool'` fallbacks: an empty raw part (or one that
|
|
46
|
+
* sanitizes to empty) yields an empty segment, and registration skips that
|
|
47
|
+
* tool with an `empty-name` error instead of masking a misconfiguration
|
|
48
|
+
* behind a plausible-looking name.
|
|
49
|
+
*/
|
|
41
50
|
export declare function sanitizeMcpName(server: string, tool: string): string;
|
|
42
51
|
export declare function registerMcpServers(cfg: {
|
|
43
52
|
servers: Record<string, McpServerSpec>;
|
|
@@ -47,4 +56,17 @@ export declare function loadAndRegisterMcp(opts: {
|
|
|
47
56
|
registry: ToolRegistry;
|
|
48
57
|
policy?: PolicyEngine;
|
|
49
58
|
clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
|
|
59
|
+
/**
|
|
60
|
+
* Consent gate for project-sourced (`.mcp.json`) servers, which can
|
|
61
|
+
* auto-spawn processes. Global-source servers connect as before; a project
|
|
62
|
+
* server connects ONLY when this callback returns true. When the callback
|
|
63
|
+
* is ABSENT, project servers are never auto-connected — each surfaces as
|
|
64
|
+
* an error (`project server requires approval (skipped)`) so the skip is
|
|
65
|
+
* visible. Callers may compose this with `McpTrust` (see `./trust.js`) to
|
|
66
|
+
* remember approvals per spec hash.
|
|
67
|
+
*/
|
|
68
|
+
approveProjectServer?: (info: {
|
|
69
|
+
name: string;
|
|
70
|
+
source: 'global' | 'project';
|
|
71
|
+
}) => Promise<boolean>;
|
|
50
72
|
}): Promise<McpRegisterResult>;
|
package/dist/mcp/registry.js
CHANGED
|
@@ -26,14 +26,23 @@ import { defineTool } from '../tools/types.js';
|
|
|
26
26
|
const MAX_NAME_LEN = 64;
|
|
27
27
|
/** Server part is capped here; the tool part takes whatever remains. */
|
|
28
28
|
const MAX_SERVER_PART = 20;
|
|
29
|
+
/** Success values are redacted FIRST, then truncated to this many chars. */
|
|
30
|
+
export const MCP_SUCCESS_MAX_CHARS = 12_000;
|
|
29
31
|
function sanitizePart(s) {
|
|
30
32
|
return s.replace(/[^A-Za-z0-9_]/g, '_');
|
|
31
33
|
}
|
|
32
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64.
|
|
36
|
+
*
|
|
37
|
+
* No `'server'`/`'tool'` fallbacks: an empty raw part (or one that
|
|
38
|
+
* sanitizes to empty) yields an empty segment, and registration skips that
|
|
39
|
+
* tool with an `empty-name` error instead of masking a misconfiguration
|
|
40
|
+
* behind a plausible-looking name.
|
|
41
|
+
*/
|
|
33
42
|
export function sanitizeMcpName(server, tool) {
|
|
34
|
-
const srv = sanitizePart(server).slice(0, MAX_SERVER_PART)
|
|
43
|
+
const srv = sanitizePart(server).slice(0, MAX_SERVER_PART);
|
|
35
44
|
const maxTool = Math.max(1, MAX_NAME_LEN - 'mcp__'.length - srv.length - '__'.length);
|
|
36
|
-
const tl = sanitizePart(tool).slice(0, maxTool)
|
|
45
|
+
const tl = sanitizePart(tool).slice(0, maxTool);
|
|
37
46
|
return `mcp__${srv}__${tl}`;
|
|
38
47
|
}
|
|
39
48
|
function errMessage(err) {
|
|
@@ -66,8 +75,17 @@ async function executeMcpTool(spec, toolDef, client, input, ctx) {
|
|
|
66
75
|
if (res.isError) {
|
|
67
76
|
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
|
|
68
77
|
}
|
|
69
|
-
// (5) Success — redact BEFORE the value reaches the model/trace
|
|
70
|
-
|
|
78
|
+
// (5) Success — redact BEFORE the value reaches the model/trace, then
|
|
79
|
+
// truncate to a bounded size with a marker.
|
|
80
|
+
const redacted = redact(res.text);
|
|
81
|
+
if (redacted.length > MCP_SUCCESS_MAX_CHARS) {
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
value: redacted.slice(0, MCP_SUCCESS_MAX_CHARS) +
|
|
85
|
+
`\n... [truncated ${redacted.length - MCP_SUCCESS_MAX_CHARS} chars]`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, value: redacted };
|
|
71
89
|
}
|
|
72
90
|
catch (err) {
|
|
73
91
|
// Tool contract: never throw — always return a ToolResult.
|
|
@@ -81,6 +99,9 @@ export async function registerMcpServers(cfg, opts) {
|
|
|
81
99
|
const errors = [];
|
|
82
100
|
const skipped = [];
|
|
83
101
|
const clients = [];
|
|
102
|
+
// Sanitized name → first raw (server, tool) that claimed it, used to tell
|
|
103
|
+
// sanitization-collisions apart from exact-duplicates (see below).
|
|
104
|
+
const claimed = new Map();
|
|
84
105
|
for (const [server, spec] of Object.entries(cfg.servers)) {
|
|
85
106
|
try {
|
|
86
107
|
// enabledServers-style: skip disabled entries silently.
|
|
@@ -116,11 +137,35 @@ export async function registerMcpServers(cfg, opts) {
|
|
|
116
137
|
}
|
|
117
138
|
clients.push(client);
|
|
118
139
|
for (const toolDef of tools) {
|
|
140
|
+
// Empty raw names (or names that sanitize to empty) are a
|
|
141
|
+
// misconfiguration — skip as an error, never with a fallback name.
|
|
142
|
+
if (!server || !toolDef.name || sanitizePart(server) === '' || sanitizePart(toolDef.name) === '') {
|
|
143
|
+
errors.push({ server, message: `empty-name: server "${server}" tool "${toolDef.name}" sanitizes to an empty name part (skipped)` });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
119
146
|
const name = sanitizeMcpName(server, toolDef.name);
|
|
147
|
+
const prior = claimed.get(name);
|
|
148
|
+
if (prior) {
|
|
149
|
+
if (prior.server === server && prior.tool === toolDef.name) {
|
|
150
|
+
skipped.push({ name, reason: 'name-collision' });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Sanitization-collision: two DIFFERENT raw names map to the same
|
|
154
|
+
// sanitized name. This is an error (not a silent skip) and names
|
|
155
|
+
// both raw identities so the conflict is actionable.
|
|
156
|
+
errors.push({
|
|
157
|
+
server,
|
|
158
|
+
message: `name collision: "${prior.server}/${prior.tool}" and "${server}/${toolDef.name}" both sanitize to "${name}" (skipped)`,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
120
162
|
if (registry.get(name)) {
|
|
163
|
+
// Exact-duplicate: the literal name already exists (e.g. a
|
|
164
|
+
// builtin) — skip quietly, first registration wins.
|
|
121
165
|
skipped.push({ name, reason: 'name-collision' });
|
|
122
166
|
continue;
|
|
123
167
|
}
|
|
168
|
+
claimed.set(name, { server, tool: toolDef.name });
|
|
124
169
|
// Capture per-tool bindings for the closure.
|
|
125
170
|
const boundSpec = spec;
|
|
126
171
|
const boundDef = toolDef;
|
|
@@ -158,7 +203,34 @@ export async function registerMcpServers(cfg, opts) {
|
|
|
158
203
|
export async function loadAndRegisterMcp(opts) {
|
|
159
204
|
try {
|
|
160
205
|
const cfg = loadMcpServers(opts.cwd);
|
|
161
|
-
|
|
206
|
+
const filtered = {};
|
|
207
|
+
const gateErrors = [];
|
|
208
|
+
for (const [name, spec] of Object.entries(cfg.servers)) {
|
|
209
|
+
if (cfg.sources[name] !== 'project') {
|
|
210
|
+
filtered[name] = spec;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!opts.approveProjectServer) {
|
|
214
|
+
gateErrors.push({ server: name, message: 'project server requires approval (skipped)' });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let ok = false;
|
|
218
|
+
try {
|
|
219
|
+
ok = await opts.approveProjectServer({ name, source: 'project' });
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
gateErrors.push({ server: name, message: `project server approval error (skipped): ${errMessage(err)}` });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!ok) {
|
|
226
|
+
gateErrors.push({ server: name, message: 'project server not approved (skipped)' });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
filtered[name] = spec;
|
|
230
|
+
}
|
|
231
|
+
const res = await registerMcpServers({ servers: filtered }, opts);
|
|
232
|
+
res.errors.unshift(...gateErrors);
|
|
233
|
+
return res;
|
|
162
234
|
}
|
|
163
235
|
catch (err) {
|
|
164
236
|
// Never throws — surface load failures as error entries.
|
package/dist/mcp/schema.d.ts
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
* P1 — JSON Schema → Zod converter for MCP tool input schemas.
|
|
3
3
|
*
|
|
4
4
|
* MCP servers describe inputs with JSON Schema; Klyro tools validate with
|
|
5
|
-
* Zod.
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
5
|
+
* Zod. Supported subset:
|
|
6
|
+
*
|
|
7
|
+
* - `type`: `object` (with `properties` + `required`), `string`,
|
|
8
|
+
* `number`, `integer`, `boolean`, `array` (with `items`)
|
|
9
|
+
* - string `enum` (handled before `type`)
|
|
10
|
+
* - a schema object WITHOUT a `type` but WITH a `properties` map is
|
|
11
|
+
* treated as `type: 'object'` (many servers omit the type)
|
|
12
|
+
*
|
|
13
|
+
* Anything else — an empty/absent schema, non-object input, an unknown
|
|
14
|
+
* `type` — falls back to `z.unknown()` (accept anything, validate nothing).
|
|
15
|
+
* Validation must never trust, but must also never crash on exotic schemas.
|
|
9
16
|
*/
|
|
10
17
|
import { z } from 'zod';
|
|
11
18
|
export declare function jsonSchemaToZod(schema: unknown): z.ZodTypeAny;
|
package/dist/mcp/schema.js
CHANGED
|
@@ -2,22 +2,43 @@
|
|
|
2
2
|
* P1 — JSON Schema → Zod converter for MCP tool input schemas.
|
|
3
3
|
*
|
|
4
4
|
* MCP servers describe inputs with JSON Schema; Klyro tools validate with
|
|
5
|
-
* Zod.
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
5
|
+
* Zod. Supported subset:
|
|
6
|
+
*
|
|
7
|
+
* - `type`: `object` (with `properties` + `required`), `string`,
|
|
8
|
+
* `number`, `integer`, `boolean`, `array` (with `items`)
|
|
9
|
+
* - string `enum` (handled before `type`)
|
|
10
|
+
* - a schema object WITHOUT a `type` but WITH a `properties` map is
|
|
11
|
+
* treated as `type: 'object'` (many servers omit the type)
|
|
12
|
+
*
|
|
13
|
+
* Anything else — an empty/absent schema, non-object input, an unknown
|
|
14
|
+
* `type` — falls back to `z.unknown()` (accept anything, validate nothing).
|
|
15
|
+
* Validation must never trust, but must also never crash on exotic schemas.
|
|
9
16
|
*/
|
|
10
17
|
import { z } from 'zod';
|
|
18
|
+
function objectFrom(s) {
|
|
19
|
+
const props = s['properties'] ?? {};
|
|
20
|
+
const required = new Set(Array.isArray(s['required']) ? s['required'].filter((v) => typeof v === 'string') : []);
|
|
21
|
+
const shape = {};
|
|
22
|
+
for (const [k, v] of Object.entries(props)) {
|
|
23
|
+
const inner = jsonSchemaToZod(v);
|
|
24
|
+
shape[k] = required.has(k) ? inner : inner.optional();
|
|
25
|
+
}
|
|
26
|
+
return z.looseObject(shape);
|
|
27
|
+
}
|
|
11
28
|
export function jsonSchemaToZod(schema) {
|
|
12
29
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
13
|
-
return z.
|
|
30
|
+
return z.unknown();
|
|
14
31
|
}
|
|
15
32
|
const s = schema;
|
|
16
33
|
const enumerated = Array.isArray(s['enum']) ? s['enum'] : undefined;
|
|
17
34
|
if (enumerated && enumerated.length > 0 && enumerated.every((v) => typeof v === 'string')) {
|
|
18
35
|
return z.enum(enumerated);
|
|
19
36
|
}
|
|
20
|
-
|
|
37
|
+
const t = s['type'];
|
|
38
|
+
if (t === 'object' || (t === undefined && s['properties'] !== undefined && typeof s['properties'] === 'object' && s['properties'] !== null && !Array.isArray(s['properties']))) {
|
|
39
|
+
return objectFrom(s);
|
|
40
|
+
}
|
|
41
|
+
switch (t) {
|
|
21
42
|
case 'string':
|
|
22
43
|
return z.string();
|
|
23
44
|
case 'number':
|
|
@@ -30,16 +51,6 @@ export function jsonSchemaToZod(schema) {
|
|
|
30
51
|
const items = jsonSchemaToZod(s['items']);
|
|
31
52
|
return z.array(items);
|
|
32
53
|
}
|
|
33
|
-
case 'object': {
|
|
34
|
-
const props = s['properties'] ?? {};
|
|
35
|
-
const required = new Set(Array.isArray(s['required']) ? s['required'].filter((v) => typeof v === 'string') : []);
|
|
36
|
-
const shape = {};
|
|
37
|
-
for (const [k, v] of Object.entries(props)) {
|
|
38
|
-
const inner = jsonSchemaToZod(v);
|
|
39
|
-
shape[k] = required.has(k) ? inner : inner.optional();
|
|
40
|
-
}
|
|
41
|
-
return z.looseObject(shape);
|
|
42
|
-
}
|
|
43
54
|
default:
|
|
44
55
|
return z.unknown();
|
|
45
56
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { McpServerSpec } from './config.js';
|
|
2
|
+
export interface McpTrustRecord {
|
|
3
|
+
sha256: string;
|
|
4
|
+
trustedAt: number;
|
|
5
|
+
}
|
|
6
|
+
export type McpTrustStore = Record<string, McpTrustRecord>;
|
|
7
|
+
/** sha256 of the canonical (key-sorted) JSON encoding of a server spec. */
|
|
8
|
+
export declare function hashSpec(spec: McpServerSpec): string;
|
|
9
|
+
export declare function defaultMcpTrustStorePath(): string;
|
|
10
|
+
export declare class McpTrust {
|
|
11
|
+
private readonly storePath;
|
|
12
|
+
private store;
|
|
13
|
+
constructor(storePath?: string);
|
|
14
|
+
private load;
|
|
15
|
+
private save;
|
|
16
|
+
/** True only when `name` was approved for exactly this spec hash. */
|
|
17
|
+
isTrusted(name: string, hash: string): boolean;
|
|
18
|
+
/** Record approval of `name` for exactly this spec hash. */
|
|
19
|
+
approve(name: string, hash: string): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — MCP server-spec trust store (hash-based approval persistence).
|
|
3
|
+
*
|
|
4
|
+
* Companions the project-server consent gate in `registry.ts`: callers ask
|
|
5
|
+
* the user to approve a project-sourced server once via
|
|
6
|
+
* `approveProjectServer`, then persist `(name → sha256(spec))` here so later
|
|
7
|
+
* runs can auto-approve unchanged specs. Any spec change (new hash) requires
|
|
8
|
+
* fresh approval. Decisions persist in `~/.klyro/mcp-trust.json`.
|
|
9
|
+
*
|
|
10
|
+
* `registry.ts` does NOT use this class directly — callers compose it with
|
|
11
|
+
* the `approveProjectServer` callback. This module is intentionally
|
|
12
|
+
* side-effect free on import (file I/O happens in the constructor).
|
|
13
|
+
*/
|
|
14
|
+
import * as crypto from 'node:crypto';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
/** Deterministic JSON: object keys sorted recursively, arrays preserved. */
|
|
19
|
+
function stableStringify(value) {
|
|
20
|
+
if (value === null || typeof value !== 'object')
|
|
21
|
+
return JSON.stringify(value) ?? 'null';
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
|
24
|
+
const entries = Object.entries(value)
|
|
25
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
26
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
27
|
+
return `{${entries.join(',')}}`;
|
|
28
|
+
}
|
|
29
|
+
/** sha256 of the canonical (key-sorted) JSON encoding of a server spec. */
|
|
30
|
+
export function hashSpec(spec) {
|
|
31
|
+
return crypto.createHash('sha256').update(stableStringify(spec), 'utf-8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
export function defaultMcpTrustStorePath() {
|
|
34
|
+
return path.join(os.homedir() || process.cwd(), '.klyro', 'mcp-trust.json');
|
|
35
|
+
}
|
|
36
|
+
export class McpTrust {
|
|
37
|
+
storePath;
|
|
38
|
+
store = {};
|
|
39
|
+
constructor(storePath = defaultMcpTrustStorePath()) {
|
|
40
|
+
this.storePath = storePath;
|
|
41
|
+
this.load();
|
|
42
|
+
}
|
|
43
|
+
load() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(this.storePath, 'utf-8');
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
48
|
+
this.store = parsed;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
this.store = {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
save() {
|
|
56
|
+
try {
|
|
57
|
+
fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
|
|
58
|
+
fs.writeFileSync(this.storePath, JSON.stringify(this.store, null, 2), 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* best-effort — trust stays in memory for the session */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** True only when `name` was approved for exactly this spec hash. */
|
|
65
|
+
isTrusted(name, hash) {
|
|
66
|
+
const rec = this.store[name];
|
|
67
|
+
return !!rec && rec.sha256 === hash;
|
|
68
|
+
}
|
|
69
|
+
/** Record approval of `name` for exactly this spec hash. */
|
|
70
|
+
approve(name, hash) {
|
|
71
|
+
this.store[name] = { sha256: hash, trustedAt: Date.now() };
|
|
72
|
+
this.save();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Audit log — append-only JSONL of runtime events. Same shape as the
|
|
3
3
|
* future durable-task scheduler will read, so the data layout is
|
|
4
4
|
* forward-compatible.
|
|
5
|
+
*
|
|
6
|
+
* Durability: every record is hash-chained. Each line carries `prevHash`
|
|
7
|
+
* (hex sha256 of the previous line's canonical JSON, 'GENESIS' for the
|
|
8
|
+
* first line) and `hash` (sha256 of the record-with-prevHash canonical
|
|
9
|
+
* JSON). `verifyAuditChain` recomputes the chain for `klyro audit`.
|
|
5
10
|
*/
|
|
6
11
|
export type AuditEvent = {
|
|
7
12
|
kind: 'session_created';
|
|
@@ -68,8 +73,31 @@ export type AuditEvent = {
|
|
|
68
73
|
attempt: number;
|
|
69
74
|
ts: number;
|
|
70
75
|
};
|
|
76
|
+
export declare const AUDIT_GENESIS = "GENESIS";
|
|
77
|
+
export interface ChainedAuditRecord {
|
|
78
|
+
prevHash: string;
|
|
79
|
+
hash: string;
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
/** Canonical JSON: object keys sorted recursively, so hashes are stable. */
|
|
83
|
+
export declare function canonicalJson(value: unknown): string;
|
|
84
|
+
export declare function sha256Hex(s: string): string;
|
|
85
|
+
/** Hash of a chained record excluding its own `hash` field. */
|
|
86
|
+
export declare function hashAuditRecord(record: Record<string, unknown>): string;
|
|
71
87
|
export declare class AuditLog {
|
|
72
88
|
private readonly filePath;
|
|
89
|
+
/** Serializes chained appends so concurrent writes can't fork the chain. */
|
|
90
|
+
private chain;
|
|
73
91
|
constructor(filePath: string);
|
|
74
92
|
write(event: AuditEvent): Promise<void>;
|
|
93
|
+
private appendChained;
|
|
75
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Recompute the hash chain of a session JSONL file.
|
|
97
|
+
* Sessions live at `<sessionsDir>/<sessionId>.jsonl` (see SessionStore).
|
|
98
|
+
*/
|
|
99
|
+
export declare function verifyAuditChain(sessionsDir: string, sessionId: string): Promise<{
|
|
100
|
+
ok: boolean;
|
|
101
|
+
events: number;
|
|
102
|
+
error?: string;
|
|
103
|
+
}>;
|