klyro 0.1.63 → 1.0.0
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.js +6 -1
- package/dist/agent/capabilities.d.ts +122 -0
- package/dist/agent/capabilities.js +150 -0
- package/dist/agent/orchestrator.d.ts +131 -0
- package/dist/agent/orchestrator.js +269 -0
- package/dist/agent/provider-adapter.d.ts +9 -0
- package/dist/agent/provider-adapter.js +24 -1
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +12 -1
- package/dist/agent/retry.js +19 -1
- package/dist/agent/runtime.d.ts +20 -0
- package/dist/agent/runtime.js +9 -1
- 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/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/repl.js +41 -2
- package/dist/cli/run.d.ts +3 -0
- package/dist/cli/run.js +118 -7
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +21 -15
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +71 -0
- package/dist/index.js +4 -0
- package/dist/mcp/client.d.ts +53 -0
- package/dist/mcp/client.js +225 -0
- package/dist/mcp/config.d.ts +30 -0
- package/dist/mcp/config.js +82 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +50 -0
- package/dist/mcp/registry.js +172 -0
- package/dist/mcp/schema.d.ts +11 -0
- package/dist/mcp/schema.js +46 -0
- package/dist/policy/engine.js +3 -2
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -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/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/types.d.ts +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
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 { 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
|
+
export class McpClient {
|
|
25
|
+
name;
|
|
26
|
+
spec;
|
|
27
|
+
child = null;
|
|
28
|
+
nextId = 1;
|
|
29
|
+
pending = new Map();
|
|
30
|
+
stdoutBuf = '';
|
|
31
|
+
stderrTail = '';
|
|
32
|
+
dead = null;
|
|
33
|
+
closed = false;
|
|
34
|
+
constructor(name, spec) {
|
|
35
|
+
this.name = name;
|
|
36
|
+
this.spec = spec;
|
|
37
|
+
}
|
|
38
|
+
async connect() {
|
|
39
|
+
if (this.child)
|
|
40
|
+
return;
|
|
41
|
+
const timeoutMs = this.spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
let child;
|
|
44
|
+
try {
|
|
45
|
+
child = spawn(this.spec.command, this.spec.args ?? [], {
|
|
46
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
47
|
+
env: { ...process.env, ...(this.spec.env ?? {}) },
|
|
48
|
+
shell: false,
|
|
49
|
+
windowsHide: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
reject(new McpError(`mcp server "${this.name}" spawn failed: ${err instanceof Error ? err.message : String(err)}`, 'SPAWN_FAILED'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.child = child;
|
|
57
|
+
child.on('error', (err) => {
|
|
58
|
+
this.failAll(new McpError(`mcp server "${this.name}" process error: ${err.message}`, 'SPAWN_FAILED'));
|
|
59
|
+
if (!this.connected)
|
|
60
|
+
reject(new McpError(`mcp server "${this.name}" spawn failed: ${err.message}`, 'SPAWN_FAILED'));
|
|
61
|
+
});
|
|
62
|
+
child.stderr?.on('data', (b) => {
|
|
63
|
+
this.stderrTail = (this.stderrTail + b.toString('utf-8')).slice(-MAX_STDERR_BYTES);
|
|
64
|
+
});
|
|
65
|
+
child.stdout?.on('data', (b) => this.onStdout(b.toString('utf-8')));
|
|
66
|
+
child.on('exit', (code, signal) => {
|
|
67
|
+
if (!this.closed) {
|
|
68
|
+
this.failAll(new McpError(`mcp server "${this.name}" exited (${signal ?? `code ${code}`})${this.stderrTail ? `: ${this.stderrTail.slice(-300)}` : ''}`, 'SERVER_EXITED'));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// Consider the transport up once the process is spawned; the
|
|
72
|
+
// initialize handshake below proves the protocol.
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
await this.request('initialize', {
|
|
77
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
78
|
+
capabilities: {},
|
|
79
|
+
clientInfo: { name: 'klyro', version: '1.0.0' },
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
this.notify('notifications/initialized', {});
|
|
82
|
+
this.connected = true;
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
await this.close();
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
connected = false;
|
|
90
|
+
async listTools() {
|
|
91
|
+
this.assertLive();
|
|
92
|
+
const res = (await this.request('tools/list', {}, this.timeout()));
|
|
93
|
+
return Array.isArray(res.tools) ? res.tools : [];
|
|
94
|
+
}
|
|
95
|
+
async callTool(name, args, signal) {
|
|
96
|
+
this.assertLive();
|
|
97
|
+
const res = (await this.request('tools/call', { name, arguments: args ?? {} }, this.timeout(), signal));
|
|
98
|
+
const blocks = Array.isArray(res.content) ? res.content : [];
|
|
99
|
+
const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
|
|
100
|
+
return { text, isError: res.isError === true, raw: res };
|
|
101
|
+
}
|
|
102
|
+
async listResources() {
|
|
103
|
+
this.assertLive();
|
|
104
|
+
const res = (await this.request('resources/list', {}, this.timeout()));
|
|
105
|
+
return Array.isArray(res.resources) ? res.resources : [];
|
|
106
|
+
}
|
|
107
|
+
async readResource(uri, signal) {
|
|
108
|
+
this.assertLive();
|
|
109
|
+
const res = (await this.request('resources/read', { uri }, this.timeout(), signal));
|
|
110
|
+
const contents = Array.isArray(res.contents) ? res.contents : [];
|
|
111
|
+
return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
|
|
112
|
+
}
|
|
113
|
+
async close() {
|
|
114
|
+
this.closed = true;
|
|
115
|
+
this.failAll(new McpError(`mcp server "${this.name}" closed`, 'CLOSED'));
|
|
116
|
+
const child = this.child;
|
|
117
|
+
this.child = null;
|
|
118
|
+
if (child && !child.killed) {
|
|
119
|
+
try {
|
|
120
|
+
child.kill();
|
|
121
|
+
}
|
|
122
|
+
catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/* ---------------- internals ---------------- */
|
|
126
|
+
timeout() {
|
|
127
|
+
return this.spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
128
|
+
}
|
|
129
|
+
assertLive() {
|
|
130
|
+
if (this.dead)
|
|
131
|
+
throw this.dead;
|
|
132
|
+
if (!this.child || this.closed)
|
|
133
|
+
throw new McpError(`mcp server "${this.name}" is not connected`, 'NOT_CONNECTED');
|
|
134
|
+
}
|
|
135
|
+
onStdout(chunk) {
|
|
136
|
+
this.stdoutBuf += chunk;
|
|
137
|
+
let idx;
|
|
138
|
+
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
139
|
+
const line = this.stdoutBuf.slice(0, idx).trim();
|
|
140
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
141
|
+
if (!line)
|
|
142
|
+
continue;
|
|
143
|
+
let msg;
|
|
144
|
+
try {
|
|
145
|
+
msg = JSON.parse(line);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue; // server chatter — ignore, don't crash
|
|
149
|
+
}
|
|
150
|
+
if (typeof msg.id !== 'number')
|
|
151
|
+
continue; // notification — ignore
|
|
152
|
+
const pend = this.pending.get(msg.id);
|
|
153
|
+
if (!pend)
|
|
154
|
+
continue;
|
|
155
|
+
this.pending.delete(msg.id);
|
|
156
|
+
clearTimeout(pend.timer);
|
|
157
|
+
pend.signal?.removeEventListener('abort', pend.onAbort);
|
|
158
|
+
if (msg.error) {
|
|
159
|
+
pend.reject(new McpError(`mcp server "${this.name}" error: ${msg.error.message ?? 'unknown'}`, 'SERVER_ERROR', msg.error));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
pend.resolve(msg.result);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
notify(method, params) {
|
|
167
|
+
try {
|
|
168
|
+
this.child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
169
|
+
}
|
|
170
|
+
catch { /* best-effort */ }
|
|
171
|
+
}
|
|
172
|
+
request(method, params, timeoutMs, signal) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
if (signal?.aborted) {
|
|
175
|
+
reject(new McpError(`mcp call "${method}" aborted`, 'ABORTED'));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!this.child?.stdin) {
|
|
179
|
+
reject(new McpError(`mcp server "${this.name}" has no stdin`, 'NOT_CONNECTED'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const id = this.nextId++;
|
|
183
|
+
const timer = setTimeout(() => {
|
|
184
|
+
this.pending.delete(id);
|
|
185
|
+
reject(new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT'));
|
|
186
|
+
}, timeoutMs);
|
|
187
|
+
const pend = { resolve, reject, timer };
|
|
188
|
+
if (signal) {
|
|
189
|
+
pend.signal = signal;
|
|
190
|
+
pend.onAbort = () => {
|
|
191
|
+
if (!this.pending.has(id))
|
|
192
|
+
return;
|
|
193
|
+
this.pending.delete(id);
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
reject(new McpError(`mcp call "${method}" aborted`, 'ABORTED'));
|
|
196
|
+
};
|
|
197
|
+
signal.addEventListener('abort', pend.onAbort, { once: true });
|
|
198
|
+
}
|
|
199
|
+
this.pending.set(id, pend);
|
|
200
|
+
try {
|
|
201
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', (err) => {
|
|
202
|
+
if (err) {
|
|
203
|
+
this.pending.delete(id);
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
reject(new McpError(`mcp call "${method}" write failed: ${err.message}`, 'IO_ERROR'));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
this.pending.delete(id);
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
reject(new McpError(`mcp call "${method}" write failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR'));
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
failAll(err) {
|
|
217
|
+
if (!this.dead)
|
|
218
|
+
this.dead = err;
|
|
219
|
+
for (const [id, pend] of [...this.pending]) {
|
|
220
|
+
this.pending.delete(id);
|
|
221
|
+
clearTimeout(pend.timer);
|
|
222
|
+
pend.reject(err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
/** Load + merge global and project MCP server specs. Invalid entries are skipped. */
|
|
28
|
+
export declare function loadMcpServers(cwd: string): McpServersConfig;
|
|
29
|
+
/** Servers eligible for connection (configured and not disabled). */
|
|
30
|
+
export declare function enabledServers(cfg: McpServersConfig): Record<string, McpServerSpec>;
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
function expandEnv(value) {
|
|
29
|
+
return value.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => process.env[name] ?? '');
|
|
30
|
+
}
|
|
31
|
+
function normalizeRaw(raw) {
|
|
32
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
33
|
+
return {};
|
|
34
|
+
const obj = raw;
|
|
35
|
+
const servers = obj['mcpServers'] ?? obj['servers'] ?? {};
|
|
36
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers))
|
|
37
|
+
return {};
|
|
38
|
+
return servers;
|
|
39
|
+
}
|
|
40
|
+
function readJsonFile(p) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Load + merge global and project MCP server specs. Invalid entries are skipped. */
|
|
49
|
+
export function loadMcpServers(cwd) {
|
|
50
|
+
const home = os.homedir() || process.cwd();
|
|
51
|
+
const globalPath = path.join(home, '.klyro', 'mcp.json');
|
|
52
|
+
const projectPath = path.join(cwd, '.mcp.json');
|
|
53
|
+
const servers = {};
|
|
54
|
+
const sources = {};
|
|
55
|
+
for (const [filePath, label] of [[globalPath, 'global'], [projectPath, 'project']]) {
|
|
56
|
+
const raw = normalizeRaw(readJsonFile(filePath));
|
|
57
|
+
for (const [name, specRaw] of Object.entries(raw)) {
|
|
58
|
+
const parsed = McpServerSpecSchema.safeParse(specRaw);
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
continue;
|
|
61
|
+
const spec = parsed.data;
|
|
62
|
+
if (spec.env) {
|
|
63
|
+
const env = {};
|
|
64
|
+
for (const [k, v] of Object.entries(spec.env))
|
|
65
|
+
env[k] = expandEnv(v);
|
|
66
|
+
spec.env = env;
|
|
67
|
+
}
|
|
68
|
+
servers[name] = spec;
|
|
69
|
+
sources[name] = label;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { servers, sources };
|
|
73
|
+
}
|
|
74
|
+
/** Servers eligible for connection (configured and not disabled). */
|
|
75
|
+
export function enabledServers(cfg) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [name, spec] of Object.entries(cfg.servers)) {
|
|
78
|
+
if (!spec.disabled)
|
|
79
|
+
out[name] = spec;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -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,50 @@
|
|
|
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
|
+
/** `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64. */
|
|
41
|
+
export declare function sanitizeMcpName(server: string, tool: string): string;
|
|
42
|
+
export declare function registerMcpServers(cfg: {
|
|
43
|
+
servers: Record<string, McpServerSpec>;
|
|
44
|
+
}, opts: RegisterMcpOpts): Promise<McpRegisterResult>;
|
|
45
|
+
export declare function loadAndRegisterMcp(opts: {
|
|
46
|
+
cwd: string;
|
|
47
|
+
registry: ToolRegistry;
|
|
48
|
+
policy?: PolicyEngine;
|
|
49
|
+
clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
|
|
50
|
+
}): Promise<McpRegisterResult>;
|
|
@@ -0,0 +1,172 @@
|
|
|
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 { McpClient, McpError } from './client.js';
|
|
20
|
+
import { loadMcpServers } from './config.js';
|
|
21
|
+
import { evaluateMcpPolicy } from './policy.js';
|
|
22
|
+
import { jsonSchemaToZod } from './schema.js';
|
|
23
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
24
|
+
import { defineTool } from '../tools/types.js';
|
|
25
|
+
/** Hard cap so generated names always fit model/tool-name limits. */
|
|
26
|
+
const MAX_NAME_LEN = 64;
|
|
27
|
+
/** Server part is capped here; the tool part takes whatever remains. */
|
|
28
|
+
const MAX_SERVER_PART = 20;
|
|
29
|
+
function sanitizePart(s) {
|
|
30
|
+
return s.replace(/[^A-Za-z0-9_]/g, '_');
|
|
31
|
+
}
|
|
32
|
+
/** `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64. */
|
|
33
|
+
export function sanitizeMcpName(server, tool) {
|
|
34
|
+
const srv = sanitizePart(server).slice(0, MAX_SERVER_PART) || 'server';
|
|
35
|
+
const maxTool = Math.max(1, MAX_NAME_LEN - 'mcp__'.length - srv.length - '__'.length);
|
|
36
|
+
const tl = sanitizePart(tool).slice(0, maxTool) || 'tool';
|
|
37
|
+
return `mcp__${srv}__${tl}`;
|
|
38
|
+
}
|
|
39
|
+
function errMessage(err) {
|
|
40
|
+
return err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
async function executeMcpTool(spec, toolDef, client, input, ctx) {
|
|
43
|
+
try {
|
|
44
|
+
// (1) Deny-by-default — runs BEFORE any client I/O.
|
|
45
|
+
const decision = evaluateMcpPolicy(spec.policy, toolDef.name);
|
|
46
|
+
if (!decision.allowed) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: { code: 'POLICY_DENIED', message: decision.reason ?? `mcp tool denied: ${toolDef.name}` },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// (2) Delegate to the server, honouring abort.
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await client.callTool(toolDef.name, input, ctx.signal);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// (3) Typed server failures keep their code; everything else is TOOL_ERROR.
|
|
59
|
+
// Redact BEFORE the message can reach the model/trace.
|
|
60
|
+
if (err instanceof McpError) {
|
|
61
|
+
return { ok: false, error: { code: err.code, message: redact(err.message) } };
|
|
62
|
+
}
|
|
63
|
+
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
64
|
+
}
|
|
65
|
+
// (4) Server-reported error → TOOL_ERROR, redacted, bounded.
|
|
66
|
+
if (res.isError) {
|
|
67
|
+
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
|
|
68
|
+
}
|
|
69
|
+
// (5) Success — redact BEFORE the value reaches the model/trace.
|
|
70
|
+
return { ok: true, value: redact(res.text) };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
// Tool contract: never throw — always return a ToolResult.
|
|
74
|
+
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function registerMcpServers(cfg, opts) {
|
|
78
|
+
const { registry, policy } = opts;
|
|
79
|
+
const factory = opts.clientFactory ?? ((name, spec) => new McpClient(name, spec));
|
|
80
|
+
const registered = [];
|
|
81
|
+
const errors = [];
|
|
82
|
+
const skipped = [];
|
|
83
|
+
const clients = [];
|
|
84
|
+
for (const [server, spec] of Object.entries(cfg.servers)) {
|
|
85
|
+
try {
|
|
86
|
+
// enabledServers-style: skip disabled entries silently.
|
|
87
|
+
if (spec.disabled)
|
|
88
|
+
continue;
|
|
89
|
+
// Default factory returns a bare McpClient — connect it here so fake
|
|
90
|
+
// McpClientLike factories (no connect method) also work.
|
|
91
|
+
let client;
|
|
92
|
+
try {
|
|
93
|
+
client = await factory(server, spec);
|
|
94
|
+
const withConnect = client;
|
|
95
|
+
if (typeof withConnect.connect === 'function') {
|
|
96
|
+
await withConnect.connect();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
errors.push({ server, message: errMessage(err) });
|
|
101
|
+
continue; // never throw — keep registering other servers
|
|
102
|
+
}
|
|
103
|
+
let tools;
|
|
104
|
+
try {
|
|
105
|
+
tools = await client.listTools();
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
errors.push({ server, message: errMessage(err) });
|
|
109
|
+
try {
|
|
110
|
+
await client.close();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* best-effort */
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
clients.push(client);
|
|
118
|
+
for (const toolDef of tools) {
|
|
119
|
+
const name = sanitizeMcpName(server, toolDef.name);
|
|
120
|
+
if (registry.get(name)) {
|
|
121
|
+
skipped.push({ name, reason: 'name-collision' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Capture per-tool bindings for the closure.
|
|
125
|
+
const boundSpec = spec;
|
|
126
|
+
const boundDef = toolDef;
|
|
127
|
+
const boundClient = client;
|
|
128
|
+
const tool = defineTool({
|
|
129
|
+
name,
|
|
130
|
+
description: `[mcp:${server}] ${boundDef.description ?? boundDef.name}`,
|
|
131
|
+
inputSchema: jsonSchemaToZod(boundDef.inputSchema ?? { type: 'object' }),
|
|
132
|
+
// 'admin': most restrictive class — MCP tools run arbitrary
|
|
133
|
+
// external side effects; nothing in src reads this field yet.
|
|
134
|
+
permission: 'admin',
|
|
135
|
+
execute: (input, ctx) => executeMcpTool(boundSpec, boundDef, boundClient, input, ctx),
|
|
136
|
+
});
|
|
137
|
+
registry.register(tool);
|
|
138
|
+
registered.push(name);
|
|
139
|
+
if (boundSpec.policy?.requireApproval && policy) {
|
|
140
|
+
policy.addAsk(name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// Belt-and-braces: registerMcpServers never throws.
|
|
146
|
+
errors.push({ server, message: errMessage(err) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
registered,
|
|
151
|
+
errors,
|
|
152
|
+
skipped,
|
|
153
|
+
closeAll: async () => {
|
|
154
|
+
await Promise.allSettled(clients.map((c) => c.close()));
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export async function loadAndRegisterMcp(opts) {
|
|
159
|
+
try {
|
|
160
|
+
const cfg = loadMcpServers(opts.cwd);
|
|
161
|
+
return await registerMcpServers(cfg, opts);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
// Never throws — surface load failures as error entries.
|
|
165
|
+
return {
|
|
166
|
+
registered: [],
|
|
167
|
+
errors: [{ server: '*', message: errMessage(err) }],
|
|
168
|
+
skipped: [],
|
|
169
|
+
closeAll: async () => { },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — JSON Schema → Zod converter for MCP tool input schemas.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers describe inputs with JSON Schema; Klyro tools validate with
|
|
5
|
+
* Zod. This converts the common subset (object/string/number/integer/
|
|
6
|
+
* boolean/array/enum + required) and falls back to a permissive
|
|
7
|
+
* `z.looseObject({}).catchall(z.unknown())`-style schema for anything else —
|
|
8
|
+
* validation must never trust, but must also never crash on exotic schemas.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
export declare function jsonSchemaToZod(schema: unknown): z.ZodTypeAny;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — JSON Schema → Zod converter for MCP tool input schemas.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers describe inputs with JSON Schema; Klyro tools validate with
|
|
5
|
+
* Zod. This converts the common subset (object/string/number/integer/
|
|
6
|
+
* boolean/array/enum + required) and falls back to a permissive
|
|
7
|
+
* `z.looseObject({}).catchall(z.unknown())`-style schema for anything else —
|
|
8
|
+
* validation must never trust, but must also never crash on exotic schemas.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
export function jsonSchemaToZod(schema) {
|
|
12
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
13
|
+
return z.looseObject({}).catchall(z.unknown());
|
|
14
|
+
}
|
|
15
|
+
const s = schema;
|
|
16
|
+
const enumerated = Array.isArray(s['enum']) ? s['enum'] : undefined;
|
|
17
|
+
if (enumerated && enumerated.length > 0 && enumerated.every((v) => typeof v === 'string')) {
|
|
18
|
+
return z.enum(enumerated);
|
|
19
|
+
}
|
|
20
|
+
switch (s['type']) {
|
|
21
|
+
case 'string':
|
|
22
|
+
return z.string();
|
|
23
|
+
case 'number':
|
|
24
|
+
return z.number();
|
|
25
|
+
case 'integer':
|
|
26
|
+
return z.number().int();
|
|
27
|
+
case 'boolean':
|
|
28
|
+
return z.boolean();
|
|
29
|
+
case 'array': {
|
|
30
|
+
const items = jsonSchemaToZod(s['items']);
|
|
31
|
+
return z.array(items);
|
|
32
|
+
}
|
|
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
|
+
default:
|
|
44
|
+
return z.unknown();
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/policy/engine.js
CHANGED
|
@@ -158,8 +158,9 @@ function asString(v) {
|
|
|
158
158
|
return typeof v === 'string' ? v : '';
|
|
159
159
|
}
|
|
160
160
|
function matchesGlobRule(call, rule) {
|
|
161
|
-
// Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)"
|
|
162
|
-
|
|
161
|
+
// Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)".
|
|
162
|
+
// Digits are allowed so generated names (e.g. mcp__server__tool2) can be targeted.
|
|
163
|
+
const m = /^([a-z0-9_]+)(?:\((.*)\))?$/.exec(rule.trim());
|
|
163
164
|
if (!m)
|
|
164
165
|
return false;
|
|
165
166
|
const tool = m[1];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ChildSummary } from '../../agent/orchestrator.js';
|
|
2
|
+
export declare const spawnAgentTool: import("../types.js").Tool<{
|
|
3
|
+
agent: string;
|
|
4
|
+
task: string;
|
|
5
|
+
cwd?: string | undefined;
|
|
6
|
+
model?: string | undefined;
|
|
7
|
+
timeoutMs?: number | undefined;
|
|
8
|
+
}, ChildSummary>;
|
|
9
|
+
export type SpawnAgentOutput = ChildSummary;
|