diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index d617eb999fa619e6619bdef917bd78f3336847ed..c294e2eba1c0adc71b4990348bab9d8314060666 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -41,6 +41,7 @@ it("isolates Claude capability probes without dropping workspace setting sources FORCE_CODE_TERMINAL: "1", }, cwd: "/workspace/project", + managedMcpConfig: false, }); assert.deepEqual(options.mcpServers, {}); @@ -59,6 +60,21 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); }); +it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController: new AbortController(), + environment: { HOME: "/home/user" }, + cwd: undefined, + managedMcpConfig: true, + }); + + // Claude exits before initializing when handed --strict-mcp-config here. + assert.equal(options.strictMcpConfig, undefined); + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); +}); + it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { it.effect( "discovers commands and skills separately for each cwd without replacing machine metadata", diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 6d53c7c79c994c72e7b1a6ae25e3dcf530eac152..d53d86b12db667f99b266d775fff9a7e1417d6d6 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -35,7 +35,7 @@ import { type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; -import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; +import { hasClaudeManagedMcpConfig, makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; @@ -223,6 +223,8 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { readonly abortController: AbortController; readonly environment: NodeJS.ProcessEnv; readonly cwd: string | undefined; + /** An administrator's `managed-mcp.json` is installed; see `hasClaudeManagedMcpConfig`. */ + readonly managedMcpConfig: boolean; }): ClaudeQueryOptions { return { persistSession: false, @@ -235,9 +237,10 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { settings: { disableAllHooks: true }, allowedTools: [], // Ignore MCP definitions from every filesystem setting source above. The - // SDK combines this empty explicit map with --strict-mcp-config. + // SDK combines this empty explicit map with --strict-mcp-config, which + // Claude refuses next to a managed-mcp.json that already excludes them. mcpServers: {}, - strictMcpConfig: true, + ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }), env: { ...input.environment, // Connected claude.ai MCP servers are discovered outside filesystem @@ -384,6 +387,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); + const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -397,6 +401,7 @@ const probeClaudeCapabilities = ( abortController: abort, environment: claudeEnvironment, cwd, + managedMcpConfig, }), }); const init = await q.initializationResult(); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 78bb34c2fd9ef4f161b1be39eacdec3b6169544e..ebd19acb8dc7335c959acf1ffd8d2c6346b37193 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,5 +1,6 @@ import type { ClaudeSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -53,6 +54,25 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }; }); +/** + * Whether an administrator installed Claude Code's `managed-mcp.json`. While it + * exists Claude refuses `--strict-mcp-config` and exits before initializing, + * but it already ignores user, project and `--mcp-config` servers on its own, + * so callers that pass the flag for isolation can drop it. Paths match the + * ones Claude Code checks. + */ +export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const platform = yield* HostProcess.Platform; + const managedMcpConfigPath = + platform === "darwin" + ? "/Library/Application Support/ClaudeCode/managed-mcp.json" + : platform === "win32" + ? "C:\\Program Files\\ClaudeCode\\managed-mcp.json" + : "/etc/claude-code/managed-mcp.json"; + return yield* fileSystem.exists(managedMcpConfigPath).pipe(Effect.orElseSucceed(() => false)); +}); + export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( function* ( config: Pick, diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index d655203af46d907f638b028120a40b043fb17dc1..b34e8c31935acb5a6b140debdce1fe9a08b3ac46 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -37,7 +37,10 @@ import { resolveClaudeModelSlug, scopeClaudeModelCatalog, } from "../provider/ClaudeModelCatalog.ts"; -import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { + hasClaudeManagedMcpConfig, + makeClaudeEnvironment, +} from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; /** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */ @@ -209,6 +212,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ), ) : cwd; + const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ @@ -236,7 +240,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", - "--strict-mcp-config", + // Claude refuses this flag next to a managed-mcp.json, which already keeps the + // user's own MCP servers out. + ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", "dontAsk", ],