Skip to content
63 changes: 63 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as NetService from "@t3tools/shared/Net";
import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as McpStdioWrapper from "../mcp/McpStdioWrapper.ts";
import { deriveServerPaths } from "../config.ts";
import { resolveServerConfig } from "./config.ts";

Expand All @@ -45,6 +46,7 @@ const makeDesktopBootstrap = (

it.layer(NodeServices.layer)("cli config resolution", (it) => {
const defaultObservabilityConfig = {
mcpStdioWrapper: undefined,
traceMinLevel: "Info",
traceTimingEnabled: true,
traceBatchWindowMs: 1_000,
Expand Down Expand Up @@ -1140,3 +1142,64 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
}),
);
});

it.effect.each([
["relativePath", "private-operator/wrapper"],
["notFound", "/private-operator-missing/wrapper"],
["notExecutable", process.cwd()],
["unmatchedQuote", '/private-operator/wrapper "'],
["emptyCommand", ""],
] as const)("server startup rejects wrapper category %s", ([category, value]) =>
Effect.gen(function* () {
const previous = process.env.T3_MCP_STDIO_WRAPPER;
process.env.T3_MCP_STDIO_WRAPPER = value;
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (previous === undefined) delete process.env.T3_MCP_STDIO_WRAPPER;
else process.env.T3_MCP_STDIO_WRAPPER = previous;
}),
);
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wrapper-startup-" });
const result = yield* Effect.result(
resolveServerConfig(
{
mode: Option.some("desktop" as const),
port: Option.some(43123),
host: Option.none(),
baseDir: Option.some(baseDir),
cwd: Option.some(baseDir),
devUrl: Option.some(new URL("http://127.0.0.1:5173")),
noBrowser: Option.none(),
bootstrapFd: Option.none(),
autoBootstrapProjectFromCwd: Option.none(),
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
},
Option.none(),
),
);
assert.isTrue(result._tag === "Failure");
if (result._tag !== "Failure") return;
assert.instanceOf(result.failure, McpStdioWrapper.McpStdioWrapperConfigError);
const error = result.failure as McpStdioWrapper.McpStdioWrapperConfigError;
assert.equal(error.category, category);
assert.notInclude(error.message, "private-operator");
assert.notInclude(error.message, process.cwd());
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
NetService.layer,
ConfigProvider.layer(
ConfigProvider.fromEnv({
preserveEmptyStrings: true,
env: { T3_MCP_STDIO_WRAPPER: value },
}),
),
),
),
),
);
3 changes: 3 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as SchemaIssue from "effect/SchemaIssue";
import * as SchemaTransformation from "effect/SchemaTransformation";
import { Argument, Flag } from "effect/unstable/cli";

import * as McpStdioWrapper from "../mcp/McpStdioWrapper.ts";
import { readBootstrapEnvelope } from "../bootstrap.ts";
import * as ServerConfig from "../config.ts";
import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
Expand Down Expand Up @@ -261,6 +262,7 @@ export const resolveServerConfig = (
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const env = yield* EnvServerConfig;
const mcpStdioWrapper = yield* McpStdioWrapper.loadMcpStdioWrapper();
const normalizedFlags = {
mode: flags.mode ?? Option.none(),
port: flags.port ?? Option.none(),
Expand Down Expand Up @@ -424,6 +426,7 @@ export const resolveServerConfig = (
);

const config: ServerConfig.ServerConfig["Service"] = {
mcpStdioWrapper,
logLevel,
traceMinLevel: env.traceMinLevel,
traceTimingEnabled: env.traceTimingEnabled,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Path from "effect/Path";
import type * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";

import type { McpStdioWrapperCommand } from "./mcp/McpStdioWrapper.ts";
import { sweepStalePendingAttachments } from "./attachmentStore.ts";
import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface DeriveServerPathsOptions {
export class ServerConfig extends Context.Service<
ServerConfig,
ServerDerivedPaths & {
readonly mcpStdioWrapper?: McpStdioWrapperCommand | undefined;
readonly logLevel: LogLevel.LogLevel;
readonly traceMinLevel: LogLevel.LogLevel;
readonly traceTimingEnabled: boolean;
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/mcp/McpProviderSession.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";

import type { McpStdioWrapperCommand } from "./McpStdioWrapper.ts";
export interface McpProviderSessionConfig {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly providerSessionId: string;
readonly providerInstanceId: ProviderInstanceId;
/** Validated startup snapshot; absent preserves the direct HTTP transport. */
readonly stdioWrapper?: McpStdioWrapperCommand | undefined;
readonly endpoint: string;
readonly authorizationHeader: string;
/**
Expand Down
42 changes: 32 additions & 10 deletions apps/server/src/mcp/McpSessionRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
import { HttpServer } from "effect/unstable/http";
import * as NetAddress from "effect/unstable/net/NetAddress";

import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as McpSessionRegistry from "./McpSessionRegistry.ts";

Expand All @@ -20,17 +21,26 @@ const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({
getDescriptor: Effect.die("unused"),
});

const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
McpSessionRegistry.__testing
.make({
now,
livenessWindowMs: 100,
})
.pipe(
Effect.provideService(HttpServer.HttpServer, httpServer),
Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment),
Effect.provide(NodeServices.layer),
const makeRegistry = (
now: () => number,
httpServer = fakeHttpServer,
stdioWrapper?: { command: string; args: ReadonlyArray<string> },
) =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
return yield* McpSessionRegistry.__testing.make({ now, livenessWindowMs: 100 }).pipe(
Effect.provideService(ServerConfig.ServerConfig, {
...config,
mcpStdioWrapper: stdioWrapper,
}),
);
}).pipe(
Effect.provideService(HttpServer.HttpServer, httpServer),
Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment),
Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-registry-" })),
Effect.scoped,
Effect.provide(NodeServices.layer),
);

it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -180,3 +190,15 @@ it.effect("does not keep credentials of other threads alive", () =>
expect(yield* registry.resolve(token)).toBeUndefined();
}),
);

it.effect("carries the validated startup wrapper into issued session configs", () =>
Effect.gen(function* () {
const stdioWrapper = { command: "/validated/startup-wrapper", args: ["--fixed"] };
const registry = yield* makeRegistry(() => 1_000, fakeHttpServer, stdioWrapper);
const issued = yield* registry.issue({
threadId: ThreadId.make("wrapper-snapshot"),
providerInstanceId: ProviderInstanceId.make("pi"),
});
expect(issued.config.stdioWrapper).toEqual(stdioWrapper);
}),
);
3 changes: 3 additions & 0 deletions apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
import { HttpServer } from "effect/unstable/http";
import * as NetAddress from "effect/unstable/net/NetAddress";

import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpProviderSession from "./McpProviderSession.ts";
Expand Down Expand Up @@ -95,6 +96,7 @@ const getHttpMcpEndpointHost = (address: NetAddress.IpAddress): string =>
const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
options: McpSessionRegistryOptions = {},
) {
const serverConfig = yield* ServerConfig.ServerConfig;
const crypto = yield* Crypto.Crypto;
const environment = yield* ServerEnvironment.ServerEnvironment;
const environmentId = yield* environment.getEnvironmentId;
Expand Down Expand Up @@ -151,6 +153,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
threadId: scope.threadId,
providerSessionId,
providerInstanceId: scope.providerInstanceId,
stdioWrapper: serverConfig.mcpStdioWrapper,
endpoint,
authorizationHeader: `Bearer ${rawToken}`,
browserToolsAvailable: scope.capabilities.has("preview"),
Expand Down
168 changes: 168 additions & 0 deletions apps/server/src/mcp/McpStdioWrapper.adapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import { assert, describe, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";

import { acpMcpActivation, acpMcpServers } from "../orchestration-v2/Adapters/AcpAdapterV2.ts";
import {
CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
claudeMcpQueryOverrides,
} from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
import { codexThreadRuntimeParams } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
import { cursorMcpServers } from "../orchestration-v2/Adapters/CursorAdapterV2.ts";
import { clearMcpProviderSession, setMcpProviderSession } from "./McpProviderSession.ts";
import {
openCodeT3McpConfig,
T3_MCP_AUTHORIZATION_ENV,
parseMcpStdioWrapperCommand,
T3_MCP_URL_ENV,
} from "./McpStdioWrapper.ts";

const token = "Bearer adapter-wrapper-token";
const endpoint = "http://127.0.0.1:43123/mcp";

let sessionCounter = 0;

function withSession(wrapper: string | undefined, run: (threadId: ThreadId) => void): void {
sessionCounter += 1;
const threadId = ThreadId.make(`thread-wrapper-${sessionCounter}`);
setMcpProviderSession({
environmentId: EnvironmentId.make("environment-wrapper"),
threadId,
providerSessionId: "mcp-session-wrapper",
providerInstanceId: ProviderInstanceId.make("codex"),
endpoint,
stdioWrapper: wrapper === undefined ? undefined : parseMcpStdioWrapperCommand(wrapper),
authorizationHeader: token,
browserToolsAvailable: true,
});
try {
run(threadId);
} finally {
clearMcpProviderSession(threadId);
}
}

describe("t3-code MCP adapters honor T3_MCP_STDIO_WRAPPER", () => {
const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-mcp-adapter-"));
const binary = NodePath.join(directory, "wrapper");
NodeFS.writeFileSync(binary, "", { mode: 0o700 });
const session = { endpoint, authorizationHeader: token };

it("keeps Claude, Codex, Cursor, OpenCode, and ACP on HTTP when unset", () => {
withSession(undefined, (threadId) => {
const claude = claudeMcpQueryOverrides({ threadId, readOnlySandbox: false });
assert.isUndefined(claude.mcpEnvironment);
assert.deepEqual(claude.mcpServers, {
"t3-code": {
type: "http",
url: endpoint,
headers: { Authorization: token },
timeout: CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
},
});
const codex = codexThreadRuntimeParams({ threadId });
assert.deepEqual(codex.config.mcp_servers, {
"t3-code": { url: endpoint, http_headers: { Authorization: token } },
});
assert.deepEqual(cursorMcpServers(threadId), {
"t3-code": { type: "http", url: endpoint, headers: { Authorization: token } },
});
assert.deepEqual(openCodeT3McpConfig(session), {
type: "remote",
url: endpoint,
headers: { Authorization: token },
oauth: false,
});
assert.deepEqual(
acpMcpServers(threadId, { command: "/usr/bin/t3", entrypoint: undefined }) as unknown,
[
{
name: "t3-code",
command: "/usr/bin/t3",
args: ["acp-mcp-bridge"],
env: [
{ name: "ELECTRON_RUN_AS_NODE", value: "1" },
{ name: "T3_ACP_MCP_ENDPOINT", value: endpoint },
{ name: "T3_ACP_MCP_AUTHORIZATION", value: token },
],
},
],
);
});
});

it("puts the credential in the wrapper environment for every stdio-capable adapter", () => {
withSession(`${binary} --fixed`, (threadId) => {
const expectedEnv = {
[T3_MCP_URL_ENV]: endpoint,
[T3_MCP_AUTHORIZATION_ENV]: token,
};
const claude = claudeMcpQueryOverrides({ threadId, readOnlySandbox: false });
assert.deepEqual(claude.mcpServers?.["t3-code"], {
type: "stdio",
command: binary,
args: ["--fixed"],
env: {
[T3_MCP_URL_ENV]: `\${${T3_MCP_URL_ENV}}`,
[T3_MCP_AUTHORIZATION_ENV]: `\${${T3_MCP_AUTHORIZATION_ENV}}`,
},
timeout: CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
});
// The SDK serializes mcpServers onto the CLI command line, so the
// credential must only reach the CLI through its environment.
assert.isFalse(JSON.stringify(claude.mcpServers).includes(token));
assert.isFalse(JSON.stringify(claude.mcpServers).includes(endpoint));
assert.deepEqual(claude.mcpEnvironment, expectedEnv);
const codexServer = (
codexThreadRuntimeParams({ threadId }).config.mcp_servers as {
readonly "t3-code": unknown;
}
)["t3-code"];
assert.deepEqual(codexServer, {
command: binary,
args: ["--fixed"],
env: expectedEnv,
});
assert.deepEqual(cursorMcpServers(threadId), {
"t3-code": { type: "stdio", command: binary, args: ["--fixed"], env: expectedEnv },
});
const openCode = openCodeT3McpConfig({
...session,
stdioWrapper: { command: binary, args: ["--fixed"] },
});
if (openCode.type !== "local") {
assert.fail("OpenCode must launch the wrapper as a local MCP server");
}
assert.deepEqual(openCode, {
type: "local",
command: [binary, "--fixed"],
environment: expectedEnv,
});
// An ACP-native descriptor would win over the wrapper for agents that
// advertise ACP MCP, leaving them with no t3-code tools.
assert.deepEqual(
acpMcpActivation(threadId, { command: "/usr/bin/t3", entrypoint: "/usr/bin/t3" })
.acpMcpServers,
[],
);
const acp = acpMcpServers(threadId, { command: "/usr/bin/t3", entrypoint: "/usr/bin/t3" });
assert.deepEqual(acp as unknown, [
{
name: "t3-code",
command: binary,
args: ["--fixed"],
env: [
{ name: T3_MCP_URL_ENV, value: endpoint },
{ name: T3_MCP_AUTHORIZATION_ENV, value: token },
],
},
]);
assert.notInclude(JSON.stringify(openCode.command), "adapter-wrapper-token");
assert.notInclude(JSON.stringify(openCode.command), endpoint);
});
});
});
Loading
Loading