klyro 0.1.63 → 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 +73 -16
- package/dist/agent/capabilities.d.ts +145 -0
- package/dist/agent/capabilities.js +191 -0
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +232 -0
- package/dist/agent/orchestrator.js +589 -0
- package/dist/agent/provider-adapter.d.ts +17 -0
- package/dist/agent/provider-adapter.js +35 -3
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +13 -2
- package/dist/agent/retry.js +21 -3
- package/dist/agent/runtime.d.ts +70 -8
- package/dist/agent/runtime.js +205 -34
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- 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/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +183 -17
- package/dist/cli/run.d.ts +10 -11
- package/dist/cli/run.js +176 -14
- 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.d.ts +6 -0
- package/dist/context/klyro-md.js +25 -16
- 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/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +99 -0
- package/dist/index.js +93 -4
- package/dist/mcp/client.d.ts +55 -0
- package/dist/mcp/client.js +294 -0
- package/dist/mcp/config.d.ts +40 -0
- package/dist/mcp/config.js +99 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +72 -0
- package/dist/mcp/registry.js +244 -0
- package/dist/mcp/schema.d.ts +18 -0
- package/dist/mcp/schema.js +57 -0
- 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 +91 -10
- 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.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -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/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +12 -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 +18 -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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { McpServerSpec } from './config.js';
|
|
2
|
+
export interface McpToolDef {
|
|
3
|
+
name: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
inputSchema?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface McpResource {
|
|
8
|
+
uri: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
mimeType?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface McpCallResult {
|
|
13
|
+
/** Normalized text payload (content blocks joined). */
|
|
14
|
+
text: string;
|
|
15
|
+
isError: boolean;
|
|
16
|
+
raw: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface McpClientLike {
|
|
19
|
+
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
20
|
+
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
21
|
+
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare class McpError extends Error {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly details?: unknown | undefined;
|
|
27
|
+
constructor(message: string, code: string, details?: unknown | undefined);
|
|
28
|
+
}
|
|
29
|
+
export declare class McpClient implements McpClientLike {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
private readonly spec;
|
|
32
|
+
private child;
|
|
33
|
+
private nextId;
|
|
34
|
+
private readonly pending;
|
|
35
|
+
private stdoutBuf;
|
|
36
|
+
private stderrTail;
|
|
37
|
+
private dead;
|
|
38
|
+
private closed;
|
|
39
|
+
constructor(name: string, spec: McpServerSpec);
|
|
40
|
+
connect(): Promise<void>;
|
|
41
|
+
private connected;
|
|
42
|
+
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
43
|
+
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
44
|
+
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
45
|
+
readResource(uri: string, signal?: AbortSignal): Promise<string>;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
/** Test hook: number of in-flight JSON-RPC requests. */
|
|
48
|
+
pendingCount(): number;
|
|
49
|
+
private timeout;
|
|
50
|
+
private assertLive;
|
|
51
|
+
private onStdout;
|
|
52
|
+
private notify;
|
|
53
|
+
private request;
|
|
54
|
+
private failAll;
|
|
55
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — MCP stdio client (r-11-17.md §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Minimal JSON-RPC 2.0 over newline-delimited stdio: `initialize`,
|
|
5
|
+
* `tools/list`, `tools/call`, `resources/list`, `resources/read`, `ping`.
|
|
6
|
+
* Every call is bound to an `AbortSignal` and a per-call timeout; spawn
|
|
7
|
+
* failures and server errors surface as typed `McpError`s — never throws
|
|
8
|
+
* raw across the boundary.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
11
|
+
export class McpError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
details;
|
|
14
|
+
constructor(message, code, details) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.details = details;
|
|
18
|
+
this.name = 'McpError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
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;
|
|
28
|
+
export class McpClient {
|
|
29
|
+
name;
|
|
30
|
+
spec;
|
|
31
|
+
child = null;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
stdoutBuf = '';
|
|
35
|
+
stderrTail = '';
|
|
36
|
+
dead = null;
|
|
37
|
+
closed = false;
|
|
38
|
+
constructor(name, spec) {
|
|
39
|
+
this.name = name;
|
|
40
|
+
this.spec = spec;
|
|
41
|
+
}
|
|
42
|
+
async connect() {
|
|
43
|
+
if (this.child)
|
|
44
|
+
return;
|
|
45
|
+
const timeoutMs = this.spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
46
|
+
await new Promise((resolve, reject) => {
|
|
47
|
+
let child;
|
|
48
|
+
try {
|
|
49
|
+
child = spawn(this.spec.command, this.spec.args ?? [], {
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
env: { ...process.env, ...(this.spec.env ?? {}) },
|
|
52
|
+
shell: false,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
reject(new McpError(`mcp server "${this.name}" spawn failed: ${err instanceof Error ? err.message : String(err)}`, 'SPAWN_FAILED'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
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', () => { });
|
|
67
|
+
child.on('error', (err) => {
|
|
68
|
+
this.failAll(new McpError(`mcp server "${this.name}" process error: ${err.message}`, 'SPAWN_FAILED'));
|
|
69
|
+
if (!this.connected)
|
|
70
|
+
reject(new McpError(`mcp server "${this.name}" spawn failed: ${err.message}`, 'SPAWN_FAILED'));
|
|
71
|
+
});
|
|
72
|
+
child.stderr?.on('data', (b) => {
|
|
73
|
+
this.stderrTail = (this.stderrTail + b.toString('utf-8')).slice(-MAX_STDERR_BYTES);
|
|
74
|
+
});
|
|
75
|
+
child.stdout?.on('data', (b) => this.onStdout(b.toString('utf-8')));
|
|
76
|
+
child.on('exit', (code, signal) => {
|
|
77
|
+
if (!this.closed) {
|
|
78
|
+
this.failAll(new McpError(`mcp server "${this.name}" exited (${signal ?? `code ${code}`})${this.stderrTail ? `: ${this.stderrTail.slice(-300)}` : ''}`, 'SERVER_EXITED'));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
// Consider the transport up once the process is spawned; the
|
|
82
|
+
// initialize handshake below proves the protocol.
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
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
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
await this.close();
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
connected = false;
|
|
116
|
+
async listTools(signal) {
|
|
117
|
+
this.assertLive();
|
|
118
|
+
const res = (await this.request('tools/list', {}, this.timeout(), signal));
|
|
119
|
+
return Array.isArray(res.tools) ? res.tools : [];
|
|
120
|
+
}
|
|
121
|
+
async callTool(name, args, signal) {
|
|
122
|
+
this.assertLive();
|
|
123
|
+
const res = (await this.request('tools/call', { name, arguments: args ?? {} }, this.timeout(), signal));
|
|
124
|
+
const blocks = Array.isArray(res.content) ? res.content : [];
|
|
125
|
+
const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
|
|
126
|
+
return { text, isError: res.isError === true, raw: res };
|
|
127
|
+
}
|
|
128
|
+
async listResources(signal) {
|
|
129
|
+
this.assertLive();
|
|
130
|
+
const res = (await this.request('resources/list', {}, this.timeout(), signal));
|
|
131
|
+
return Array.isArray(res.resources) ? res.resources : [];
|
|
132
|
+
}
|
|
133
|
+
async readResource(uri, signal) {
|
|
134
|
+
this.assertLive();
|
|
135
|
+
const res = (await this.request('resources/read', { uri }, this.timeout(), signal));
|
|
136
|
+
const contents = Array.isArray(res.contents) ? res.contents : [];
|
|
137
|
+
return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
|
|
138
|
+
}
|
|
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;
|
|
145
|
+
this.closed = true;
|
|
146
|
+
this.failAll(new McpError(`mcp server "${this.name}" closed`, 'CLOSED'));
|
|
147
|
+
const child = this.child;
|
|
148
|
+
this.child = null;
|
|
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() */ }
|
|
161
|
+
try {
|
|
162
|
+
child.kill();
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore */ }
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
child.kill('SIGTERM');
|
|
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;
|
|
183
|
+
}
|
|
184
|
+
/* ---------------- internals ---------------- */
|
|
185
|
+
timeout() {
|
|
186
|
+
return this.spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
187
|
+
}
|
|
188
|
+
assertLive() {
|
|
189
|
+
if (this.dead)
|
|
190
|
+
throw this.dead;
|
|
191
|
+
if (!this.child || this.closed)
|
|
192
|
+
throw new McpError(`mcp server "${this.name}" is not connected`, 'NOT_CONNECTED');
|
|
193
|
+
}
|
|
194
|
+
onStdout(chunk) {
|
|
195
|
+
this.stdoutBuf += chunk;
|
|
196
|
+
let idx;
|
|
197
|
+
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
198
|
+
const line = this.stdoutBuf.slice(0, idx).trim();
|
|
199
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
200
|
+
if (!line)
|
|
201
|
+
continue;
|
|
202
|
+
let msg;
|
|
203
|
+
try {
|
|
204
|
+
msg = JSON.parse(line);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
continue; // server chatter — ignore, don't crash
|
|
208
|
+
}
|
|
209
|
+
if (typeof msg.id !== 'number')
|
|
210
|
+
continue; // notification — ignore
|
|
211
|
+
const pend = this.pending.get(msg.id);
|
|
212
|
+
if (!pend)
|
|
213
|
+
continue;
|
|
214
|
+
this.pending.delete(msg.id);
|
|
215
|
+
clearTimeout(pend.timer);
|
|
216
|
+
if (pend.signal && pend.onAbort)
|
|
217
|
+
pend.signal.removeEventListener('abort', pend.onAbort);
|
|
218
|
+
if (msg.error) {
|
|
219
|
+
pend.reject(new McpError(`mcp server "${this.name}" error: ${msg.error.message ?? 'unknown'}`, 'SERVER_ERROR', msg.error));
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
pend.resolve(msg.result);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
notify(method, params) {
|
|
227
|
+
try {
|
|
228
|
+
this.child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
229
|
+
}
|
|
230
|
+
catch { /* best-effort */ }
|
|
231
|
+
}
|
|
232
|
+
request(method, params, timeoutMs, signal) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
if (signal?.aborted) {
|
|
235
|
+
reject(new McpError(`mcp call "${method}" aborted`, 'ABORTED'));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!this.child?.stdin) {
|
|
239
|
+
reject(new McpError(`mcp server "${this.name}" has no stdin`, 'NOT_CONNECTED'));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const id = this.nextId++;
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
const p = this.pending.get(id);
|
|
245
|
+
if (p?.signal && p.onAbort)
|
|
246
|
+
p.signal.removeEventListener('abort', p.onAbort);
|
|
247
|
+
this.pending.delete(id);
|
|
248
|
+
reject(new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT'));
|
|
249
|
+
}, timeoutMs);
|
|
250
|
+
const pend = { resolve, reject, timer };
|
|
251
|
+
if (signal) {
|
|
252
|
+
pend.signal = signal;
|
|
253
|
+
pend.onAbort = () => {
|
|
254
|
+
if (!this.pending.has(id))
|
|
255
|
+
return;
|
|
256
|
+
this.pending.delete(id);
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
reject(new McpError(`mcp call "${method}" aborted`, 'ABORTED'));
|
|
259
|
+
};
|
|
260
|
+
signal.addEventListener('abort', pend.onAbort, { once: true });
|
|
261
|
+
}
|
|
262
|
+
this.pending.set(id, pend);
|
|
263
|
+
try {
|
|
264
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', (err) => {
|
|
265
|
+
if (err) {
|
|
266
|
+
this.pending.delete(id);
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
if (signal && pend.onAbort)
|
|
269
|
+
signal.removeEventListener('abort', pend.onAbort);
|
|
270
|
+
reject(new McpError(`mcp call "${method}" write failed: ${err.message}`, 'IO_ERROR'));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
this.pending.delete(id);
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
if (signal && pend.onAbort)
|
|
278
|
+
signal.removeEventListener('abort', pend.onAbort);
|
|
279
|
+
reject(new McpError(`mcp call "${method}" write failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR'));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
failAll(err) {
|
|
284
|
+
if (!this.dead)
|
|
285
|
+
this.dead = err;
|
|
286
|
+
for (const [id, pend] of [...this.pending]) {
|
|
287
|
+
this.pending.delete(id);
|
|
288
|
+
clearTimeout(pend.timer);
|
|
289
|
+
if (pend.signal && pend.onAbort)
|
|
290
|
+
pend.signal.removeEventListener('abort', pend.onAbort);
|
|
291
|
+
pend.reject(err);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const McpServerPolicySchema: z.ZodObject<{
|
|
3
|
+
allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
4
|
+
denyTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
5
|
+
requireApproval: z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type McpServerPolicy = z.infer<typeof McpServerPolicySchema>;
|
|
9
|
+
export declare const McpServerSpecSchema: z.ZodObject<{
|
|
10
|
+
command: z.ZodString;
|
|
11
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
12
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
13
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
policy: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
16
|
+
denyTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
|
+
requireApproval: z.ZodOptional<z.ZodBoolean>;
|
|
18
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
}, z.core.$strip>>;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
export type McpServerSpec = z.infer<typeof McpServerSpecSchema>;
|
|
22
|
+
export interface McpServersConfig {
|
|
23
|
+
servers: Record<string, McpServerSpec>;
|
|
24
|
+
/** Where each server entry came from (for doctor/diagnostics). */
|
|
25
|
+
sources: Record<string, string>;
|
|
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;
|
|
37
|
+
/** Load + merge global and project MCP server specs. Invalid entries are skipped. */
|
|
38
|
+
export declare function loadMcpServers(cwd: string): McpServersConfig;
|
|
39
|
+
/** Servers eligible for connection (configured and not disabled). */
|
|
40
|
+
export declare function enabledServers(cfg: McpServersConfig): Record<string, McpServerSpec>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — MCP server configuration (r-11-17.md §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's project-level `.mcp.json` (`{mcpServers: {...}}`) for
|
|
5
|
+
* drop-in compatibility, and also tolerates the legacy Klyro shape
|
|
6
|
+
* (`{servers: {...}}` shown by `/mcp`). Global defaults live in
|
|
7
|
+
* `~/.klyro/mcp.json`; project config wins per-server.
|
|
8
|
+
*
|
|
9
|
+
* `${env:VAR}` references in `env` values expand from `process.env`.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
export const McpServerPolicySchema = z.object({
|
|
16
|
+
allowTools: z.array(z.string()).optional(),
|
|
17
|
+
denyTools: z.array(z.string()).optional(),
|
|
18
|
+
requireApproval: z.boolean().optional(),
|
|
19
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
20
|
+
});
|
|
21
|
+
export const McpServerSpecSchema = z.object({
|
|
22
|
+
command: z.string().min(1),
|
|
23
|
+
args: z.array(z.string()).optional(),
|
|
24
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
25
|
+
disabled: z.boolean().optional(),
|
|
26
|
+
policy: McpServerPolicySchema.optional(),
|
|
27
|
+
});
|
|
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) {
|
|
38
|
+
return value.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => process.env[name] ?? '');
|
|
39
|
+
}
|
|
40
|
+
function normalizeRaw(raw) {
|
|
41
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
42
|
+
return {};
|
|
43
|
+
const obj = raw;
|
|
44
|
+
const servers = obj['mcpServers'] ?? obj['servers'] ?? {};
|
|
45
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers))
|
|
46
|
+
return {};
|
|
47
|
+
return servers;
|
|
48
|
+
}
|
|
49
|
+
function readJsonFile(p) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Load + merge global and project MCP server specs. Invalid entries are skipped. */
|
|
58
|
+
export function loadMcpServers(cwd) {
|
|
59
|
+
const home = os.homedir() || process.cwd();
|
|
60
|
+
const globalPath = path.join(home, '.klyro', 'mcp.json');
|
|
61
|
+
const projectPath = path.join(cwd, '.mcp.json');
|
|
62
|
+
const servers = {};
|
|
63
|
+
const sources = {};
|
|
64
|
+
for (const [filePath, label] of [[globalPath, 'global'], [projectPath, 'project']]) {
|
|
65
|
+
const raw = normalizeRaw(readJsonFile(filePath));
|
|
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;
|
|
72
|
+
const parsed = McpServerSpecSchema.safeParse(specRaw);
|
|
73
|
+
if (!parsed.success)
|
|
74
|
+
continue;
|
|
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
|
+
}
|
|
79
|
+
if (spec.env) {
|
|
80
|
+
const env = {};
|
|
81
|
+
for (const [k, v] of Object.entries(spec.env))
|
|
82
|
+
env[k] = expandEnv(v);
|
|
83
|
+
spec.env = env;
|
|
84
|
+
}
|
|
85
|
+
servers[name] = spec;
|
|
86
|
+
sources[name] = label;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { servers, sources };
|
|
90
|
+
}
|
|
91
|
+
/** Servers eligible for connection (configured and not disabled). */
|
|
92
|
+
export function enabledServers(cfg) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [name, spec] of Object.entries(cfg.servers)) {
|
|
95
|
+
if (!spec.disabled)
|
|
96
|
+
out[name] = spec;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — per-server MCP tool policy (r-11-17.md: "deny by default").
|
|
3
|
+
*
|
|
4
|
+
* A server without an explicit `allowTools` list cannot execute anything:
|
|
5
|
+
* the registration layer refuses the call before any subprocess I/O. An
|
|
6
|
+
* explicit `denyTools` entry always wins over `allowTools`.
|
|
7
|
+
*/
|
|
8
|
+
import type { McpServerPolicy } from './config.js';
|
|
9
|
+
export interface McpPolicyDecision {
|
|
10
|
+
allowed: boolean;
|
|
11
|
+
reason?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function evaluateMcpPolicy(policy: McpServerPolicy | undefined, toolName: string): McpPolicyDecision;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function evaluateMcpPolicy(policy, toolName) {
|
|
2
|
+
if (!policy || !policy.allowTools || policy.allowTools.length === 0) {
|
|
3
|
+
return { allowed: false, reason: 'mcp tool denied: server has no allowTools allowlist' };
|
|
4
|
+
}
|
|
5
|
+
if (policy.denyTools?.includes(toolName)) {
|
|
6
|
+
return { allowed: false, reason: `mcp tool denied by server denyTools: ${toolName}` };
|
|
7
|
+
}
|
|
8
|
+
if (!policy.allowTools.includes(toolName)) {
|
|
9
|
+
return { allowed: false, reason: `mcp tool not in server allowTools: ${toolName}` };
|
|
10
|
+
}
|
|
11
|
+
return { allowed: true };
|
|
12
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — MCP tool registration (r-11-17.md §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Exposes MCP server tools through the {@link ToolRegistry} under
|
|
5
|
+
* `mcp__<server>__<tool>` names. Security properties (non-negotiable):
|
|
6
|
+
*
|
|
7
|
+
* 1. Deny-by-default: `evaluateMcpPolicy` runs BEFORE any client I/O; a
|
|
8
|
+
* denied tool returns POLICY_DENIED without touching the subprocess.
|
|
9
|
+
* 2. Redact-before-transcript: every success value AND error message passes
|
|
10
|
+
* through `redact()` before it can reach the model or trace.
|
|
11
|
+
* 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
|
|
12
|
+
* runtime loop prompts before executing.
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
import { type McpClientLike } from './client.js';
|
|
20
|
+
import { type McpServerSpec } from './config.js';
|
|
21
|
+
import type { PolicyEngine } from '../policy/engine.js';
|
|
22
|
+
import type { ToolRegistry } from '../tools/registry.js';
|
|
23
|
+
export interface McpRegisterResult {
|
|
24
|
+
registered: string[];
|
|
25
|
+
errors: {
|
|
26
|
+
server: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}[];
|
|
29
|
+
skipped: {
|
|
30
|
+
name: string;
|
|
31
|
+
reason: string;
|
|
32
|
+
}[];
|
|
33
|
+
closeAll: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export interface RegisterMcpOpts {
|
|
36
|
+
registry: ToolRegistry;
|
|
37
|
+
policy?: PolicyEngine;
|
|
38
|
+
clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
|
|
39
|
+
}
|
|
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
|
+
*/
|
|
50
|
+
export declare function sanitizeMcpName(server: string, tool: string): string;
|
|
51
|
+
export declare function registerMcpServers(cfg: {
|
|
52
|
+
servers: Record<string, McpServerSpec>;
|
|
53
|
+
}, opts: RegisterMcpOpts): Promise<McpRegisterResult>;
|
|
54
|
+
export declare function loadAndRegisterMcp(opts: {
|
|
55
|
+
cwd: string;
|
|
56
|
+
registry: ToolRegistry;
|
|
57
|
+
policy?: PolicyEngine;
|
|
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>;
|
|
72
|
+
}): Promise<McpRegisterResult>;
|