From 33f8b5090821a6235e940e916663fc766c6ca108 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:11:38 +0200 Subject: [PATCH] fix(claude): isolate capability probe MCP startup (PR #4015) Preserve workspace cwd and command discovery while preventing periodic capability checks from loading configured MCP servers or claude.ai connectors. Co-authored-by: codex --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 138 ++++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 54 +++++-- 2 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts new file mode 100644 index 00000000000..ab6e5992990 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,138 @@ +import { ClaudeSettings } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + probeClaudeCapabilities, +} from "./ClaudeProvider.ts"; + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +it("isolates Claude capability probes without dropping workspace setting sources", () => { + const abortController = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController, + environment: { + HOME: "/home/user", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + cwd: "/workspace/project", + }); + + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.cwd, "/workspace/project"); + assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); + assert.deepEqual(options.allowedTools, []); + assert.equal(options.persistSession, false); + assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); + assert.equal(options.abortController, abortController); + assert.equal(options.env?.HOME, "/home/user"); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); +}); + +it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { + it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + const workspaceCwd = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + + yield* fs.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const args = process.argv.slice(2);", + 'const mcpConfigIndex = args.indexOf("--mcp-config");', + "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", + "let mcpConfig;", + "if (rawMcpConfig) {", + ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', + " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", + "}", + "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", + " args,", + " cwd: process.cwd(),", + " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", + " mcpConfig,", + "}));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fs.chmod(executablePath, 0o755); + + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + workspaceCwd, + ); + + assert.deepEqual(capabilities, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + readonly mcpConfig: unknown; + }; + assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.connectorEnv, "false"); + assert.equal(invocation.args.includes("--strict-mcp-config"), true); + assert.equal(invocation.args.includes("--mcp-config"), false); + assert.equal(invocation.mcpConfig, undefined); + + assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9df672f54b0..4920f96465a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -21,8 +21,10 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, + type SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -508,6 +510,44 @@ function apiProviderAuthMetadata( // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +/** + * Keep workspace-scoped command discovery intact while isolating the periodic + * health check from configured MCP servers. + */ +export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** Build the exact SDK options used by the periodic Claude capability probe. */ +export function buildClaudeCapabilitiesProbeQueryOptions(input: { + readonly executablePath: string; + readonly abortController: AbortController; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}): ClaudeQueryOptions { + return { + persistSession: false, + pathToClaudeCodeExecutable: input.executablePath, + abortController: input.abortController, + settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], + allowedTools: [], + // Ignore MCP definitions from every filesystem setting source above. The + // SDK combines this empty explicit map with --strict-mcp-config. + mcpServers: {}, + strictMcpConfig: true, + env: { + ...input.environment, + // Connected claude.ai MCP servers are discovered outside filesystem + // config; disable them independently for this health check. + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + ...(input.cwd ? { cwd: input.cwd } : {}), + stderr: () => {}, + }; +} + function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); return candidate ? candidate : undefined; @@ -631,16 +671,12 @@ const probeClaudeCapabilities = ( prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), - options: { - persistSession: false, - pathToClaudeCodeExecutable: executablePath, + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - env: claudeEnvironment, - ...(cwd ? { cwd } : {}), - stderr: () => {}, - }, + environment: claudeEnvironment, + cwd, + }), }); const init = await q.initializationResult(); const account = init.account as