klyro 1.0.5 → 1.0.7
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/README.md +13 -0
- package/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +26 -0
- package/dist/agent/orchestrator.js +41 -4
- package/dist/agent/runtime.d.ts +15 -0
- package/dist/agent/runtime.js +232 -61
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +11 -0
- package/dist/checkpoints/store.js +32 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/completion.js +2 -2
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +43 -5
- package/dist/cli/hooks.d.ts +74 -5
- package/dist/cli/hooks.js +118 -7
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +188 -30
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +92 -50
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +9 -1
- package/dist/cli/slash/parser.js +34 -9
- package/dist/cli/update.d.ts +3 -1
- package/dist/cli/update.js +16 -1
- package/dist/context/accounting.d.ts +6 -0
- package/dist/context/accounting.js +8 -2
- package/dist/context/compaction.d.ts +2 -1
- package/dist/context/compaction.js +39 -12
- package/dist/context/memory.d.ts +11 -0
- package/dist/context/memory.js +59 -4
- package/dist/eval/harness.d.ts +40 -5
- package/dist/eval/harness.js +103 -10
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +239 -130
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +31 -1
- package/dist/mcp/config.js +84 -1
- package/dist/mcp/registry.d.ts +19 -0
- package/dist/mcp/registry.js +118 -2
- package/dist/mcp/remote.d.ts +36 -0
- package/dist/mcp/remote.js +207 -0
- package/dist/mcp/sse.d.ts +42 -0
- package/dist/mcp/sse.js +310 -0
- package/dist/persistence/audit.d.ts +15 -3
- package/dist/persistence/audit.js +84 -13
- package/dist/persistence/store.d.ts +9 -0
- package/dist/persistence/store.js +17 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/policy/engine.js +9 -0
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +17 -14
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tui/app.js +172 -15
- package/dist/tui/app.test.js +27 -2
- package/dist/tui/approval.js +55 -1
- package/dist/tui/scroll-model.d.ts +2 -2
- package/dist/tui/scroll-model.js +9 -3
- package/dist/tui/tokens.d.ts +8 -11
- package/dist/tui/tokens.js +18 -11
- package/package.json +1 -1
package/dist/mcp/auth.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B2 — MCP OAuth/PKCE auth surface (remote MCP reach parity).
|
|
3
|
+
*
|
|
4
|
+
* Token-backed OAuth 2.0 Authorization Code flow with PKCE (RFC 7636) for
|
|
5
|
+
* remote MCP servers (Risk #7 / #3 residual: "OAuth absent"). Access tokens
|
|
6
|
+
* are cached at `~/.klyro/mcp-tokens.json` (mode 0600) and keyed by the
|
|
7
|
+
* server's URL + clientId, so a cached credential survives restarts and is
|
|
8
|
+
* reused (with lazy refresh) instead of re-prompting every run.
|
|
9
|
+
*
|
|
10
|
+
* The auth code flow runs against a short-lived loopback listener on
|
|
11
|
+
* 127.0.0.1 (RFC 8252 native-app redirect). In a headless context the flow
|
|
12
|
+
* prints the authorization URL and the user completes it by opening the
|
|
13
|
+
* link; the code is captured on the loopback redirect. Every function is
|
|
14
|
+
* fail-safe: network/server failures never throw raw into the MCP tool path
|
|
15
|
+
* — they surface a typed `AuthError`.
|
|
16
|
+
*/
|
|
17
|
+
import * as crypto from 'node:crypto';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as http from 'node:http';
|
|
20
|
+
import * as os from 'node:os';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
export class AuthError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'AuthError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const TOKEN_STALE_MARGIN_S = 60;
|
|
29
|
+
export const TOKENS_FILE = 'mcp-tokens.json';
|
|
30
|
+
function tokensPath() {
|
|
31
|
+
const base = process.env.KLYRO_CONFIG_DIR ?? path.join(os.homedir() || process.cwd(), '.klyro');
|
|
32
|
+
return path.join(base, TOKENS_FILE);
|
|
33
|
+
}
|
|
34
|
+
/** Fully load the token cache as a plain object (missing/corrupt → {}). */
|
|
35
|
+
export function loadMcpTokens() {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(tokensPath(), 'utf-8');
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
40
|
+
? parsed
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Persist a token to the 0600 cache. Merges by key so concurrent server
|
|
49
|
+
* writes don't clobber each other. A symlinked cache path is refused (the
|
|
50
|
+
* write would land outside the klyro config dir or be swapped mid-write).
|
|
51
|
+
*/
|
|
52
|
+
export function saveMcpToken(cacheKey, token) {
|
|
53
|
+
const p = tokensPath();
|
|
54
|
+
try {
|
|
55
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
56
|
+
throw new AuthError(`refusing to write token cache through symlink: ${p}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (e instanceof AuthError)
|
|
61
|
+
throw e;
|
|
62
|
+
/* ENOENT — first write, fine */
|
|
63
|
+
}
|
|
64
|
+
const dir = path.dirname(p);
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
const all = loadMcpTokens();
|
|
67
|
+
all[cacheKey] = token;
|
|
68
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
69
|
+
fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
70
|
+
fs.renameSync(tmp, p);
|
|
71
|
+
}
|
|
72
|
+
/** Stable cache key for a server URL + clientId. */
|
|
73
|
+
export function mcpTokenCacheKey(url, clientId) {
|
|
74
|
+
return `${url}|${clientId}`;
|
|
75
|
+
}
|
|
76
|
+
/** True when a cached token is still usable (with a clock-skew margin). */
|
|
77
|
+
export function tokenValid(t) {
|
|
78
|
+
if (!t || typeof t.access_token !== 'string' || !t.access_token)
|
|
79
|
+
return false;
|
|
80
|
+
// expires_at 0 = unknown lifetime — assume still valid.
|
|
81
|
+
return t.expires_at === 0 || t.expires_at > Date.now() / 1000 + TOKEN_STALE_MARGIN_S;
|
|
82
|
+
}
|
|
83
|
+
/** PKCE (RFC 7636 §4.1): cryptographically-random 43-128 char code verifier. */
|
|
84
|
+
export function generateCodeVerifier() {
|
|
85
|
+
return crypto.randomBytes(64).toString('base64url'); // ~86 chars, within §4.1 range
|
|
86
|
+
}
|
|
87
|
+
/** PKCE §4.2: base64url SHA-256 challenge of the verifier. */
|
|
88
|
+
export function codeChallengeFromVerifier(verifier) {
|
|
89
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve auth headers for a server: reuse a valid cached token, else run
|
|
93
|
+
* the interactive Authorization Code + PKCE flow. Returns only the headers;
|
|
94
|
+
* failures surface as `AuthError` so callers (the MCP probe/connect path)
|
|
95
|
+
* can turn them into a clear error instead of a hang.
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveAuth(auth, cacheKey) {
|
|
98
|
+
const cached = loadMcpTokens()[cacheKey];
|
|
99
|
+
if (cached && tokenValid(cached)) {
|
|
100
|
+
return { headers: { Authorization: `${cached.token_type || 'Bearer'} ${cached.access_token}` }, fromCache: true };
|
|
101
|
+
}
|
|
102
|
+
// No usable token — run the code flow.
|
|
103
|
+
const token = await runCodeFlow(auth);
|
|
104
|
+
if (!token)
|
|
105
|
+
throw new AuthError(`OAuth flow for "${auth.clientId}" returned no token`);
|
|
106
|
+
const expiryUnix = token.expires_in ? Math.floor(Date.now() / 1000 + token.expires_in) : 0;
|
|
107
|
+
const saved = {
|
|
108
|
+
access_token: token.access_token,
|
|
109
|
+
token_type: token.token_type ?? 'Bearer',
|
|
110
|
+
expires_at: expiryUnix,
|
|
111
|
+
refresh_token: token.refresh_token,
|
|
112
|
+
scope: token.scope,
|
|
113
|
+
cachedAtMs: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
saveMcpToken(cacheKey, saved);
|
|
116
|
+
return { headers: { Authorization: `${saved.token_type} ${saved.access_token}` }, fromCache: false };
|
|
117
|
+
}
|
|
118
|
+
function resolveIssuer(auth, serverUrl) {
|
|
119
|
+
let issuer;
|
|
120
|
+
try {
|
|
121
|
+
issuer = new URL(auth.authorizationEndpoint ?? serverUrl);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new AuthError(`invalid OAuth issuer URL for "${auth.clientId}"`);
|
|
125
|
+
}
|
|
126
|
+
return issuer;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run the Authorization Code flow with PKCE over a one-shot loopback
|
|
130
|
+
* redirect (RFC 8252). Best-effort and headless-friendly: on any failure it
|
|
131
|
+
* throws `AuthError` rather than leaving a stray listener. The token endpoint
|
|
132
|
+
* defaults to the OAuth metadata `token_endpoint` when the server advertises
|
|
133
|
+
* it, else the spec's `tokenEndpoint`.
|
|
134
|
+
*/
|
|
135
|
+
export async function runCodeFlow(auth, serverUrl) {
|
|
136
|
+
const issuer = resolveIssuer(auth, serverUrl ?? '');
|
|
137
|
+
const verifier = generateCodeVerifier();
|
|
138
|
+
const challenge = codeChallengeFromVerifier(verifier);
|
|
139
|
+
const listener = http.createServer();
|
|
140
|
+
const port = await new Promise((resolve, reject) => {
|
|
141
|
+
listener.once('error', reject);
|
|
142
|
+
listener.listen(0, '127.0.0.1', () => {
|
|
143
|
+
const addr = listener.address();
|
|
144
|
+
if (addr && typeof addr === 'object')
|
|
145
|
+
resolve(addr.port);
|
|
146
|
+
else
|
|
147
|
+
reject(new AuthError('could not bind loopback listener'));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
let code = null;
|
|
151
|
+
const pending = new Promise((resolve, reject) => {
|
|
152
|
+
listener.on('request', (req, res) => {
|
|
153
|
+
const u = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
154
|
+
code = u.searchParams.get('code');
|
|
155
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
156
|
+
res.end('You can close this tab and return to the terminal.');
|
|
157
|
+
if (code)
|
|
158
|
+
resolve(code);
|
|
159
|
+
else
|
|
160
|
+
reject(new AuthError('authorization redirect carried no code'));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
const redirectUri = auth.redirectUri ?? `http://127.0.0.1:${port}/callback`;
|
|
165
|
+
const authorizeUrl = buildAuthorizeUrl(auth, issuer, challenge, redirectUri);
|
|
166
|
+
process.stdout.write(`\nOpen this URL in your browser to authorize klyro for MCP server "${auth.clientId}":\n${authorizeUrl}\n`);
|
|
167
|
+
code = await pending;
|
|
168
|
+
return await exchangeCode(auth, issuer, code, verifier, redirectUri);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
listener.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function buildAuthorizeUrl(auth, issuer, challenge, redirectUri) {
|
|
175
|
+
const u = new URL(auth.authorizationEndpoint || issuer.origin, issuer);
|
|
176
|
+
u.searchParams.set('response_type', 'code');
|
|
177
|
+
u.searchParams.set('client_id', auth.clientId);
|
|
178
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
179
|
+
u.searchParams.set('code_challenge', challenge);
|
|
180
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
181
|
+
u.searchParams.set('state', crypto.randomBytes(8).toString('base64url'));
|
|
182
|
+
if (auth.scopes)
|
|
183
|
+
u.searchParams.set('scope', auth.scopes);
|
|
184
|
+
return u.toString();
|
|
185
|
+
}
|
|
186
|
+
async function exchangeCode(auth, issuer, code, verifier, redirectUri) {
|
|
187
|
+
const tokenEndpoint = auth.tokenEndpoint || `${issuer.origin}/token`;
|
|
188
|
+
const res = await fetch(tokenEndpoint, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
191
|
+
body: new URLSearchParams({
|
|
192
|
+
grant_type: 'authorization_code',
|
|
193
|
+
code,
|
|
194
|
+
redirect_uri: redirectUri,
|
|
195
|
+
client_id: auth.clientId,
|
|
196
|
+
code_verifier: verifier,
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
if (!res.ok)
|
|
200
|
+
throw new AuthError(`token exchange failed: HTTP ${res.status}`);
|
|
201
|
+
const data = (await res.json());
|
|
202
|
+
if (!data.access_token)
|
|
203
|
+
throw new AuthError('token exchange returned no access_token');
|
|
204
|
+
return data;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Scrub credential-bearing headers from debug capture so `KLYRO_MCP_DEBUG`
|
|
208
|
+
* never writes a live access token to disk. Non-credential headers pass
|
|
209
|
+
* through untouched.
|
|
210
|
+
*/
|
|
211
|
+
export function scrubAuthHeaders(headers) {
|
|
212
|
+
const out = {};
|
|
213
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
214
|
+
if (k.toLowerCase() === 'authorization' || k.toLowerCase() === 'cookie' || k.toLowerCase() === 'x-api-key') {
|
|
215
|
+
out[k] = '[REDACTED]';
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
out[k] = v;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
/** Refreshes a stale token when a refresh_token is cached (best-effort). */
|
|
224
|
+
export async function tryRefresh(auth, cached) {
|
|
225
|
+
if (!cached.refresh_token || !auth.tokenEndpoint)
|
|
226
|
+
return null;
|
|
227
|
+
const res = await fetch(auth.tokenEndpoint, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
230
|
+
body: new URLSearchParams({
|
|
231
|
+
grant_type: 'refresh_token',
|
|
232
|
+
refresh_token: cached.refresh_token,
|
|
233
|
+
client_id: auth.clientId,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
if (!res.ok)
|
|
237
|
+
return null;
|
|
238
|
+
const data = (await res.json());
|
|
239
|
+
if (!data.access_token)
|
|
240
|
+
return null;
|
|
241
|
+
return {
|
|
242
|
+
access_token: data.access_token,
|
|
243
|
+
token_type: data.token_type ?? cached.token_type,
|
|
244
|
+
expires_at: data.expires_in ? Math.floor(Date.now() / 1000 + data.expires_in) : cached.expires_at,
|
|
245
|
+
refresh_token: data.refresh_token ?? cached.refresh_token,
|
|
246
|
+
scope: data.scope ?? cached.scope,
|
|
247
|
+
cachedAtMs: Date.now(),
|
|
248
|
+
};
|
|
249
|
+
}
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ export interface McpResource {
|
|
|
9
9
|
name?: string;
|
|
10
10
|
mimeType?: string;
|
|
11
11
|
}
|
|
12
|
+
export interface McpPromptDef {
|
|
13
|
+
name: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
arguments?: Array<{
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
12
21
|
export interface McpCallResult {
|
|
13
22
|
/** Normalized text payload (content blocks joined). */
|
|
14
23
|
text: string;
|
|
@@ -19,6 +28,8 @@ export interface McpClientLike {
|
|
|
19
28
|
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
20
29
|
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
21
30
|
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
31
|
+
promptsList?(signal?: AbortSignal): Promise<McpPromptDef[]>;
|
|
32
|
+
promptsGet?(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
|
|
22
33
|
close(): Promise<void>;
|
|
23
34
|
}
|
|
24
35
|
export declare class McpError extends Error {
|
|
@@ -43,6 +54,10 @@ export declare class McpClient implements McpClientLike {
|
|
|
43
54
|
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
44
55
|
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
45
56
|
readResource(uri: string, signal?: AbortSignal): Promise<string>;
|
|
57
|
+
/** List prompts; servers without prompts support yield [] (not an error). */
|
|
58
|
+
promptsList(signal?: AbortSignal): Promise<McpPromptDef[]>;
|
|
59
|
+
/** Get a prompt's messages as text. Throws McpError when unsupported/unknown. */
|
|
60
|
+
promptsGet(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
|
|
46
61
|
close(): Promise<void>;
|
|
47
62
|
/** Test hook: number of in-flight JSON-RPC requests. */
|
|
48
63
|
pendingCount(): number;
|
package/dist/mcp/client.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* P1 — MCP stdio client (r-11-17.md §3.1).
|
|
3
3
|
*
|
|
4
4
|
* Minimal JSON-RPC 2.0 over newline-delimited stdio: `initialize`,
|
|
5
|
-
* `tools/list`, `tools/call`, `resources/list`, `resources/read`,
|
|
5
|
+
* `tools/list`, `tools/call`, `resources/list`, `resources/read`,
|
|
6
|
+
* `prompts/list`, `prompts/get`, `ping`.
|
|
6
7
|
* Every call is bound to an `AbortSignal` and a per-call timeout; spawn
|
|
7
8
|
* failures and server errors surface as typed `McpError`s — never throws
|
|
8
9
|
* raw across the boundary.
|
|
@@ -38,15 +39,22 @@ export class McpClient {
|
|
|
38
39
|
constructor(name, spec) {
|
|
39
40
|
this.name = name;
|
|
40
41
|
this.spec = spec;
|
|
42
|
+
// URL specs route to RemoteMcpClient via makeMcpClient — direct
|
|
43
|
+
// construction without a command is a programming error, fail fast.
|
|
44
|
+
if (!spec.command)
|
|
45
|
+
throw new McpError(`mcp server "${name}" has no command (use url for remote servers)`, 'INVALID_SPEC');
|
|
41
46
|
}
|
|
42
47
|
async connect() {
|
|
43
48
|
if (this.child)
|
|
44
49
|
return;
|
|
50
|
+
const command = this.spec.command;
|
|
51
|
+
if (!command)
|
|
52
|
+
throw new McpError(`mcp server "${this.name}" has no command (use url for remote servers)`, 'INVALID_SPEC');
|
|
45
53
|
const timeoutMs = this.spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
46
54
|
await new Promise((resolve, reject) => {
|
|
47
55
|
let child;
|
|
48
56
|
try {
|
|
49
|
-
child = spawn(
|
|
57
|
+
child = spawn(command, this.spec.args ?? [], {
|
|
50
58
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
59
|
env: { ...process.env, ...(this.spec.env ?? {}) },
|
|
52
60
|
shell: false,
|
|
@@ -136,6 +144,38 @@ export class McpClient {
|
|
|
136
144
|
const contents = Array.isArray(res.contents) ? res.contents : [];
|
|
137
145
|
return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
|
|
138
146
|
}
|
|
147
|
+
/** List prompts; servers without prompts support yield [] (not an error). */
|
|
148
|
+
async promptsList(signal) {
|
|
149
|
+
this.assertLive();
|
|
150
|
+
let res;
|
|
151
|
+
try {
|
|
152
|
+
res = (await this.request('prompts/list', {}, this.timeout(), signal));
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
if (err instanceof McpError && err.code === 'SERVER_ERROR' && err.details?.code === -32601) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
return Array.isArray(res.prompts) ? res.prompts : [];
|
|
161
|
+
}
|
|
162
|
+
/** Get a prompt's messages as text. Throws McpError when unsupported/unknown. */
|
|
163
|
+
async promptsGet(name, args, signal) {
|
|
164
|
+
this.assertLive();
|
|
165
|
+
const res = (await this.request('prompts/get', { name, arguments: args ?? {} }, this.timeout(), signal));
|
|
166
|
+
const messages = Array.isArray(res.messages) ? res.messages : [];
|
|
167
|
+
const parts = [];
|
|
168
|
+
if (typeof res.description === 'string' && res.description)
|
|
169
|
+
parts.push(res.description);
|
|
170
|
+
for (const m of messages) {
|
|
171
|
+
const c = m.content;
|
|
172
|
+
if (c && typeof c.text === 'string')
|
|
173
|
+
parts.push(c.text);
|
|
174
|
+
else if (c !== undefined)
|
|
175
|
+
parts.push(JSON.stringify(c));
|
|
176
|
+
}
|
|
177
|
+
return parts.join('\n\n');
|
|
178
|
+
}
|
|
139
179
|
async close() {
|
|
140
180
|
// Idempotent vs the exit handler: closed is set FIRST (the on('exit')
|
|
141
181
|
// callback checks it before failAll), and a second close() with no child
|
package/dist/mcp/config.d.ts
CHANGED
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
/** MCP remote URL guard — https:// (or loopback http://) only. */
|
|
3
|
+
export declare function assertSafeMcpUrl(url: string): void;
|
|
4
|
+
/** True when the plain-HTTP MCP opt-in is set (env or persisted equivalent). */
|
|
5
|
+
export declare function insecureMcpAllowed(): boolean;
|
|
2
6
|
export declare const McpServerPolicySchema: z.ZodObject<{
|
|
3
7
|
allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
4
8
|
denyTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
5
9
|
requireApproval: z.ZodOptional<z.ZodBoolean>;
|
|
6
10
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
7
11
|
}, z.core.$strip>;
|
|
12
|
+
/**
|
|
13
|
+
* B2 — optional OAuth/PKCE auth surface for remote (`url`) transports.
|
|
14
|
+
* Present ⇒ the probe/connect path resolves a bearer token and attaches
|
|
15
|
+
* `Authorization`; the debug capture scrubs it (see `scrubAuthHeaders`).
|
|
16
|
+
*/
|
|
17
|
+
export declare const McpServerAuthSchema: z.ZodObject<{
|
|
18
|
+
clientId: z.ZodString;
|
|
19
|
+
tokenEndpoint: z.ZodOptional<z.ZodString>;
|
|
20
|
+
authorizationEndpoint: z.ZodOptional<z.ZodString>;
|
|
21
|
+
redirectUri: z.ZodOptional<z.ZodString>;
|
|
22
|
+
scopes: z.ZodOptional<z.ZodString>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
export type McpServerAuth = z.infer<typeof McpServerAuthSchema>;
|
|
8
25
|
export type McpServerPolicy = z.infer<typeof McpServerPolicySchema>;
|
|
9
26
|
export declare const McpServerSpecSchema: z.ZodObject<{
|
|
10
|
-
command: z.ZodString
|
|
27
|
+
command: z.ZodOptional<z.ZodString>;
|
|
11
28
|
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
12
29
|
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
30
|
+
url: z.ZodOptional<z.ZodString>;
|
|
31
|
+
transport: z.ZodOptional<z.ZodEnum<{
|
|
32
|
+
streamable: "streamable";
|
|
33
|
+
sse: "sse";
|
|
34
|
+
}>>;
|
|
35
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
36
|
+
auth: z.ZodOptional<z.ZodObject<{
|
|
37
|
+
clientId: z.ZodString;
|
|
38
|
+
tokenEndpoint: z.ZodOptional<z.ZodString>;
|
|
39
|
+
authorizationEndpoint: z.ZodOptional<z.ZodString>;
|
|
40
|
+
redirectUri: z.ZodOptional<z.ZodString>;
|
|
41
|
+
scopes: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, z.core.$strip>>;
|
|
13
43
|
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
14
44
|
policy: z.ZodOptional<z.ZodObject<{
|
|
15
45
|
allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/dist/mcp/config.js
CHANGED
|
@@ -12,18 +12,95 @@ import * as fs from 'node:fs';
|
|
|
12
12
|
import * as os from 'node:os';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
/** MCP remote URL guard — https:// (or loopback http://) only. */
|
|
16
|
+
export function assertSafeMcpUrl(url) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = new URL(url);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error(`invalid MCP server url: ${url}`);
|
|
23
|
+
}
|
|
24
|
+
if (parsed.protocol === 'https:')
|
|
25
|
+
return;
|
|
26
|
+
if (parsed.protocol === 'http:') {
|
|
27
|
+
const h = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
28
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0' || h === '::' || h.startsWith('127.'))
|
|
29
|
+
return;
|
|
30
|
+
throw new Error(`insecure MCP server url (plain HTTP) for host ${parsed.hostname} — use https:// or a loopback URL, or set KLYRO_ALLOW_INSECURE_MCP=1`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`unsupported MCP server url protocol: ${parsed.protocol}`);
|
|
33
|
+
}
|
|
34
|
+
/** True when the plain-HTTP MCP opt-in is set (env or persisted equivalent). */
|
|
35
|
+
export function insecureMcpAllowed() {
|
|
36
|
+
return process.env.KLYRO_ALLOW_INSECURE_MCP === '1';
|
|
37
|
+
}
|
|
15
38
|
export const McpServerPolicySchema = z.object({
|
|
16
39
|
allowTools: z.array(z.string()).optional(),
|
|
17
40
|
denyTools: z.array(z.string()).optional(),
|
|
18
41
|
requireApproval: z.boolean().optional(),
|
|
19
42
|
timeoutMs: z.number().int().positive().optional(),
|
|
20
43
|
});
|
|
44
|
+
/**
|
|
45
|
+
* B2 — optional OAuth/PKCE auth surface for remote (`url`) transports.
|
|
46
|
+
* Present ⇒ the probe/connect path resolves a bearer token and attaches
|
|
47
|
+
* `Authorization`; the debug capture scrubs it (see `scrubAuthHeaders`).
|
|
48
|
+
*/
|
|
49
|
+
export const McpServerAuthSchema = z.object({
|
|
50
|
+
/** OAuth 2.0 client id registered with the authorization server. */
|
|
51
|
+
clientId: z.string().min(1),
|
|
52
|
+
/** Token endpoint; when absent, derived from the authorization issuer. */
|
|
53
|
+
tokenEndpoint: z.string().url().optional(),
|
|
54
|
+
/** Authorization endpoint / metadata issuer; defaults to the server URL. */
|
|
55
|
+
authorizationEndpoint: z.string().url().optional(),
|
|
56
|
+
/** Custom redirect URI; defaults to a loopback listener on 127.0.0.1. */
|
|
57
|
+
redirectUri: z.string().url().optional(),
|
|
58
|
+
/** Space-separated additional scopes. */
|
|
59
|
+
scopes: z.string().optional(),
|
|
60
|
+
});
|
|
21
61
|
export const McpServerSpecSchema = z.object({
|
|
22
|
-
|
|
62
|
+
/** stdio transport: command to spawn. Required unless `url` is set. */
|
|
63
|
+
command: z.string().min(1).optional(),
|
|
23
64
|
args: z.array(z.string()).optional(),
|
|
24
65
|
env: z.record(z.string(), z.string()).optional(),
|
|
66
|
+
/** Remote transport: Streamable-HTTP JSON-RPC endpoint. Required unless `command` is set. */
|
|
67
|
+
url: z.string().url().optional(),
|
|
68
|
+
/**
|
|
69
|
+
* Remote transport flavor: `streamable` (default, POST per message) or
|
|
70
|
+
* `sse` (legacy HTTP+SSE: persistent GET event stream + message POST).
|
|
71
|
+
*/
|
|
72
|
+
transport: z.enum(['streamable', 'sse']).optional(),
|
|
73
|
+
/** Extra HTTP headers for remote transport (`${env:VAR}` expanded). */
|
|
74
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
75
|
+
/** B2 — optional OAuth/PKCE auth surface; only valid on remote transports. */
|
|
76
|
+
auth: McpServerAuthSchema.optional(),
|
|
25
77
|
disabled: z.boolean().optional(),
|
|
26
78
|
policy: McpServerPolicySchema.optional(),
|
|
79
|
+
}).superRefine((s, ctx) => {
|
|
80
|
+
if (s.auth && !s.url) {
|
|
81
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server "auth" requires a remote "url" transport (stdio has no OAuth surface)' });
|
|
82
|
+
}
|
|
83
|
+
if (!s.command && !s.url) {
|
|
84
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server needs either "command" (stdio) or "url" (remote HTTP)' });
|
|
85
|
+
}
|
|
86
|
+
if (s.command && s.url) {
|
|
87
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server takes either "command" or "url", not both' });
|
|
88
|
+
}
|
|
89
|
+
if (s.url && !s.command) {
|
|
90
|
+
const u = s.url;
|
|
91
|
+
let parsed = null;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(u);
|
|
94
|
+
}
|
|
95
|
+
catch { /* invalid URL caught by z.string().url() */ }
|
|
96
|
+
if (parsed && parsed.protocol === 'http:') {
|
|
97
|
+
const h = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
98
|
+
const loopback = h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0' || h === '::' || h.startsWith('127.');
|
|
99
|
+
if (!loopback && process.env.KLYRO_ALLOW_INSECURE_MCP !== '1') {
|
|
100
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `insecure MCP server url (plain HTTP) for host ${parsed.hostname} — use https:// or a loopback URL, or set KLYRO_ALLOW_INSECURE_MCP=1` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
27
104
|
});
|
|
28
105
|
/** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
|
|
29
106
|
export const MAX_MCP_TIMEOUT_MS = 600_000;
|
|
@@ -82,6 +159,12 @@ export function loadMcpServers(cwd) {
|
|
|
82
159
|
env[k] = expandEnv(v);
|
|
83
160
|
spec.env = env;
|
|
84
161
|
}
|
|
162
|
+
if (spec.headers) {
|
|
163
|
+
const headers = {};
|
|
164
|
+
for (const [k, v] of Object.entries(spec.headers))
|
|
165
|
+
headers[k] = expandEnv(v);
|
|
166
|
+
spec.headers = headers;
|
|
167
|
+
}
|
|
85
168
|
servers[name] = spec;
|
|
86
169
|
sources[name] = label;
|
|
87
170
|
}
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
|
|
|
30
30
|
* behind a plausible-looking name.
|
|
31
31
|
*/
|
|
32
32
|
export declare function sanitizeMcpName(server: string, tool: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Construct the right client for a spec: remote HTTP when `url` is set
|
|
35
|
+
* (`transport: 'sse'` selects the legacy event-stream flavor, default is
|
|
36
|
+
* Streamable HTTP), else stdio. Used by registration, probe, and prompts.
|
|
37
|
+
*/
|
|
38
|
+
export declare function makeMcpClient(name: string, spec: McpServerSpec): McpClientLike;
|
|
33
39
|
export declare function registerMcpServers(cfg: {
|
|
34
40
|
servers: Record<string, McpServerSpec>;
|
|
35
41
|
}, opts: RegisterMcpOpts): Promise<McpRegisterResult>;
|
|
@@ -52,3 +58,16 @@ export declare function loadAndRegisterMcp(opts: {
|
|
|
52
58
|
source: 'global' | 'project';
|
|
53
59
|
}) => Promise<boolean>;
|
|
54
60
|
}): Promise<McpRegisterResult>;
|
|
61
|
+
/**
|
|
62
|
+
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
|
|
63
|
+
* connection per invocation (no client lifecycle to manage), redacted
|
|
64
|
+
* output. Project servers need trust-store approval (see `klyro mcp
|
|
65
|
+
* trust`) — typing the prompt name is intent, not approval. Throws
|
|
66
|
+
* McpError / Error on unknown server, disabled server, or prompt failure.
|
|
67
|
+
*/
|
|
68
|
+
export declare function runMcpPrompt(cwd: string, server: string, prompt: string, tokens: string[], opts?: {
|
|
69
|
+
trust?: {
|
|
70
|
+
isTrusted: (name: string, hash: string) => boolean;
|
|
71
|
+
};
|
|
72
|
+
clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
|
|
73
|
+
}): Promise<string>;
|