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.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- 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
|
+
}
|