klyro 1.0.6 → 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/orchestrator.d.ts +7 -1
- package/dist/agent/orchestrator.js +18 -7
- package/dist/agent/runtime.d.ts +10 -0
- package/dist/agent/runtime.js +70 -13
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +2 -0
- package/dist/checkpoints/store.js +12 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.js +22 -16
- package/dist/cli/hooks.d.ts +21 -1
- package/dist/cli/hooks.js +34 -2
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +54 -17
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/parser.d.ts +1 -1
- package/dist/cli/slash/parser.js +6 -3
- 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 +47 -9
- package/dist/eval/harness.d.ts +19 -2
- package/dist/eval/harness.js +72 -7
- package/dist/index.js +90 -4
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/config.d.ts +28 -0
- package/dist/mcp/config.js +65 -0
- package/dist/mcp/registry.d.ts +13 -7
- package/dist/mcp/registry.js +90 -14
- package/dist/mcp/remote.d.ts +7 -0
- package/dist/mcp/remote.js +56 -2
- 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/engine.js +9 -0
- package/dist/providers.js +4 -4
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tui/app.js +35 -5
- package/dist/tui/app.test.js +3 -2
- package/dist/tui/approval.js +3 -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/config.d.ts
CHANGED
|
@@ -1,17 +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
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>>;
|
|
13
30
|
url: z.ZodOptional<z.ZodString>;
|
|
31
|
+
transport: z.ZodOptional<z.ZodEnum<{
|
|
32
|
+
streamable: "streamable";
|
|
33
|
+
sse: "sse";
|
|
34
|
+
}>>;
|
|
14
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>>;
|
|
15
43
|
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
16
44
|
policy: z.ZodOptional<z.ZodObject<{
|
|
17
45
|
allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/dist/mcp/config.js
CHANGED
|
@@ -12,12 +12,52 @@ 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. */
|
|
23
63
|
command: z.string().min(1).optional(),
|
|
@@ -25,17 +65,42 @@ export const McpServerSpecSchema = z.object({
|
|
|
25
65
|
env: z.record(z.string(), z.string()).optional(),
|
|
26
66
|
/** Remote transport: Streamable-HTTP JSON-RPC endpoint. Required unless `command` is set. */
|
|
27
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(),
|
|
28
73
|
/** Extra HTTP headers for remote transport (`${env:VAR}` expanded). */
|
|
29
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(),
|
|
30
77
|
disabled: z.boolean().optional(),
|
|
31
78
|
policy: McpServerPolicySchema.optional(),
|
|
32
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
|
+
}
|
|
33
83
|
if (!s.command && !s.url) {
|
|
34
84
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server needs either "command" (stdio) or "url" (remote HTTP)' });
|
|
35
85
|
}
|
|
36
86
|
if (s.command && s.url) {
|
|
37
87
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server takes either "command" or "url", not both' });
|
|
38
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
|
+
}
|
|
39
104
|
});
|
|
40
105
|
/** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
|
|
41
106
|
export const MAX_MCP_TIMEOUT_MS = 600_000;
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -31,8 +31,9 @@ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function sanitizeMcpName(server: string, tool: string): string;
|
|
33
33
|
/**
|
|
34
|
-
* Construct the right client for a spec: remote HTTP when `url` is set
|
|
35
|
-
*
|
|
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.
|
|
36
37
|
*/
|
|
37
38
|
export declare function makeMcpClient(name: string, spec: McpServerSpec): McpClientLike;
|
|
38
39
|
export declare function registerMcpServers(cfg: {
|
|
@@ -58,10 +59,15 @@ export declare function loadAndRegisterMcp(opts: {
|
|
|
58
59
|
}) => Promise<boolean>;
|
|
59
60
|
}): Promise<McpRegisterResult>;
|
|
60
61
|
/**
|
|
61
|
-
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
|
|
62
|
+
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
|
|
62
63
|
* connection per invocation (no client lifecycle to manage), redacted
|
|
63
|
-
* output.
|
|
64
|
-
*
|
|
65
|
-
* Error on unknown server, disabled server, or prompt failure.
|
|
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.
|
|
66
67
|
*/
|
|
67
|
-
export declare function runMcpPrompt(cwd: string, server: string, prompt: string,
|
|
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>;
|
package/dist/mcp/registry.js
CHANGED
|
@@ -31,6 +31,7 @@ import * as os from 'node:os';
|
|
|
31
31
|
import * as path from 'node:path';
|
|
32
32
|
import { McpClient, McpError } from './client.js';
|
|
33
33
|
import { RemoteMcpClient } from './remote.js';
|
|
34
|
+
import { SseMcpClient } from './sse.js';
|
|
34
35
|
import { loadMcpServers } from './config.js';
|
|
35
36
|
import { evaluateMcpPolicy } from './policy.js';
|
|
36
37
|
import { jsonSchemaToZod } from './schema.js';
|
|
@@ -62,6 +63,29 @@ export function sanitizeMcpName(server, tool) {
|
|
|
62
63
|
function errMessage(err) {
|
|
63
64
|
return err instanceof Error ? err.message : String(err);
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Recursively scrub credential-bearing keys (`authorization`, `cookie`,
|
|
68
|
+
* `x-api-key`) from a debug payload so a server echoing credentials back in
|
|
69
|
+
* an error body can't leak a live token into the capture file. Other values
|
|
70
|
+
* pass through untouched.
|
|
71
|
+
*/
|
|
72
|
+
function scrubCredentialKeys(value) {
|
|
73
|
+
if (Array.isArray(value))
|
|
74
|
+
return value.map(scrubCredentialKeys);
|
|
75
|
+
if (value && typeof value === 'object') {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, v] of Object.entries(value)) {
|
|
78
|
+
if (k.toLowerCase() === 'authorization' || k.toLowerCase() === 'cookie' || k.toLowerCase() === 'x-api-key') {
|
|
79
|
+
out[k] = '[REDACTED]';
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
out[k] = scrubCredentialKeys(v);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
65
89
|
/** Debug-capture filename disambiguator when Date.now() collides. */
|
|
66
90
|
let mcpDebugCounter = 0;
|
|
67
91
|
/**
|
|
@@ -82,7 +106,7 @@ function captureMcpDebug(server, tool, payload) {
|
|
|
82
106
|
mcpDebugCounter += 1;
|
|
83
107
|
file = path.join(dir, `mcp-${safe}-${Date.now()}-${mcpDebugCounter}.json`);
|
|
84
108
|
}
|
|
85
|
-
fs.writeFileSync(file, JSON.stringify({ server, tool, payload }, null, 2), { mode: 0o600 });
|
|
109
|
+
fs.writeFileSync(file, JSON.stringify({ server, tool, payload: scrubCredentialKeys(payload) }, null, 2), { mode: 0o600 });
|
|
86
110
|
try {
|
|
87
111
|
process.stderr.write(`klyro: mcp debug captured ${server}/${tool} -> ${file}\n`);
|
|
88
112
|
}
|
|
@@ -143,12 +167,13 @@ async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
|
|
|
143
167
|
}
|
|
144
168
|
}
|
|
145
169
|
/**
|
|
146
|
-
* Construct the right client for a spec: remote HTTP when `url` is set
|
|
147
|
-
*
|
|
170
|
+
* Construct the right client for a spec: remote HTTP when `url` is set
|
|
171
|
+
* (`transport: 'sse'` selects the legacy event-stream flavor, default is
|
|
172
|
+
* Streamable HTTP), else stdio. Used by registration, probe, and prompts.
|
|
148
173
|
*/
|
|
149
174
|
export function makeMcpClient(name, spec) {
|
|
150
175
|
if (spec.url)
|
|
151
|
-
return new RemoteMcpClient(name, spec);
|
|
176
|
+
return spec.transport === 'sse' ? new SseMcpClient(name, spec) : new RemoteMcpClient(name, spec);
|
|
152
177
|
if (!spec.command)
|
|
153
178
|
throw new McpError(`mcp server "${name}" has neither command nor url`, 'INVALID_SPEC');
|
|
154
179
|
return new McpClient(name, spec);
|
|
@@ -304,27 +329,78 @@ export async function loadAndRegisterMcp(opts) {
|
|
|
304
329
|
}
|
|
305
330
|
}
|
|
306
331
|
/**
|
|
307
|
-
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
|
|
332
|
+
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
|
|
308
333
|
* connection per invocation (no client lifecycle to manage), redacted
|
|
309
|
-
* output.
|
|
310
|
-
*
|
|
311
|
-
* Error on unknown server, disabled server, or prompt failure.
|
|
334
|
+
* output. Project servers need trust-store approval (see `klyro mcp
|
|
335
|
+
* trust`) — typing the prompt name is intent, not approval. Throws
|
|
336
|
+
* McpError / Error on unknown server, disabled server, or prompt failure.
|
|
312
337
|
*/
|
|
313
|
-
export async function runMcpPrompt(cwd, server, prompt,
|
|
338
|
+
export async function runMcpPrompt(cwd, server, prompt, tokens, opts = {}) {
|
|
314
339
|
const cfg = loadMcpServers(cwd);
|
|
315
340
|
const spec = cfg.servers[server];
|
|
316
341
|
if (!spec)
|
|
317
342
|
throw new Error(`mcp server not found: ${server}`);
|
|
318
343
|
if (spec.disabled)
|
|
319
344
|
throw new Error(`mcp server disabled: ${server}`);
|
|
320
|
-
|
|
345
|
+
// Consent parity with the tool path: project-sourced servers require a
|
|
346
|
+
// trust-store approval (see `klyro mcp trust`). Typing the prompt name
|
|
347
|
+
// is intent, not approval — auto-spawning a project subprocess stays gated.
|
|
348
|
+
if (cfg.sources[server] === 'project') {
|
|
349
|
+
const { McpTrust, hashSpec } = await import('./trust.js');
|
|
350
|
+
const trust = opts.trust ?? new McpTrust();
|
|
351
|
+
if (!trust.isTrusted(server, hashSpec(spec))) {
|
|
352
|
+
throw new Error(`mcp server "${server}" is not trusted — run: klyro mcp trust ${server}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const client = (opts.clientFactory ?? makeMcpClient)(server, spec);
|
|
321
356
|
if (!client.promptsGet)
|
|
322
357
|
throw new Error(`mcp server "${server}" does not support prompts`);
|
|
358
|
+
const withConnect = client;
|
|
359
|
+
if (typeof withConnect.connect === 'function')
|
|
360
|
+
await withConnect.connect();
|
|
361
|
+
// Typed args: `key=value` tokens bind by name; bare tokens fill the
|
|
362
|
+
// prompt's declared argument names in order; leftovers join into `input`.
|
|
363
|
+
// Servers without declared arguments keep the legacy arg1..N + input form.
|
|
364
|
+
let mapped;
|
|
365
|
+
try {
|
|
366
|
+
const defs = typeof client.promptsList === 'function' ? await client.promptsList() : [];
|
|
367
|
+
const def = defs.find((d) => d.name === prompt);
|
|
368
|
+
const declared = def?.arguments?.map((a) => a.name) ?? [];
|
|
369
|
+
// Split tokens once: key=value binds by name, bare tokens are positional.
|
|
370
|
+
mapped = {};
|
|
371
|
+
const positional = [];
|
|
372
|
+
for (const tok of tokens) {
|
|
373
|
+
const eq = tok.indexOf('=');
|
|
374
|
+
if (eq > 0)
|
|
375
|
+
mapped[tok.slice(0, eq)] = tok.slice(eq + 1);
|
|
376
|
+
else
|
|
377
|
+
positional.push(tok);
|
|
378
|
+
}
|
|
379
|
+
if (declared.length > 0) {
|
|
380
|
+
// Named bindings win; bare tokens fill the remaining declared slots
|
|
381
|
+
// in order; anything left over joins into `input`.
|
|
382
|
+
const free = declared.filter((d) => !(d in mapped));
|
|
383
|
+
positional.forEach((v, i) => {
|
|
384
|
+
if (i < free.length)
|
|
385
|
+
mapped[free[i]] = v;
|
|
386
|
+
});
|
|
387
|
+
const extras = positional.slice(free.length);
|
|
388
|
+
if (extras.length > 0)
|
|
389
|
+
mapped['input'] = extras.join(' ');
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
positional.forEach((v, i) => { mapped[`arg${i + 1}`] = v; });
|
|
393
|
+
mapped['input'] = positional.join(' ');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// prompts/list failed — fall back to legacy positional mapping.
|
|
398
|
+
mapped = {};
|
|
399
|
+
tokens.forEach((v, i) => { mapped[`arg${i + 1}`] = v; });
|
|
400
|
+
mapped['input'] = tokens.join(' ');
|
|
401
|
+
}
|
|
323
402
|
try {
|
|
324
|
-
const
|
|
325
|
-
if (typeof withConnect.connect === 'function')
|
|
326
|
-
await withConnect.connect();
|
|
327
|
-
const text = await client.promptsGet(prompt, args);
|
|
403
|
+
const text = await client.promptsGet(prompt, mapped);
|
|
328
404
|
return redact(text).slice(0, 8000);
|
|
329
405
|
}
|
|
330
406
|
finally {
|
package/dist/mcp/remote.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { McpServerSpec } from './config.js';
|
|
11
11
|
import { type McpClientLike, type McpToolDef, type McpCallResult, type McpResource, type McpPromptDef } from './client.js';
|
|
12
|
+
/** Loopback check shared with the SSE transport guard. */
|
|
13
|
+
export declare function isLoopback(url: string): boolean;
|
|
12
14
|
/** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
|
|
13
15
|
export declare function parseSseBody(text: string): unknown[];
|
|
14
16
|
export declare class RemoteMcpClient implements McpClientLike {
|
|
@@ -17,7 +19,12 @@ export declare class RemoteMcpClient implements McpClientLike {
|
|
|
17
19
|
private nextId;
|
|
18
20
|
private readonly headers;
|
|
19
21
|
private readonly timeoutMs;
|
|
22
|
+
private authHeader;
|
|
20
23
|
constructor(name: string, spec: McpServerSpec);
|
|
24
|
+
/** B2: resolve OAuth bearer token; returns true when usable headers exist. */
|
|
25
|
+
private ensureAuth;
|
|
26
|
+
/** Base headers merged with the OAuth bearer token (never mutates base). */
|
|
27
|
+
private headersFor;
|
|
21
28
|
connect(): Promise<void>;
|
|
22
29
|
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
23
30
|
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|