diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts
index 35ffd1a4756..f1f5e77f7d8 100644
--- a/apps/server/src/provider/CodexDeveloperInstructions.ts
+++ b/apps/server/src/provider/CodexDeveloperInstructions.ts
@@ -11,7 +11,7 @@ For browser work, first call \`preview_status\`. If no automation-capable previe
Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
`;
-export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational)
+const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS_BASE = `# Plan Mode (Conversational)
You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
@@ -131,10 +131,9 @@ plan content should be human and agent digestible. The final plan must be plan-o
Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan.
Only produce at most one \`\` block per turn, and only when you are presenting a complete spec.
-${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
`;
-export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default
+const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS_BASE = `# Collaboration Mode: Default
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
@@ -145,14 +144,44 @@ Your active mode changes only when new developer instructions with a different \
The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.
In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
-${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
`;
+function withOptionalT3PreviewInstructions(base: string, includeT3PreviewTools: boolean): string {
+ if (!includeT3PreviewTools) {
+ return base;
+ }
+ // Target the final closing tag only. Default-mode prose mentions
+ // `` inside an inline code span earlier in the body.
+ const closingTag = "";
+ const closingTagIndex = base.lastIndexOf(closingTag);
+ if (closingTagIndex === -1) {
+ return base;
+ }
+ return `${base.slice(0, closingTagIndex)}${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
+${closingTag}`;
+}
+
+/** Plan-mode developer instructions without T3 Preview routing. */
+export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS_BASE;
+
+/** Default-mode developer instructions without T3 Preview routing. */
+export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS =
+ CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS_BASE;
+
export interface CodexRuntimeInfo {
readonly model: string;
readonly reasoningEffort: string;
}
+export interface BuildCodexDeveloperInstructionsOptions {
+ /**
+ * When true, append T3 Preview collaborative-browser routing. Callers must
+ * pass the actual t3-code MCP mount state so instructions cannot advertise
+ * tools the session does not have.
+ */
+ readonly includeT3PreviewTools?: boolean;
+}
+
// Values come from trusted config, but keep the block single-line regardless.
function toSingleLine(value: string): string {
return value.replaceAll(/\s+/g, " ").trim();
@@ -161,12 +190,17 @@ function toSingleLine(value: string): string {
export function buildCodexDeveloperInstructions(
interactionMode: ProviderInteractionMode,
runtime: CodexRuntimeInfo,
+ options?: BuildCodexDeveloperInstructionsOptions,
): string {
const base =
interactionMode === "plan"
- ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
- : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS;
- return `${base}
+ ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS_BASE
+ : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS_BASE;
+ const modeInstructions = withOptionalT3PreviewInstructions(
+ base,
+ options?.includeT3PreviewTools === true,
+ );
+ return `${modeInstructions}
In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.`;
}
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
index 760f0e7fbab..45e04e34dc8 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
@@ -14,6 +14,7 @@ import type {
import {
ApprovalRequestId,
ClaudeSettings,
+ EnvironmentId,
ProviderDriverKind,
ProviderItemId,
ProviderRuntimeEvent,
@@ -34,6 +35,7 @@ import * as TestClock from "effect/testing/TestClock";
import { attachmentRelativePath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
@@ -298,6 +300,64 @@ describe("ClaudeAdapterLive", () => {
);
});
+ it.effect("omits t3-code MCP servers when no provider MCP session exists", () => {
+ const harness = makeHarness();
+ return Effect.gen(function* () {
+ const adapter = yield* ClaudeAdapter;
+ yield* adapter.startSession({
+ threadId: THREAD_ID,
+ provider: ProviderDriverKind.make("claudeAgent"),
+ runtimeMode: "full-access",
+ });
+
+ const createInput = harness.getLastCreateQueryInput();
+ assert.equal(createInput?.options.mcpServers, undefined);
+ }).pipe(
+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
+ Effect.provide(harness.layer),
+ );
+ });
+
+ it.effect("mounts t3-code MCP servers when a provider MCP session exists", () => {
+ const harness = makeHarness();
+ const threadId = ThreadId.make("thread-claude-mcp");
+ McpProviderSession.setMcpProviderSession({
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId,
+ providerSessionId: "provider-session-1",
+ providerInstanceId: ProviderInstanceId.make("claudeAgent"),
+ endpoint: "http://127.0.0.1:43123/mcp",
+ authorizationHeader: "Bearer preview-token",
+ });
+
+ return Effect.gen(function* () {
+ const adapter = yield* ClaudeAdapter;
+ try {
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("claudeAgent"),
+ runtimeMode: "full-access",
+ });
+
+ const createInput = harness.getLastCreateQueryInput();
+ assert.deepEqual(createInput?.options.mcpServers, {
+ "t3-code": {
+ type: "http",
+ url: "http://127.0.0.1:43123/mcp",
+ headers: {
+ Authorization: "Bearer preview-token",
+ },
+ },
+ });
+ } finally {
+ McpProviderSession.clearMcpProviderSession(threadId);
+ }
+ }).pipe(
+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
+ Effect.provide(harness.layer),
+ );
+ });
+
it.effect("retains Claude session startup causes without exposing their messages", () => {
const cause = new Error("credential material that must remain in the cause chain");
const layer = Layer.effect(
diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts
index 7b8fbec5666..972daee07f1 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.test.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts
@@ -6,6 +6,7 @@ import * as NodePath from "node:path";
import {
ApprovalRequestId,
CodexSettings,
+ EnvironmentId,
EventId,
ProviderDriverKind,
ProviderInstanceId,
@@ -35,6 +36,7 @@ import * as Stream from "effect/Stream";
import * as CodexErrors from "effect-codex-app-server/errors";
import { ServerConfig } from "../../config.ts";
+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterValidationError } from "../Errors.ts";
import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
@@ -288,6 +290,63 @@ validationLayer("CodexAdapterLive validation", (it) => {
});
}),
);
+
+ it.effect("omits t3-code MCP config when no provider MCP session exists", () =>
+ Effect.gen(function* () {
+ validationRuntimeFactory.factory.mockClear();
+ const adapter = yield* CodexAdapter;
+ const threadId = asThreadId("thread-no-mcp");
+
+ yield* adapter.startSession({
+ provider: ProviderDriverKind.make("codex"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+
+ const options = validationRuntimeFactory.factory.mock.calls[0]?.[0] as
+ | CodexSessionRuntimeOptions
+ | undefined;
+ NodeAssert.equal(options?.appServerArgs, undefined);
+ NodeAssert.equal(options?.environment, undefined);
+ }),
+ );
+
+ it.effect("mounts t3-code MCP config when a provider MCP session exists", () =>
+ Effect.gen(function* () {
+ validationRuntimeFactory.factory.mockClear();
+ const adapter = yield* CodexAdapter;
+ const threadId = asThreadId("thread-with-mcp");
+ McpProviderSession.setMcpProviderSession({
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId,
+ providerSessionId: "provider-session-1",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ endpoint: "http://127.0.0.1:43123/mcp",
+ authorizationHeader: "Bearer preview-token",
+ });
+
+ try {
+ yield* adapter.startSession({
+ provider: ProviderDriverKind.make("codex"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+
+ const options = validationRuntimeFactory.factory.mock.calls[0]?.[0] as
+ | CodexSessionRuntimeOptions
+ | undefined;
+ NodeAssert.deepStrictEqual(options?.appServerArgs, [
+ "-c",
+ "mcp_servers.t3-code.url=http://127.0.0.1:43123/mcp",
+ "-c",
+ 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
+ ]);
+ NodeAssert.equal(options?.environment?.T3_MCP_BEARER_TOKEN, "preview-token");
+ } finally {
+ McpProviderSession.clearMcpProviderSession(threadId);
+ }
+ }),
+ );
});
const sessionRuntimeFactory = makeRuntimeFactory();
diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
index d7346a0e0db..f96ba1aa833 100644
--- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
+++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
@@ -17,6 +17,7 @@ import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
import {
buildTurnStartParams,
hasConfiguredMcpServer,
+ hasConfiguredT3CodeMcpServer,
isRecoverableThreadResumeError,
openCodexThread,
} from "./CodexSessionRuntime.ts";
@@ -291,30 +292,146 @@ describe("buildCodexDeveloperInstructions", () => {
NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/);
NodeAssert.doesNotMatch(instructions, /[^<]*\n/);
});
+
+ it("omits T3 Preview routing unless the t3-code MCP server is mounted", () => {
+ for (const mode of ["default", "plan"] as const) {
+ const withoutPreview = buildCodexDeveloperInstructions(mode, {
+ model: "gpt-5.3-codex",
+ reasoningEffort: "medium",
+ });
+ const withPreview = buildCodexDeveloperInstructions(
+ mode,
+ {
+ model: "gpt-5.3-codex",
+ reasoningEffort: "medium",
+ },
+ { includeT3PreviewTools: true },
+ );
+
+ NodeAssert.doesNotMatch(withoutPreview, /preview_status/);
+ NodeAssert.doesNotMatch(withoutPreview, /t3-code/);
+ NodeAssert.doesNotMatch(withoutPreview, /## T3 Code collaborative browser/);
+ NodeAssert.match(withPreview, /t3-code/);
+ NodeAssert.match(withPreview, /preview_status/);
+ NodeAssert.match(withPreview, /preview_open/);
+ NodeAssert.match(withPreview, /Do not switch to global browser skills/);
+
+ // Preview instructions must land after the mode body and immediately before
+ // the final collaboration-mode closing tag — not at the first literal match
+ // (Default mode mentions that tag inside an earlier inline code span).
+ const previewHeading = "## T3 Code collaborative browser";
+ const closingTag = "";
+ const previewIndex = withPreview.indexOf(previewHeading);
+ const lastClosingIndex = withPreview.lastIndexOf(closingTag);
+ NodeAssert.ok(previewIndex > 0);
+ NodeAssert.ok(lastClosingIndex > previewIndex);
+ const previewThroughClose = withPreview.slice(
+ previewIndex,
+ lastClosingIndex + closingTag.length,
+ );
+ NodeAssert.ok(previewThroughClose.startsWith(previewHeading));
+ NodeAssert.ok(previewThroughClose.endsWith(closingTag));
+ NodeAssert.equal(
+ previewThroughClose.indexOf(closingTag),
+ previewThroughClose.lastIndexOf(closingTag),
+ );
+ NodeAssert.doesNotMatch(
+ withPreview.slice(lastClosingIndex + closingTag.length),
+ /## T3 Code collaborative browser/,
+ );
+ if (mode === "default") {
+ const inlineReference = "`...`";
+ const inlineIndex = withPreview.indexOf(inlineReference);
+ NodeAssert.ok(inlineIndex !== -1);
+ NodeAssert.ok(inlineIndex < previewIndex);
+ }
+ }
+ });
});
describe("T3 browser developer instructions", () => {
- it("prefers the product-native preview tools in both collaboration modes", () => {
+ it("keeps base collaboration mode instructions free of T3 Preview routing", () => {
for (const instructions of [
CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
]) {
- NodeAssert.match(instructions, /t3-code/);
- NodeAssert.match(instructions, /preview_status/);
- NodeAssert.match(instructions, /preview_open/);
- NodeAssert.match(instructions, /Do not switch to global browser skills/);
+ NodeAssert.doesNotMatch(instructions, /t3-code/);
+ NodeAssert.doesNotMatch(instructions, /preview_status/);
}
});
});
describe("hasConfiguredMcpServer", () => {
- it("detects inline Codex MCP configuration arguments", () => {
+ it("detects any inline Codex MCP configuration arguments", () => {
NodeAssert.equal(hasConfiguredMcpServer(undefined), false);
NodeAssert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false);
NodeAssert.equal(
hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']),
true,
);
+ NodeAssert.equal(
+ hasConfiguredMcpServer(["-c", 'mcp_servers.other.url="http://127.0.0.1/mcp"']),
+ true,
+ );
+ });
+});
+
+describe("hasConfiguredT3CodeMcpServer", () => {
+ it("detects only the product-native t3-code MCP server", () => {
+ NodeAssert.equal(hasConfiguredT3CodeMcpServer(undefined), false);
+ NodeAssert.equal(hasConfiguredT3CodeMcpServer(["--model", "gpt-5.4"]), false);
+ NodeAssert.equal(
+ hasConfiguredT3CodeMcpServer(["-c", 'mcp_servers.other.url="http://127.0.0.1/mcp"']),
+ false,
+ );
+ NodeAssert.equal(
+ hasConfiguredT3CodeMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']),
+ true,
+ );
+ });
+
+ it("honors t3-code MCP entries supplied through launch args after merge", () => {
+ const withT3CodeFromLaunchArgs = codexSessionAppServerArgs(
+ undefined,
+ '-c mcp_servers.t3-code.url="http://127.0.0.1/mcp"',
+ );
+ const withOtherMcpFromLaunchArgs = codexSessionAppServerArgs(
+ undefined,
+ '-c mcp_servers.other.url="http://127.0.0.1/mcp"',
+ );
+
+ NodeAssert.equal(hasConfiguredT3CodeMcpServer(withT3CodeFromLaunchArgs), true);
+ NodeAssert.equal(hasConfiguredT3CodeMcpServer(withOtherMcpFromLaunchArgs), false);
+
+ const previewFromLaunchArgs = Effect.runSync(
+ buildTurnStartParams({
+ threadId: "provider-thread-1",
+ runtimeMode: "full-access",
+ prompt: "Browse",
+ model: "gpt-5.3-codex",
+ interactionMode: "default",
+ includeT3PreviewTools: hasConfiguredT3CodeMcpServer(withT3CodeFromLaunchArgs),
+ }),
+ );
+ const unrelatedFromLaunchArgs = Effect.runSync(
+ buildTurnStartParams({
+ threadId: "provider-thread-1",
+ runtimeMode: "full-access",
+ prompt: "Browse",
+ model: "gpt-5.3-codex",
+ interactionMode: "default",
+ includeT3PreviewTools: hasConfiguredT3CodeMcpServer(withOtherMcpFromLaunchArgs),
+ }),
+ );
+
+ NodeAssert.match(
+ previewFromLaunchArgs.collaborationMode?.settings.developer_instructions ?? "",
+ /## T3 Code collaborative browser/,
+ );
+ NodeAssert.doesNotMatch(
+ unrelatedFromLaunchArgs.collaborationMode?.settings.developer_instructions ?? "",
+ /## T3 Code collaborative browser/,
+ );
});
});
diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts
index 67108dd4dbb..1d54bf9adee 100644
--- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts
+++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts
@@ -64,6 +64,13 @@ export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | un
return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;
}
+/** True only when the product-native `t3-code` MCP server is configured in app-server args. */
+export function hasConfiguredT3CodeMcpServer(
+ appServerArgs: ReadonlyArray | undefined,
+): boolean {
+ return appServerArgs?.some((argument) => argument.includes("mcp_servers.t3-code.")) === true;
+}
+
export const CodexResumeCursorSchema = Schema.Struct({
threadId: Schema.String,
});
@@ -339,6 +346,7 @@ function buildCodexCollaborationMode(input: {
readonly interactionMode?: ProviderInteractionMode;
readonly model?: string;
readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
+ readonly includeT3PreviewTools?: boolean;
}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined {
if (input.interactionMode === undefined) {
return undefined;
@@ -350,10 +358,14 @@ function buildCodexCollaborationMode(input: {
settings: {
model,
reasoning_effort: reasoningEffort,
- developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, {
- model,
- reasoningEffort,
- }),
+ developer_instructions: buildCodexDeveloperInstructions(
+ input.interactionMode,
+ {
+ model,
+ reasoningEffort,
+ },
+ { includeT3PreviewTools: input.includeT3PreviewTools === true },
+ ),
},
};
}
@@ -370,6 +382,7 @@ export function buildTurnStartParams(input: {
readonly serviceTier?: CodexServiceTier;
readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
readonly interactionMode?: ProviderInteractionMode;
+ readonly includeT3PreviewTools?: boolean;
}): Effect.Effect<
CodexTurnStartParamsWithCollaborationMode,
CodexErrors.CodexAppServerProtocolParseError
@@ -390,6 +403,9 @@ export function buildTurnStartParams(input: {
...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
...(input.model ? { model: input.model } : {}),
...(input.effort ? { effort: input.effort } : {}),
+ ...(input.includeT3PreviewTools !== undefined
+ ? { includeT3PreviewTools: input.includeT3PreviewTools }
+ : {}),
});
return decodeCodexTurnStartParamsWithCollaborationMode({
@@ -1280,7 +1296,7 @@ export const makeCodexSessionRuntime = (
sendTurn: (input) =>
Effect.gen(function* () {
const providerThreadId = yield* readProviderThreadId;
- if (hasConfiguredMcpServer(options.appServerArgs)) {
+ if (hasConfiguredMcpServer(appServerArgs)) {
yield* client.request("config/mcpServer/reload", undefined).pipe(
Effect.catch((cause) =>
Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", {
@@ -1301,6 +1317,7 @@ export const makeCodexSessionRuntime = (
...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
...(input.effort ? { effort: input.effort } : {}),
...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
+ includeT3PreviewTools: hasConfiguredT3CodeMcpServer(appServerArgs),
});
const rawResponse = yield* client.raw.request("turn/start", params);
const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts
index ccbbce1759f..8b7b2b293e2 100644
--- a/apps/server/src/provider/Layers/ProviderService.test.ts
+++ b/apps/server/src/provider/Layers/ProviderService.test.ts
@@ -12,6 +12,7 @@ import type {
} from "@t3tools/contracts";
import {
ApprovalRequestId,
+ EnvironmentId,
EventId,
ProviderDriverKind,
ProviderInstanceId,
@@ -58,6 +59,8 @@ import {
import * as ServerSettings from "../../serverSettings.ts";
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
+import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
@@ -1892,3 +1895,189 @@ validation.layer("ProviderServiceLive validation", (it) => {
}),
);
});
+
+it.effect(
+ "ProviderServiceLive does not issue t3-code MCP credentials in provider-native mode",
+ () =>
+ Effect.gen(function* () {
+ const issueSpy = vi
+ .spyOn(McpSessionRegistry, "issueActiveMcpCredential")
+ .mockImplementation(() => Effect.succeed(undefined));
+
+ const codex = makeFakeCodexAdapter();
+ const registry = makeAdapterRegistryMock({
+ [CODEX_DRIVER]: codex.adapter,
+ });
+ const providerAdapterLayer = Layer.succeed(
+ ProviderAdapterRegistry.ProviderAdapterRegistry,
+ registry,
+ );
+ const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
+ Layer.provide(SqlitePersistenceMemory),
+ );
+ const directoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(runtimeRepositoryLayer),
+ );
+ const providerLayer = makeProviderServiceLive().pipe(
+ Layer.provide(providerAdapterLayer),
+ Layer.provide(directoryLayer),
+ Layer.provide(defaultServerSettingsLayer),
+ Layer.provide(AnalyticsService.layerTest),
+ Layer.provide(
+ Layer.succeed(
+ ProviderEventLoggers.ProviderEventLoggers,
+ ProviderEventLoggers.NoOpProviderEventLoggers,
+ ),
+ ),
+ );
+
+ yield* Effect.gen(function* () {
+ const provider = yield* ProviderService.ProviderService;
+ yield* provider.startSession(asThreadId("thread-native-mcp"), {
+ provider: CODEX_DRIVER,
+ providerInstanceId: codexInstanceId,
+ threadId: asThreadId("thread-native-mcp"),
+ runtimeMode: "full-access",
+ });
+ }).pipe(Effect.provide(providerLayer));
+
+ assert.equal(issueSpy.mock.calls.length, 0);
+ issueSpy.mockRestore();
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect(
+ "ProviderServiceLive clears stale MCP state when starting a provider-native session",
+ () =>
+ Effect.gen(function* () {
+ const threadId = asThreadId("thread-stale-mcp");
+ McpProviderSession.setMcpProviderSession({
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId,
+ providerSessionId: "stale-provider-session",
+ providerInstanceId: codexInstanceId,
+ endpoint: "http://127.0.0.1:43123/mcp",
+ authorizationHeader: "Bearer stale-token",
+ });
+ const issueSpy = vi
+ .spyOn(McpSessionRegistry, "issueActiveMcpCredential")
+ .mockImplementation(() => Effect.succeed(undefined));
+ const revokeSpy = vi
+ .spyOn(McpSessionRegistry, "revokeActiveMcpThread")
+ .mockImplementation(() => Effect.void);
+
+ const codex = makeFakeCodexAdapter();
+ const registry = makeAdapterRegistryMock({
+ [CODEX_DRIVER]: codex.adapter,
+ });
+ const providerAdapterLayer = Layer.succeed(
+ ProviderAdapterRegistry.ProviderAdapterRegistry,
+ registry,
+ );
+ const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
+ Layer.provide(SqlitePersistenceMemory),
+ );
+ const directoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(runtimeRepositoryLayer),
+ );
+ const providerLayer = makeProviderServiceLive().pipe(
+ Layer.provide(providerAdapterLayer),
+ Layer.provide(directoryLayer),
+ Layer.provide(defaultServerSettingsLayer),
+ Layer.provide(AnalyticsService.layerTest),
+ Layer.provide(
+ Layer.succeed(
+ ProviderEventLoggers.ProviderEventLoggers,
+ ProviderEventLoggers.NoOpProviderEventLoggers,
+ ),
+ ),
+ );
+
+ try {
+ yield* Effect.gen(function* () {
+ const provider = yield* ProviderService.ProviderService;
+ yield* provider.startSession(threadId, {
+ provider: CODEX_DRIVER,
+ providerInstanceId: codexInstanceId,
+ threadId,
+ runtimeMode: "full-access",
+ });
+ assert.equal(McpProviderSession.readMcpProviderSession(threadId), undefined);
+ }).pipe(Effect.provide(providerLayer));
+
+ assert.equal(issueSpy.mock.calls.length, 0);
+ assert.ok(revokeSpy.mock.calls.some((call) => call[0] === threadId));
+ assert.equal(McpProviderSession.readMcpProviderSession(threadId), undefined);
+ } finally {
+ McpProviderSession.clearMcpProviderSession(threadId);
+ revokeSpy.mockRestore();
+ issueSpy.mockRestore();
+ }
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect(
+ "ProviderServiceLive issues t3-code MCP credentials when T3 Preview mode is selected",
+ () =>
+ Effect.gen(function* () {
+ const issuedConfig = {
+ environmentId: EnvironmentId.make("environment-1"),
+ threadId: asThreadId("thread-preview-mcp"),
+ providerSessionId: "provider-session-1",
+ providerInstanceId: codexInstanceId,
+ endpoint: "http://127.0.0.1:43123/mcp",
+ authorizationHeader: "Bearer test-token",
+ };
+ const issueSpy = vi
+ .spyOn(McpSessionRegistry, "issueActiveMcpCredential")
+ .mockImplementation(() => Effect.succeed({ config: issuedConfig }));
+ const setSessionSpy = vi.spyOn(McpProviderSession, "setMcpProviderSession");
+
+ const codex = makeFakeCodexAdapter();
+ const registry = makeAdapterRegistryMock({
+ [CODEX_DRIVER]: codex.adapter,
+ });
+ const providerAdapterLayer = Layer.succeed(
+ ProviderAdapterRegistry.ProviderAdapterRegistry,
+ registry,
+ );
+ const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
+ Layer.provide(SqlitePersistenceMemory),
+ );
+ const directoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(runtimeRepositoryLayer),
+ );
+ const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest({
+ agentVisualToolsMode: "t3-preview",
+ });
+ const providerLayer = makeProviderServiceLive().pipe(
+ Layer.provide(providerAdapterLayer),
+ Layer.provide(directoryLayer),
+ Layer.provide(serverSettingsLayer),
+ Layer.provide(AnalyticsService.layerTest),
+ Layer.provide(
+ Layer.succeed(
+ ProviderEventLoggers.ProviderEventLoggers,
+ ProviderEventLoggers.NoOpProviderEventLoggers,
+ ),
+ ),
+ );
+
+ yield* Effect.gen(function* () {
+ const provider = yield* ProviderService.ProviderService;
+ yield* provider.startSession(asThreadId("thread-preview-mcp"), {
+ provider: CODEX_DRIVER,
+ providerInstanceId: codexInstanceId,
+ threadId: asThreadId("thread-preview-mcp"),
+ runtimeMode: "full-access",
+ });
+ }).pipe(Effect.provide(providerLayer));
+
+ assert.equal(issueSpy.mock.calls.length, 1);
+ assert.equal(issueSpy.mock.calls[0]?.[0]?.threadId, asThreadId("thread-preview-mcp"));
+ assert.equal(setSessionSpy.mock.calls.length, 1);
+ assert.equal(setSessionSpy.mock.calls[0]?.[0]?.endpoint, issuedConfig.endpoint);
+ setSessionSpy.mockRestore();
+ issueSpy.mockRestore();
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts
index ecf26a914c1..c3674a3f184 100644
--- a/apps/server/src/provider/Layers/ProviderService.ts
+++ b/apps/server/src/provider/Layers/ProviderService.ts
@@ -55,6 +55,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
+import * as ServerSettings from "../../serverSettings.ts";
const isModelSelection = Schema.is(ModelSelection);
/**
@@ -212,20 +213,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
const runtimeEventPubSub = yield* PubSub.unbounded();
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
- const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
- McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
- Effect.tap((credential) =>
- credential
- ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config))
- : Effect.void,
- ),
- );
const clearMcpSession = (threadId: ThreadId) =>
McpSessionRegistry.revokeActiveMcpThread(threadId).pipe(
Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))),
);
+ const prepareMcpSession = (
+ threadId: ThreadId,
+ providerInstanceId: ProviderInstanceId,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ // Fail closed to provider-native if settings cannot be read, so agents never
+ // receive T3 Preview tools by accident. Keeps ServerSettingsError out of the
+ // ProviderService error channel.
+ const settingsResult = yield* Effect.result(serverSettings.getSettings);
+ const agentVisualToolsMode =
+ settingsResult._tag === "Success"
+ ? settingsResult.success.agentVisualToolsMode
+ : ("provider-native" as const);
+ if (agentVisualToolsMode !== "t3-preview") {
+ // Drop any leftover credential/session from a prior T3 Preview run on
+ // this thread so provider-native sessions cannot inherit stale tools.
+ yield* clearMcpSession(threadId);
+ return;
+ }
+ const credential = yield* McpSessionRegistry.issueActiveMcpCredential({
+ threadId,
+ providerInstanceId,
+ });
+ if (credential) {
+ McpProviderSession.setMcpProviderSession(credential.config);
+ }
+ });
const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect =>
Effect.succeed(event).pipe(
diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts
index d0bdb58db2e..d1d0a3c9ed9 100644
--- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts
+++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts
@@ -6,6 +6,7 @@ import {
type ProviderInstanceConfig,
} from "@t3tools/contracts";
import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings";
+import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
import * as Duration from "effect/Duration";
import { describe, expect, it } from "vite-plus/test";
import {
@@ -18,6 +19,23 @@ import {
resolveBackgroundActivityProfileOption,
} from "./SettingsPanels.logic";
+describe("agent visual tools setting defaults", () => {
+ it("defaults to provider-native and stays patchable to T3 Preview", () => {
+ expect(DEFAULT_UNIFIED_SETTINGS.agentVisualToolsMode).toBe("provider-native");
+ expect(DEFAULT_SERVER_SETTINGS.agentVisualToolsMode).toBe("provider-native");
+
+ const preview = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
+ agentVisualToolsMode: "t3-preview",
+ });
+ expect(preview.agentVisualToolsMode).toBe("t3-preview");
+
+ const restored = applyServerSettingsPatch(preview, {
+ agentVisualToolsMode: "provider-native",
+ });
+ expect(restored.agentVisualToolsMode).toBe("provider-native");
+ });
+});
+
describe("background activity settings restore", () => {
it("detects legacy interval values even when the structured setting is at its default", () => {
expect(
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index c08e4126796..daca658d876 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import {
defaultInstanceIdForDriver,
+ type AgentVisualToolsMode,
type BackgroundActivityProfile,
type BackgroundActivitySettings,
type DesktopUpdateChannel,
@@ -190,6 +191,11 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
+const AGENT_VISUAL_TOOLS_MODE_LABELS: Record = {
+ "provider-native": "Provider native",
+ "t3-preview": "T3 Preview",
+};
+
const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = {
balanced: "Balanced",
performance: "Performance",
@@ -632,6 +638,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
? ["Provider update checks"]
: []),
+ ...(settings.agentVisualToolsMode !== DEFAULT_UNIFIED_SETTINGS.agentVisualToolsMode
+ ? ["Agent visual tools"]
+ : []),
...(isBackgroundActivityDirty ? ["Background activity"] : []),
...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
? ["New thread mode"]
@@ -673,6 +682,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.glassOpacity,
settings.enableAssistantStreaming,
settings.enableProviderUpdateChecks,
+ settings.agentVisualToolsMode,
settings.sidebarProjectGroupingMode,
settings.sidebarThreadPreviewCount,
settings.timestampFormat,
@@ -703,6 +713,7 @@ export function useSettingsRestore(onRestored?: () => void) {
autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
+ agentVisualToolsMode: DEFAULT_UNIFIED_SETTINGS.agentVisualToolsMode,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
@@ -1806,6 +1817,47 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({
+ agentVisualToolsMode: DEFAULT_UNIFIED_SETTINGS.agentVisualToolsMode,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index 464f92547e5..4b5138f0554 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -67,6 +67,17 @@ describe("searchSettings", () => {
it("serves anchor props to panels from the catalog", () => {
expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" });
expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" });
+ expect(searchableSetting("agent-visual-tools")).toEqual({
+ id: "agent-visual-tools",
+ title: "Agent visual tools",
+ });
+ });
+
+ it("routes agent visual tools to General settings", () => {
+ expect(searchSettings("agent visual")[0]).toMatchObject({
+ id: "agent-visual-tools",
+ to: "/settings/general",
+ });
});
it("routes appearance settings to their current section", () => {
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 1ba231a5835..ea191d85b2f 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -110,6 +110,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Provider update checks",
to: "/settings/general",
},
+ {
+ id: "agent-visual-tools",
+ title: "Agent visual tools",
+ to: "/settings/general",
+ },
{
id: "auto-open-task-panel",
title: "Auto-open task panel",
diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md
index 79f1211cf40..eaa160497cc 100644
--- a/docs/user/providers-claude.md
+++ b/docs/user/providers-claude.md
@@ -208,3 +208,15 @@ If the preset needs different Claude files, give it a different `CLAUDE_CONFIG_D
different API keys, base URLs, or router settings, use Environment variables.
Do not put environment variable assignments in `Launch arguments`.
+
+## Visual Tools And T3 Preview
+
+In Settings → General → Agent visual tools, choose how newly started Claude sessions should get
+browser/visual tools from T3:
+
+- **Provider native** (default): T3 does not mount the T3 Preview MCP server on the Claude session.
+ Claude can use its own visual tools.
+- **T3 Preview**: newly started sessions receive the T3 Preview MCP tools.
+
+You can still open and use T3 Preview yourself in either mode. Changing the setting does not remove
+tools from a session that is already running — start a new session after you switch.
diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md
index 7c5ea91f043..1dd3f199508 100644
--- a/docs/user/providers-codex.md
+++ b/docs/user/providers-codex.md
@@ -139,3 +139,16 @@ Use a totally separate `CODEX_HOME path` only when you want a separate Codex wor
That means separate sessions and less account switching inside old threads. Most dual-account users
should use the shared-home plus shadow-home setup instead.
+
+## Visual Tools And T3 Preview
+
+In Settings → General → Agent visual tools, choose how newly started Codex sessions should browse
+and inspect pages:
+
+- **Provider native** (default): T3 does not give Codex the T3 Preview MCP tools or collaborative
+ browser instructions. Codex can use its own visual tools.
+- **T3 Preview**: newly started sessions receive the T3 Preview MCP tools and Codex routing
+ instructions that prefer those tools.
+
+You can still open and use T3 Preview yourself in either mode. Changing the setting does not remove
+tools from a session that is already running — start a new session after you switch.
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 5bd22e95f20..53d5e736671 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -187,6 +187,35 @@ describe("ServerSettings worktree defaults", () => {
});
});
+describe("ServerSettings.agentVisualToolsMode", () => {
+ it("defaults to provider-native for legacy configs", () => {
+ expect(decodeServerSettings({}).agentVisualToolsMode).toBe("provider-native");
+ expect(DEFAULT_SERVER_SETTINGS.agentVisualToolsMode).toBe("provider-native");
+ });
+
+ it("accepts both routing modes through the patch schema", () => {
+ expect(
+ decodeServerSettingsPatch({ agentVisualToolsMode: "t3-preview" }).agentVisualToolsMode,
+ ).toBe("t3-preview");
+ expect(
+ decodeServerSettingsPatch({ agentVisualToolsMode: "provider-native" }).agentVisualToolsMode,
+ ).toBe("provider-native");
+ });
+
+ it("rejects unsupported routing modes", () => {
+ expect(() => decodeServerSettings({ agentVisualToolsMode: "both" })).toThrow();
+ expect(() => decodeServerSettingsPatch({ agentVisualToolsMode: "both" })).toThrow();
+ });
+
+ it("round-trips the selected mode through encode/decode", () => {
+ const encoded = encodeServerSettings({
+ ...DEFAULT_SERVER_SETTINGS,
+ agentVisualToolsMode: "t3-preview",
+ });
+ expect(decodeServerSettings(encoded).agentVisualToolsMode).toBe("t3-preview");
+ });
+});
+
describe("ServerSettings.sourceControlWritingStyle", () => {
it("defaults all style settings for legacy configs", () => {
const settings = decodeServerSettings({});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index cbb547b95fb..e97ae044ee4 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -534,9 +534,21 @@ export const BackgroundActivitySettings = Schema.Struct({
}).pipe(Schema.withDecodingDefault(Effect.succeed({})));
export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
+/**
+ * Controls whether newly started provider sessions receive T3 Preview MCP tools
+ * and Codex collaborative-browser routing, or keep provider-native visual tools.
+ * Human-operated T3 Preview is unaffected; this only gates agent access.
+ */
+export const AgentVisualToolsMode = Schema.Literals(["provider-native", "t3-preview"]);
+export type AgentVisualToolsMode = typeof AgentVisualToolsMode.Type;
+export const DEFAULT_AGENT_VISUAL_TOOLS_MODE: AgentVisualToolsMode = "provider-native";
+
export const ServerSettings = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ agentVisualToolsMode: AgentVisualToolsMode.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_AGENT_VISUAL_TOOLS_MODE)),
+ ),
backgroundActivity: BackgroundActivitySettings,
// Legacy flat fields retained for old settings files and old clients. New
// consumers should resolve `backgroundActivity` instead.
@@ -700,6 +712,7 @@ export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
+ agentVisualToolsMode: Schema.optionalKey(AgentVisualToolsMode),
backgroundActivity: Schema.optionalKey(
Schema.Struct({
schemaVersion: Schema.optionalKey(Schema.Literal(1)),