authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:30:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:30:29-07:00
logf82796a16abb707c1e2c41301ad8d6e3153668c5
treef132eae764422ba8eef2b63132eeeee8bab28450
parenta4c8745abee3f73f960c914800974dee530dbc3b
signature Signed by PGP key B5690EEEBB952194

fix(server): send Claude MCP servers over the control channel (#17898)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 198 insertions(+), 47 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+123-15
......@@ -1,10 +1,15 @@
1// @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams.
2import * as NodeEvents from "node:events";
13import * as NodeOS from "node:os";
4import * as NodeStream from "node:stream";
25
36import type {
47 Query as ClaudeQuery,
58 SDKMessage,
69 SDKResultMessage,
710 SDKUserMessage,
11 SpawnedProcess,
12 SpawnOptions,
813} from "@anthropic-ai/claude-agent-sdk";
914import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools";
1015import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -63,7 +68,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
6368import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
6469import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
6570import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
66import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
71import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
6772import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
6873import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
6974import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
......@@ -499,14 +504,11 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
499504 type: "http",
500505 url: "http://127.0.0.1:43123/mcp",
501506 headers: {
502 Authorization: "${T3_CODE_MCP_AUTHORIZATION}",
507 Authorization: "Bearer secret-claude-token",
503508 },
504509 timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
505510 },
506511 } as const;
507 const T3_MCP_ENVIRONMENT = {
508 T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token",
509 } as const;
510512
511513 const mcpSessionFor = (
512514 threadId: ThreadId,
......@@ -551,7 +553,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
551553 assert.deepEqual(overrides, {
552554 allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD],
553555 mcpServers: T3_MCP_SERVERS,
554 mcpEnvironment: T3_MCP_ENVIRONMENT,
555556 });
556557 });
557558
......@@ -567,7 +568,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
567568 assert.deepEqual(overrides, {
568569 allowedTools: ["Read", "mcp__t3-code__*"],
569570 mcpServers: T3_MCP_SERVERS,
570 mcpEnvironment: T3_MCP_ENVIRONMENT,
571571 });
572572 });
573573
......@@ -586,7 +586,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
586586 ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS,
587587 ],
588588 mcpServers: T3_MCP_SERVERS,
589 mcpEnvironment: T3_MCP_ENVIRONMENT,
590589 });
591590 assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD));
592591 });
......@@ -706,12 +705,11 @@ describe("ClaudeAdapterV2 native protocol logging", () => {
706705 type: "http",
707706 url: "http://127.0.0.1:43123/mcp",
708707 headers: {
709 Authorization: "${T3_CODE_MCP_AUTHORIZATION}",
708 Authorization: "Bearer secret-claude-token",
710709 },
711710 timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
712711 },
713712 },
714 mcpEnvironment: { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token" },
715713 });
716714
717715 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
......@@ -724,12 +722,8 @@ describe("ClaudeAdapterV2 native protocol logging", () => {
724722 cwd: "/workspace",
725723 allowedTools: overrides.allowedTools ?? [],
726724 mcpServers: overrides.mcpServers ?? {},
727 environment: { ...overrides.mcpEnvironment },
725 environment: {},
728726 });
729 // mcpServers becomes a CLI argument, readable by every local user; the
730 // credential may only travel in the child's environment.
731 assert.notInclude(JSON.stringify(options.mcpServers), "secret-claude-token");
732 assert.equal(options.env?.T3_CODE_MCP_AUTHORIZATION, "Bearer secret-claude-token");
733727 assert.isObject(options.systemPrompt);
734728 const systemPrompt = options.systemPrompt as {
735729 readonly type: string;
......@@ -1850,6 +1844,120 @@ describe("ClaudeAdapterV2 native fork", () => {
18501844 );
18511845});
18521846
1847// Stands in for the Claude CLI behind the SDK's spawn hook: records how it was
1848// started and answers stdin control requests, except mcp_set_servers if asked.
1849function makeFakeClaudeCli(answerSetServers = true) {
1850 const spawns: Array<SpawnOptions> = [];
1851 const controlRequests: Array<object> = [];
1852 const stdins: Array<NodeStream.PassThrough> = [];
1853 const spawn = (options: SpawnOptions): SpawnedProcess => {
1854 spawns.push(options);
1855 const stdin = new NodeStream.PassThrough();
1856 const stdout = new NodeStream.PassThrough();
1857 stdins.push(stdin);
1858 const child = Object.assign(new NodeEvents.EventEmitter(), {
1859 stdin,
1860 stdout,
1861 killed: false,
1862 exitCode: null as number | null,
1863 kill: () => {
1864 stdout.end();
1865 child.emit("exit", null, "SIGTERM");
1866 return true;
1867 },
1868 });
1869 let pending = "";
1870 stdin.on("data", (chunk: Buffer) => {
1871 pending += chunk.toString("utf8");
1872 for (let end = pending.indexOf("\n"); end >= 0; end = pending.indexOf("\n")) {
1873 const frame = JSON.parse(pending.slice(0, end));
1874 pending = pending.slice(end + 1);
1875 if (frame.type !== "control_request") continue;
1876 controlRequests.push(frame.request);
1877 const setServers = frame.request.subtype === "mcp_set_servers";
1878 if (setServers && !answerSetServers) continue;
1879 const response = {
1880 subtype: "success",
1881 request_id: frame.request_id,
1882 response: setServers ? { added: [], removed: [], errors: {} } : {},
1883 };
1884 stdout.write(`${JSON.stringify({ type: "control_response", response })}\n`);
1885 }
1886 });
1887 stdin.on("end", () => {
1888 stdout.end();
1889 child.emit("exit", 0, null);
1890 });
1891 return child;
1892 };
1893 // The SDK closes the CLI by ending its stdin.
1894 const closed = () => stdins.length > 0 && stdins.every((stdin) => stdin.writableEnded);
1895 return { spawn, spawns, controlRequests, closed };
1896}
1897
1898describe("ClaudeAdapterV2 MCP credential channel", () => {
1899 const t3McpServer = {
1900 type: "http" as const,
1901 url: "http://127.0.0.1:43123/mcp",
1902 headers: { Authorization: "Bearer dummy-mcp-credential" },
1903 };
1904 const openWith = (cli: ReturnType<typeof makeFakeClaudeCli>) =>
1905 Effect.gen(function* () {
1906 const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner;
1907 return yield* runner.open({
1908 threadId: ThreadId.make("thread-claude-mcp-channel"),
1909 providerSessionId: ProviderSessionId.make("provider-session-claude-mcp-channel"),
1910 options: {
1911 ...ClaudeAdapterV2.makeClaudeQueryOptions({
1912 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1913 nativeThreadId: "native-thread-claude-mcp-channel",
1914 resume: false,
1915 cwd: null,
1916 environment: { PATH: "/usr/bin" },
1917 mcpServers: { "t3-code": t3McpServer },
1918 }),
1919 pathToClaudeCodeExecutable: "/opt/claude/cli.js",
1920 spawnClaudeCodeProcess: cli.spawn,
1921 },
1922 });
1923 }).pipe(
1924 Effect.provide(ClaudeAdapterV2.layerQueryRunner),
1925 Effect.provideService(
1926 ProviderEventLoggers.ProviderEventLoggers,
1927 ProviderEventLoggers.NoOpProviderEventLoggers,
1928 ),
1929 Effect.provide(NodeServices.layer),
1930 );
1931
1932 it.effect("sends MCP servers over stdin, not argv or the environment", () =>
1933 Effect.gen(function* () {
1934 const cli = makeFakeClaudeCli();
1935 const session = yield* openWith(cli);
1936 yield* session.close;
1937
1938 assert.deepInclude(cli.controlRequests, {
1939 subtype: "mcp_set_servers",
1940 servers: { "t3-code": t3McpServer },
1941 });
1942 const spawned = JSON.stringify(cli.spawns);
1943 assert.notInclude(spawned, "--mcp-config");
1944 assert.notInclude(spawned, "dummy-mcp-credential");
1945 }),
1946 );
1947
1948 it.effect("closes the CLI when it never answers the MCP registration", () =>
1949 Effect.gen(function* () {
1950 const cli = makeFakeClaudeCli(false);
1951 const opening = yield* openWith(cli).pipe(Effect.result, Effect.forkChild);
1952 yield* TestClock.adjust("90 seconds");
1953 const opened = yield* Fiber.join(opening);
1954
1955 assert.equal(opened._tag, "Failure");
1956 assert.isTrue(cli.closed());
1957 }),
1958 );
1959});
1960
18531961describe("ClaudeAdapterV2 native session identity", () => {
18541962 const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) =>
18551963 Effect.scoped(
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+75-32
......@@ -185,6 +185,9 @@ export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROV
185185const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({});
186186// Entry extensions the Claude SDK runs through node instead of executing.
187187const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"];
188// The SDK puts no deadline on control requests. The CLI answers
189// mcp_set_servers once each server connected or hit its own 30 s timeout.
190const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds";
188191
189192export const ClaudeProviderCapabilitiesV2 = {
190193 sessions: {
......@@ -636,11 +639,16 @@ export const layerQueryRunner: Layer.Layer<
636639 ),
637640 Stream.toAsyncIterable,
638641 );
642 // The SDK would pass `mcpServers` as an inline `--mcp-config` argument,
643 // which every local user can read. They go over the CLI's stdin control
644 // channel instead, so header credentials stay out of its argv and out
645 // of the environment every command the agent runs inherits.
646 const { mcpServers, ...queryOptions } = input.options;
639647 // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`,
640648 // which lets the agent scope wrapper go in front of the CLI while the
641649 // SDK keeps its own stderr capture and exit reporting. The SDK runs a
642650 // script entry through node instead, so those launch unwrapped.
643 const binaryPath = input.options.pathToClaudeCodeExecutable;
651 const binaryPath = queryOptions.pathToClaudeCodeExecutable;
644652 const launch =
645653 binaryPath === undefined ||
646654 CLAUDE_SCRIPT_EXTENSIONS.some((ext) => binaryPath.endsWith(ext))
......@@ -650,32 +658,74 @@ export const layerQueryRunner: Layer.Layer<
650658 args: [],
651659 name: "claude",
652660 threadId: input.threadId,
653 env: input.options.env,
661 env: queryOptions.env,
654662 });
655663 const options =
656664 launch === undefined || launch.args.length === 0
657 ? input.options
665 ? queryOptions
658666 : {
659 ...input.options,
667 ...queryOptions,
660668 pathToClaudeCodeExecutable: launch.command,
661 executableArgs: [...launch.args, ...(input.options.executableArgs ?? [])],
669 executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])],
662670 };
663 const queryRuntime = yield* Effect.try({
664 try: () =>
665 query({
666 prompt,
667 options,
668 }),
669 catch: (cause) => queryRunnerError(cause, "query"),
670 });
671 yield* logProtocolEvent({
672 direction: "outgoing",
673 stage: "decoded",
674 payload: {
675 type: "query.open",
676 options: loggedClaudeQueryOptions(input.options),
677 },
678 });
671 // The CLI is running once query() returns, so it is closed if anything
672 // before the session is handed out fails or is interrupted.
673 const queryRuntime = yield* Effect.uninterruptibleMask((restore) =>
674 Effect.try({
675 try: () => query({ prompt, options }),
676 catch: (cause) => queryRunnerError(cause, "query"),
677 }).pipe(
678 Effect.tap((queryRuntime) =>
679 restore(
680 Effect.gen(function* () {
681 if (mcpServers !== undefined) {
682 // A rejected request or a server that failed to connect
683 // leaves the turn without those tools, as `--mcp-config`
684 // did. Error texts and the CLI's mcp_status response can
685 // echo the headers, so only server names are logged.
686 const failed = yield* Effect.tryPromise(() =>
687 queryRuntime.setMcpServers(mcpServers),
688 ).pipe(
689 Effect.map((result) => Object.keys(result.errors)),
690 Effect.orElseSucceed(() => Object.keys(mcpServers)),
691 Effect.timeoutOrElse({
692 duration: CLAUDE_MCP_REGISTRATION_TIMEOUT,
693 orElse: () =>
694 Effect.fail(
695 queryRunnerError(
696 "Claude Code did not answer the MCP server registration.",
697 "setMcpServers",
698 ),
699 ),
700 }),
701 );
702 if (failed.length > 0) {
703 yield* Effect.logWarning("orchestration-v2.claude-mcp-servers-unavailable", {
704 threadId: input.threadId,
705 servers: failed,
706 });
707 }
708 }
709 yield* logProtocolEvent({
710 direction: "outgoing",
711 stage: "decoded",
712 payload: {
713 type: "query.open",
714 options: loggedClaudeQueryOptions(input.options),
715 },
716 });
717 }),
718 ).pipe(
719 Effect.onError(() =>
720 Queue.shutdown(promptQueue).pipe(
721 Effect.andThen(closeClaudeQuery(queryRuntime)),
722 Effect.ignore,
723 ),
724 ),
725 ),
726 ),
727 ),
728 );
679729
680730 return {
681731 messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) =>
......@@ -1005,11 +1055,8 @@ export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000;
10051055// read-only orchestrator tools so a read-only session cannot silently spawn
10061056// threads or scheduled tasks.
10071057//
1008// The SDK passes `mcpServers` to the CLI as an inline `--mcp-config` argument,
1009// and process arguments are readable by every local user. The credential
1010// therefore travels in the child's environment, which only its owner can read,
1011// and the CLI expands the `${VAR}` reference when it connects.
1012const CLAUDE_T3_MCP_AUTHORIZATION_ENV = "T3_CODE_MCP_AUTHORIZATION";
1058// The header carries the credential itself: the query runner sends
1059// `mcpServers` over the CLI's stdin, never its argv or environment.
10131060
10141061export function claudeMcpQueryOverrides(input: {
10151062 readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;
......@@ -1018,7 +1065,6 @@ export function claudeMcpQueryOverrides(input: {
10181065}): {
10191066 readonly allowedTools?: ReadonlyArray<string>;
10201067 readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
1021 readonly mcpEnvironment?: Readonly<Record<string, string>>;
10221068} {
10231069 const session = input.mcpSession;
10241070 if (session === undefined) {
......@@ -1034,12 +1080,11 @@ export function claudeMcpQueryOverrides(input: {
10341080 type: "http",
10351081 url: session.endpoint,
10361082 headers: {
1037 Authorization: `\${${CLAUDE_T3_MCP_AUTHORIZATION_ENV}}`,
1083 Authorization: session.authorizationHeader,
10381084 },
10391085 timeout: CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
10401086 },
10411087 },
1042 mcpEnvironment: { [CLAUDE_T3_MCP_AUTHORIZATION_ENV]: session.authorizationHeader },
10431088 };
10441089}
10451090
......@@ -1670,7 +1715,6 @@ export function claudeEffectiveQueryPolicyKey(
16701715 mcpOverrides: {
16711716 readonly allowedTools?: ReadonlyArray<string>;
16721717 readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
1673 readonly mcpEnvironment?: Readonly<Record<string, string>>;
16741718 },
16751719): string {
16761720 return JSON.stringify({
......@@ -1681,7 +1725,6 @@ export function claudeEffectiveQueryPolicyKey(
16811725 : { allowedTools: mcpOverrides.allowedTools }),
16821726 }),
16831727 mcpServers: mcpOverrides.mcpServers,
1684 mcpEnvironment: mcpOverrides.mcpEnvironment,
16851728 });
16861729}
16871730
......@@ -7488,7 +7531,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
74887531 cwd: turnInput.runtimePolicy.cwd,
74897532 attachmentsDir,
74907533 settings: adapterOptions.settings,
7491 environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment },
7534 environment: adapterOptions.environment,
74927535 tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
74937536 ...(mcpOverrides.allowedTools === undefined
74947537 ? {}