kritya 0.8.2-beta

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,945 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { VERSION } from "../version.js";
5
+ import { NOOP_TRACER } from "../telemetry/tracer.js";
6
+ import { McpAuthRequiredError } from "./oauth.js";
7
+ import { missingVars } from "./servers.js";
8
+ import { HttpTransport, StdioTransport } from "./transport.js";
9
+ import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
10
+ /**
11
+ * Model Context Protocol client with two transports and no SDK dependency
12
+ * (to keep the install lean):
13
+ *
14
+ * - stdio: the server is a child process speaking newline-delimited JSON-RPC
15
+ * 2.0 (config: `command` + `args`).
16
+ * - Streamable HTTP: the server is a remote endpoint (config: `url` +
17
+ * `headers`); each JSON-RPC message is POSTed, and the response is either
18
+ * a plain JSON body or a text/event-stream carrying one or more messages.
19
+ * The `Mcp-Session-Id` response header is captured on initialize and sent
20
+ * on every subsequent request, per the spec.
21
+ *
22
+ * Each configured server is launched/connected, initialized, and its tools
23
+ * are wrapped as kritya ToolDefs. Tool output is treated as external
24
+ * (untrusted) content.
25
+ */
26
+ export const PROTOCOL_VERSION = "2026-07-28";
27
+ const CONNECT_TIMEOUT_MS = 15_000;
28
+ const CALL_TIMEOUT_MS = 120_000;
29
+ /**
30
+ * Provider function-name ceiling. OpenAI-compatible endpoints reject the whole
31
+ * request — every tool, not just the offending one — when any name exceeds
32
+ * this, which surfaces as an inexplicable model failure rather than an MCP one.
33
+ */
34
+ const MAX_TOOL_NAME_LEN = 64;
35
+ /** Rough size of a base64 payload, for the placeholder that stands in for it. */
36
+ function base64Bytes(data) {
37
+ return Math.floor((data.length * 3) / 4);
38
+ }
39
+ /**
40
+ * Flatten a tool result into the text the model sees.
41
+ *
42
+ * Everything that wasn't a text block used to become the literal string
43
+ * `[image content]`, which threw away three things that carry real payload
44
+ * today: `structuredContent` (the machine-readable result), resources the
45
+ * server inlined into the response, and links to resources it can serve. Text
46
+ * is still the only channel a tool result has, so binary blobs stay
47
+ * placeholders — but they now say what they are and how big, instead of
48
+ * pretending nothing was there.
49
+ */
50
+ function renderToolResult(result) {
51
+ const parts = [];
52
+ for (const block of result.content ?? []) {
53
+ switch (block.type) {
54
+ case "text":
55
+ parts.push(block.text ?? "");
56
+ break;
57
+ case "image":
58
+ case "audio": {
59
+ const size = block.data ? `, ~${base64Bytes(block.data)} bytes` : "";
60
+ parts.push(`[${block.type}: ${block.mimeType ?? "unknown type"}${size}]`);
61
+ break;
62
+ }
63
+ case "resource_link":
64
+ parts.push(`[resource: ${block.uri ?? "unknown"}${block.name ? ` — ${block.name}` : ""}` +
65
+ `${block.description ? ` (${block.description})` : ""}]`);
66
+ break;
67
+ case "resource": {
68
+ // Embedded, so the payload is already here — no second round trip.
69
+ const r = block.resource;
70
+ if (r?.text !== undefined) {
71
+ parts.push(`[resource: ${r.uri ?? "inline"}]\n${r.text}`);
72
+ }
73
+ else if (r?.blob) {
74
+ parts.push(`[resource: ${r.uri ?? "inline"} — ${r.mimeType ?? "binary"}, ` +
75
+ `~${base64Bytes(r.blob)} bytes]`);
76
+ }
77
+ break;
78
+ }
79
+ default:
80
+ parts.push(`[${block.type} content]`);
81
+ }
82
+ }
83
+ // Servers that declare an outputSchema SHOULD send the same data as both
84
+ // structuredContent and a serialized text block, so only fall back to it
85
+ // when the content blocks gave us nothing — otherwise every such call would
86
+ // pay for the payload twice.
87
+ const text = parts.filter((p) => p !== "").join("\n");
88
+ if (text)
89
+ return text;
90
+ if (result.structuredContent !== undefined)
91
+ return JSON.stringify(result.structuredContent);
92
+ return "";
93
+ }
94
+ /** `_meta` block a task-enabled server's `tools/call` request carries, per the extension's negotiation mechanism. */
95
+ export const TASKS_EXTENSION_META = {
96
+ "io.modelcontextprotocol/clientCapabilities": {
97
+ extensions: { "io.modelcontextprotocol/tasks": {} },
98
+ },
99
+ };
100
+ export const DEFAULT_POLL_INTERVAL_MS = 2000;
101
+ /** Floor and ceiling for a server-reported `pollIntervalMs` — never trust
102
+ * untrusted server data to schedule a busy-loop or an effectively-infinite sleep. */
103
+ const MIN_POLL_INTERVAL_MS = 250;
104
+ const MAX_POLL_INTERVAL_MS = 60_000;
105
+ /** Clamp a server-reported poll interval into a sane range, falling back to the
106
+ * default for anything non-finite (missing, zero, negative, NaN). */
107
+ function sanitizePollInterval(ms) {
108
+ if (typeof ms !== "number" || !Number.isFinite(ms))
109
+ return DEFAULT_POLL_INTERVAL_MS;
110
+ return Math.min(Math.max(ms, MIN_POLL_INTERVAL_MS), MAX_POLL_INTERVAL_MS);
111
+ }
112
+ /** Sleep that rejects promptly on abort instead of running the full duration,
113
+ * and `unref`s its timer so it never holds the event loop open. */
114
+ function sleep(ms, signal) {
115
+ return new Promise((resolve, reject) => {
116
+ const onAbort = () => {
117
+ clearTimeout(timer);
118
+ reject(new Error("aborted"));
119
+ };
120
+ const timer = setTimeout(() => {
121
+ signal?.removeEventListener("abort", onAbort);
122
+ resolve();
123
+ }, ms);
124
+ timer.unref?.();
125
+ if (signal?.aborted) {
126
+ onAbort();
127
+ return;
128
+ }
129
+ signal?.addEventListener("abort", onAbort, { once: true });
130
+ });
131
+ }
132
+ /** Race an arbitrary promise (e.g. a UI callback we can't otherwise interrupt)
133
+ * against abort, so a hung callback can't stall a cancellation. */
134
+ function raceAbort(promise, signal) {
135
+ if (!signal)
136
+ return promise;
137
+ if (signal.aborted)
138
+ return Promise.reject(new Error("aborted"));
139
+ return new Promise((resolve, reject) => {
140
+ const onAbort = () => reject(new Error("aborted"));
141
+ signal.addEventListener("abort", onAbort, { once: true });
142
+ promise.then((v) => {
143
+ signal.removeEventListener("abort", onAbort);
144
+ resolve(v);
145
+ }, (e) => {
146
+ signal.removeEventListener("abort", onAbort);
147
+ reject(e);
148
+ });
149
+ });
150
+ }
151
+ class McpConnection {
152
+ name;
153
+ transport;
154
+ workspace;
155
+ options;
156
+ nextId = 1;
157
+ pending = new Map();
158
+ closed = false;
159
+ constructor(name, transport,
160
+ /** The workspace this session is working on — what `roots/list` reports. */
161
+ workspace, options = {}) {
162
+ this.name = name;
163
+ this.transport = transport;
164
+ this.workspace = workspace;
165
+ this.options = options;
166
+ transport.onMessage = (msg) => this.onMessage(msg);
167
+ transport.onError = (err) => this.fail(new Error(`MCP server "${name}": ${err.message}`));
168
+ }
169
+ /** Remove a pending request and release everything attached to it. */
170
+ take(id) {
171
+ const p = this.pending.get(id);
172
+ if (!p)
173
+ return undefined;
174
+ this.pending.delete(id);
175
+ clearTimeout(p.timer);
176
+ p.cleanup();
177
+ return p;
178
+ }
179
+ onMessage(msg) {
180
+ if (msg.method) {
181
+ // A request from the server (it has an id) or a notification (it doesn't).
182
+ if (typeof msg.id === "number")
183
+ this.onServerRequest(msg.id, msg.method, msg.params);
184
+ return;
185
+ }
186
+ if (typeof msg.id !== "number")
187
+ return;
188
+ const p = this.take(msg.id);
189
+ if (!p)
190
+ return;
191
+ if (msg.error)
192
+ p.reject(new Error(msg.error.message ?? "MCP error"));
193
+ else
194
+ p.resolve(msg.result);
195
+ }
196
+ /**
197
+ * Answer a request the server made of us.
198
+ *
199
+ * Only `roots/list` for now. Without it a filesystem-style server has no way
200
+ * to learn where the user's project is and falls back to whatever its own
201
+ * config guessed; with it, one workspace root is all most servers need. Every
202
+ * other method gets a proper "method not found" rather than silence, so a
203
+ * server can tell the difference between an unsupported client and a hung one.
204
+ */
205
+ onServerRequest(id, method, params) {
206
+ const reply = (body) => this.transport.send({ jsonrpc: "2.0", id, ...body }, CONNECT_TIMEOUT_MS).catch(() => { });
207
+ if (method === "roots/list") {
208
+ reply({
209
+ result: {
210
+ roots: [{ uri: pathToFileURL(this.workspace).href, name: path.basename(this.workspace) }],
211
+ },
212
+ });
213
+ return;
214
+ }
215
+ if (method === "sampling/createMessage") {
216
+ if (!this.options.onSampling) {
217
+ reply({
218
+ error: { code: -32601, message: `method "${method}" is not supported by kritya` },
219
+ });
220
+ return;
221
+ }
222
+ const p = params;
223
+ const req = {
224
+ server: this.name,
225
+ messages: (p?.messages ?? []).map((m) => ({
226
+ role: m.role === "assistant" ? "assistant" : "user",
227
+ content: m.content?.text ?? "",
228
+ })),
229
+ systemPrompt: p?.systemPrompt,
230
+ maxTokens: p?.maxTokens,
231
+ };
232
+ this.options
233
+ .onSampling(this.name, req)
234
+ .then((result) => {
235
+ if (!result.ok) {
236
+ reply({ error: { code: -32603, message: result.reason } });
237
+ return;
238
+ }
239
+ reply({
240
+ result: {
241
+ role: "assistant",
242
+ content: { type: "text", text: result.content },
243
+ model: result.model,
244
+ stopReason: result.stopReason,
245
+ },
246
+ });
247
+ })
248
+ .catch((err) => reply({ error: { code: -32603, message: err.message } }));
249
+ return;
250
+ }
251
+ if (method === "elicitation/create") {
252
+ if (!this.options.onElicitation) {
253
+ reply({
254
+ error: { code: -32601, message: `method "${method}" is not supported by kritya` },
255
+ });
256
+ return;
257
+ }
258
+ const p = params;
259
+ let fields;
260
+ try {
261
+ fields = toElicitationFields(p?.requestedSchema ?? {});
262
+ }
263
+ catch (err) {
264
+ reply({
265
+ error: { code: -32602, message: err instanceof Error ? err.message : String(err) },
266
+ });
267
+ return;
268
+ }
269
+ this.options
270
+ .onElicitation(this.name, p?.message ?? "", fields)
271
+ .then((result) => reply({ result }))
272
+ .catch((err) => reply({ error: { code: -32603, message: err.message } }));
273
+ return;
274
+ }
275
+ reply({ error: { code: -32601, message: `method "${method}" is not supported by kritya` } });
276
+ }
277
+ fail(err) {
278
+ if (this.closed)
279
+ return;
280
+ this.closed = true;
281
+ for (const p of this.pending.values()) {
282
+ clearTimeout(p.timer);
283
+ p.cleanup();
284
+ p.reject(err);
285
+ }
286
+ this.pending.clear();
287
+ }
288
+ request(method, params, timeoutMs, signal) {
289
+ if (this.closed)
290
+ return Promise.reject(new Error(`MCP server "${this.name}" is not running`));
291
+ if (signal?.aborted) {
292
+ return Promise.reject(new Error(`MCP request "${method}" was cancelled`));
293
+ }
294
+ const id = this.nextId++;
295
+ return new Promise((resolve, reject) => {
296
+ const onAbort = () => {
297
+ const p = this.take(id);
298
+ if (!p)
299
+ return;
300
+ // Tell the server to stop: without this it runs the request to
301
+ // completion on the far side long after the user has moved on. This is
302
+ // what notifications/cancelled is for.
303
+ this.notify("notifications/cancelled", { requestId: id, reason: "cancelled by user" });
304
+ p.reject(new Error(`MCP request "${method}" was cancelled`));
305
+ };
306
+ signal?.addEventListener("abort", onAbort, { once: true });
307
+ const timer = setTimeout(() => {
308
+ const p = this.take(id);
309
+ p?.reject(new Error(`MCP request "${method}" timed out`));
310
+ }, timeoutMs);
311
+ this.pending.set(id, {
312
+ resolve,
313
+ reject,
314
+ timer,
315
+ cleanup: () => signal?.removeEventListener("abort", onAbort),
316
+ });
317
+ this.transport
318
+ .send({ jsonrpc: "2.0", id, method, params }, timeoutMs, signal)
319
+ .catch((err) => {
320
+ // Transport-level failure for this request (HTTP error, closed pipe):
321
+ // reject it directly rather than waiting for the timeout.
322
+ const p = this.take(id);
323
+ p?.reject(err);
324
+ });
325
+ });
326
+ }
327
+ notify(method, params) {
328
+ this.transport.send({ jsonrpc: "2.0", method, params }, CONNECT_TIMEOUT_MS).catch(() => { });
329
+ }
330
+ async initialize() {
331
+ const init = (await this.request("initialize", {
332
+ protocolVersion: PROTOCOL_VERSION,
333
+ // Declaring roots is what lets a server scope itself to the user's
334
+ // project instead of whatever its own config guessed.
335
+ capabilities: { roots: {}, sampling: {}, elicitation: {} },
336
+ clientInfo: { name: "kritya", version: VERSION },
337
+ }, CONNECT_TIMEOUT_MS));
338
+ if (this.transport instanceof HttpTransport) {
339
+ this.transport.protocolVersion = init.protocolVersion ?? PROTOCOL_VERSION;
340
+ }
341
+ this.notify("notifications/initialized");
342
+ const caps = init.capabilities ?? {};
343
+ // Tools are always asked for, declared or not: servers in the wild are
344
+ // sloppy about announcing capabilities, and a missing `tools` key is far
345
+ // more often an oversight than a server with no tools. Prompts and
346
+ // resources are gated, because there asking costs a round trip (and an
347
+ // error on strict servers) for something most servers genuinely lack.
348
+ const [tools, prompts, resources] = await Promise.all([
349
+ this.listTools(),
350
+ caps.prompts ? this.listPrompts() : Promise.resolve([]),
351
+ caps.resources ? this.listResources() : Promise.resolve([]),
352
+ ]);
353
+ return { tools, prompts, resources };
354
+ }
355
+ async listTools() {
356
+ const listed = (await this.request("tools/list", {}, CONNECT_TIMEOUT_MS));
357
+ return listed.tools ?? [];
358
+ }
359
+ /** Prompts and resources are extras: a server that fails to list them still connects. */
360
+ async listPrompts() {
361
+ try {
362
+ const listed = (await this.request("prompts/list", {}, CONNECT_TIMEOUT_MS));
363
+ return listed.prompts ?? [];
364
+ }
365
+ catch {
366
+ return [];
367
+ }
368
+ }
369
+ async listResources() {
370
+ try {
371
+ const listed = (await this.request("resources/list", {}, CONNECT_TIMEOUT_MS));
372
+ return listed.resources ?? [];
373
+ }
374
+ catch {
375
+ return [];
376
+ }
377
+ }
378
+ async getPrompt(name, args) {
379
+ const result = (await this.request("prompts/get", { name, arguments: args }, CALL_TIMEOUT_MS));
380
+ const messages = result.messages ?? [];
381
+ // A prompt is normally a single user message; when it isn't, keep the roles
382
+ // visible rather than silently flattening a scripted exchange into one voice.
383
+ const multiRole = new Set(messages.map((m) => m.role ?? "user")).size > 1;
384
+ return messages
385
+ .map((m) => {
386
+ const body = m.content ? renderToolResult({ content: [m.content] }) : "";
387
+ return multiRole ? `[${m.role ?? "user"}] ${body}` : body;
388
+ })
389
+ .filter(Boolean)
390
+ .join("\n\n");
391
+ }
392
+ async readResource(uri) {
393
+ const result = (await this.request("resources/read", { uri }, CALL_TIMEOUT_MS));
394
+ return (result.contents ?? [])
395
+ .map((c) => c.text !== undefined
396
+ ? c.text
397
+ : `[binary resource: ${c.mimeType ?? "unknown"}, ~${base64Bytes(c.blob ?? "")} bytes]`)
398
+ .join("\n");
399
+ }
400
+ async callTool(toolName, args, signal, tasksEnabled, onProgress) {
401
+ const params = { name: toolName, arguments: args };
402
+ if (tasksEnabled) {
403
+ params._meta = TASKS_EXTENSION_META;
404
+ }
405
+ const result = (await this.request("tools/call", params, CALL_TIMEOUT_MS, signal));
406
+ if (result.resultType === "task") {
407
+ if (!tasksEnabled) {
408
+ throw new Error("MCP server returned a task, but this server has not declared tasks support — refusing to poll it");
409
+ }
410
+ return this.pollTask(result, signal, onProgress);
411
+ }
412
+ const text = renderToolResult(result);
413
+ if (result.isError)
414
+ throw new Error(text || "MCP tool reported an error");
415
+ return text || "(no output)";
416
+ }
417
+ /** Poll `tasks/get` until a task reaches a terminal status, answering
418
+ * elicitation-shaped `input_required` requests along the way. */
419
+ async pollTask(initial, signal, onProgress) {
420
+ const taskId = initial.taskId;
421
+ onProgress?.(initial.statusMessage ?? "task created — waiting…");
422
+ let pollIntervalMs = sanitizePollInterval(initial.pollIntervalMs);
423
+ // `ttlMs: null` means the server explicitly declared no expiry — leave
424
+ // it unbounded. Any other non-positive/non-finite value is nonsense, not
425
+ // an instruction, so it's likewise treated as no deadline.
426
+ const deadline = typeof initial.ttlMs === "number" && Number.isFinite(initial.ttlMs) && initial.ttlMs > 0
427
+ ? Date.now() + initial.ttlMs
428
+ : null;
429
+ try {
430
+ for (;;) {
431
+ if (deadline !== null && Date.now() >= deadline) {
432
+ this.notify("tasks/cancel", { taskId });
433
+ throw new Error("MCP task exceeded its ttlMs");
434
+ }
435
+ await sleep(pollIntervalMs, signal);
436
+ if (deadline !== null && Date.now() >= deadline) {
437
+ this.notify("tasks/cancel", { taskId });
438
+ throw new Error("MCP task exceeded its ttlMs");
439
+ }
440
+ const detailed = (await this.request("tasks/get", { taskId }, CALL_TIMEOUT_MS, signal));
441
+ pollIntervalMs = sanitizePollInterval(detailed.pollIntervalMs ?? pollIntervalMs);
442
+ switch (detailed.status) {
443
+ case "working":
444
+ onProgress?.(detailed.statusMessage ?? "working…");
445
+ continue;
446
+ case "input_required": {
447
+ onProgress?.(detailed.statusMessage ?? "waiting for input…");
448
+ await this.answerInputRequired(taskId, detailed.inputRequests ?? {}, signal);
449
+ continue;
450
+ }
451
+ case "completed": {
452
+ onProgress?.(detailed.statusMessage ?? "completed");
453
+ const text = renderToolResult(detailed.result ?? {});
454
+ if (detailed.result?.isError)
455
+ throw new Error(text || "MCP tool reported an error");
456
+ return text || "(no output)";
457
+ }
458
+ case "failed":
459
+ throw new Error(detailed.error?.message ?? "MCP task failed");
460
+ case "cancelled":
461
+ throw new Error("MCP task was cancelled");
462
+ default:
463
+ throw new Error(`MCP task entered unrecognized status "${String(detailed.status)}"`);
464
+ }
465
+ }
466
+ }
467
+ catch (err) {
468
+ // Whatever went wrong — the sleep was interrupted, a request was cut
469
+ // short, an elicitation callback never returned — if it's because the
470
+ // caller aborted, make sure the server hears about it (best-effort,
471
+ // fire-and-forget) and surface one clear, consistent message rather
472
+ // than whichever request happened to be in flight at the time.
473
+ if (signal?.aborted) {
474
+ this.notify("tasks/cancel", { taskId });
475
+ throw new Error("MCP task cancelled by user", { cause: err });
476
+ }
477
+ throw err;
478
+ }
479
+ }
480
+ /** Answer every `input_required` entry via elicitation, or cancel the task
481
+ * and throw if any entry isn't elicitation-shaped. */
482
+ async answerInputRequired(taskId, inputRequests, signal) {
483
+ const entries = Object.entries(inputRequests);
484
+ const unsupported = entries.find(([, req]) => req.method !== "elicitation/create");
485
+ if (unsupported) {
486
+ this.notify("tasks/cancel", { taskId });
487
+ throw new Error(`MCP task requires unsupported input method "${unsupported[1].method}" — cancelled`);
488
+ }
489
+ if (!this.options.onElicitation) {
490
+ this.notify("tasks/cancel", { taskId });
491
+ throw new Error("MCP task requires elicitation, but elicitation is not supported here");
492
+ }
493
+ const inputResponses = {};
494
+ try {
495
+ for (const [key, req] of entries) {
496
+ const p = req.params;
497
+ const fields = toElicitationFields(p?.requestedSchema ?? {});
498
+ inputResponses[key] = await raceAbort(this.options.onElicitation(this.name, p?.message ?? "", fields), signal);
499
+ }
500
+ }
501
+ catch (err) {
502
+ this.notify("tasks/cancel", { taskId });
503
+ throw err;
504
+ }
505
+ await this.request("tasks/update", { taskId, inputResponses }, CALL_TIMEOUT_MS, signal);
506
+ }
507
+ close() {
508
+ this.closed = true;
509
+ this.transport.close();
510
+ }
511
+ }
512
+ /**
513
+ * Match a tool name against one `*`-wildcard pattern, e.g. `github_*`.
514
+ * Everything else is literal — these are tool names, not paths.
515
+ */
516
+ function matchesPattern(name, pattern) {
517
+ const rx = pattern
518
+ .split("*")
519
+ .map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
520
+ .join(".*");
521
+ return new RegExp(`^${rx}$`).test(name);
522
+ }
523
+ /**
524
+ * Whether a server's tool passes its configured allow/deny lists. Deny wins,
525
+ * so a broad allow can be trimmed without rewriting it; an absent or empty
526
+ * allow means everything.
527
+ */
528
+ export function toolAllowed(name, filter) {
529
+ if (!filter)
530
+ return true;
531
+ if (filter.deny?.some((p) => matchesPattern(name, p)))
532
+ return false;
533
+ if (!filter.allow?.length)
534
+ return true;
535
+ return filter.allow.some((p) => matchesPattern(name, p));
536
+ }
537
+ /** Loopback is exempt from the https requirement: there's no network to sniff. */
538
+ function isLoopback(hostname) {
539
+ const h = hostname.replace(/^\[|\]$/g, "");
540
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
541
+ }
542
+ /**
543
+ * Reject a remote server reachable only over plaintext. `/mcp add` already
544
+ * refuses these, but that guards one entrance: a server hand-written into
545
+ * ~/.kritya/config.json or a repo's .mcp.json never passes through it and
546
+ * would happily POST a bearer token in the clear. This is the choke point all
547
+ * three sources share.
548
+ */
549
+ export function assertSafeUrl(name, url) {
550
+ let parsed;
551
+ try {
552
+ parsed = new URL(url);
553
+ }
554
+ catch {
555
+ throw new Error(`server "${name}" has an invalid url: ${url}`);
556
+ }
557
+ // https alone doesn't mean the traffic (incl. any bearer token in headers)
558
+ // stays where the user intends — a config pointing at 169.254.169.254 or
559
+ // another private/internal address would still ship credentials there.
560
+ // Loopback is exempt: that's this app talking to itself, nothing to leak to.
561
+ if (!isLoopback(parsed.hostname) && isPrivateOrLoopbackHost(parsed.hostname)) {
562
+ throw new Error(`server "${name}" points at a private/internal address (${parsed.hostname}) — refusing to connect.`);
563
+ }
564
+ if (parsed.protocol === "https:")
565
+ return parsed;
566
+ if (parsed.protocol === "http:" && isLoopback(parsed.hostname))
567
+ return parsed;
568
+ if (parsed.protocol !== "http:") {
569
+ throw new Error(`server "${name}" uses unsupported scheme "${parsed.protocol}" — use https://`);
570
+ }
571
+ throw new Error(`server "${name}" uses plain http:// (${parsed.host}) — an MCP session carries ` +
572
+ `your credentials in cleartext over it. Use https:// (localhost is exempt).`);
573
+ }
574
+ function makeTransport(name, cfg, workspace) {
575
+ if (cfg.url) {
576
+ if (cfg.command) {
577
+ throw new Error(`server "${name}" sets both "command" and "url"; pick one`);
578
+ }
579
+ assertSafeUrl(name, cfg.url);
580
+ return new HttpTransport(cfg.url, cfg.headers ?? {});
581
+ }
582
+ if (!cfg.command) {
583
+ throw new Error(`server "${name}" needs either "command" (stdio) or "url" (HTTP)`);
584
+ }
585
+ // Relative to the workspace, so a checked-in .mcp.json stays portable.
586
+ const cwd = cfg.cwd ? path.resolve(workspace, cfg.cwd) : workspace;
587
+ return new StdioTransport(cfg.command, cfg.args ?? [], cfg.env, cwd);
588
+ }
589
+ const connections = [];
590
+ let statuses = [];
591
+ /**
592
+ * Exposed tool name -> the identity that claimed it, so a second tool whose
593
+ * sanitized form lands on the same string doesn't silently shadow the first.
594
+ */
595
+ const registeredNames = new Map();
596
+ /** Server -> the exposed tool names it currently owns, for withdrawal and reuse. */
597
+ const serverToolNames = new Map();
598
+ const prompts = [];
599
+ const resources = [];
600
+ /** Slash commands contributed by connected MCP servers. */
601
+ export function mcpPrompts() {
602
+ return prompts;
603
+ }
604
+ /** Attachable documents contributed by connected MCP servers. */
605
+ export function mcpResources() {
606
+ return resources;
607
+ }
608
+ function forgetContributions(server) {
609
+ for (const list of [prompts, resources]) {
610
+ for (let i = list.length - 1; i >= 0; i--) {
611
+ if (list[i].server === server)
612
+ list.splice(i, 1);
613
+ }
614
+ }
615
+ }
616
+ function toolIdentity(server, toolName) {
617
+ return `${server}\u0000${toolName}`;
618
+ }
619
+ /** Status of every configured MCP server from the last loadMcpTools call. */
620
+ export function mcpStatus() {
621
+ return statuses;
622
+ }
623
+ function sanitize(s) {
624
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_");
625
+ }
626
+ function shortHash(s) {
627
+ return crypto.createHash("sha256").update(s).digest("hex").slice(0, 8);
628
+ }
629
+ /**
630
+ * The name a tool is exposed to the model under.
631
+ *
632
+ * Two things can go wrong with the obvious `mcp_<server>_<tool>`. sanitize is
633
+ * lossy — `my.tool` and `my-tool` both become `my_tool`, so one tool shadows
634
+ * the other and calls silently go to the wrong place. And the result can
635
+ * exceed the provider's 64-character function-name limit, which fails the
636
+ * entire request rather than the one tool, looking like a broken model.
637
+ *
638
+ * Both are resolved by folding a hash of the true identity into the name:
639
+ * deterministic across runs (so allow-rules and transcripts stay valid), and
640
+ * distinct wherever the sanitized forms are not.
641
+ */
642
+ function exposedToolName(server, toolName) {
643
+ const base = `mcp_${sanitize(server)}_${sanitize(toolName)}`;
644
+ const identity = toolIdentity(server, toolName);
645
+ const claimed = registeredNames.get(base);
646
+ const needsHash = base.length > MAX_TOOL_NAME_LEN || (claimed !== undefined && claimed !== identity);
647
+ if (!needsHash)
648
+ return base;
649
+ const suffix = `_${shortHash(identity)}`;
650
+ return base.slice(0, MAX_TOOL_NAME_LEN - suffix.length) + suffix;
651
+ }
652
+ /**
653
+ * Connect to all configured MCP servers and return their tools as ToolDefs.
654
+ * Resilient: a server that fails to start is skipped with a warning (and shows
655
+ * as failed in /mcp), never crashing kritya. Returns an empty list when
656
+ * nothing is configured.
657
+ *
658
+ * `trace` is optional so callers that don't care (tests, tools that load MCP
659
+ * servers ad hoc) can omit it; when given, each connect attempt gets a span
660
+ * and a failure is also written to the audit log — otherwise a failed server
661
+ * was only ever a single stderr line that nothing else recorded.
662
+ */
663
+ export async function loadMcpTools(servers, trace) {
664
+ statuses = [];
665
+ registeredNames.clear();
666
+ serverToolNames.clear();
667
+ prompts.length = 0;
668
+ resources.length = 0;
669
+ if (!servers || Object.keys(servers).length === 0)
670
+ return [];
671
+ const tools = [];
672
+ const results = await Promise.all(Object.entries(servers).map(([name, cfg]) => connectServer(name, cfg, trace)));
673
+ for (const { tools: t, status } of results) {
674
+ tools.push(...t);
675
+ statuses.push(status);
676
+ }
677
+ return tools;
678
+ }
679
+ /**
680
+ * Connect one server and wrap its tools. Split out of loadMcpTools so a login
681
+ * can bring a server up mid-session without restarting kritya (and without
682
+ * disturbing the servers that are already connected).
683
+ *
684
+ * A server that needs OAuth is *not* an error: it reports `needsAuth` and no
685
+ * tools, so startup stays quiet and `/mcp` can tell the user what to run. The
686
+ * alternative — opening a browser during `kritya` startup — would hijack the
687
+ * terminal before the user has typed anything.
688
+ */
689
+ export async function connectServer(name, cfg, trace) {
690
+ const tracer = trace?.tracer ?? NOOP_TRACER;
691
+ const workspace = trace?.workspace ?? process.cwd();
692
+ const tools = [];
693
+ const status = {
694
+ name,
695
+ transport: cfg.url ? "http" : "stdio",
696
+ target: cfg.url ?? [cfg.command, ...(cfg.args ?? [])].filter(Boolean).join(" "),
697
+ ok: false,
698
+ tools: [],
699
+ prompts: [],
700
+ resources: [],
701
+ };
702
+ const span = tracer.startSpan("mcp.connect", {
703
+ attributes: { "kritya.mcp_server": name, "kritya.mcp_transport": status.transport },
704
+ });
705
+ let conn;
706
+ try {
707
+ // Checked here rather than at expansion time: this is where a per-server
708
+ // failure has somewhere to go (`status.error`, and the /mcp table).
709
+ const missing = missingVars(cfg);
710
+ if (missing.length) {
711
+ throw new Error(`missing env var${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`);
712
+ }
713
+ conn = new McpConnection(name, makeTransport(name, cfg, workspace), workspace, {
714
+ onSampling: trace?.onSampling,
715
+ onElicitation: trace?.onElicitation,
716
+ });
717
+ const listed = await conn.initialize();
718
+ const specs = listed.tools;
719
+ connections.push(conn);
720
+ // Reconnects re-derive names from scratch; releasing the old claims first
721
+ // keeps a server from colliding with its own previous incarnation.
722
+ releaseToolNames(name);
723
+ forgetContributions(name);
724
+ registerPrompts(conn, name, listed.prompts, status);
725
+ registerResources(conn, name, listed.resources, status);
726
+ const owned = new Set();
727
+ const exposed = specs.filter((s) => toolAllowed(s.name, cfg.tools));
728
+ status.hiddenTools = specs.length - exposed.length;
729
+ for (const spec of exposed) {
730
+ const def = mcpToolDef(conn, name, spec, cfg);
731
+ registeredNames.set(def.name, toolIdentity(name, spec.name));
732
+ owned.add(def.name);
733
+ tools.push(def);
734
+ status.tools.push(spec.name);
735
+ }
736
+ serverToolNames.set(name, owned);
737
+ status.ok = true;
738
+ span.setAttribute("kritya.mcp_tool_count", status.tools.length);
739
+ span.setStatus("OK");
740
+ }
741
+ catch (err) {
742
+ conn?.close();
743
+ if (err instanceof McpAuthRequiredError) {
744
+ status.needsAuth = true;
745
+ status.authMetadataUrl = err.resourceMetadataUrl;
746
+ status.error = `needs login — run /mcp login ${name}`;
747
+ span.setAttribute("kritya.mcp_needs_auth", true);
748
+ span.setStatus("OK");
749
+ }
750
+ else {
751
+ status.error = err instanceof Error ? err.message : String(err);
752
+ process.stderr.write(`kritya: MCP server "${name}" failed to start: ${status.error}\n`);
753
+ span.setStatus("ERROR", status.error);
754
+ trace?.audit?.logTool({
755
+ tool: "mcp_connect",
756
+ summary: `server "${name}" failed to start: ${status.error}`,
757
+ outcome: "error",
758
+ });
759
+ }
760
+ }
761
+ finally {
762
+ span.end();
763
+ }
764
+ return { tools, status };
765
+ }
766
+ /**
767
+ * Expose a server's prompts as slash commands.
768
+ *
769
+ * Named `/<server>-<prompt>` so two servers offering "triage" don't fight over
770
+ * one command, and so it's obvious where a command came from. A server's
771
+ * prompt never displaces a built-in or a user's own command file: those are
772
+ * matched first, and a server that names a prompt "plan" shouldn't be able to
773
+ * quietly redefine /plan.
774
+ */
775
+ function registerPrompts(conn, server, specs, status) {
776
+ for (const spec of specs) {
777
+ const command = `/${sanitizeCommand(server)}-${sanitizeCommand(spec.name)}`;
778
+ const args = spec.arguments ?? [];
779
+ prompts.push({
780
+ server,
781
+ name: spec.name,
782
+ command,
783
+ description: `[MCP: ${server}] ${spec.description ?? spec.name}`,
784
+ args,
785
+ expand: (argText) => conn.getPrompt(spec.name, splitPromptArgs(argText, args)),
786
+ });
787
+ status.prompts.push(spec.name);
788
+ }
789
+ }
790
+ /**
791
+ * Map what the user typed after the command onto the prompt's named arguments.
792
+ *
793
+ * Positional by whitespace, with the last declared argument soaking up the
794
+ * remainder — so a single-argument prompt gets the whole line, which is how
795
+ * every slash command in kritya already behaves.
796
+ */
797
+ function splitPromptArgs(argText, args) {
798
+ const out = {};
799
+ if (!args.length)
800
+ return out;
801
+ const rest = argText.trim();
802
+ if (args.length === 1) {
803
+ if (rest)
804
+ out[args[0].name] = rest;
805
+ return out;
806
+ }
807
+ const parts = rest.split(/\s+/).filter(Boolean);
808
+ args.forEach((arg, i) => {
809
+ const value = i === args.length - 1 ? parts.slice(i).join(" ") : parts[i];
810
+ if (value)
811
+ out[arg.name] = value;
812
+ });
813
+ return out;
814
+ }
815
+ /** Expose a server's resources as `@mcp:<server>/<name>` attachments. */
816
+ function registerResources(conn, server, specs, status) {
817
+ for (const spec of specs) {
818
+ const label = spec.name ?? spec.uri;
819
+ resources.push({
820
+ server,
821
+ uri: spec.uri,
822
+ mention: `mcp:${sanitizeCommand(server)}/${sanitizeCommand(label)}`,
823
+ description: spec.description ?? spec.mimeType ?? spec.uri,
824
+ read: () => conn.readResource(spec.uri),
825
+ });
826
+ status.resources.push(label);
827
+ }
828
+ }
829
+ /** Command/mention-safe form of a name: no spaces, no leading slash confusion. */
830
+ function sanitizeCommand(s) {
831
+ return s.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
832
+ }
833
+ /** Replace one server's entry in the /mcp status table after a reconnect. */
834
+ export function replaceStatus(status) {
835
+ const idx = statuses.findIndex((s) => s.name === status.name);
836
+ if (idx >= 0)
837
+ statuses[idx] = status;
838
+ else
839
+ statuses.push(status);
840
+ }
841
+ /**
842
+ * Whether a tool can run without asking the user first.
843
+ *
844
+ * Marking every MCP tool as permission-requiring costs more than it looks. It
845
+ * prompts on pure lookups (a docs fetch, an issue search), and approval fatigue
846
+ * is exactly what trains people to accept without reading — so the blanket
847
+ * prompt makes the prompts that matter *less* effective. It also silently
848
+ * excluded MCP from subagents, which are only handed tools that don't prompt.
849
+ *
850
+ * We take readOnlyHint at face value, but only that one: it is a claim by a
851
+ * server the user has already trusted (workspace trust + per-server trust) that
852
+ * a tool doesn't change anything. destructiveHint being set overrides it, since
853
+ * a server contradicting itself should get the cautious reading.
854
+ */
855
+ /**
856
+ * A flat requestedSchema (object of string/boolean/enum properties) becomes
857
+ * ElicitationFields the UI can render. Anything with nesting or an
858
+ * unrecognized type is rejected outright, rather than guessed at.
859
+ */
860
+ function toElicitationFields(schema) {
861
+ const props = schema.properties ?? {};
862
+ return Object.entries(props).map(([name, prop]) => {
863
+ const label = prop.title ?? name;
864
+ if (prop.enum)
865
+ return { name, kind: "enum", label, options: prop.enum };
866
+ if (prop.type === "boolean")
867
+ return { name, kind: "boolean", label };
868
+ if (prop.type === "string")
869
+ return { name, kind: "string", label };
870
+ throw new Error(`elicitation field "${name}" has an unsupported schema (type: ${prop.type ?? "nested/unknown"})`);
871
+ });
872
+ }
873
+ function isReadOnly(spec) {
874
+ const a = spec.annotations;
875
+ return a?.readOnlyHint === true && a.destructiveHint !== true;
876
+ }
877
+ export function mcpToolDef(conn, server, spec, cfg = {}) {
878
+ return {
879
+ name: exposedToolName(server, spec.name),
880
+ description: `[MCP: ${server}] ${spec.description ?? spec.name}`,
881
+ parameters: spec.inputSchema ?? { type: "object", properties: {} },
882
+ requiresPermission: cfg.consent === "always-confirm" ? true : !isReadOnly(spec),
883
+ // Self-managed: every request already carries CALL_TIMEOUT_MS, and the
884
+ // connection rejects in-flight calls when the transport dies.
885
+ timeoutMs: 0,
886
+ // Read-only or not, the output came from outside the workspace and is
887
+ // wrapped as untrusted — a lookup tool is a prime injection vector.
888
+ external: true,
889
+ summarize: (args) => `${server}/${spec.name}(${JSON.stringify(args).slice(0, 80)})`,
890
+ execute: (args, _ctx, signal, onProgress) => conn.callTool(spec.name, args, signal, cfg.tasks, onProgress),
891
+ };
892
+ }
893
+ /** The tool-name prefix every tool from a given server shares. */
894
+ export function toolPrefix(server) {
895
+ return `mcp_${sanitize(server)}_`;
896
+ }
897
+ /**
898
+ * Predicate matching the tools a server currently contributes. Prefer this to
899
+ * a bare prefix test: a name that had to be hash-shortened is a truncation of
900
+ * `mcp_<server>_...` and can lose the prefix outright.
901
+ */
902
+ export function isToolOf(server) {
903
+ const owned = serverToolNames.get(server);
904
+ if (!owned)
905
+ return (t) => t.startsWith(toolPrefix(server));
906
+ return (t) => owned.has(t);
907
+ }
908
+ /** Release a server's claimed tool names so they can be reused. */
909
+ function releaseToolNames(server) {
910
+ for (const n of serverToolNames.get(server) ?? [])
911
+ registeredNames.delete(n);
912
+ serverToolNames.delete(server);
913
+ }
914
+ /**
915
+ * Close one server's connection and forget it. Used by `/mcp remove`,
916
+ * `/mcp logout`, and before a reconnect — leaving the old connection open
917
+ * would keep a stdio child process alive and, after a logout, keep a
918
+ * now-revoked session warm on the remote side.
919
+ */
920
+ export function disconnectServer(name) {
921
+ for (let i = connections.length - 1; i >= 0; i--) {
922
+ if (connections[i].name === name) {
923
+ connections[i].close();
924
+ connections.splice(i, 1);
925
+ }
926
+ }
927
+ }
928
+ /** Drop a server from the /mcp status table entirely. */
929
+ export function forgetStatus(name) {
930
+ statuses = statuses.filter((s) => s.name !== name);
931
+ // Callers withdraw the tools first (they need isToolOf while it still
932
+ // resolves), so this is the right point to give the names back.
933
+ releaseToolNames(name);
934
+ forgetContributions(name);
935
+ }
936
+ /** Kill all MCP servers (call on exit). */
937
+ export function shutdownMcp() {
938
+ for (const conn of connections)
939
+ conn.close();
940
+ connections.length = 0;
941
+ registeredNames.clear();
942
+ serverToolNames.clear();
943
+ prompts.length = 0;
944
+ resources.length = 0;
945
+ }