authorgravatar for jaretbottoms@gmail.comJaret Bottoms <jaretbottoms@gmail.com> 2026-07-22 08:54:30-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 15:54:30+02:00
logaa5ec8036451a8dcf16d93294c69f524907c42c8
treec9cdedbd93182d22784099410630451cf9a3038b
parent282ecb317877e6cce296976a03278cc388b7ca37
signature Signed by PGP key B5690EEEBB952194

fix(claude): isolate capability probe from user MCP servers (#4015)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 183 insertions(+), 9 deletions(-)

apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts created+138
......@@ -0,0 +1,138 @@
1import { ClaudeSettings } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8
9import {
10 buildClaudeCapabilitiesProbeQueryOptions,
11 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
12 probeClaudeCapabilities,
13} from "./ClaudeProvider.ts";
14
15const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
16
17it("isolates Claude capability probes without dropping workspace setting sources", () => {
18 const abortController = new AbortController();
19 const options = buildClaudeCapabilitiesProbeQueryOptions({
20 executablePath: "/usr/bin/claude",
21 abortController,
22 environment: {
23 HOME: "/home/user",
24 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
25 },
26 cwd: "/workspace/project",
27 });
28
29 assert.deepEqual(options.mcpServers, {});
30 assert.equal(options.strictMcpConfig, true);
31 assert.equal(options.cwd, "/workspace/project");
32 assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]);
33 assert.deepEqual(options.allowedTools, []);
34 assert.equal(options.persistSession, false);
35 assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude");
36 assert.equal(options.abortController, abortController);
37 assert.equal(options.env?.HOME, "/home/user");
38 assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
39});
40
41it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
42 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" });
47 const executablePath = path.join(tempDir, "fake-claude.mjs");
48 const invocationPath = path.join(tempDir, "invocation.json");
49 const workspaceCwd = path.join(tempDir, "workspace");
50 yield* fs.makeDirectory(workspaceCwd, { recursive: true });
51
52 yield* fs.writeFileString(
53 executablePath,
54 [
55 "#!/usr/bin/env node",
56 'import { existsSync, readFileSync, writeFileSync } from "node:fs";',
57 'import { createInterface } from "node:readline";',
58 "const args = process.argv.slice(2);",
59 'const mcpConfigIndex = args.indexOf("--mcp-config");',
60 "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;",
61 "let mcpConfig;",
62 "if (rawMcpConfig) {",
63 ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;',
64 " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }",
65 "}",
66 "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({",
67 " args,",
68 " cwd: process.cwd(),",
69 " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,",
70 " mcpConfig,",
71 "}));",
72 "const lines = createInterface({ input: process.stdin });",
73 'lines.on("line", (line) => {',
74 " const message = JSON.parse(line);",
75 ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
76 " process.stdout.write(JSON.stringify({",
77 ' type: "control_response",',
78 " response: {",
79 ' subtype: "success",',
80 " request_id: message.request_id,",
81 " response: {",
82 ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],',
83 " agents: [],",
84 ' output_style: "default",',
85 ' available_output_styles: ["default"],',
86 " models: [],",
87 ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },',
88 " },",
89 " },",
90 ' }) + "\\n");',
91 "});",
92 "setInterval(() => {}, 1_000);",
93 "",
94 ].join("\n"),
95 );
96 yield* fs.chmod(executablePath, 0o755);
97
98 const capabilities = yield* probeClaudeCapabilities(
99 decodeClaudeSettings({ binaryPath: executablePath }),
100 {
101 ...process.env,
102 T3_PROBE_INVOCATION_PATH: invocationPath,
103 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
104 },
105 workspaceCwd,
106 );
107
108 assert.deepEqual(capabilities, {
109 email: "dev@example.com",
110 subscriptionType: "pro",
111 tokenSource: "oauth",
112 apiProvider: undefined,
113 slashCommands: [
114 {
115 name: "review",
116 description: "Review changes",
117 input: { hint: "[path]" },
118 },
119 ],
120 });
121
122 // @effect-diagnostics-next-line preferSchemaOverJson:off
123 const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as {
124 readonly args: ReadonlyArray<string>;
125 readonly cwd: string;
126 readonly connectorEnv: string;
127 readonly mcpConfig: unknown;
128 };
129 assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd));
130 assert.equal(invocation.connectorEnv, "false");
131 assert.equal(invocation.args.includes("--strict-mcp-config"), true);
132 assert.equal(invocation.args.includes("--mcp-config"), false);
133 assert.equal(invocation.mcpConfig, undefined);
134
135 assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true);
136 }).pipe(Effect.scoped),
137 );
138});
apps/server/src/provider/Layers/ClaudeProvider.ts+45-9
......@@ -21,8 +21,10 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
2121import { compareSemverVersions } from "@t3tools/shared/semver";
2222import {
2323 query as claudeQuery,
24 type Options as ClaudeQueryOptions,
2425 type SlashCommand as ClaudeSlashCommand,
2526 type SDKUserMessage,
27 type SettingSource,
2628} from "@anthropic-ai/claude-agent-sdk";
2729
2830import {
......@@ -508,6 +510,44 @@ function apiProviderAuthMetadata(
508510// `undefined` and left the provider unverified and unselectable in the picker.
509511const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;
510512
513/**
514 * Keep workspace-scoped command discovery intact while isolating the periodic
515 * health check from configured MCP servers.
516 */
517export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [
518 "user",
519 "project",
520 "local",
521] as const satisfies ReadonlyArray<SettingSource>;
522
523/** Build the exact SDK options used by the periodic Claude capability probe. */
524export function buildClaudeCapabilitiesProbeQueryOptions(input: {
525 readonly executablePath: string;
526 readonly abortController: AbortController;
527 readonly environment: NodeJS.ProcessEnv;
528 readonly cwd: string | undefined;
529}): ClaudeQueryOptions {
530 return {
531 persistSession: false,
532 pathToClaudeCodeExecutable: input.executablePath,
533 abortController: input.abortController,
534 settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES],
535 allowedTools: [],
536 // Ignore MCP definitions from every filesystem setting source above. The
537 // SDK combines this empty explicit map with --strict-mcp-config.
538 mcpServers: {},
539 strictMcpConfig: true,
540 env: {
541 ...input.environment,
542 // Connected claude.ai MCP servers are discovered outside filesystem
543 // config; disable them independently for this health check.
544 ENABLE_CLAUDEAI_MCP_SERVERS: "false",
545 },
546 ...(input.cwd ? { cwd: input.cwd } : {}),
547 stderr: () => {},
548 };
549}
550
511551function nonEmptyProbeString(value: string): string | undefined {
512552 const candidate = value.trim();
513553 return candidate ? candidate : undefined;
......@@ -631,16 +671,12 @@ const probeClaudeCapabilities = (
631671 prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
632672 await waitForAbortSignal(abort.signal);
633673 })(),
634 options: {
635 persistSession: false,
636 pathToClaudeCodeExecutable: executablePath,
674 options: buildClaudeCapabilitiesProbeQueryOptions({
675 executablePath,
637676 abortController: abort,
638 settingSources: ["user", "project", "local"],
639 allowedTools: [],
640 env: claudeEnvironment,
641 ...(cwd ? { cwd } : {}),
642 stderr: () => {},
643 },
677 environment: claudeEnvironment,
678 cwd,
679 }),
644680 });
645681 const init = await q.initializationResult();
646682 const account = init.account as