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.
Files changed (78) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +26 -0
  5. package/dist/agent/orchestrator.js +41 -4
  6. package/dist/agent/runtime.d.ts +15 -0
  7. package/dist/agent/runtime.js +232 -61
  8. package/dist/chat.d.ts +10 -0
  9. package/dist/chat.js +39 -7
  10. package/dist/checkpoints/store.d.ts +11 -0
  11. package/dist/checkpoints/store.js +32 -0
  12. package/dist/cli/auth.d.ts +10 -3
  13. package/dist/cli/auth.js +43 -5
  14. package/dist/cli/completion.js +2 -2
  15. package/dist/cli/config.d.ts +4 -4
  16. package/dist/cli/doctor.js +0 -1
  17. package/dist/cli/eval.d.ts +15 -1
  18. package/dist/cli/eval.js +43 -5
  19. package/dist/cli/hooks.d.ts +74 -5
  20. package/dist/cli/hooks.js +118 -7
  21. package/dist/cli/init.d.ts +6 -0
  22. package/dist/cli/init.js +60 -0
  23. package/dist/cli/keychain.d.ts +10 -0
  24. package/dist/cli/keychain.js +86 -0
  25. package/dist/cli/repl.js +188 -30
  26. package/dist/cli/run.d.ts +7 -1
  27. package/dist/cli/run.js +92 -50
  28. package/dist/cli/setup.js +3 -2
  29. package/dist/cli/slash/custom.d.ts +25 -0
  30. package/dist/cli/slash/custom.js +166 -0
  31. package/dist/cli/slash/parser.d.ts +9 -1
  32. package/dist/cli/slash/parser.js +34 -9
  33. package/dist/cli/update.d.ts +3 -1
  34. package/dist/cli/update.js +16 -1
  35. package/dist/context/accounting.d.ts +6 -0
  36. package/dist/context/accounting.js +8 -2
  37. package/dist/context/compaction.d.ts +2 -1
  38. package/dist/context/compaction.js +39 -12
  39. package/dist/context/memory.d.ts +11 -0
  40. package/dist/context/memory.js +59 -4
  41. package/dist/eval/harness.d.ts +40 -5
  42. package/dist/eval/harness.js +103 -10
  43. package/dist/eval/judge.d.ts +32 -0
  44. package/dist/eval/judge.js +63 -0
  45. package/dist/eval/tasks.js +134 -0
  46. package/dist/index.js +239 -130
  47. package/dist/mcp/auth.d.ts +85 -0
  48. package/dist/mcp/auth.js +249 -0
  49. package/dist/mcp/client.d.ts +15 -0
  50. package/dist/mcp/client.js +42 -2
  51. package/dist/mcp/config.d.ts +31 -1
  52. package/dist/mcp/config.js +84 -1
  53. package/dist/mcp/registry.d.ts +19 -0
  54. package/dist/mcp/registry.js +118 -2
  55. package/dist/mcp/remote.d.ts +36 -0
  56. package/dist/mcp/remote.js +207 -0
  57. package/dist/mcp/sse.d.ts +42 -0
  58. package/dist/mcp/sse.js +310 -0
  59. package/dist/persistence/audit.d.ts +15 -3
  60. package/dist/persistence/audit.js +84 -13
  61. package/dist/persistence/store.d.ts +9 -0
  62. package/dist/persistence/store.js +17 -0
  63. package/dist/policy/approval.d.ts +15 -1
  64. package/dist/policy/approval.js +8 -0
  65. package/dist/policy/engine.js +9 -0
  66. package/dist/providers/endpoints.d.ts +43 -0
  67. package/dist/providers/endpoints.js +104 -0
  68. package/dist/providers.js +17 -14
  69. package/dist/tools/shell/shell-exec.d.ts +13 -0
  70. package/dist/tools/shell/shell-exec.js +64 -2
  71. package/dist/tui/app.js +172 -15
  72. package/dist/tui/app.test.js +27 -2
  73. package/dist/tui/approval.js +55 -1
  74. package/dist/tui/scroll-model.d.ts +2 -2
  75. package/dist/tui/scroll-model.js +9 -3
  76. package/dist/tui/tokens.d.ts +8 -11
  77. package/dist/tui/tokens.js +18 -11
  78. 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
+ }
@@ -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;
@@ -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`, `ping`.
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(this.spec.command, this.spec.args ?? [], {
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
@@ -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>>;
@@ -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
- command: z.string().min(1),
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
  }
@@ -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>;