@cruxy/cli 0.26.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.
@@ -414,11 +414,17 @@ export const UsageConfigSchema = z
414
414
  })
415
415
  .strict();
416
416
  /**
417
- * One MCP server entry (C.27). A `command` (+ optional `args`/`env`) is a stdio
418
- * server cruxy spawns as a child process; a `url` names a remote server. Exactly
419
- * one transport must be given. Trusting a stdio server runs its code UNSANDBOXED
420
- * with your privileges, which is why connection is gated by an explicit,
421
- * fingerprinted trust decision (see `mcp/trust.ts`).
417
+ * One MCP server entry. A `command` (+ optional `args`/`env`) is a stdio server
418
+ * cruxy spawns as a child process (C.27); a `url` names a remote server reached
419
+ * over Streamable HTTP (C.27b). Exactly one transport must be given.
420
+ *
421
+ * Both are gated by an explicit, fingerprinted trust decision (`mcp/trust.ts`),
422
+ * but the escalation differs: a stdio server runs its code UNSANDBOXED with your
423
+ * privileges, while a `url` server runs remotely — cruxy sends it your tool
424
+ * arguments over the network (https + cert-validated + pinned to a public IP; http
425
+ * only for a loopback dev server) and treats its responses as untrusted data. A
426
+ * url's trust also binds its resolved IP set at trust time, so a later IP-set
427
+ * change re-gates (weaker than a local binary fingerprint — see `mcp/types.ts`).
422
428
  */
423
429
  export const McpServerSchema = z
424
430
  .object({
@@ -430,11 +436,43 @@ export const McpServerSchema = z
430
436
  env: z.record(z.string(), z.string()).default({}),
431
437
  /** Remote transport: the server URL (mutually exclusive with `command`). */
432
438
  url: z.string().url().optional(),
439
+ /**
440
+ * Remote auth (C.27c, url only): the NAME of a bearer credential, resolved
441
+ * solely from `~/.cruxy/credentials.json` (0600) as `Authorization: Bearer …`.
442
+ * This field holds a NAME, never the secret — a project config may name a
443
+ * credential it does not contain, so the value never lives in-repo. Resolution
444
+ * never consults the environment or any config file. Requires an `https` URL.
445
+ */
446
+ credentialRef: z.string().min(1).optional(),
447
+ /**
448
+ * Remote auth (C.27c, url only): raw request headers sent to the endpoint.
449
+ * A LIVE header value is a secret, so this is accepted ONLY from user-scope
450
+ * config (`~/.cruxy/config.json`) — the loader REJECTS it from project-scope
451
+ * config (a cloned repo must not inject live headers). Prefer `credentialRef`.
452
+ * Requires an `https` URL; reserved framing headers cannot be overridden.
453
+ */
454
+ headers: z.record(z.string(), z.string()).optional(),
433
455
  })
434
456
  .strict()
435
457
  .refine((s) => Boolean(s.command) !== Boolean(s.url), {
436
458
  message: "an MCP server needs exactly one of `command` (stdio) or `url`",
459
+ })
460
+ .refine((s) => !((s.credentialRef || s.headers) && s.command), {
461
+ message: "`credentialRef`/`headers` are for `url` servers only (stdio auth uses `env`)",
462
+ })
463
+ .refine((s) => !((s.credentialRef || s.headers) && s.url && !isHttps(s.url)), {
464
+ message: "a url server with `credentialRef`/`headers` must use https — a credential is " +
465
+ "NEVER sent over http (including loopback); there is no plaintext dev carve-out",
437
466
  });
467
+ /** Whether a URL string parses as https (a credential is only ever sent over TLS). */
468
+ function isHttps(url) {
469
+ try {
470
+ return new URL(url).protocol === "https:";
471
+ }
472
+ catch {
473
+ return false; // an invalid URL is rejected by `z.string().url()` upstream
474
+ }
475
+ }
438
476
  /**
439
477
  * MCP client integration (C.27): connect to trusted MCP servers and expose their
440
478
  * tools to the agent. OFF by default — like the sandbox, LSP, and hooks, it runs
@@ -303,6 +303,35 @@ export declare function mcpUntrusted(root: string, servers: string[]): CruxyErro
303
303
  * gag-scrubbed (U.8) before it reaches the user-facing cause.
304
304
  */
305
305
  export declare function mcpConnect(server: string, underlying?: unknown): CruxyError;
306
+ /**
307
+ * A network (`url`) MCP server was REFUSED for a security reason (SSRF address
308
+ * block, a non-https/non-loopback scheme, or an endpoint redirect) — distinct
309
+ * from a transient connect failure. `reason` is our own guard's message (not
310
+ * server-controlled), but it is still scrubbed for symmetry with `mcpConnect`.
311
+ */
312
+ export declare function mcpBlocked(server: string, reason: string): CruxyError;
313
+ /**
314
+ * A network (`url`) MCP server's CREDENTIAL failed (C.27c) — either the endpoint
315
+ * REJECTED it (401/403) or the configured `credentialRef` names no token in the
316
+ * `~/.cruxy` store. Distinct from `mcpConnect` (the server was reachable) and from
317
+ * `mcpBlocked` (a security refusal before connect). The token is NEVER included in
318
+ * this error — only the server name and the credential NAME appear.
319
+ */
320
+ export declare function mcpAuth(server: string, detail: {
321
+ kind: "rejected";
322
+ status: number;
323
+ } | {
324
+ kind: "missing";
325
+ ref: string;
326
+ }): CruxyError;
327
+ /**
328
+ * A project-scope config tried to set raw `headers` on an MCP server (C.27c). A
329
+ * live header value is a secret, and project scope is a possibly-cloned repo, so
330
+ * this is REFUSED at load — never silently dropped (a silent drop would let a repo
331
+ * believe auth is configured when it is not). The repo may only NAME a credential
332
+ * via `credentialRef`; the value lives solely in the user's `~/.cruxy` store.
333
+ */
334
+ export declare function mcpProjectHeaders(server: string, file: string): CruxyError;
306
335
  /**
307
336
  * `web.enabled` is on but no usable search provider is configured — the API-key
308
337
  * environment variable is unset (or the provider is unknown). THE HONESTY RULE:
@@ -1034,6 +1034,75 @@ export function mcpConnect(server, underlying) {
1034
1034
  underlying,
1035
1035
  });
1036
1036
  }
1037
+ /**
1038
+ * A network (`url`) MCP server was REFUSED for a security reason (SSRF address
1039
+ * block, a non-https/non-loopback scheme, or an endpoint redirect) — distinct
1040
+ * from a transient connect failure. `reason` is our own guard's message (not
1041
+ * server-controlled), but it is still scrubbed for symmetry with `mcpConnect`.
1042
+ */
1043
+ export function mcpBlocked(server, reason) {
1044
+ return new CruxyError({
1045
+ code: ErrorCode.McpBlocked,
1046
+ title: `refused to connect to MCP server "${server}"`,
1047
+ cause: reason,
1048
+ nextSteps: [
1049
+ "use an `https://` URL that resolves to a public address",
1050
+ "for a local dev server use `http://` to a loopback host, or a `command` (stdio) server",
1051
+ ],
1052
+ meta: { server },
1053
+ });
1054
+ }
1055
+ /**
1056
+ * A network (`url`) MCP server's CREDENTIAL failed (C.27c) — either the endpoint
1057
+ * REJECTED it (401/403) or the configured `credentialRef` names no token in the
1058
+ * `~/.cruxy` store. Distinct from `mcpConnect` (the server was reachable) and from
1059
+ * `mcpBlocked` (a security refusal before connect). The token is NEVER included in
1060
+ * this error — only the server name and the credential NAME appear.
1061
+ */
1062
+ export function mcpAuth(server, detail) {
1063
+ if (detail.kind === "rejected") {
1064
+ return new CruxyError({
1065
+ code: ErrorCode.McpAuth,
1066
+ title: `credential for MCP server "${server}" was rejected`,
1067
+ cause: `the endpoint returned ${detail.status} — the bearer credential is missing, wrong, or expired`,
1068
+ nextSteps: [
1069
+ `check the credential in \`~/.cruxy/credentials.json\` and re-set it with \`cruxy mcp login ${server}\``,
1070
+ "confirm the token is still valid on the server side",
1071
+ ],
1072
+ meta: { server, status: detail.status },
1073
+ });
1074
+ }
1075
+ return new CruxyError({
1076
+ code: ErrorCode.McpAuth,
1077
+ title: `MCP server "${server}" needs a credential that is not set`,
1078
+ cause: `no credential named "${detail.ref}" was found in \`~/.cruxy/credentials.json\``,
1079
+ nextSteps: [
1080
+ `set it with \`cruxy mcp login ${server}\` (stored owner-only in ~/.cruxy)`,
1081
+ "the token is never read from the repo, config, or the environment",
1082
+ ],
1083
+ meta: { server, credentialRef: detail.ref },
1084
+ });
1085
+ }
1086
+ /**
1087
+ * A project-scope config tried to set raw `headers` on an MCP server (C.27c). A
1088
+ * live header value is a secret, and project scope is a possibly-cloned repo, so
1089
+ * this is REFUSED at load — never silently dropped (a silent drop would let a repo
1090
+ * believe auth is configured when it is not). The repo may only NAME a credential
1091
+ * via `credentialRef`; the value lives solely in the user's `~/.cruxy` store.
1092
+ */
1093
+ export function mcpProjectHeaders(server, file) {
1094
+ return new CruxyError({
1095
+ code: ErrorCode.McpConfig,
1096
+ title: `raw MCP \`headers\` are not allowed in project config`,
1097
+ cause: `server "${server}" in ${file} sets \`headers\` directly — a live header value ` +
1098
+ "is a secret, and a project config (a possibly-cloned repo) must never carry one",
1099
+ nextSteps: [
1100
+ `remove \`headers\` from server "${server}" and use \`credentialRef: "<name>"\` instead`,
1101
+ `store the token with \`cruxy mcp login ${server}\`, or set raw \`headers\` only in \`~/.cruxy/config.json\``,
1102
+ ],
1103
+ meta: { server, file },
1104
+ });
1105
+ }
1037
1106
  // ── web search + fetch (exit 17) — C.20 ───────────────────────────────────────
1038
1107
  /**
1039
1108
  * `web.enabled` is on but no usable search provider is configured — the API-key
@@ -110,6 +110,21 @@ export declare const ErrorCode: {
110
110
  * or list its tools. Surfaced (that server contributes no tools) rather than
111
111
  * silently swallowed; never fatal to the run. */
112
112
  readonly McpConnect: "CRUXY_E_MCP_CONNECT";
113
+ /** A network (`url`) MCP server was REFUSED before/at connect for a SECURITY
114
+ * reason, distinct from an ordinary connect failure: a non-https(non-loopback)
115
+ * scheme, a host that resolves into a private/loopback/link-local range (SSRF),
116
+ * or an endpoint that tried to redirect. A refusal, not a transient error. */
117
+ readonly McpBlocked: "CRUXY_E_MCP_BLOCKED";
118
+ /** A network (`url`) MCP server's CREDENTIAL failed (C.27c): the endpoint
119
+ * rejected it (401/403) or the named `credentialRef` has no token in the
120
+ * `~/.cruxy` store. Distinct from a transport connect failure — the server was
121
+ * reached (or the credential was simply absent), not merely unreachable. */
122
+ readonly McpAuth: "CRUXY_E_MCP_AUTH";
123
+ /** A project-scope config was REFUSED for a credential-safety reason (C.27c):
124
+ * it tried to set raw `headers` on an MCP server. A live header value is a
125
+ * secret; project scope (a possibly-cloned repo) may only NAME a credential via
126
+ * `credentialRef`, never carry the value. Rejected loudly, never silently. */
127
+ readonly McpConfig: "CRUXY_E_MCP_CONFIG";
113
128
  /** `web.enabled` is on but no usable search provider is configured — the API
114
129
  * key env var is unset or the provider is unknown. Actionable, NEVER a silent
115
130
  * empty result: "no provider" must not read as "no search results". */
@@ -128,6 +128,21 @@ export const ErrorCode = {
128
128
  * or list its tools. Surfaced (that server contributes no tools) rather than
129
129
  * silently swallowed; never fatal to the run. */
130
130
  McpConnect: "CRUXY_E_MCP_CONNECT",
131
+ /** A network (`url`) MCP server was REFUSED before/at connect for a SECURITY
132
+ * reason, distinct from an ordinary connect failure: a non-https(non-loopback)
133
+ * scheme, a host that resolves into a private/loopback/link-local range (SSRF),
134
+ * or an endpoint that tried to redirect. A refusal, not a transient error. */
135
+ McpBlocked: "CRUXY_E_MCP_BLOCKED",
136
+ /** A network (`url`) MCP server's CREDENTIAL failed (C.27c): the endpoint
137
+ * rejected it (401/403) or the named `credentialRef` has no token in the
138
+ * `~/.cruxy` store. Distinct from a transport connect failure — the server was
139
+ * reached (or the credential was simply absent), not merely unreachable. */
140
+ McpAuth: "CRUXY_E_MCP_AUTH",
141
+ /** A project-scope config was REFUSED for a credential-safety reason (C.27c):
142
+ * it tried to set raw `headers` on an MCP server. A live header value is a
143
+ * secret; project scope (a possibly-cloned repo) may only NAME a credential via
144
+ * `credentialRef`, never carry the value. Rejected loudly, never silently. */
145
+ McpConfig: "CRUXY_E_MCP_CONFIG",
131
146
  // web search + fetch (exit 17) — C.20
132
147
  /** `web.enabled` is on but no usable search provider is configured — the API
133
148
  * key env var is unset or the provider is unknown. Actionable, NEVER a silent
@@ -293,6 +308,9 @@ const EXIT_CODES = {
293
308
  // never fatal on its own. Grouped for a greppable exit code.
294
309
  [ErrorCode.McpUntrusted]: 16,
295
310
  [ErrorCode.McpConnect]: 16,
311
+ [ErrorCode.McpBlocked]: 16,
312
+ [ErrorCode.McpAuth]: 16,
313
+ [ErrorCode.McpConfig]: 16,
296
314
  // Web search + fetch (C.20). A missing provider, a search/fetch failure, and an
297
315
  // SSRF-blocked host all surface inside a tool result (the agent reads and
298
316
  // adapts) and only exit the process if thrown directly. Grouped for a greppable
@@ -0,0 +1,89 @@
1
+ import { fetch as undiciFetch } from "undici";
2
+ import type { McpTransport } from "./types.js";
3
+ /**
4
+ * JSON-RPC 2.0 over MCP's Streamable HTTP transport (C.27b) — the network sibling
5
+ * of {@link ../mcp/transport McpStdioTransport}. It satisfies the SAME
6
+ * {@link McpTransport} seam, so `McpClient`, the single adapter (`mcpToolsFrom`),
7
+ * bounds, demarcation, gating, and non-persistence are all inherited UNCHANGED —
8
+ * every C.27 security property holds over the wire by construction, and all the
9
+ * network-specific hardening lives here, below the seam.
10
+ *
11
+ * Trust boundary (this is a remote endpoint, not a local process):
12
+ * - PINNED CONNECTIONS. The connection is pinned (undici `Agent` + {@link pinnedLookup})
13
+ * to the exact address set the SSRF guard already validated at trust time. The
14
+ * transport itself does NO DNS resolution — it dials only the passed addresses,
15
+ * so a DNS rebind cannot flip check→connect. The Host header / TLS SNI still
16
+ * carry the original hostname, so certificate hostname validation is unaffected.
17
+ * - HTTPS + CERT VALIDATION. TLS validation is undici's default and is NEVER
18
+ * disabled — there is deliberately no skip-verify path (JC-E). `https` vs
19
+ * loopback-`http` is enforced upstream by the url-guard.
20
+ * - NO REDIRECTS. `maxRedirections: 0` and a manual 3xx check: an MCP RPC endpoint
21
+ * has no legitimate reason to redirect, so a redirect is REFUSED (never re-pinned
22
+ * and followed to a new, unvalidated host).
23
+ * - NO SOCKET BEFORE TRUST (JC-C). The constructor opens no socket and resolves no
24
+ * DNS; the first network I/O is the `initialize` POST, which the service issues
25
+ * only after the trust gate passes.
26
+ *
27
+ * Inbound posture mirrors stdio: cruxy advertises NO capabilities (no sampling, no
28
+ * roots, no elicitation), so it never solicits server→client requests; any that
29
+ * arrive on a response stream are ignored, never acted on. This is a deliberate
30
+ * SUBSET of Streamable HTTP — no standalone GET listening stream and no SSE
31
+ * resumption — sufficient for request/response tool use and easy to reason about.
32
+ */
33
+ export interface McpHttpSpec {
34
+ /** The server endpoint (already scheme/SSRF-validated by the url-guard). */
35
+ url: string;
36
+ /** Pre-validated address set to pin the connection to (never re-resolved here). */
37
+ addresses: string[];
38
+ /** `initialize` + `tools/list` budget, ms. */
39
+ connectTimeout: number;
40
+ /** Per `tools/call` budget, ms. */
41
+ requestTimeout: number;
42
+ /**
43
+ * Resolved auth headers (C.27c), attached to every request to THIS pinned,
44
+ * https endpoint (never on a redirect target, never over http). Empty/absent for
45
+ * an unauthenticated server. Reserved framing headers here are ignored — a
46
+ * credential can never rewrite content-type/accept/session framing.
47
+ */
48
+ authHeaders?: Record<string, string>;
49
+ }
50
+ /**
51
+ * The endpoint rejected our credential (401/403) — C.27c. A DISTINCT type so the
52
+ * service maps it to `CRUXY_E_MCP_AUTH` ("credential rejected") rather than the
53
+ * generic connect failure. Carries only the status; the token never touches it.
54
+ */
55
+ export declare class McpHttpAuthError extends Error {
56
+ readonly status: number;
57
+ constructor(status: number);
58
+ }
59
+ /** Minimal fetch surface we depend on (injectable for tests). */
60
+ type FetchLike = typeof undiciFetch;
61
+ export declare class McpHttpTransport implements McpTransport {
62
+ private readonly url;
63
+ private readonly connectTimeout;
64
+ private readonly requestTimeout;
65
+ private readonly dispatcher;
66
+ private readonly fetchImpl;
67
+ /** Sanitized auth headers, sent ONLY over https to this pinned endpoint. */
68
+ private readonly authHeaders;
69
+ private nextId;
70
+ private disposed;
71
+ /** Streamable HTTP session id, captured from the `initialize` response. */
72
+ private sessionId;
73
+ private crashHandler;
74
+ /** In-flight request aborts, so `dispose()` can cancel them. */
75
+ private readonly inflight;
76
+ constructor(spec: McpHttpSpec, fetchImpl?: FetchLike);
77
+ /** Whether the `initialize` handshake budget or the per-call budget applies. */
78
+ private budgetFor;
79
+ request(method: string, params: unknown, timeoutMs: number): Promise<unknown>;
80
+ notify(method: string, params: unknown): void;
81
+ onCrash(handler: (info: {
82
+ code: number | null;
83
+ signal: string | null;
84
+ }) => void): void;
85
+ dispose(): Promise<void>;
86
+ /** POST one framed JSON-RPC message; refuse redirects; reject non-2xx. */
87
+ private post;
88
+ }
89
+ export {};
@@ -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
+ }
@@ -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";