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.
Files changed (58) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/orchestrator.d.ts +7 -1
  3. package/dist/agent/orchestrator.js +18 -7
  4. package/dist/agent/runtime.d.ts +10 -0
  5. package/dist/agent/runtime.js +70 -13
  6. package/dist/chat.d.ts +10 -0
  7. package/dist/chat.js +39 -7
  8. package/dist/checkpoints/store.d.ts +2 -0
  9. package/dist/checkpoints/store.js +12 -0
  10. package/dist/cli/auth.d.ts +10 -3
  11. package/dist/cli/auth.js +43 -5
  12. package/dist/cli/doctor.js +0 -1
  13. package/dist/cli/eval.js +22 -16
  14. package/dist/cli/hooks.d.ts +21 -1
  15. package/dist/cli/hooks.js +34 -2
  16. package/dist/cli/keychain.d.ts +10 -0
  17. package/dist/cli/keychain.js +86 -0
  18. package/dist/cli/repl.js +54 -17
  19. package/dist/cli/setup.js +3 -2
  20. package/dist/cli/slash/parser.d.ts +1 -1
  21. package/dist/cli/slash/parser.js +6 -3
  22. package/dist/cli/update.d.ts +3 -1
  23. package/dist/cli/update.js +16 -1
  24. package/dist/context/accounting.d.ts +6 -0
  25. package/dist/context/accounting.js +8 -2
  26. package/dist/context/compaction.d.ts +2 -1
  27. package/dist/context/compaction.js +39 -12
  28. package/dist/context/memory.d.ts +11 -0
  29. package/dist/context/memory.js +47 -9
  30. package/dist/eval/harness.d.ts +19 -2
  31. package/dist/eval/harness.js +72 -7
  32. package/dist/index.js +90 -4
  33. package/dist/mcp/auth.d.ts +85 -0
  34. package/dist/mcp/auth.js +249 -0
  35. package/dist/mcp/config.d.ts +28 -0
  36. package/dist/mcp/config.js +65 -0
  37. package/dist/mcp/registry.d.ts +13 -7
  38. package/dist/mcp/registry.js +90 -14
  39. package/dist/mcp/remote.d.ts +7 -0
  40. package/dist/mcp/remote.js +56 -2
  41. package/dist/mcp/sse.d.ts +42 -0
  42. package/dist/mcp/sse.js +310 -0
  43. package/dist/persistence/audit.d.ts +15 -3
  44. package/dist/persistence/audit.js +84 -13
  45. package/dist/persistence/store.d.ts +9 -0
  46. package/dist/persistence/store.js +17 -0
  47. package/dist/policy/engine.js +9 -0
  48. package/dist/providers.js +4 -4
  49. package/dist/tools/shell/shell-exec.d.ts +13 -0
  50. package/dist/tools/shell/shell-exec.js +64 -2
  51. package/dist/tui/app.js +35 -5
  52. package/dist/tui/app.test.js +3 -2
  53. package/dist/tui/approval.js +3 -1
  54. package/dist/tui/scroll-model.d.ts +2 -2
  55. package/dist/tui/scroll-model.js +9 -3
  56. package/dist/tui/tokens.d.ts +8 -11
  57. package/dist/tui/tokens.js +18 -11
  58. package/package.json +1 -1
@@ -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
+ }
@@ -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>>;
@@ -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;
@@ -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
- * else stdio. Used by registration, probe, and prompt runs.
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 stdio
62
+ * Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
62
63
  * connection per invocation (no client lifecycle to manage), redacted
63
- * output. Typing the prompt name is explicit consent, so project servers
64
- * connect without the registration-time approval gate. Throws McpError /
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, args: Record<string, string>): Promise<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>;
@@ -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
- * else stdio. Used by registration, probe, and prompt runs.
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 stdio
332
+ * Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh
308
333
  * connection per invocation (no client lifecycle to manage), redacted
309
- * output. Typing the prompt name is explicit consent, so project servers
310
- * connect without the registration-time approval gate. Throws McpError /
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, args) {
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
- const client = makeMcpClient(server, spec);
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 withConnect = client;
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 {
@@ -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>;