@cruxy/cli 0.25.0 → 0.27.0
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/dist/approval/prompt.d.ts +7 -1
- package/dist/approval/prompt.js +52 -17
- package/dist/cli/commands/mcp.js +106 -7
- package/dist/cli/commands/skills.js +10 -2
- package/dist/cli/repl.js +9 -3
- package/dist/components/frame.d.ts +6 -3
- package/dist/components/frame.js +21 -23
- package/dist/components/fuzzy.js +5 -1
- package/dist/components/select.js +4 -1
- package/dist/config/credentials.d.ts +9 -0
- package/dist/config/credentials.js +29 -1
- package/dist/config/manager.js +30 -3
- package/dist/config/schema.d.ts +182 -8
- package/dist/config/schema.js +43 -5
- package/dist/errors/constructors.d.ts +29 -0
- package/dist/errors/constructors.js +69 -0
- package/dist/errors/types.d.ts +15 -0
- package/dist/errors/types.js +18 -0
- package/dist/mcp/http-transport.d.ts +89 -0
- package/dist/mcp/http-transport.js +299 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/service.d.ts +19 -2
- package/dist/mcp/service.js +92 -20
- package/dist/mcp/trust-gate.d.ts +35 -11
- package/dist/mcp/trust-gate.js +87 -22
- package/dist/mcp/trust.d.ts +12 -2
- package/dist/mcp/trust.js +26 -2
- package/dist/mcp/types.d.ts +10 -0
- package/dist/mcp/url-guard.d.ts +48 -0
- package/dist/mcp/url-guard.js +62 -0
- package/dist/net/ip-guard.d.ts +55 -0
- package/dist/net/ip-guard.js +229 -0
- package/dist/render/capabilities.d.ts +11 -0
- package/dist/render/capabilities.js +19 -3
- package/dist/render/diff.d.ts +1 -1
- package/dist/render/diff.js +23 -7
- package/dist/render/index.d.ts +5 -2
- package/dist/render/index.js +9 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -0
- package/dist/render/resize.d.ts +36 -0
- package/dist/render/resize.js +45 -0
- package/dist/render/state.d.ts +13 -0
- package/dist/render/state.js +38 -0
- package/dist/render/tty-renderer.d.ts +25 -3
- package/dist/render/tty-renderer.js +94 -32
- package/dist/render/types.d.ts +15 -1
- package/dist/web/ssrf.d.ts +8 -22
- package/dist/web/ssrf.js +11 -183
- package/dist/web/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
2
|
+
import { pinnedLookup } from "../net/ip-guard.js";
|
|
3
|
+
/**
|
|
4
|
+
* The endpoint rejected our credential (401/403) — C.27c. A DISTINCT type so the
|
|
5
|
+
* service maps it to `CRUXY_E_MCP_AUTH` ("credential rejected") rather than the
|
|
6
|
+
* generic connect failure. Carries only the status; the token never touches it.
|
|
7
|
+
*/
|
|
8
|
+
export class McpHttpAuthError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(status) {
|
|
11
|
+
super(`MCP endpoint rejected the credential (${status})`);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = "McpHttpAuthError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Framing headers a credential/user header can never override (case-insensitive). */
|
|
17
|
+
const RESERVED_HEADERS = new Set(["content-type", "accept", "mcp-session-id"]);
|
|
18
|
+
/** Drop any reserved framing header so auth headers can only ADD, never rewrite. */
|
|
19
|
+
function sanitizeAuthHeaders(headers) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
22
|
+
if (!RESERVED_HEADERS.has(k.toLowerCase()))
|
|
23
|
+
out[k] = v;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** The URL's protocol, or "" if unparseable (never throws). */
|
|
28
|
+
function safeProtocol(url) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(url).protocol;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class McpHttpTransport {
|
|
37
|
+
url;
|
|
38
|
+
connectTimeout;
|
|
39
|
+
requestTimeout;
|
|
40
|
+
dispatcher;
|
|
41
|
+
fetchImpl;
|
|
42
|
+
/** Sanitized auth headers, sent ONLY over https to this pinned endpoint. */
|
|
43
|
+
authHeaders;
|
|
44
|
+
nextId = 1;
|
|
45
|
+
disposed = false;
|
|
46
|
+
/** Streamable HTTP session id, captured from the `initialize` response. */
|
|
47
|
+
sessionId = null;
|
|
48
|
+
crashHandler = null;
|
|
49
|
+
/** In-flight request aborts, so `dispose()` can cancel them. */
|
|
50
|
+
inflight = new Set();
|
|
51
|
+
constructor(spec, fetchImpl = undiciFetch) {
|
|
52
|
+
this.url = spec.url;
|
|
53
|
+
this.connectTimeout = spec.connectTimeout;
|
|
54
|
+
this.requestTimeout = spec.requestTimeout;
|
|
55
|
+
this.fetchImpl = fetchImpl;
|
|
56
|
+
// Credential floor (C.27c, JC-3): a credential is sent ONLY over https. This
|
|
57
|
+
// is belt-and-suspenders to the schema's https refine — if an authed spec ever
|
|
58
|
+
// reaches here with a non-https url, we drop the headers rather than transmit
|
|
59
|
+
// a bearer token over plaintext (including loopback — no dev carve-out).
|
|
60
|
+
const wantsAuth = Object.keys(spec.authHeaders ?? {}).length > 0;
|
|
61
|
+
const isHttps = safeProtocol(spec.url) === "https:";
|
|
62
|
+
this.authHeaders =
|
|
63
|
+
wantsAuth && isHttps ? sanitizeAuthHeaders(spec.authHeaders) : {};
|
|
64
|
+
// Our OWN Agent, tuned for the POST + SSE shape (distinct from web's one-shot
|
|
65
|
+
// fetch dispatcher). Pinned to the validated addresses; no redirects. Building
|
|
66
|
+
// an Agent opens no socket — the first connection is deferred to initialize().
|
|
67
|
+
this.dispatcher = spec.addresses.length
|
|
68
|
+
? new Agent({
|
|
69
|
+
connect: { lookup: pinnedLookup(spec.addresses) },
|
|
70
|
+
maxRedirections: 0,
|
|
71
|
+
})
|
|
72
|
+
: new Agent({ maxRedirections: 0 });
|
|
73
|
+
}
|
|
74
|
+
/** Whether the `initialize` handshake budget or the per-call budget applies. */
|
|
75
|
+
budgetFor(method) {
|
|
76
|
+
return method === "tools/call" ? this.requestTimeout : this.connectTimeout;
|
|
77
|
+
}
|
|
78
|
+
async request(method, params, timeoutMs) {
|
|
79
|
+
if (this.disposed)
|
|
80
|
+
throw new Error("transport disposed");
|
|
81
|
+
const id = this.nextId++;
|
|
82
|
+
const budget = Math.min(timeoutMs, this.budgetFor(method));
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
this.inflight.add(controller);
|
|
85
|
+
const timer = setTimeout(() => controller.abort(), budget);
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
try {
|
|
88
|
+
const res = await this.post(JSON.stringify({ jsonrpc: "2.0", id, method, params }), controller.signal, method);
|
|
89
|
+
// Streamable HTTP: the response is either a single JSON body or an SSE
|
|
90
|
+
// stream carrying the JSON-RPC response event. Capture the session id from
|
|
91
|
+
// the initialize response so later requests are correlated to the session.
|
|
92
|
+
const sid = res.headers.get("mcp-session-id");
|
|
93
|
+
if (sid)
|
|
94
|
+
this.sessionId = sid;
|
|
95
|
+
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
96
|
+
const msg = contentType.includes("text/event-stream")
|
|
97
|
+
? await readSseResponse(res, id)
|
|
98
|
+
: await readJsonResponse(res, id);
|
|
99
|
+
if (msg.error)
|
|
100
|
+
throw new Error(msg.error.message ?? "MCP error");
|
|
101
|
+
return msg.result;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
this.inflight.delete(controller);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
notify(method, params) {
|
|
109
|
+
if (this.disposed)
|
|
110
|
+
return;
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
this.inflight.add(controller);
|
|
113
|
+
const timer = setTimeout(() => controller.abort(), this.connectTimeout);
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
// Fire-and-forget: a notification has no id and no response body to await.
|
|
116
|
+
this.post(JSON.stringify({ jsonrpc: "2.0", method, params }), controller.signal, method)
|
|
117
|
+
.catch(() => {
|
|
118
|
+
/* a dropped notification is not fatal; requests carry the real errors */
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
this.inflight.delete(controller);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
onCrash(handler) {
|
|
126
|
+
this.crashHandler = handler;
|
|
127
|
+
}
|
|
128
|
+
async dispose() {
|
|
129
|
+
if (this.disposed)
|
|
130
|
+
return;
|
|
131
|
+
this.disposed = true;
|
|
132
|
+
// Cancel every in-flight request/notification.
|
|
133
|
+
for (const c of this.inflight)
|
|
134
|
+
c.abort();
|
|
135
|
+
this.inflight.clear();
|
|
136
|
+
// Best-effort Streamable HTTP session teardown, then close the dispatcher.
|
|
137
|
+
if (this.sessionId) {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const timer = setTimeout(() => controller.abort(), this.connectTimeout);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
try {
|
|
142
|
+
await this.fetchImpl(this.url, {
|
|
143
|
+
method: "DELETE",
|
|
144
|
+
redirect: "manual",
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
dispatcher: this.dispatcher,
|
|
147
|
+
// Same pinned https endpoint — the server may require auth to end the
|
|
148
|
+
// session. Session-id last so it always wins over any auth header.
|
|
149
|
+
headers: { ...this.authHeaders, "mcp-session-id": this.sessionId },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* the server may be gone; teardown is best-effort */
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await this.dispatcher.close().catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
/** POST one framed JSON-RPC message; refuse redirects; reject non-2xx. */
|
|
162
|
+
async post(body, signal, method) {
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = await this.fetchImpl(this.url, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
redirect: "manual",
|
|
168
|
+
signal,
|
|
169
|
+
dispatcher: this.dispatcher,
|
|
170
|
+
headers: {
|
|
171
|
+
// Auth first so the fixed framing headers below always win — a
|
|
172
|
+
// credential can never rewrite content-type/accept/session framing.
|
|
173
|
+
...this.authHeaders,
|
|
174
|
+
"content-type": "application/json",
|
|
175
|
+
accept: "application/json, text/event-stream",
|
|
176
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
|
|
177
|
+
},
|
|
178
|
+
body,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
// A genuine connection failure (not a teardown we triggered) is the network
|
|
183
|
+
// analog of a stdio crash — notify, mirroring McpStdioTransport.onExit.
|
|
184
|
+
if (!this.disposed && !signal.aborted) {
|
|
185
|
+
this.crashHandler?.({ code: null, signal: null });
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`MCP request "${method}" failed: ${err.message}`);
|
|
188
|
+
}
|
|
189
|
+
if (res.status >= 300 && res.status < 400) {
|
|
190
|
+
// An MCP endpoint has no legitimate reason to redirect (JC: maxRedirections 0).
|
|
191
|
+
throw new Error(`MCP endpoint returned a ${res.status} redirect; MCP URLs must not redirect`);
|
|
192
|
+
}
|
|
193
|
+
if (res.status === 202)
|
|
194
|
+
return res; // accepted notification, no body
|
|
195
|
+
if (res.status === 401 || res.status === 403) {
|
|
196
|
+
// Credential rejected — a DISTINCT failure from an unreachable server. The
|
|
197
|
+
// service maps this to CRUXY_E_MCP_AUTH. The status only; never the token.
|
|
198
|
+
throw new McpHttpAuthError(res.status);
|
|
199
|
+
}
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
throw new Error(`MCP endpoint responded ${res.status} ${res.statusText}`);
|
|
202
|
+
}
|
|
203
|
+
return res;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Parse a single JSON response body and select the message for our request id. */
|
|
207
|
+
async function readJsonResponse(res, id) {
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = await res.json();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new Error("malformed MCP response (invalid JSON)");
|
|
214
|
+
}
|
|
215
|
+
const found = selectResponse(parsed, id);
|
|
216
|
+
if (!found)
|
|
217
|
+
throw new Error("MCP response contained no result for the request");
|
|
218
|
+
return found;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Read an SSE stream and return the first JSON-RPC RESPONSE matching our id.
|
|
222
|
+
* Server→client requests (method + id) embedded in the stream are declined by
|
|
223
|
+
* being ignored — cruxy solicits none and acts on none. Notifications are ignored.
|
|
224
|
+
*/
|
|
225
|
+
async function readSseResponse(res, id) {
|
|
226
|
+
const reader = res.body?.getReader?.();
|
|
227
|
+
if (!reader)
|
|
228
|
+
return readJsonResponse(res, id);
|
|
229
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
230
|
+
let buffer = "";
|
|
231
|
+
for (;;) {
|
|
232
|
+
const { done, value } = await reader.read();
|
|
233
|
+
if (value)
|
|
234
|
+
buffer += decoder.decode(value, { stream: true });
|
|
235
|
+
// SSE events are separated by a blank line; process every complete event.
|
|
236
|
+
let sep;
|
|
237
|
+
while ((sep = indexOfEventBoundary(buffer)) !== -1) {
|
|
238
|
+
const rawEvent = buffer.slice(0, sep);
|
|
239
|
+
buffer = buffer.slice(sep).replace(/^(\r?\n){1,2}/, "");
|
|
240
|
+
const data = sseData(rawEvent);
|
|
241
|
+
if (data === undefined)
|
|
242
|
+
continue;
|
|
243
|
+
let msg;
|
|
244
|
+
try {
|
|
245
|
+
msg = JSON.parse(data);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
continue; // ignore unparseable event data
|
|
249
|
+
}
|
|
250
|
+
const found = selectResponse(msg, id);
|
|
251
|
+
if (found) {
|
|
252
|
+
await reader.cancel().catch(() => { });
|
|
253
|
+
return found;
|
|
254
|
+
}
|
|
255
|
+
// else: a server→client request or notification — ignored (declined).
|
|
256
|
+
}
|
|
257
|
+
if (done)
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
throw new Error("MCP stream ended without a response for the request");
|
|
261
|
+
}
|
|
262
|
+
/** Index just past an SSE event boundary (blank line), or -1 if none yet. */
|
|
263
|
+
function indexOfEventBoundary(buf) {
|
|
264
|
+
const a = buf.indexOf("\n\n");
|
|
265
|
+
const b = buf.indexOf("\r\n\r\n");
|
|
266
|
+
if (a === -1)
|
|
267
|
+
return b === -1 ? -1 : b;
|
|
268
|
+
if (b === -1)
|
|
269
|
+
return a;
|
|
270
|
+
return Math.min(a, b);
|
|
271
|
+
}
|
|
272
|
+
/** Concatenate the `data:` lines of one SSE event, or undefined if none. */
|
|
273
|
+
function sseData(event) {
|
|
274
|
+
const parts = [];
|
|
275
|
+
for (const line of event.split(/\r?\n/)) {
|
|
276
|
+
if (line.startsWith("data:"))
|
|
277
|
+
parts.push(line.slice(5).replace(/^ /, ""));
|
|
278
|
+
}
|
|
279
|
+
return parts.length ? parts.join("\n") : undefined;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* From a parsed message (object or JSON-RPC batch array), pick the RESPONSE whose
|
|
283
|
+
* id matches — i.e. it has a result/error and no `method` (a request/notification
|
|
284
|
+
* has a `method`). Returns null if none matches.
|
|
285
|
+
*/
|
|
286
|
+
function selectResponse(parsed, id) {
|
|
287
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (!entry || typeof entry !== "object")
|
|
290
|
+
continue;
|
|
291
|
+
const m = entry;
|
|
292
|
+
if (m.id === id &&
|
|
293
|
+
m.method === undefined &&
|
|
294
|
+
(m.result !== undefined || m.error !== undefined)) {
|
|
295
|
+
return m;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { McpTrust, McpTransport, RawMcpTool, McpCallResult, } from "./types.js";
|
|
2
|
-
export { mcpTrustPath, fingerprintMcpServers, isMcpTrusted, fileMcpTrustStore, memoryMcpTrustStore, type McpTrustStore, } from "./trust.js";
|
|
3
|
-
export { ensureMcpTrust, type EnsureMcpTrustDeps, type McpTrustIO, type McpTrustOutcome, } from "./trust-gate.js";
|
|
2
|
+
export { mcpTrustPath, fingerprintMcpServers, isMcpTrusted, endpointsMatch, fileMcpTrustStore, memoryMcpTrustStore, type McpTrustStore, } from "./trust.js";
|
|
3
|
+
export { ensureMcpTrust, type EnsureMcpTrustDeps, type EnsureMcpTrustResult, type McpTrustIO, type McpTrustOutcome, } from "./trust-gate.js";
|
|
4
|
+
export { validateMcpUrl, resolveMcpEndpoints, type ValidatedMcpUrl, } from "./url-guard.js";
|
|
5
|
+
export { McpHttpTransport, McpHttpAuthError, type McpHttpSpec, } from "./http-transport.js";
|
|
4
6
|
export { demarcateDescription, demarcateResult } from "./demarcate.js";
|
|
5
7
|
export { boundToolList, type McpBounds, type BoundedTool, type BoundedToolList, } from "./bounds.js";
|
|
6
8
|
export { McpStdioTransport, type McpSpawnSpec } from "./transport.js";
|
package/dist/mcp/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export { mcpTrustPath, fingerprintMcpServers, isMcpTrusted, fileMcpTrustStore, memoryMcpTrustStore, } from "./trust.js";
|
|
1
|
+
export { mcpTrustPath, fingerprintMcpServers, isMcpTrusted, endpointsMatch, fileMcpTrustStore, memoryMcpTrustStore, } from "./trust.js";
|
|
2
2
|
export { ensureMcpTrust, } from "./trust-gate.js";
|
|
3
|
+
export { validateMcpUrl, resolveMcpEndpoints, } from "./url-guard.js";
|
|
4
|
+
export { McpHttpTransport, McpHttpAuthError, } from "./http-transport.js";
|
|
3
5
|
export { demarcateDescription, demarcateResult } from "./demarcate.js";
|
|
4
6
|
export { boundToolList, } from "./bounds.js";
|
|
5
7
|
export { McpStdioTransport } from "./transport.js";
|
package/dist/mcp/service.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CruxyConfig, McpServerConfig } from "../config/index.js";
|
|
2
|
+
import type { HostResolver } from "../net/ip-guard.js";
|
|
2
3
|
import type { Tool } from "../tools/types.js";
|
|
3
4
|
import { type McpTrustStore } from "./trust.js";
|
|
4
5
|
import { type McpTrustIO } from "./trust-gate.js";
|
|
@@ -21,12 +22,18 @@ interface ServiceLogger {
|
|
|
21
22
|
}
|
|
22
23
|
/** Explicit dependency overrides, for tests only. Production passes none. */
|
|
23
24
|
export interface McpServiceDeps {
|
|
24
|
-
/** Substitute the transport (a fake peer — no real server binary). */
|
|
25
|
-
transportFactory?: (server: string, cfg: McpServerConfig, root: string
|
|
25
|
+
/** Substitute the transport (a fake peer — no real server binary/socket). */
|
|
26
|
+
transportFactory?: (server: string, cfg: McpServerConfig, root: string,
|
|
27
|
+
/** Pre-validated, pinned address set (url servers only). */
|
|
28
|
+
addresses: string[],
|
|
29
|
+
/** Resolved auth headers (url servers only; C.27c). */
|
|
30
|
+
authHeaders?: Record<string, string>) => McpTransport;
|
|
26
31
|
/** Substitute the trust store. */
|
|
27
32
|
trustStore?: McpTrustStore;
|
|
28
33
|
/** ISO-timestamp source for a recorded trust decision. */
|
|
29
34
|
now?: () => string;
|
|
35
|
+
/** DNS resolver for the trust gate's url-endpoint capture + SSRF guard. */
|
|
36
|
+
resolveHost?: HostResolver;
|
|
30
37
|
}
|
|
31
38
|
export interface ConnectMcpToolsParams {
|
|
32
39
|
cwd: string;
|
|
@@ -43,6 +50,16 @@ export interface ConnectMcpToolsResult {
|
|
|
43
50
|
tools: Tool[];
|
|
44
51
|
}
|
|
45
52
|
export declare function connectMcpTools(params: ConnectMcpToolsParams): Promise<ConnectMcpToolsResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the auth headers to send to a `url` server (C.27c), or `undefined` for
|
|
55
|
+
* an unauthenticated/stdio server. Two sources, both user-owned and outside the
|
|
56
|
+
* repo: a `credentialRef` looked up SOLELY in `~/.cruxy/credentials.json` (never
|
|
57
|
+
* the env, never any config file), and raw `headers` (accepted only from
|
|
58
|
+
* user-scope config — the loader already rejected them from project scope). A
|
|
59
|
+
* named credential with no stored token throws {@link mcpAuth} (missing) so a
|
|
60
|
+
* dangling reference fails cleanly rather than connecting unauthenticated.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveMcpAuthHeaders(server: string, cfg: McpServerConfig, read?: (ref: string) => string | undefined): Record<string, string> | undefined;
|
|
46
63
|
/**
|
|
47
64
|
* Dispose every live MCP connection (session end / process teardown). Mirrors
|
|
48
65
|
* `resetLspServices`; the shared child-tree exit backstop reaps anything a hard
|
package/dist/mcp/service.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { readMcpCredential } from "../config/credentials.js";
|
|
3
|
+
import { CruxyError, ErrorCode, mcpAuth, mcpConnect } from "../errors/index.js";
|
|
3
4
|
import { mcpToolsFrom } from "./adapter.js";
|
|
4
5
|
import { McpClient } from "./client.js";
|
|
6
|
+
import { McpHttpAuthError, McpHttpTransport } from "./http-transport.js";
|
|
5
7
|
import { McpStdioTransport } from "./transport.js";
|
|
6
8
|
import { fileMcpTrustStore } from "./trust.js";
|
|
7
9
|
import { ensureMcpTrust } from "./trust-gate.js";
|
|
@@ -17,16 +19,32 @@ export async function connectMcpTools(params) {
|
|
|
17
19
|
return { tools: [] };
|
|
18
20
|
const root = path.resolve(params.cwd);
|
|
19
21
|
// Trust gate. Non-interactive + untrusted THROWS CRUXY_E_MCP_UNTRUSTED here,
|
|
20
|
-
// before any spawn. Interactive shows the disclosure; a
|
|
21
|
-
// nothing.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
// before any spawn OR socket/DNS (JC-C). Interactive shows the disclosure; a
|
|
23
|
+
// decline connects to nothing. A url server refused by the SSRF guard throws
|
|
24
|
+
// CRUXY_E_MCP_BLOCKED — surfaced (coded), non-fatal to the run.
|
|
25
|
+
let endpoints;
|
|
26
|
+
try {
|
|
27
|
+
const result = await ensureMcpTrust(root, servers, {
|
|
28
|
+
store: deps?.trustStore ?? fileMcpTrustStore(),
|
|
29
|
+
interactive,
|
|
30
|
+
io,
|
|
31
|
+
now: deps?.now,
|
|
32
|
+
resolveHost: deps?.resolveHost,
|
|
33
|
+
});
|
|
34
|
+
if (result.outcome === "declined") {
|
|
35
|
+
logger.info("mcp: servers not trusted — no MCP tools were loaded");
|
|
36
|
+
return { tools: [] };
|
|
37
|
+
}
|
|
38
|
+
endpoints = result.endpoints;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// Untrusted is the fail-closed supply-chain stop — stays fatal. Any other
|
|
42
|
+
// gate error (SSRF block, unresolvable endpoint) degrades the feature to
|
|
43
|
+
// "no MCP tools" with a coded, visible reason rather than crashing the run.
|
|
44
|
+
if (err instanceof CruxyError && err.code === ErrorCode.McpUntrusted)
|
|
45
|
+
throw err;
|
|
46
|
+
const coded = err instanceof CruxyError ? err : mcpConnect("url server", err);
|
|
47
|
+
logger.warn(`${coded.code}: ${coded.title} — ${coded.cause ?? ""}`);
|
|
30
48
|
return { tools: [] };
|
|
31
49
|
}
|
|
32
50
|
const timeouts = {
|
|
@@ -40,9 +58,21 @@ export async function connectMcpTools(params) {
|
|
|
40
58
|
};
|
|
41
59
|
const tools = [];
|
|
42
60
|
for (const [server, cfg] of Object.entries(servers)) {
|
|
43
|
-
|
|
61
|
+
// Resolve the credential (C.27c) BEFORE building the transport. A named
|
|
62
|
+
// credential with no token in ~/.cruxy fails cleanly (coded, visible) and the
|
|
63
|
+
// server simply contributes no tools — a bad reference never connects.
|
|
64
|
+
let authHeaders;
|
|
65
|
+
try {
|
|
66
|
+
authHeaders = resolveMcpAuthHeaders(server, cfg);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const coded = err instanceof CruxyError ? err : mcpConnect(server, err);
|
|
70
|
+
logger.warn(`${coded.code}: ${coded.title} — ${coded.cause ?? ""}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const transport = makeTransport(server, cfg, root, endpoints[server] ?? [], timeouts, deps, authHeaders);
|
|
44
74
|
if (!transport) {
|
|
45
|
-
logger.warn(`${mcpConnect(server).code}: server "${server}" uses an unsupported transport (
|
|
75
|
+
logger.warn(`${mcpConnect(server).code}: server "${server}" uses an unsupported transport (need \`command\` (stdio) or \`url\`)`);
|
|
46
76
|
continue;
|
|
47
77
|
}
|
|
48
78
|
const client = new McpClient(transport, timeouts);
|
|
@@ -61,20 +91,62 @@ export async function connectMcpTools(params) {
|
|
|
61
91
|
logger.debug(`mcp: connected "${server}" (${serverTools.length} tool(s))`);
|
|
62
92
|
}
|
|
63
93
|
catch (err) {
|
|
64
|
-
|
|
94
|
+
// A rejected credential (401/403) is coded distinctly from a connect
|
|
95
|
+
// failure so the user knows to fix the credential, not the network.
|
|
96
|
+
const coded = err instanceof McpHttpAuthError
|
|
97
|
+
? mcpAuth(server, { kind: "rejected", status: err.status })
|
|
98
|
+
: mcpConnect(server, err);
|
|
65
99
|
logger.warn(`${coded.code}: ${coded.title} — ${coded.cause ?? ""}`);
|
|
66
100
|
await client.dispose(true).catch(() => { });
|
|
67
101
|
}
|
|
68
102
|
}
|
|
69
103
|
return { tools };
|
|
70
104
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the auth headers to send to a `url` server (C.27c), or `undefined` for
|
|
107
|
+
* an unauthenticated/stdio server. Two sources, both user-owned and outside the
|
|
108
|
+
* repo: a `credentialRef` looked up SOLELY in `~/.cruxy/credentials.json` (never
|
|
109
|
+
* the env, never any config file), and raw `headers` (accepted only from
|
|
110
|
+
* user-scope config — the loader already rejected them from project scope). A
|
|
111
|
+
* named credential with no stored token throws {@link mcpAuth} (missing) so a
|
|
112
|
+
* dangling reference fails cleanly rather than connecting unauthenticated.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveMcpAuthHeaders(server, cfg, read = readMcpCredential) {
|
|
115
|
+
const headers = { ...(cfg.headers ?? {}) };
|
|
116
|
+
if (cfg.credentialRef) {
|
|
117
|
+
const token = read(cfg.credentialRef);
|
|
118
|
+
if (!token) {
|
|
119
|
+
throw mcpAuth(server, { kind: "missing", ref: cfg.credentialRef });
|
|
120
|
+
}
|
|
121
|
+
headers.Authorization = `Bearer ${token}`;
|
|
122
|
+
}
|
|
123
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the transport for a server: stdio (spawns a child) or network (an HTTP
|
|
127
|
+
* transport pinned to the already-validated `addresses`). Returns null for a
|
|
128
|
+
* server with neither. The stdio path is byte-identical to C.27 — network is a
|
|
129
|
+
* new branch, never a change to how stdio servers are constructed.
|
|
130
|
+
*/
|
|
131
|
+
function makeTransport(server, cfg, root, addresses, timeouts, deps, authHeaders) {
|
|
73
132
|
if (deps?.transportFactory)
|
|
74
|
-
return deps.transportFactory(server, cfg, root);
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
77
|
-
|
|
133
|
+
return deps.transportFactory(server, cfg, root, addresses, authHeaders);
|
|
134
|
+
if (cfg.command) {
|
|
135
|
+
return new McpStdioTransport({ command: cfg.command, args: cfg.args ?? [], env: cfg.env ?? {} }, root);
|
|
136
|
+
}
|
|
137
|
+
if (cfg.url) {
|
|
138
|
+
// Trust already resolved + SSRF-validated the URL and handed us the pinned
|
|
139
|
+
// address set; the transport opens NO socket until `initialize()` (JC-C).
|
|
140
|
+
// authHeaders reach ONLY this pinned https endpoint (transport re-checks).
|
|
141
|
+
return new McpHttpTransport({
|
|
142
|
+
url: cfg.url,
|
|
143
|
+
addresses,
|
|
144
|
+
connectTimeout: timeouts.startupTimeout,
|
|
145
|
+
requestTimeout: timeouts.requestTimeout,
|
|
146
|
+
authHeaders,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
78
150
|
}
|
|
79
151
|
/**
|
|
80
152
|
* Dispose every live MCP connection (session end / process teardown). Mirrors
|
package/dist/mcp/trust-gate.d.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
+
import { type HostResolver } from "../net/ip-guard.js";
|
|
1
2
|
import type { McpServerConfig } from "../config/index.js";
|
|
2
3
|
import { type McpTrustStore } from "./trust.js";
|
|
3
4
|
/**
|
|
4
|
-
* The connect-time trust decision (C.27
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The connect-time trust decision (C.27, extended for network transport in
|
|
6
|
+
* C.27b). This is the gate that stands between a configured MCP server and it
|
|
7
|
+
* actually running / being connected to.
|
|
8
|
+
*
|
|
9
|
+
* For a STDIO server the escalation is "run this third-party code UNSANDBOXED with
|
|
10
|
+
* your full privileges." For a NETWORK (`url`) server the escalation is different
|
|
11
|
+
* and is disclosed differently: cruxy does NOT run the server's code locally, but
|
|
12
|
+
* it will SEND your tool arguments to a remote endpoint over the network and treat
|
|
13
|
+
* its responses as untrusted data. Both are real; the wording branches so neither
|
|
14
|
+
* is over- nor under-stated.
|
|
10
15
|
*
|
|
11
16
|
* Behavior:
|
|
12
|
-
* - Already trusted
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
17
|
+
* - Already trusted → proceed. "Trusted" means the config fingerprint matches AND,
|
|
18
|
+
* for url servers, the resolved IP set still matches the set bound at trust time
|
|
19
|
+
* (JC-D). A changed IP set is treated like a changed command → stale → re-gate.
|
|
20
|
+
* - Untrusted + interactive → show the disclosure, read one key; only `y` trusts.
|
|
15
21
|
* - Untrusted + NON-interactive → throw {@link mcpUntrusted} (CRUXY_E_MCP_UNTRUSTED)
|
|
16
|
-
* BEFORE
|
|
22
|
+
* BEFORE any socket or DNS lookup. For network, CONNECTING IS THE ACTION, so a
|
|
23
|
+
* never-trusted config fails closed with ZERO network I/O (JC-C).
|
|
24
|
+
*
|
|
25
|
+
* Ordering that guarantees zero-DNS-before-trust: the "is any decision recorded?"
|
|
26
|
+
* and static-fingerprint checks are pure (no I/O). Endpoint resolution (DNS + the
|
|
27
|
+
* SSRF guard) runs ONLY after a recorded, static-matching config is found (to
|
|
28
|
+
* re-validate a previously-trusted repo) or after the user presses `y` (to record
|
|
29
|
+
* a fresh decision) — never on the path that throws for an untrusted clone.
|
|
17
30
|
*/
|
|
18
31
|
/** The minimal prompt surface — satisfied by the shared `defaultPromptIO`. */
|
|
19
32
|
export interface McpTrustIO {
|
|
@@ -30,6 +43,17 @@ export interface EnsureMcpTrustDeps {
|
|
|
30
43
|
io?: McpTrustIO;
|
|
31
44
|
/** ISO-timestamp source for the recorded decision (injected for tests). */
|
|
32
45
|
now?: () => string;
|
|
46
|
+
/** DNS resolver for url-server endpoint capture (injected for tests). */
|
|
47
|
+
resolveHost?: HostResolver;
|
|
33
48
|
}
|
|
34
49
|
export type McpTrustOutcome = "trusted" | "declined";
|
|
35
|
-
export
|
|
50
|
+
export interface EnsureMcpTrustResult {
|
|
51
|
+
outcome: McpTrustOutcome;
|
|
52
|
+
/**
|
|
53
|
+
* Validated, pinned address sets per url server (empty for stdio/declined). The
|
|
54
|
+
* SAME set that was compared for trust — the caller pins the connection to it so
|
|
55
|
+
* the addresses trusted are exactly the addresses dialed (no second resolve).
|
|
56
|
+
*/
|
|
57
|
+
endpoints: Record<string, string[]>;
|
|
58
|
+
}
|
|
59
|
+
export declare function ensureMcpTrust(root: string, servers: Record<string, McpServerConfig>, deps: EnsureMcpTrustDeps): Promise<EnsureMcpTrustResult>;
|