@cruxy/cli 0.20.0 → 0.22.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.
Files changed (63) hide show
  1. package/dist/approval/classify.js +24 -0
  2. package/dist/approval/policy.js +7 -0
  3. package/dist/approval/prompt.js +7 -0
  4. package/dist/approval/types.d.ts +6 -0
  5. package/dist/brand/voice.d.ts +1 -1
  6. package/dist/brand/voice.js +1 -1
  7. package/dist/cli/commands/mcp.d.ts +9 -0
  8. package/dist/cli/commands/mcp.js +87 -0
  9. package/dist/cli/commands/run.js +22 -5
  10. package/dist/cli/program.js +2 -0
  11. package/dist/cli/session-factory.d.ts +2 -2
  12. package/dist/cli/session-factory.js +20 -2
  13. package/dist/config/schema.d.ts +362 -38
  14. package/dist/config/schema.js +100 -5
  15. package/dist/constants.d.ts +8 -0
  16. package/dist/constants.js +8 -0
  17. package/dist/errors/constructors.d.ts +46 -0
  18. package/dist/errors/constructors.js +123 -0
  19. package/dist/errors/types.d.ts +26 -0
  20. package/dist/errors/types.js +41 -0
  21. package/dist/lsp/transport.d.ts +6 -15
  22. package/dist/lsp/transport.js +10 -66
  23. package/dist/mcp/adapter.d.ts +44 -0
  24. package/dist/mcp/adapter.js +70 -0
  25. package/dist/mcp/bounds.d.ts +35 -0
  26. package/dist/mcp/bounds.js +36 -0
  27. package/dist/mcp/client.d.ts +19 -0
  28. package/dist/mcp/client.js +93 -0
  29. package/dist/mcp/demarcate.d.ts +12 -0
  30. package/dist/mcp/demarcate.js +71 -0
  31. package/dist/mcp/index.d.ts +9 -0
  32. package/dist/mcp/index.js +8 -0
  33. package/dist/mcp/service.d.ts +54 -0
  34. package/dist/mcp/service.js +99 -0
  35. package/dist/mcp/transport.d.ts +30 -0
  36. package/dist/mcp/transport.js +188 -0
  37. package/dist/mcp/trust-gate.d.ts +35 -0
  38. package/dist/mcp/trust-gate.js +40 -0
  39. package/dist/mcp/trust.d.ts +52 -0
  40. package/dist/mcp/trust.js +111 -0
  41. package/dist/mcp/types.d.ts +52 -0
  42. package/dist/mcp/types.js +7 -0
  43. package/dist/tools/registry.js +3 -1
  44. package/dist/tools/types.d.ts +15 -1
  45. package/dist/utils/child-tree.d.ts +35 -0
  46. package/dist/utils/child-tree.js +76 -0
  47. package/dist/web/demarcate.d.ts +13 -0
  48. package/dist/web/demarcate.js +78 -0
  49. package/dist/web/fetch.d.ts +11 -0
  50. package/dist/web/fetch.js +174 -0
  51. package/dist/web/index.d.ts +7 -0
  52. package/dist/web/index.js +7 -0
  53. package/dist/web/provider.d.ts +29 -0
  54. package/dist/web/provider.js +77 -0
  55. package/dist/web/search.d.ts +17 -0
  56. package/dist/web/search.js +42 -0
  57. package/dist/web/ssrf.d.ts +55 -0
  58. package/dist/web/ssrf.js +223 -0
  59. package/dist/web/tools.d.ts +20 -0
  60. package/dist/web/tools.js +81 -0
  61. package/dist/web/types.d.ts +62 -0
  62. package/dist/web/types.js +1 -0
  63. package/package.json +2 -1
package/dist/constants.js CHANGED
@@ -61,6 +61,14 @@ export const MEMORY_FILE_NAME = "entries.json";
61
61
  * (`~/.cruxy/memory-trust.json`) — its own file, independent of hook trust, so
62
62
  * cloning a repo carries zero memory trust (C.29 supply-chain safety). */
63
63
  export const MEMORY_TRUST_FILE_NAME = "memory-trust.json";
64
+ /**
65
+ * MCP server integration (C.27). Per-repo trust for configured MCP servers, in
66
+ * the GLOBAL dir only (`~/.cruxy/mcp-trust.json`) — its own file, independent of
67
+ * hook and memory trust, so cloning a repo carries zero MCP trust. Trusting a
68
+ * server runs its code UNSANDBOXED with your privileges, so this decision is
69
+ * fingerprinted and re-checked on every run (C.27 supply-chain safety).
70
+ */
71
+ export const MCP_TRUST_FILE_NAME = "mcp-trust.json";
64
72
  /**
65
73
  * Usage telemetry + cost tracking (C.22). Per-run/per-session usage records live
66
74
  * in the GLOBAL dir only (`~/.cruxy/usage/runs.json`), `0600` — LOCAL accounting
@@ -180,6 +180,52 @@ export declare function lspTimeout(language: string, phase: "startup" | "request
180
180
  * that answered with zero results (that is an ordinary, non-error outcome).
181
181
  */
182
182
  export declare function lspCrashed(language: string, detail?: string): CruxyError;
183
+ /**
184
+ * A project configures MCP servers that this repo has not trusted, and cruxy is
185
+ * running non-interactively so it cannot ask. Fail closed BEFORE any server is
186
+ * spawned: trusting a server runs its code UNSANDBOXED with the user's full
187
+ * privileges (a stdio server's own side effects can't be contained by the shell
188
+ * sandbox), so an untrusted config must never connect silently. The wording says
189
+ * this plainly — it is a real escalation, not a "gated shell in a box".
190
+ */
191
+ export declare function mcpUntrusted(root: string, servers: string[]): CruxyError;
192
+ /**
193
+ * A trusted MCP server could not be reached — spawn failed, the `initialize`
194
+ * handshake errored/timed out, or `tools/list` failed. That server simply
195
+ * contributes no tools (the run continues); the coded reason is surfaced so a
196
+ * misconfigured server is visible, not a silent absence. External server text is
197
+ * gag-scrubbed (U.8) before it reaches the user-facing cause.
198
+ */
199
+ export declare function mcpConnect(server: string, underlying?: unknown): CruxyError;
200
+ /**
201
+ * `web.enabled` is on but no usable search provider is configured — the API-key
202
+ * environment variable is unset (or the provider is unknown). THE HONESTY RULE:
203
+ * this is a coded, actionable failure, never an empty result — "no provider"
204
+ * must not read as "the web had no results". `apiKeyEnv` names the variable to set.
205
+ */
206
+ export declare function webUnavailable(provider: string, apiKeyEnv: string): CruxyError;
207
+ /**
208
+ * A web search did not complete — the provider returned an HTTP error, the network
209
+ * failed, or the request timed out. Distinct from a search that ran and found
210
+ * nothing (that stays an ordinary `ok:true` empty result). External provider text
211
+ * is gag-scrubbed (U.8) before it reaches the user-facing cause.
212
+ */
213
+ export declare function webSearchFailed(underlying?: unknown): CruxyError;
214
+ /**
215
+ * A `web_fetch` failed to retrieve a page: network error, timeout, an oversize or
216
+ * non-text body, or too many redirects. Distinct from a page that was fetched but
217
+ * held no readable text (an ordinary `ok:true` empty result). External response
218
+ * text is gag-scrubbed (U.8) before it reaches the user-facing cause.
219
+ */
220
+ export declare function webFetchFailed(url: string, underlying?: unknown): CruxyError;
221
+ /**
222
+ * A `web_fetch` was REFUSED before any request went out (SSRF guard): a non-http(s)
223
+ * scheme, or a host that resolves into a private/loopback/link-local range
224
+ * (127.0.0.1, 169.254.169.254 cloud metadata, 10.x, internal DNS, …). A security
225
+ * stop kept distinct from an ordinary fetch failure — the request is never
226
+ * dispatched. `web.allowPrivateHosts` is the deliberate escape hatch.
227
+ */
228
+ export declare function webBlockedHost(url: string, reason: string): CruxyError;
183
229
  export declare function internal(underlying?: unknown): CruxyError;
184
230
  /**
185
231
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
@@ -733,6 +733,129 @@ export function lspCrashed(language, detail) {
733
733
  meta: { language },
734
734
  });
735
735
  }
736
+ // ── MCP client (exit 16) — C.27 ───────────────────────────────────────────────
737
+ /**
738
+ * A project configures MCP servers that this repo has not trusted, and cruxy is
739
+ * running non-interactively so it cannot ask. Fail closed BEFORE any server is
740
+ * spawned: trusting a server runs its code UNSANDBOXED with the user's full
741
+ * privileges (a stdio server's own side effects can't be contained by the shell
742
+ * sandbox), so an untrusted config must never connect silently. The wording says
743
+ * this plainly — it is a real escalation, not a "gated shell in a box".
744
+ */
745
+ export function mcpUntrusted(root, servers) {
746
+ const list = servers.join(", ");
747
+ return new CruxyError({
748
+ code: ErrorCode.McpUntrusted,
749
+ title: "this project's MCP servers have not been trusted",
750
+ cause: `${servers.length} configured server${servers.length === 1 ? "" : "s"} (${list}) ` +
751
+ "would run UNSANDBOXED with your full privileges — cruxy will not connect to them " +
752
+ "non-interactively without an explicit, recorded trust decision",
753
+ nextSteps: [
754
+ "review the servers with `cruxy mcp list`",
755
+ "then trust them with `cruxy mcp trust .` (re-trust is required if the config changes)",
756
+ "or set `mcp.enabled = false` to disable MCP for this project",
757
+ ],
758
+ meta: { root, servers },
759
+ });
760
+ }
761
+ /**
762
+ * A trusted MCP server could not be reached — spawn failed, the `initialize`
763
+ * handshake errored/timed out, or `tools/list` failed. That server simply
764
+ * contributes no tools (the run continues); the coded reason is surfaced so a
765
+ * misconfigured server is visible, not a silent absence. External server text is
766
+ * gag-scrubbed (U.8) before it reaches the user-facing cause.
767
+ */
768
+ export function mcpConnect(server, underlying) {
769
+ return new CruxyError({
770
+ code: ErrorCode.McpConnect,
771
+ title: `could not connect to MCP server "${server}"`,
772
+ cause: scrubbedMessageOf(underlying) ??
773
+ "the server failed to start, handshake, or list its tools",
774
+ nextSteps: [
775
+ "re-run with --verbose to see the server's error",
776
+ `verify the command in \`mcp.servers.${server}\` runs standalone`,
777
+ ],
778
+ meta: { server },
779
+ underlying,
780
+ });
781
+ }
782
+ // ── web search + fetch (exit 17) — C.20 ───────────────────────────────────────
783
+ /**
784
+ * `web.enabled` is on but no usable search provider is configured — the API-key
785
+ * environment variable is unset (or the provider is unknown). THE HONESTY RULE:
786
+ * this is a coded, actionable failure, never an empty result — "no provider"
787
+ * must not read as "the web had no results". `apiKeyEnv` names the variable to set.
788
+ */
789
+ export function webUnavailable(provider, apiKeyEnv) {
790
+ return new CruxyError({
791
+ code: ErrorCode.WebUnavailable,
792
+ title: `web search is enabled but the "${provider}" provider has no API key`,
793
+ cause: `the \`${apiKeyEnv}\` environment variable is not set`,
794
+ nextSteps: [
795
+ `export ${apiKeyEnv}=<your ${provider} api key> and re-run`,
796
+ "or set `web.enabled = false` to disable web tools for this project",
797
+ ],
798
+ meta: { provider, apiKeyEnv },
799
+ });
800
+ }
801
+ /**
802
+ * A web search did not complete — the provider returned an HTTP error, the network
803
+ * failed, or the request timed out. Distinct from a search that ran and found
804
+ * nothing (that stays an ordinary `ok:true` empty result). External provider text
805
+ * is gag-scrubbed (U.8) before it reaches the user-facing cause.
806
+ */
807
+ export function webSearchFailed(underlying) {
808
+ return new CruxyError({
809
+ code: ErrorCode.WebSearch,
810
+ title: "web search failed",
811
+ cause: scrubbedMessageOf(underlying) ??
812
+ "the search provider errored, was unreachable, or timed out",
813
+ nextSteps: [
814
+ "re-run with --verbose to see the provider's error",
815
+ "check the provider status and your network, then retry",
816
+ ],
817
+ underlying,
818
+ });
819
+ }
820
+ /**
821
+ * A `web_fetch` failed to retrieve a page: network error, timeout, an oversize or
822
+ * non-text body, or too many redirects. Distinct from a page that was fetched but
823
+ * held no readable text (an ordinary `ok:true` empty result). External response
824
+ * text is gag-scrubbed (U.8) before it reaches the user-facing cause.
825
+ */
826
+ export function webFetchFailed(url, underlying) {
827
+ return new CruxyError({
828
+ code: ErrorCode.WebFetch,
829
+ title: `could not fetch ${url}`,
830
+ cause: scrubbedMessageOf(underlying) ??
831
+ "the request errored, timed out, or returned a non-text/oversize body",
832
+ nextSteps: [
833
+ "verify the URL is reachable and serves text (html/plain/json)",
834
+ "re-run with --verbose to see the underlying error",
835
+ ],
836
+ meta: { url },
837
+ underlying,
838
+ });
839
+ }
840
+ /**
841
+ * A `web_fetch` was REFUSED before any request went out (SSRF guard): a non-http(s)
842
+ * scheme, or a host that resolves into a private/loopback/link-local range
843
+ * (127.0.0.1, 169.254.169.254 cloud metadata, 10.x, internal DNS, …). A security
844
+ * stop kept distinct from an ordinary fetch failure — the request is never
845
+ * dispatched. `web.allowPrivateHosts` is the deliberate escape hatch.
846
+ */
847
+ export function webBlockedHost(url, reason) {
848
+ return new CruxyError({
849
+ code: ErrorCode.WebBlockedHost,
850
+ title: `refused to fetch ${url}`,
851
+ cause: reason,
852
+ nextSteps: [
853
+ "fetch a public http(s) URL instead",
854
+ "if you intentionally target an internal host, set `web.allowPrivateHosts = true`",
855
+ ],
856
+ meta: { url },
857
+ });
858
+ }
736
859
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
737
860
  export function internal(underlying) {
738
861
  return new CruxyError({
@@ -93,6 +93,32 @@ export declare const ErrorCode: {
93
93
  /** A language server crashed (and, where applicable, a single restart also
94
94
  * failed). Distinct from "server returned no results". */
95
95
  readonly LspCrashed: "CRUXY_E_LSP_CRASHED";
96
+ /** A project configures MCP servers that have not been trusted for this repo.
97
+ * Trusting a server runs its code UNSANDBOXED with your privileges, so an
98
+ * untrusted config is never connected silently — and in non-interactive mode
99
+ * it fails closed BEFORE any server is spawned (supply-chain safety). */
100
+ readonly McpUntrusted: "CRUXY_E_MCP_UNTRUSTED";
101
+ /** A trusted MCP server failed to spawn, complete the `initialize` handshake,
102
+ * or list its tools. Surfaced (that server contributes no tools) rather than
103
+ * silently swallowed; never fatal to the run. */
104
+ readonly McpConnect: "CRUXY_E_MCP_CONNECT";
105
+ /** `web.enabled` is on but no usable search provider is configured — the API
106
+ * key env var is unset or the provider is unknown. Actionable, NEVER a silent
107
+ * empty result: "no provider" must not read as "no search results". */
108
+ readonly WebUnavailable: "CRUXY_E_WEB_UNAVAILABLE";
109
+ /** A web search failed (provider HTTP error, network failure, or timeout). The
110
+ * search did NOT run to completion — distinct from a search that ran and found
111
+ * nothing (that is an ordinary `ok:true` empty result). */
112
+ readonly WebSearch: "CRUXY_E_WEB_SEARCH";
113
+ /** A `web_fetch` failed: network error, timeout, oversize/non-text body, or too
114
+ * many redirects. Distinct from a page fetched successfully that had no readable
115
+ * text (an ordinary `ok:true` empty result). */
116
+ readonly WebFetch: "CRUXY_E_WEB_FETCH";
117
+ /** A `web_fetch` was REFUSED before any request was dispatched: a non-http(s)
118
+ * scheme, or a host that resolves into a private/loopback/link-local range
119
+ * (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
120
+ * security stop, kept distinct from an ordinary fetch failure for grep-ability. */
121
+ readonly WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST";
96
122
  };
97
123
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
98
124
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -110,6 +110,34 @@ export const ErrorCode = {
110
110
  /** A language server crashed (and, where applicable, a single restart also
111
111
  * failed). Distinct from "server returned no results". */
112
112
  LspCrashed: "CRUXY_E_LSP_CRASHED",
113
+ // MCP client (exit 16) — C.27
114
+ /** A project configures MCP servers that have not been trusted for this repo.
115
+ * Trusting a server runs its code UNSANDBOXED with your privileges, so an
116
+ * untrusted config is never connected silently — and in non-interactive mode
117
+ * it fails closed BEFORE any server is spawned (supply-chain safety). */
118
+ McpUntrusted: "CRUXY_E_MCP_UNTRUSTED",
119
+ /** A trusted MCP server failed to spawn, complete the `initialize` handshake,
120
+ * or list its tools. Surfaced (that server contributes no tools) rather than
121
+ * silently swallowed; never fatal to the run. */
122
+ McpConnect: "CRUXY_E_MCP_CONNECT",
123
+ // web search + fetch (exit 17) — C.20
124
+ /** `web.enabled` is on but no usable search provider is configured — the API
125
+ * key env var is unset or the provider is unknown. Actionable, NEVER a silent
126
+ * empty result: "no provider" must not read as "no search results". */
127
+ WebUnavailable: "CRUXY_E_WEB_UNAVAILABLE",
128
+ /** A web search failed (provider HTTP error, network failure, or timeout). The
129
+ * search did NOT run to completion — distinct from a search that ran and found
130
+ * nothing (that is an ordinary `ok:true` empty result). */
131
+ WebSearch: "CRUXY_E_WEB_SEARCH",
132
+ /** A `web_fetch` failed: network error, timeout, oversize/non-text body, or too
133
+ * many redirects. Distinct from a page fetched successfully that had no readable
134
+ * text (an ordinary `ok:true` empty result). */
135
+ WebFetch: "CRUXY_E_WEB_FETCH",
136
+ /** A `web_fetch` was REFUSED before any request was dispatched: a non-http(s)
137
+ * scheme, or a host that resolves into a private/loopback/link-local range
138
+ * (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
139
+ * security stop, kept distinct from an ordinary fetch failure for grep-ability. */
140
+ WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST",
113
141
  };
114
142
  /**
115
143
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -187,6 +215,19 @@ const EXIT_CODES = {
187
215
  [ErrorCode.LspServerNotFound]: 15,
188
216
  [ErrorCode.LspTimeout]: 15,
189
217
  [ErrorCode.LspCrashed]: 15,
218
+ // MCP client (C.27). An untrusted MCP config is an execution-safety stop that
219
+ // fails closed before any spawn; a connect failure surfaces per-server and is
220
+ // never fatal on its own. Grouped for a greppable exit code.
221
+ [ErrorCode.McpUntrusted]: 16,
222
+ [ErrorCode.McpConnect]: 16,
223
+ // Web search + fetch (C.20). A missing provider, a search/fetch failure, and an
224
+ // SSRF-blocked host all surface inside a tool result (the agent reads and
225
+ // adapts) and only exit the process if thrown directly. Grouped for a greppable
226
+ // exit code.
227
+ [ErrorCode.WebUnavailable]: 17,
228
+ [ErrorCode.WebSearch]: 17,
229
+ [ErrorCode.WebFetch]: 17,
230
+ [ErrorCode.WebBlockedHost]: 17,
190
231
  };
191
232
  /** The process exit code for an error code (defaults to 1 for safety). */
192
233
  export function exitCodeFor(code) {
@@ -1,4 +1,10 @@
1
+ import { killTree, killTrackedTrees, trackedTreeCount } from "../utils/child-tree.js";
1
2
  import type { LspTransport, ServerSpec } from "./types.js";
3
+ export { killTree };
4
+ /** @deprecated Use the shared backstop; kept for LSP tests. */
5
+ export declare const killTrackedServers: typeof killTrackedTrees;
6
+ /** @deprecated Use the shared backstop; kept for LSP tests. */
7
+ export declare const trackedServerCount: typeof trackedTreeCount;
2
8
  export declare class StdioTransport implements LspTransport {
3
9
  private readonly child;
4
10
  private nextId;
@@ -31,18 +37,3 @@ export declare class TransportTimeoutError extends Error {
31
37
  readonly timeoutMs: number;
32
38
  constructor(method: string, timeoutMs: number);
33
39
  }
34
- /**
35
- * Kill the process's entire group (POSIX negative-PID `SIGKILL`), same helper
36
- * shape as run_command's `killTree`. Swallows errors — the process may be gone.
37
- */
38
- export declare function killTree(pid: number | undefined): void;
39
- /**
40
- * Force-kill the process group of every tracked-but-not-yet-shut-down server,
41
- * then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
42
- * handlers run — the last line against orphaned language servers on a hard exit.
43
- * Exported so it is directly testable (like `resetIndexServices`) without having
44
- * to raise real process signals. Idempotent: a second call is a no-op.
45
- */
46
- export declare function killTrackedServers(): void;
47
- /** Number of servers currently tracked by the exit backstop (for tests). */
48
- export declare function trackedServerCount(): number;
@@ -1,4 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { killTree, killTrackedTrees, registerForCleanup, trackedTreeCount, } from "../utils/child-tree.js";
3
+ // Re-exported under their historical LSP names so callers and tests keep
4
+ // importing them from here; the machinery now lives in the shared child-tree
5
+ // backstop (also used by the C.27 MCP transport) so exit-time reaping is
6
+ // unified across every managed process tree.
7
+ export { killTree };
8
+ /** @deprecated Use the shared backstop; kept for LSP tests. */
9
+ export const killTrackedServers = killTrackedTrees;
10
+ /** @deprecated Use the shared backstop; kept for LSP tests. */
11
+ export const trackedServerCount = trackedTreeCount;
2
12
  /**
3
13
  * JSON-RPC 2.0 over a language server's stdio (C.12). Owns the child process:
4
14
  * spawns it in its OWN process group (`detached`) so the whole tree is killable,
@@ -196,69 +206,3 @@ export class TransportTimeoutError extends Error {
196
206
  this.name = "TransportTimeoutError";
197
207
  }
198
208
  }
199
- /**
200
- * Kill the process's entire group (POSIX negative-PID `SIGKILL`), same helper
201
- * shape as run_command's `killTree`. Swallows errors — the process may be gone.
202
- */
203
- export function killTree(pid) {
204
- if (pid === undefined)
205
- return;
206
- try {
207
- process.kill(-pid, "SIGKILL");
208
- }
209
- catch {
210
- try {
211
- // Fall back to a direct kill if there was no group (or on win32).
212
- process.kill(pid, "SIGKILL");
213
- }
214
- catch {
215
- /* already exited */
216
- }
217
- }
218
- }
219
- // ── process-exit kill-tree backstop ───────────────────────────────────────────
220
- //
221
- // Language servers are long-lived child processes. A per-session shutdown covers
222
- // the normal path, but a hard exit (Ctrl-C, an uncaught throw) would otherwise
223
- // orphan them — so every live server's process group is tracked here and killed
224
- // on process teardown. Handlers are registered ONCE, lazily, on the first spawn,
225
- // so unit tests using the fake transport never install them.
226
- const livePids = new Set();
227
- let handlersInstalled = false;
228
- /**
229
- * Force-kill the process group of every tracked-but-not-yet-shut-down server,
230
- * then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
231
- * handlers run — the last line against orphaned language servers on a hard exit.
232
- * Exported so it is directly testable (like `resetIndexServices`) without having
233
- * to raise real process signals. Idempotent: a second call is a no-op.
234
- */
235
- export function killTrackedServers() {
236
- for (const pid of livePids)
237
- killTree(pid);
238
- livePids.clear();
239
- }
240
- /** Number of servers currently tracked by the exit backstop (for tests). */
241
- export function trackedServerCount() {
242
- return livePids.size;
243
- }
244
- function installExitHandlers() {
245
- if (handlersInstalled)
246
- return;
247
- handlersInstalled = true;
248
- process.once("exit", killTrackedServers);
249
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
250
- process.once(sig, () => {
251
- killTrackedServers();
252
- // Restore default behavior and re-raise so the exit code is correct.
253
- process.exit(130);
254
- });
255
- }
256
- }
257
- /** Track a live child for the exit backstop; returns a deregister callback. */
258
- function registerForCleanup(pid) {
259
- if (pid === undefined)
260
- return () => { };
261
- installExitHandlers();
262
- livePids.add(pid);
263
- return () => livePids.delete(pid);
264
- }
@@ -0,0 +1,44 @@
1
+ import type { Tool } from "../tools/types.js";
2
+ import { type McpBounds } from "./bounds.js";
3
+ import type { McpCallResult, RawMcpTool } from "./types.js";
4
+ /**
5
+ * THE SINGLE SEAM (C.27). `mcpToolsFrom` is the ONE and ONLY place an MCP server
6
+ * becomes an agent {@link Tool}. Nothing else in the codebase constructs an
7
+ * MCP-backed tool, so every security property is enforced here, by construction:
8
+ *
9
+ * - GATE. Every produced tool's `execute` calls `ctx.requestApproval({kind:"mcp",
10
+ * …})` BEFORE it ever calls the server. A rejection returns the feedback as the
11
+ * tool result and `tools/call` never fires. The action is classified
12
+ * `destructive` and the classifier never reads the server's `readOnlyHint`
13
+ * (which this adapter deliberately does not even forward) — a server cannot
14
+ * self-declare its tool safe. A session grant is keyed on the exact server+tool
15
+ * pair, so approving one tool never covers another.
16
+ * - DEMARCATE + GAG. Both the advertised description and every result are wrapped
17
+ * as untrusted external data with upstream model names scrubbed
18
+ * ({@link demarcateDescription} / {@link demarcateResult}) — the model never
19
+ * sees a raw, un-boxed description or result.
20
+ * - BOUNDS. The server's tool list is capped in count and per-tool size
21
+ * ({@link boundToolList}); overflow is truncated with a visible note, never a
22
+ * silent drop or an unbounded context blow-up.
23
+ * - NON-PERSISTENCE. `execute` returns a plain {@link ToolResult}; it writes to
24
+ * no store. Results live only in the in-memory conversation (asserted by test).
25
+ *
26
+ * Trust is enforced UPSTREAM (the service never calls this until the server is
27
+ * trusted), so reaching this function already means "the user accepted running
28
+ * this server's code unsandboxed".
29
+ */
30
+ export interface McpToolSource {
31
+ /** The configured server id (the tool-name prefix and gate key). */
32
+ server: string;
33
+ /** Tools exactly as the server advertised them (untrusted). */
34
+ tools: readonly RawMcpTool[];
35
+ /** Invoke a tool on the server by its ORIGINAL name. Adapter-internal only. */
36
+ call(toolName: string, args: unknown): Promise<McpCallResult>;
37
+ /** Size/count caps for the advertised list. */
38
+ bounds: McpBounds;
39
+ /** Where the visible "N tools dropped" note is surfaced. */
40
+ logger?: {
41
+ warn(message: string): void;
42
+ };
43
+ }
44
+ export declare function mcpToolsFrom(source: McpToolSource): Tool[];
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import { boundToolList } from "./bounds.js";
3
+ import { demarcateDescription, demarcateResult } from "./demarcate.js";
4
+ /** Args validation is delegated to the server; accept any object here. */
5
+ const PASSTHROUGH = z.object({}).passthrough();
6
+ export function mcpToolsFrom(source) {
7
+ const { server, tools, call, bounds, logger } = source;
8
+ const bounded = boundToolList(tools, bounds);
9
+ if (bounded.droppedCount > 0) {
10
+ // Visible, coded note — a hostile server flooding the tool list is bounded
11
+ // exactly like find_references' output, never silently truncated.
12
+ logger?.warn(`[CRUXY_E_MCP_CONNECT] server "${server}" advertised ${tools.length} tools; ` +
13
+ `kept ${bounded.tools.length} (mcp.maxToolsPerServer), dropped ${bounded.droppedCount}`);
14
+ }
15
+ return bounded.tools.map((t) => {
16
+ // The original (unsanitized) name is what the server expects on tools/call;
17
+ // the wire name is sanitized so it is a valid, collision-resistant tool id.
18
+ const originalName = t.name;
19
+ const wireName = `mcp__${sanitizeId(server)}__${sanitizeId(originalName)}`;
20
+ const description = demarcateDescription(server, originalName, t.description) +
21
+ (t.notes.length > 0
22
+ ? `\n[cruxy applied limits: ${t.notes.join("; ")}]`
23
+ : "");
24
+ return {
25
+ name: wireName,
26
+ description,
27
+ parameters: PASSTHROUGH,
28
+ // Advertise the server's own (bounds-capped) schema verbatim; the registry
29
+ // uses this instead of deriving one from `parameters` (C.27 seam on Tool).
30
+ rawInputSchema: t.inputSchema,
31
+ async execute(input, ctx) {
32
+ // GATE — always, before any call. readOnlyHint is intentionally NOT
33
+ // passed: the tier is decided by the classifier (destructive), never by
34
+ // the server. A rejection means the call is never made.
35
+ const decision = await ctx.requestApproval({
36
+ kind: "mcp",
37
+ server,
38
+ tool: originalName,
39
+ });
40
+ if (!decision.allow) {
41
+ return {
42
+ ok: false,
43
+ error: decision.feedback ??
44
+ `the call to MCP tool "${originalName}" on server "${server}" was rejected`,
45
+ };
46
+ }
47
+ let result;
48
+ try {
49
+ result = await call(originalName, input);
50
+ }
51
+ catch (err) {
52
+ // A transport/protocol failure — surface it, scrubbed + demarcated.
53
+ return {
54
+ ok: false,
55
+ error: demarcateResult(server, originalName, err.message ?? "MCP tool call failed"),
56
+ };
57
+ }
58
+ const body = demarcateResult(server, originalName, result.text);
59
+ return result.isError
60
+ ? { ok: false, error: body }
61
+ : { ok: true, output: body };
62
+ },
63
+ };
64
+ });
65
+ }
66
+ /** Reduce an arbitrary MCP id to a safe, stable wire token. */
67
+ function sanitizeId(id) {
68
+ const cleaned = id.replace(/[^A-Za-z0-9_-]/g, "_");
69
+ return cleaned === "" ? "unnamed" : cleaned;
70
+ }
@@ -0,0 +1,35 @@
1
+ import type { RawMcpTool } from "./types.js";
2
+ /**
3
+ * Tool-list bounds (C.27). A malicious or buggy MCP server can advertise
4
+ * thousands of tools with enormous descriptions and schemas — a context-budget
5
+ * DoS. So the list a server returns is bounded exactly like `find_references`'
6
+ * output: capped in count and per-tool size, and every truncation is surfaced as
7
+ * a VISIBLE note (never a silent drop, never an unbounded blow-up). The caps are
8
+ * config-driven (`mcp.maxToolsPerServer` / `maxDescriptionChars` / `maxSchemaBytes`).
9
+ */
10
+ export interface McpBounds {
11
+ maxTools: number;
12
+ maxDescriptionChars: number;
13
+ maxSchemaBytes: number;
14
+ }
15
+ /** A single tool after bounding, carrying any truncation notes for the model. */
16
+ export interface BoundedTool {
17
+ name: string;
18
+ description: string;
19
+ inputSchema: Record<string, unknown>;
20
+ /** Human notes about what was truncated on THIS tool (surfaced as data). */
21
+ notes: string[];
22
+ }
23
+ export interface BoundedToolList {
24
+ tools: BoundedTool[];
25
+ /** How many tools were dropped because the server exceeded the count cap. */
26
+ droppedCount: number;
27
+ }
28
+ /**
29
+ * Apply the bounds to a raw `tools/list`. Over the count cap → keep the first N
30
+ * (by advertised order) and report `droppedCount`. Per tool: an over-long
31
+ * description is truncated with a marker; an over-size input schema is replaced
32
+ * with a permissive `object` schema and a note (we never forward an unbounded
33
+ * schema, but we also never claim the args are constrained when we dropped it).
34
+ */
35
+ export declare function boundToolList(tools: readonly RawMcpTool[], bounds: McpBounds): BoundedToolList;
@@ -0,0 +1,36 @@
1
+ const PERMISSIVE_SCHEMA = {
2
+ type: "object",
3
+ additionalProperties: true,
4
+ };
5
+ /**
6
+ * Apply the bounds to a raw `tools/list`. Over the count cap → keep the first N
7
+ * (by advertised order) and report `droppedCount`. Per tool: an over-long
8
+ * description is truncated with a marker; an over-size input schema is replaced
9
+ * with a permissive `object` schema and a note (we never forward an unbounded
10
+ * schema, but we also never claim the args are constrained when we dropped it).
11
+ */
12
+ export function boundToolList(tools, bounds) {
13
+ const kept = tools.slice(0, bounds.maxTools);
14
+ const droppedCount = tools.length - kept.length;
15
+ const bounded = kept.map((t) => {
16
+ const notes = [];
17
+ const rawDesc = t.description ?? "";
18
+ let description = rawDesc;
19
+ if (description.length > bounds.maxDescriptionChars) {
20
+ description =
21
+ description.slice(0, bounds.maxDescriptionChars) +
22
+ " …[description truncated by cruxy]";
23
+ notes.push(`description truncated to ${bounds.maxDescriptionChars} chars`);
24
+ }
25
+ let inputSchema = t.inputSchema ?? {
26
+ ...PERMISSIVE_SCHEMA,
27
+ };
28
+ const schemaBytes = Buffer.byteLength(JSON.stringify(inputSchema), "utf8");
29
+ if (schemaBytes > bounds.maxSchemaBytes) {
30
+ inputSchema = { ...PERMISSIVE_SCHEMA };
31
+ notes.push(`input schema (${schemaBytes} bytes) exceeded the ${bounds.maxSchemaBytes}-byte cap and was replaced with a permissive one`);
32
+ }
33
+ return { name: t.name, description, inputSchema, notes };
34
+ });
35
+ return { tools: bounded, droppedCount };
36
+ }
@@ -0,0 +1,19 @@
1
+ import type { McpCallResult, McpTransport, RawMcpTool } from "./types.js";
2
+ export interface McpClientTimeouts {
3
+ /** `initialize` + `tools/list` budget (connect-time). */
4
+ startupTimeout: number;
5
+ /** Per `tools/call` budget. */
6
+ requestTimeout: number;
7
+ }
8
+ export declare class McpClient {
9
+ private readonly transport;
10
+ private readonly timeouts;
11
+ constructor(transport: McpTransport, timeouts: McpClientTimeouts);
12
+ /** Perform the MCP handshake: `initialize`, then the `initialized` notice. */
13
+ initialize(): Promise<void>;
14
+ /** List the server's tools. Malformed entries are dropped, not thrown on. */
15
+ listTools(): Promise<RawMcpTool[]>;
16
+ /** Call one tool. Normalizes content to flat text + the server's error flag. */
17
+ callTool(name: string, args: unknown): Promise<McpCallResult>;
18
+ dispose(force?: boolean): Promise<void>;
19
+ }