From 98a909fffc065db2cd34d18f269d69bd5ca28ffe Mon Sep 17 00:00:00 2001 From: Samer Khatib Date: Mon, 2 Nov 2026 12:58:59 -0500 Subject: [PATCH 1/7] feat(settings): manage T3 native tools --- apps/mobile/src/Stack.tsx | 6 ++ .../SettingsNativeToolsRouteScreen.tsx | 100 ++++++++++++++++++ .../features/settings/SettingsRouteScreen.tsx | 1 + .../components/settings-page-targets.ts | 1 + .../src/features/settings/settings-search.ts | 1 + apps/server/src/mcp/McpHttpServer.test.ts | 8 ++ apps/server/src/mcp/McpHttpServer.ts | 39 +++++-- apps/server/src/mcp/McpInvocationContext.ts | 8 ++ apps/server/src/mcp/nativeToolCatalog.ts | 36 +++++++ .../src/provider/AgentContextBudget.test.ts | 4 + apps/server/src/serverSettings.test.ts | 20 +++- apps/server/src/ws.ts | 2 + .../settings/NativeToolsSettings.tsx | 75 +++++++++++++ .../settings/SettingsSidebarNav.tsx | 1 + .../src/components/settings/settingsSearch.ts | 11 ++ apps/web/src/routeTree.gen.ts | 21 ++++ apps/web/src/routes/_chat.settings.tools.tsx | 4 + apps/web/src/routes/_chat.settings.tsx | 6 +- docs/user/outside-agents.md | 12 +++ packages/contracts/src/server.ts | 8 ++ packages/contracts/src/settings.ts | 4 + 21 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx create mode 100644 apps/server/src/mcp/nativeToolCatalog.ts create mode 100644 apps/web/src/components/settings/NativeToolsSettings.tsx create mode 100644 apps/web/src/routes/_chat.settings.tools.tsx diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 872ca142c8aaa..170fb3560c946 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -95,6 +95,7 @@ import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavig import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; +import { SettingsNativeToolsRouteScreen } from "./features/settings/SettingsNativeToolsRouteScreen"; import { SettingsProviderAccountsRouteScreen } from "./features/settings/SettingsProviderAccountsRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen"; @@ -282,6 +283,11 @@ const SETTINGS_SCREENS = { linking: "settings/labs", options: { ...SETTINGS_PAGE_OPTIONS, title: "Labs" }, }), + SettingsNativeTools: createNativeStackScreen({ + screen: SettingsNativeToolsRouteScreen, + linking: "settings/tools", + options: { ...SETTINGS_PAGE_OPTIONS, title: "T3 tools" }, + }), SettingsProviderAccounts: createNativeStackScreen({ screen: SettingsProviderAccountsRouteScreen, linking: "settings/provider-accounts", diff --git a/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx new file mode 100644 index 0000000000000..a632351b61005 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx @@ -0,0 +1,100 @@ +import { AuthSettingsWriteScope, isManagedSettingPath } from "@t3tools/contracts"; +import { useState } from "react"; +import { TextInput, View } from "react-native"; +import { AppText as Text } from "../../components/ui/AppText"; +import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentsWithScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + AndroidSettingsEnvironmentFilter, + SettingsEnvironmentFilterHeader, +} from "./components/SettingsEnvironmentFilterHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; + +export function SettingsNativeToolsRouteScreen() { + const { selectedTargets } = useSettingsEnvironmentFilter(); + const writable = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); + const update = useAtomCommand(serverEnvironment.updateSettings, { + label: "T3 tool settings update", + reportFailure: true, + }); + const [search, setSearch] = useState(""); + const query = search.trim().toLowerCase(); + return ( + <> + + }> + + + Choose which built-in T3 tools agents can use across all providers. Disabled tools block + new calls immediately. Reconnect existing agent sessions to refresh their tool lists. + Calls already running are not cancelled. + + + {selectedTargets.length === 0 ? ( + Select a connected environment. + ) : ( + selectedTargets.map((environment) => { + const tools = environment.serverConfig.nativeTools; + const visible = tools?.filter((tool) => + `${tool.name} ${tool.description}`.toLowerCase().includes(query), + ); + return ( + + {tools === undefined ? ( + + Update this environment to manage T3 tools. + + ) : visible?.length === 0 ? ( + No matching tools. + ) : ( + visible?.map((tool) => ( + + { + void update({ + environmentId: environment.environmentId, + input: { patch: { nativeToolEnabled: { [tool.name]: enabled } } }, + }); + }} + /> + + )) + )} + + ); + }) + )} + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 34de55fd44082..d53bc157e891f 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -256,6 +256,7 @@ function useSettingsIndexSections(): SettingsListSection[] { title: "Agents", rows: [ serverPage("SettingsProviderAccounts", "person.crop.circle", "Providers"), + serverPage("SettingsNativeTools", "gearshape", "T3 tools"), serverPage("SettingsThreads", "text.bubble", "Threads"), page("SettingsScheduledTasks", "clock", "Scheduled tasks"), ], diff --git a/apps/mobile/src/features/settings/components/settings-page-targets.ts b/apps/mobile/src/features/settings/components/settings-page-targets.ts index 6a98023f2ed7c..091166278723b 100644 --- a/apps/mobile/src/features/settings/components/settings-page-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-page-targets.ts @@ -14,6 +14,7 @@ export const SETTINGS_PAGE_TARGETS = [ "SettingsLabs", "SettingsEnvironmentMaintenance", "SettingsProviderAccounts", + "SettingsNativeTools", "SettingsKeyboard", "SettingsMicrophone", "SettingsFollowUp", diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index c11a89a7c3a10..232b7d1b05e76 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -58,6 +58,7 @@ const SETTINGS_SEARCH_TERMS = { ], SettingsFollowUp: ["follow-ups", "queue", "steer", "while the agent is running"], SettingsArchive: ["archive", "archived", "restore", "unarchive"], + SettingsNativeTools: ["native tools", "MCP", "enable", "disable", "t3_thread_send"], SettingsProviderAccounts: [ "provider accounts", "codex", diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index f380da4b9675e..43d99613ba8e4 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1167,6 +1167,14 @@ it.effect("hides tools for links and refuses another environment while the beta }); expect((yield* ranIn(environmentId)).structuredContent).toEqual({ ranIn: "here" }); + yield* settings.updateSettings({ nativeToolEnabled: { where: false, links: true } }); + expect(yield* listed()).toEqual([]); + const denied = yield* rpc("tools/call", { name: "where", arguments: { environmentId } }); + expect(denied).toMatchObject({ error: { code: -32602 } }); + yield* settings.updateSettings({ nativeToolEnabled: { where: true } }); + expect(yield* listed()).toEqual(["where"]); + expect((yield* ranIn(environmentId)).structuredContent).toEqual({ ranIn: "here" }); + yield* settings.updateSettings({ linkedEnvironmentsEnabled: true }); expect(yield* listed()).toEqual(["links", "where"]); // On, the call goes on to the forwarding, which this server lacks. diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 18f21abf5794e..c4b544c86fb47 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -198,10 +198,6 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { const clients = yield* Effect.serviceOption(McpClientAuthenticator); const serverSettings = yield* ServerSettings.ServerSettingsService; const runtimeModeCeiling = serverSettings.managedPolicy?.limits?.maxRuntimeMode; - const linksEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.linkedEnvironmentsEnabled), - Effect.orElseSucceed(() => false), - ); return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { const request = yield* HttpServerRequest.HttpServerRequest; const authorization = request.headers.authorization; @@ -231,12 +227,14 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { const asked = withModeLimit(invocation, request); if (asked === undefined) return invalidModeLimit; const limited = McpInvocationContext.withRuntimeModeCeiling(asked, runtimeModeCeiling); - const linkedEnvironments = yield* linksEnabled; + const settings = yield* serverSettings.getSettings.pipe(Effect.orDie); + const linkedEnvironments = settings.linkedEnvironmentsEnabled; if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff; return yield* httpEffect.pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, { ...limited, linkedEnvironments, + nativeToolEnabled: settings.nativeToolEnabled, }), Effect.map(normalizeMcpHttpResponse), ); @@ -1017,6 +1015,29 @@ const withToolAnalytics = return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer)); }); +const withToolAvailability = Effect.fn("McpHttpServer.withToolAvailability")(function* ( + registration: Effect.Effect, +) { + const server = yield* McpServer.McpServer; + const filteredServer = McpServer.McpServer.of({ + ...server, + addTool: Effect.fn("McpHttpServer.addAvailableTool")((options) => { + const enabledWhen = Context.getOrUndefined(options.annotations, McpSchema.EnabledWhen); + return server.addTool({ + ...options, + annotations: Context.add( + options.annotations, + McpSchema.EnabledWhen, + (client) => + McpInvocationContext.isToolEnabled(options.tool.name) && + (enabledWhen === undefined || enabledWhen(client)), + ), + }); + }), + }); + return yield* registration.pipe(Effect.provideService(McpServer.McpServer, filteredServer)); +}); + /** `McpServer.registerToolkit` advertises the full codec schema; agents get the compact one. */ const withAdvertisedSchemas = Effect.fn("McpHttpServer.withAdvertisedSchemas")(function* < Tools extends Record, @@ -1057,8 +1078,10 @@ export const toolkitRegistration = , EX, handlers: McpToolAccess.HandlersLayer, ) => { const registration = Layer.effectDiscard( - withToolAnalytics( - withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + withToolAvailability( + withToolAnalytics( + withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + ), ), ).pipe(Layer.provide(McpServer.McpServer.layer)); // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off - the auth middleware provides it per request. @@ -1074,7 +1097,7 @@ const imageToolRegistration = , A, E, R, register: Effect.Effect, handlers: McpToolAccess.HandlersLayer, ) => - Layer.effectDiscard(withToolAnalytics(register)).pipe( + Layer.effectDiscard(withToolAvailability(withToolAnalytics(register))).pipe( Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)), ); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 181ac3a9cfb2c..d3ee248c2983e 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -131,6 +131,7 @@ export interface McpInvocationScope { readonly client: McpClientCaller | undefined; /** False while the Linked environments beta is off here; `/mcp` reads it per request. */ readonly linkedEnvironments?: boolean; + readonly nativeToolEnabled?: Readonly>; } /** @@ -169,6 +170,13 @@ export const hiddenWhileLinksOff = (): boolean => { return scope?.linkedEnvironments !== false; }; +/** MCP visibility predicates run synchronously in the authenticated request fiber. */ +export const isToolEnabled = (name: string): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.nativeToolEnabled?.[name] !== false; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/nativeToolCatalog.ts b/apps/server/src/mcp/nativeToolCatalog.ts new file mode 100644 index 0000000000000..cb3c0e7ebdf42 --- /dev/null +++ b/apps/server/src/mcp/nativeToolCatalog.ts @@ -0,0 +1,36 @@ +import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; +import { DeviceStandardToolkit, DeviceScreenshotToolkit } from "./toolkits/device/tools.ts"; +import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; +import { HtmlRenderToolkit, HtmlPreviewToolkit } from "./toolkits/html/tools.ts"; +import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; +import { PreviewStandardToolkit, PreviewSnapshotToolkit } from "./toolkits/preview/tools.ts"; +import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; +import { ProjectToolkit } from "./toolkits/project/tools.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { ThreadToolkit } from "./toolkits/thread/tools.ts"; +import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; + +export const nativeToolCatalog = [ + ...Object.values(AttachmentToolkit.tools), + ...Object.values(DeviceStandardToolkit.tools), + ...Object.values(DeviceScreenshotToolkit.tools), + ...Object.values(EnvironmentToolkit.tools), + ...Object.values(HtmlRenderToolkit.tools), + ...Object.values(HtmlPreviewToolkit.tools), + ...Object.values(OrchestratorToolkit.tools), + ...Object.values(PreviewStandardToolkit.tools), + ...Object.values(PreviewSnapshotToolkit.tools), + ...Object.values(PreviewControlsToolkit.tools), + ...Object.values(ProjectToolkit.tools), + ...Object.values(PullRequestsToolkit.tools), + ...Object.values(ThreadToolkit.tools), + ...Object.values(WorktreeToolkit.tools), +] + .map((tool) => { + const summary = (tool.description ?? "").split(/(?<=\.)\s|\n/, 1)[0] ?? ""; + return { + name: tool.name, + description: summary.length > 300 ? `${summary.slice(0, 299)}…` : summary, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 885053c7d0f24..6c0a070085cf3 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -1,3 +1,4 @@ +import { nativeToolCatalog } from "../mcp/nativeToolCatalog.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -110,6 +111,9 @@ const codexAdditionalContext = () => it.effect("agent context matches the committed budget", () => Effect.gen(function* () { const mcp = yield* McpServer.McpServer; + expect(nativeToolCatalog.map((tool) => tool.name).toSorted()).toEqual( + mcp.tools.map(({ tool }) => tool.name).toSorted(), + ); const measured = { claudeSystemAppendBytes: utf8Bytes(claudeSystemAppend()), codexAdditionalContextBytes: utf8Bytes(codexAdditionalContext()), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 281bab5e4227b..16bac696f3704 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -134,7 +134,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(settings.providerModelPreferences?.[provider]?.modelOrder, ["luna", "sol"]); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("persists and broadcasts model preferences to other connected clients", () => + it.effect("persists and broadcasts settings to other connected clients", () => Effect.scoped( Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -142,6 +142,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fs = yield* FileSystem.FileSystem; const provider = ProviderInstanceId.make("codex_work"); const patch = { + nativeToolEnabled: { t3_thread_send: false }, favorites: [{ provider, model: "sol" }], providerModelPreferences: { [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] }, @@ -150,11 +151,28 @@ it.layer(NodeServices.layer)("server settings", (it) => { const changes = yield* service.subscribeChanges; yield* service.updateSettings(patch); const received = Option.getOrThrow(yield* Stream.runHead(changes)); + assert.deepEqual(received.nativeToolEnabled, patch.nativeToolEnabled); assert.deepEqual(received.favorites, patch.favorites); assert.deepEqual(received.providerModelPreferences, patch.providerModelPreferences); const saved = yield* decodeServerSettingsJson( yield* fs.readFileString(config.settingsPath), ); + assert.deepEqual(saved.nativeToolEnabled, patch.nativeToolEnabled); + yield* service.updateSettings({ + nativeToolEnabled: { t3_thread_send_attachments: false }, + }); + const reenabled = yield* service.updateSettings({ + nativeToolEnabled: { t3_thread_send: true }, + }); + assert.deepEqual(reenabled.nativeToolEnabled, { + t3_thread_send: true, + t3_thread_send_attachments: false, + }); + assert.deepEqual( + (yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath))) + .nativeToolEnabled, + reenabled.nativeToolEnabled, + ); assert.deepEqual(saved.favorites, patch.favorites); assert.deepEqual(saved.providerModelPreferences, patch.providerModelPreferences); const hidden = yield* service.updateSettings({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 00842afde41b7..74e3467760a75 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -116,6 +116,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; +import { nativeToolCatalog } from "./mcp/nativeToolCatalog.ts"; import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; @@ -1851,6 +1852,7 @@ export const layerWsRpc = ( otlpLogsEnabled: config.otlpLogsUrl !== undefined, }, settings, + nativeTools: nativeToolCatalog, ...(serverSettings.managedPolicy === undefined ? {} : { managedPolicy: serverSettings.managedPolicy }), diff --git a/apps/web/src/components/settings/NativeToolsSettings.tsx b/apps/web/src/components/settings/NativeToolsSettings.tsx new file mode 100644 index 0000000000000..05dc155052182 --- /dev/null +++ b/apps/web/src/components/settings/NativeToolsSettings.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { + useScopedSettings, + useScopedSettingsWriteAllowed, + useUpdateScopedSettings, +} from "./useScopedSettings"; +import { SettingsPageContainer, SettingsSection, SettingsRow } from "./settingsLayout"; + +export function NativeToolsSettings() { + const { environment } = useSettingsScope(); + const enabled = useScopedSettings((settings) => settings.nativeToolEnabled); + const update = useUpdateScopedSettings(); + const canWrite = useScopedSettingsWriteAllowed(); + const [search, setSearch] = useState(""); + const tools = environment?.serverConfig?.nativeTools; + const query = search.trim().toLowerCase(); + const visible = tools?.filter((tool) => + `${tool.name} ${tool.description}`.toLowerCase().includes(query), + ); + return ( + + +
+

+ Choose which built-in T3 tools agents can use on this environment, across all providers. + Disabling a tool blocks new calls immediately. Reconnect existing agent sessions to + refresh their tool lists. Calls already running are not cancelled. +

+ setSearch(event.target.value)} + /> + {tools ? ( +

+ {tools.filter((tool) => enabled[tool.name] !== false).length} of {tools.length} tools + enabled +

+ ) : null} +
+ {tools === undefined ? ( +

+ Connect to an environment with T3 tool settings support. +

+ ) : visible?.length === 0 ? ( +

No matching tools.

+ ) : ( + visible?.map((tool) => ( + + update({ nativeToolEnabled: { [tool.name]: checked } }) + } + /> + } + /> + )) + )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index a9c8b01f077e1..1770d19a4fd06 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -95,6 +95,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/keybindings": KeyboardIcon, "/settings/snap-shot": SnapShotIcon, "/settings/providers": BotIcon, + "/settings/tools": BotIcon, "/settings/threads": MessagesSquareIcon, "/settings/browser": GlobeIcon, "/settings/devices": SmartphoneIcon, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index d28e467b0cf70..44fdf8585ec66 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -24,6 +24,7 @@ export type SettingsPath = | "/settings/diffs" | "/settings/keybindings" | "/settings/snap-shot" + | "/settings/tools" | "/settings/providers" | "/settings/threads" | "/settings/browser" @@ -94,6 +95,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/diffs": "Diffs & pull requests", "/settings/keybindings": "Keybindings", "/settings/snap-shot": "SnapShots", + "/settings/tools": "T3 tools", "/settings/providers": "Providers", "/settings/threads": "Threads", "/settings/browser": "Browser", @@ -130,6 +132,7 @@ export const SETTINGS_NAV_GROUPS: ReadonlyArray<{ heading: "Agents", paths: [ "/settings/providers", + "/settings/tools", "/settings/threads", "/settings/browser", "/settings/devices", @@ -216,6 +219,13 @@ const SOURCE_CONTROL_HOST_TERMS = sourceControlClients.definitions .join(" "); export const SETTINGS_SEARCH_ITEMS = [ + { + id: "native-tools", + title: "T3 tools", + to: "/settings/tools", + scope: "environment", + searchTerms: ["native tools MCP enable disable t3_thread_send providers permissions"], + }, { id: "storage-worktree-keep-when", targetId: "storage-worktrees", @@ -1199,6 +1209,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly ChatSettingsRoute, } as any) +const ChatSettingsToolsRoute = ChatSettingsToolsRouteImport.update({ + id: '/tools', + path: '/tools', + getParentRoute: () => ChatSettingsRoute, +} as any) const ChatSettingsThreadsRoute = ChatSettingsThreadsRouteImport.update({ id: '/threads', path: '/threads', @@ -294,6 +300,7 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute '/settings/threads': typeof ChatSettingsThreadsRoute + '/settings/tools': typeof ChatSettingsToolsRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute } export interface FileRoutesByTo { @@ -335,6 +342,7 @@ export interface FileRoutesByTo { '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute '/settings/threads': typeof ChatSettingsThreadsRoute + '/settings/tools': typeof ChatSettingsToolsRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute } export interface FileRoutesById { @@ -378,6 +386,7 @@ export interface FileRoutesById { '/_chat/settings/source-control': typeof ChatSettingsSourceControlRoute '/_chat/settings/storage': typeof ChatSettingsStorageRoute '/_chat/settings/threads': typeof ChatSettingsThreadsRoute + '/_chat/settings/tools': typeof ChatSettingsToolsRoute '/_chat/settings/worktrees': typeof ChatSettingsWorktreesRoute } export interface FileRouteTypes { @@ -421,6 +430,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/settings/storage' | '/settings/threads' + | '/settings/tools' | '/settings/worktrees' fileRoutesByTo: FileRoutesByTo to: @@ -462,6 +472,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/settings/storage' | '/settings/threads' + | '/settings/tools' | '/settings/worktrees' id: | '__root__' @@ -504,6 +515,7 @@ export interface FileRouteTypes { | '/_chat/settings/source-control' | '/_chat/settings/storage' | '/_chat/settings/threads' + | '/_chat/settings/tools' | '/_chat/settings/worktrees' fileRoutesById: FileRoutesById } @@ -610,6 +622,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsWorktreesRouteImport parentRoute: typeof ChatSettingsRoute } + '/_chat/settings/tools': { + id: '/_chat/settings/tools' + path: '/tools' + fullPath: '/settings/tools' + preLoaderRoute: typeof ChatSettingsToolsRouteImport + parentRoute: typeof ChatSettingsRoute + } '/_chat/settings/threads': { id: '/_chat/settings/threads' path: '/threads' @@ -828,6 +847,7 @@ interface ChatSettingsRouteChildren { ChatSettingsSourceControlRoute: typeof ChatSettingsSourceControlRoute ChatSettingsStorageRoute: typeof ChatSettingsStorageRoute ChatSettingsThreadsRoute: typeof ChatSettingsThreadsRoute + ChatSettingsToolsRoute: typeof ChatSettingsToolsRoute ChatSettingsWorktreesRoute: typeof ChatSettingsWorktreesRoute } @@ -857,6 +877,7 @@ const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { ChatSettingsSourceControlRoute: ChatSettingsSourceControlRoute, ChatSettingsStorageRoute: ChatSettingsStorageRoute, ChatSettingsThreadsRoute: ChatSettingsThreadsRoute, + ChatSettingsToolsRoute: ChatSettingsToolsRoute, ChatSettingsWorktreesRoute: ChatSettingsWorktreesRoute, } diff --git a/apps/web/src/routes/_chat.settings.tools.tsx b/apps/web/src/routes/_chat.settings.tools.tsx new file mode 100644 index 0000000000000..fa23b19dc5dcf --- /dev/null +++ b/apps/web/src/routes/_chat.settings.tools.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { NativeToolsSettings } from "../components/settings/NativeToolsSettings"; + +export const Route = createFileRoute("/_chat/settings/tools")({ component: NativeToolsSettings }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 7758c9ca55919..9b3be6dd70555 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -150,7 +150,11 @@ function SettingsRouteLayout() { return ( { // Send every axis so retain middleware recognizes "all" as an explicit target. void navigate({ diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f040f2218058d..ad1492d2645f2 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -6,6 +6,18 @@ check which providers and models are available. This covers Claude Code or Codex in your own terminal, ChatGPT, and bots that support MCP. Each agent signs in once, and you choose what it may do. +## Manage T3 tools + +Open **Settings → T3 tools**, select the environment, and search for a tool to +turn it off or back on. For example, disable `t3_thread_send` to prevent agents +from using that tool to send messages to threads. These settings apply to every +provider and outside MCP client connected to that environment. + +Disabling a tool blocks new calls immediately, including calls from existing +sessions. Reconnect an agent session to refresh its cached tool list. Calls +already running are not cancelled. Other tools that can send messages, such as +`t3_thread_send_attachments`, have their own switches. + ## Get the MCP URL In **Settings → Environments**, open a saved environment's menu and choose diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e027930c116ba..5bf2498159cdc 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -608,6 +608,14 @@ export const ServerEventLoopStall = Schema.Struct({ export type ServerEventLoopStall = typeof ServerEventLoopStall.Type; export const ServerConfig = Schema.Struct({ + nativeTools: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + name: Schema.String, + description: Schema.String, + }), + ), + ), environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, cwd: TrimmedNonEmptyString, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f291cec115f73..7931cb90c134f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1180,6 +1180,9 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ + nativeToolEnabled: Schema.Record(TrimmedNonEmptyString, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), // Null means unmigrated; empty is authoritative and must not resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), providerModelPreferences: Schema.NullOr(ProviderModelPreferences).pipe( @@ -1555,6 +1558,7 @@ const ModelSelectionPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + nativeToolEnabled: Schema.optionalKey(Schema.Record(TrimmedNonEmptyString, Schema.Boolean)), setModelFavorites: Schema.optionalKey( Schema.Array(Schema.Struct({ ...ModelFavorite.fields, favorite: Schema.Boolean })), ), From 6ee9113a8f8810c1dbf642605e0feccb8062d3ab Mon Sep 17 00:00:00 2001 From: Samer Khatib Date: Mon, 2 Nov 2026 14:06:10 -0500 Subject: [PATCH 2/7] test(web): avoid PDF worker transform overhead in preview tests --- .../files/AttachmentFilePreview.browser.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 98b34fbc00f89..ced407049a04d 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -5,6 +5,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +// Vite adds a large inline source map to the PDF worker; use its raw code so document +// reloads do not repeatedly pay that cost under CI contention. +vi.mock(import("pdfjs-dist/build/pdf.worker.min.mjs?url"), async () => { + const { default: source } = await import("pdfjs-dist/build/pdf.worker.min.mjs?raw"); + return { + default: URL.createObjectURL(new Blob([source], { type: "text/javascript" })), + }; +}); + const { refresh, assetUrlState } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>(), assetUrlState: vi.fn<() => import("~/assets/assetUrls").AssetUrlState>(), From 439d1105a1e3d9b4966441ae9ebf3c4f5b462c41 Mon Sep 17 00:00:00 2001 From: Samer Khatib Date: Mon, 2 Nov 2026 14:27:00 -0500 Subject: [PATCH 3/7] fix(server): keep MCP tools available when secret reads fail --- apps/server/src/device/DeviceService.test.ts | 1 + apps/server/src/mcp/McpHttpServer.test.ts | 150 +++++++++++++++++- apps/server/src/mcp/McpHttpServer.ts | 2 +- .../ThreadSettlementService.test.ts | 1 + .../src/provider/ProviderRegistry.test.ts | 1 + apps/server/src/serverSettings.ts | 25 +++ 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index cdcb100918beb..e57c64ad699fd 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -293,6 +293,7 @@ const fixture = Effect.fn("fixture")(function* ( withSettingsSnapshot: (use) => Effect.flatMap(Ref.get(settings), use), start: Effect.void, ready: Effect.void, + getMcpToolSettings: Ref.get(settings), getSettings: Ref.get(settings), updateSettings: (patch) => Ref.updateAndGet(settings, (current) => ({ diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 43d99613ba8e4..7d5bf37da40d1 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Bind the test listener to the same address family as its client. +import * as NodeHttp from "node:http"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -19,6 +21,9 @@ import { import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpServer from "effect/http/HttpServer"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; @@ -50,6 +55,16 @@ import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; import { PreviewToolkit } from "./toolkits/preview/tools.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; +// A wildcard listener can share an IPv4-only app's port on macOS. +const layerHttpTest = HttpServer.layerTestClient.pipe( + Layer.provide( + Layer.fresh(FetchHttpClient.layer).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), + ), + ), + Layer.provideMerge(NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 0 })), +); + const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); @@ -828,7 +843,7 @@ it.effect("terminates HTTP MCP sessions with DELETE", () => }); expect(reusedSessionResponse.status).toBe(404); }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ).pipe(Effect.provide(layerHttpTest)), ); it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => @@ -951,7 +966,7 @@ it.effect("admits provider and OAuth client credentials and points only clients expect(deadProvider.status).toBe(401); expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"'); }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ).pipe(Effect.provide(layerHttpTest)), ); it.effect("accepts case-insensitive MCP bearer schemes and diagnoses rejected credentials", () => { @@ -1047,7 +1062,7 @@ it.effect("accepts case-insensitive MCP bearer schemes and diagnoses rejected cr ).pipe( Effect.provide( layerAuth.pipe( - Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(layerHttpTest), Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), ), ), @@ -1183,7 +1198,130 @@ it.effect("hides tools for links and refuses another environment while the beta content: [{ text: expect.stringContaining("cannot reach linked environments") }], }); }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ).pipe(Effect.provide(layerHttpTest)), +); + +it.effect.each(["stored", "managed", "malformed"] as const)( + "enforces native tool policy without resolving provider secrets (%s)", + (mode) => { + const layerSettings = ServerSettings.layer.pipe( + Layer.provide( + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: (resource) => + Effect.fail( + new ServerSecretStore.SecretStoreReadError({ + resource, + cause: new Error("Secret store unavailable."), + }), + ), + }), + ), + Layer.provide( + ManagedPolicy.layerTest( + mode === "managed" + ? { + schemaVersion: 1, + organization: "Example Corp", + settings: { "nativeToolEnabled.disabled": { value: false, locked: true } }, + } + : undefined, + ), + ), + Layer.provideMerge(Sqlite.layerMemory), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-tool-policy-test-" }), + ), + ); + return Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + config.settingsPath, + mode === "malformed" + ? '{"nativeToolEnabled":' + : JSON.stringify({ + nativeToolEnabled: { disabled: mode === "managed" }, + providerInstances: { + codex: { + driver: "codex", + config: {}, + environment: [ + { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, + ], + }, + }, + }), + ); + if (mode !== "malformed") { + const settings = yield* ServerSettings.ServerSettingsService; + expect((yield* settings.getSettings.pipe(Effect.flip)).operation).toBe("read-secret"); + } + const Tools = Toolkit.make( + Tool.make("enabled", { success: Schema.String }), + Tool.make("disabled", { success: Schema.String }), + ); + const serverLayer = McpHttpServer.toolkitRegistration( + Tools, + McpToolAccess.toLayer(Tools, { + enabled: McpToolAccess.reads(() => Effect.succeed("available")), + disabled: McpToolAccess.reads(() => Effect.die("Disabled tool ran.")), + }), + ).pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: () => Effect.succeed(invocation), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const request = (method: string, params: object, sessionId?: string) => + httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + authorization: "Bearer policy-test", + ...(sessionId === undefined ? {} : { "mcp-session-id": sessionId }), + }, + body: HttpBody.text( + JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + "application/json", + ), + }); + const initialized = yield* request("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "policy-test", version: "1.0.0" }, + }); + expect(initialized.status).toBe(mode === "malformed" ? 500 : 200); + if (mode === "malformed") return; + const rpc = (method: string, params: object) => + request(method, params, initialized.headers["mcp-session-id"]!).pipe( + Effect.flatMap((response) => response.json), + ); + expect(yield* rpc("tools/list", {})).toMatchObject({ + result: { tools: [{ name: "enabled" }] }, + }); + expect(yield* rpc("tools/call", { name: "enabled", arguments: {} })).toMatchObject({ + result: { content: [{ type: "text", text: "available" }] }, + }); + expect(yield* rpc("tools/call", { name: "disabled", arguments: {} })).toMatchObject({ + error: { code: -32602 }, + }); + }), + ).pipe( + Effect.provide( + layerSettings.pipe( + Layer.provideMerge(layerHttpTest), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }, ); it.effect("lists pull request watch tools only to threads that are not delegated children", () => @@ -1272,7 +1410,7 @@ it.effect("lists pull request watch tools only to threads that are not delegated expect(childTools).not.toContain("watch_pull_request"); expect(childTools).not.toContain("unwatch_pull_request"); }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ).pipe(Effect.provide(layerHttpTest)), ); it.effect( @@ -1418,5 +1556,5 @@ it.effect( expect(yield* start("auto/default/extra")).toBe(400); expect(yield* start("root/default")).toBe(400); }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ).pipe(Effect.provide(layerHttpTest)), ); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index c4b544c86fb47..9e53840309115 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -227,7 +227,7 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { const asked = withModeLimit(invocation, request); if (asked === undefined) return invalidModeLimit; const limited = McpInvocationContext.withRuntimeModeCeiling(asked, runtimeModeCeiling); - const settings = yield* serverSettings.getSettings.pipe(Effect.orDie); + const settings = yield* serverSettings.getMcpToolSettings.pipe(Effect.orDie); const linkedEnvironments = settings.linkedEnvironmentsEnabled; if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff; return yield* httpEffect.pipe( diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index e55d585c307db..a71d2a9055ba4 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -641,6 +641,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const serverSettings = ServerSettings.ServerSettingsService.of({ start: Effect.void, ready: Effect.void, + getMcpToolSettings: Ref.get(settings), getSettings: Ref.update(settingsReadCount, (count) => count + 1).pipe( Effect.andThen(Ref.get(settings)), ), diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f04db9118ecc3..ae2ef647a93d3 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -281,6 +281,7 @@ function makeMutableServerSettingsService( return { start: Effect.void, ready: Effect.void, + getMcpToolSettings: Ref.get(settingsRef), getSettings: Ref.get(settingsRef), updateSettings: (patch) => Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1ead9e27bfcf5..21a63268c26ec 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -220,6 +220,11 @@ function ensureProviderInstanceMutationAllowed( return Effect.void; } +const selectMcpToolSettings = (settings: ServerSettings) => ({ + linkedEnvironmentsEnabled: settings.linkedEnvironmentsEnabled, + nativeToolEnabled: settings.nativeToolEnabled, +}); + export class ServerSettingsService extends Context.Service< ServerSettingsService, { @@ -232,6 +237,12 @@ export class ServerSettingsService extends Context.Service< readonly getSettings: Effect.Effect; + /** Tool access must not depend on resolving unrelated provider secrets. */ + readonly getMcpToolSettings: Effect.Effect< + Pick, + ServerSettingsError + >; + readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect; @@ -294,6 +305,7 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( start: Effect.void, ready: Effect.void, getSettings, + getMcpToolSettings: getSettings.pipe(Effect.map(selectMcpToolSettings)), updateSettings: (patch) => updateTestSettings((currentSettings) => Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), @@ -908,6 +920,18 @@ const make = Effect.gen(function* () { Effect.andThen(Cache.get(settingsCache, cacheKey)), ); + const getMcpToolSettings = Effect.gen(function* () { + const { settings, parseFailure } = yield* Cache.get(settingsCache, cacheKey); + if (Option.isSome(parseFailure)) { + return yield* new ServerSettingsError({ + settingsPath, + operation: "read-file", + cause: parseFailure.value, + }); + } + return selectMcpToolSettings(toEffective(settings)); + }); + const materializeSettingsSecrets = Effect.fn("serverSettings.materializeSettingsSecrets")( function* (settings: ServerSettings): Effect.fn.Return { const storedSecrets = new Map(); @@ -1306,6 +1330,7 @@ const make = Effect.gen(function* () { start, ready: Deferred.await(startedDeferred), getSettings: getMaterializedSettings, + getMcpToolSettings, updateSettings: (patch) => updateAndPersistSettings(patch, (current) => ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( From 7ced3f0b3b3e99b36503da3dedec54e86539d5ba Mon Sep 17 00:00:00 2001 From: Samer Khatib Date: Mon, 2 Nov 2026 20:02:02 -0500 Subject: [PATCH 4/7] fix(web): save native tool toggles to the selected environment --- .../NativeToolsSettings.browser.test.tsx | 94 +++++++++++++++++++ .../settings/NativeToolsSettings.tsx | 2 +- .../components/settings/useScopedSettings.ts | 11 ++- 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/settings/NativeToolsSettings.browser.test.tsx diff --git a/apps/web/src/components/settings/NativeToolsSettings.browser.test.tsx b/apps/web/src/components/settings/NativeToolsSettings.browser.test.tsx new file mode 100644 index 0000000000000..8a6f92c1dff4c --- /dev/null +++ b/apps/web/src/components/settings/NativeToolsSettings.browser.test.tsx @@ -0,0 +1,94 @@ +import { + DEFAULT_SERVER_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + EnvironmentId, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { AsyncResult } from "effect/reactivity"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; +import { render } from "../../../test/render"; +import "~/index.css"; + +const state = vi.hoisted(() => ({ + kind: "project" as "project" | "checkout", + settings: {} as typeof DEFAULT_SERVER_SETTINGS, + persist: vi.fn(), + toast: vi.fn(), +})); +const selectedId = EnvironmentId.make("selected-remote"); +const otherId = EnvironmentId.make("other-environment"); + +vi.mock("./SettingsScopeContext", () => ({ + useOptionalSettingsScope: () => null, + useSettingsScope: () => { + const environment = { + environmentId: selectedId, + label: "Selected remote", + connection: { phase: "connected" }, + serverConfig: { + settings: state.settings, + nativeTools: [{ name: "t3_thread_send", description: "Send a message to a thread." }], + }, + }; + return { + scope: { kind: state.kind, label: "Project", members: [], environmentIds: [selectedId] }, + environment, + environments: [environment, { ...environment, environmentId: otherId }], + connectedEnvironments: [environment], + target: { settings: state.settings }, + }; + }, +})); +vi.mock("../../hooks/useSettings", () => ({ + useMergedSettings: ( + settings: typeof DEFAULT_SERVER_SETTINGS, + selector: (settings: typeof DEFAULT_UNIFIED_SETTINGS) => unknown, + ) => selector({ ...DEFAULT_UNIFIED_SETTINGS, nativeToolEnabled: settings.nativeToolEnabled }), + persistClientSettingsPatch: vi.fn(), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + useEnvironmentsWithScope: () => new Set([selectedId]), + readEnvironmentScope: (id: EnvironmentId) => id === selectedId, +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("./settingsLayout", () => ({ + SettingsPageContainer: ({ children }: { children: ReactNode }) => children, + SettingsSection: ({ children }: { children: ReactNode }) => children, + SettingsRow: ({ control }: { control: ReactNode }) => control, +})); + +import { NativeToolsSettings } from "./NativeToolsSettings"; + +it.each(["project", "checkout"] as const)( + "saves tool changes only to the selected environment while retaining %s scope", + async (kind) => { + state.kind = kind; + state.settings = { ...DEFAULT_SERVER_SETTINGS, nativeToolEnabled: {} }; + state.toast.mockReset(); + state.persist.mockReset().mockImplementation(async ({ input }) => { + state.settings = applyServerSettingsPatch(state.settings, input.patch); + return AsyncResult.success(state.settings); + }); + await using screen = await render(); + const toggle = screen.getByRole("switch", { name: "t3_thread_send" }); + await userEvent.click(toggle); + await vi.waitFor(() => expect(state.persist).toHaveBeenCalledOnce()); + expect(state.persist).toHaveBeenLastCalledWith({ + environmentId: selectedId, + input: { patch: { nativeToolEnabled: { t3_thread_send: false } } }, + }); + await screen.rerender(); + await expect.element(toggle).not.toBeChecked(); + await userEvent.click(toggle); + await vi.waitFor(() => expect(state.persist).toHaveBeenCalledTimes(2)); + expect(state.persist).toHaveBeenLastCalledWith({ + environmentId: selectedId, + input: { patch: { nativeToolEnabled: { t3_thread_send: true } } }, + }); + expect(state.toast).not.toHaveBeenCalled(); + }, +); diff --git a/apps/web/src/components/settings/NativeToolsSettings.tsx b/apps/web/src/components/settings/NativeToolsSettings.tsx index 05dc155052182..c9cdee346e8c8 100644 --- a/apps/web/src/components/settings/NativeToolsSettings.tsx +++ b/apps/web/src/components/settings/NativeToolsSettings.tsx @@ -12,7 +12,7 @@ import { SettingsPageContainer, SettingsSection, SettingsRow } from "./settingsL export function NativeToolsSettings() { const { environment } = useSettingsScope(); const enabled = useScopedSettings((settings) => settings.nativeToolEnabled); - const update = useUpdateScopedSettings(); + const update = useUpdateScopedSettings({ scope: "environment" }); const canWrite = useScopedSettingsWriteAllowed(); const [search, setSearch] = useState(""); const tools = environment?.serverConfig?.nativeTools; diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 5fb9d048a9e3d..b6cd1fb590b69 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -19,6 +19,7 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; +import { resolveSettingsScope } from "./settingsScope"; import { persistScopedSettingsPatch, planProjectOverridesClear, @@ -97,12 +98,16 @@ function useRunScopedPlan() { }; } -export function useUpdateScopedSettings() { - const { scope, environments } = useSettingsScope(); +export function useUpdateScopedSettings(options?: { readonly scope?: "environment" }) { + const { scope, environments, environment } = useSettingsScope(); + const writeScope = + options?.scope === "environment" && scope.kind !== "unavailable" && environment !== null + ? resolveSettingsScope({ machine: environment.environmentId }, [], environments) + : scope; const run = useRunScopedPlan(); /** `onFailed` runs when any target rejects or drops the patch, so a draft can fall back. */ return (patch: ScopedSettingsPatch, options?: { readonly onFailed?: () => void }) => - run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); + run(planScopedSettingsPatch(writeScope, environments, patch), options?.onFailed); } export function useClearScopedSettings() { From aa244b12effde8f1e648b0309ba98cb41af757e1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 02:42:57 +0000 Subject: [PATCH 5/7] fix(web): keep native tool controls usable in project scope --- apps/web/src/components/settings/NativeToolsSettings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/settings/NativeToolsSettings.tsx b/apps/web/src/components/settings/NativeToolsSettings.tsx index c9cdee346e8c8..33df64ddb199a 100644 --- a/apps/web/src/components/settings/NativeToolsSettings.tsx +++ b/apps/web/src/components/settings/NativeToolsSettings.tsx @@ -54,7 +54,6 @@ export function NativeToolsSettings() { key={tool.name} title={tool.name} description={tool.description} - serverScoped managedPaths={[`nativeToolEnabled.${tool.name}`]} control={ Date: Thu, 5 Nov 2026 03:52:06 +0000 Subject: [PATCH 6/7] fix(mobile): use the settings environment menu on the T3 tools screen --- .../src/features/settings/SettingsNativeToolsRouteScreen.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx index a632351b61005..fa6504b864788 100644 --- a/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx @@ -7,8 +7,8 @@ import { serverEnvironment } from "../../state/server"; import { useEnvironmentsWithScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { - AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, + useSettingsEnvironmentMenu, } from "./components/SettingsEnvironmentFilterHeader"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; @@ -17,6 +17,7 @@ import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; export function SettingsNativeToolsRouteScreen() { const { selectedTargets } = useSettingsEnvironmentFilter(); + const environmentMenu = useSettingsEnvironmentMenu(); const writable = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); const update = useAtomCommand(serverEnvironment.updateSettings, { label: "T3 tool settings update", @@ -27,7 +28,7 @@ export function SettingsNativeToolsRouteScreen() { return ( <> - }> + Date: Fri, 6 Nov 2026 02:25:00 +0000 Subject: [PATCH 7/7] fix(mobile): T3 tools screen uses the shared settings header after main's AppBar refactor --- apps/mobile/src/Stack.tsx | 2 +- .../SettingsNativeToolsRouteScreen.tsx | 144 +++++++++--------- 2 files changed, 70 insertions(+), 76 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 896451e85850a..d9e81b2c68683 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -205,7 +205,7 @@ const SETTINGS_SCREENS = { SettingsNativeTools: createNativeStackScreen({ screen: SettingsNativeToolsRouteScreen, linking: "settings/tools", - options: { ...SETTINGS_PAGE_OPTIONS, title: "T3 tools" }, + options: settingsPage("T3 tools"), }), SettingsProviderAccounts: createNativeStackScreen({ screen: SettingsProviderAccountsRouteScreen, diff --git a/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx index fa6504b864788..48f41903a7a78 100644 --- a/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx @@ -6,10 +6,7 @@ import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; import { serverEnvironment } from "../../state/server"; import { useEnvironmentsWithScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; -import { - SettingsEnvironmentFilterHeader, - useSettingsEnvironmentMenu, -} from "./components/SettingsEnvironmentFilterHeader"; +import { useSettingsEnvironmentMenu } from "./components/SettingsEnvironmentFilterHeader"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -26,76 +23,73 @@ export function SettingsNativeToolsRouteScreen() { const [search, setSearch] = useState(""); const query = search.trim().toLowerCase(); return ( - <> - - - - - Choose which built-in T3 tools agents can use across all providers. Disabled tools block - new calls immediately. Reconnect existing agent sessions to refresh their tool lists. - Calls already running are not cancelled. - - - {selectedTargets.length === 0 ? ( - Select a connected environment. - ) : ( - selectedTargets.map((environment) => { - const tools = environment.serverConfig.nativeTools; - const visible = tools?.filter((tool) => - `${tool.name} ${tool.description}`.toLowerCase().includes(query), - ); - return ( - - {tools === undefined ? ( - - Update this environment to manage T3 tools. - - ) : visible?.length === 0 ? ( - No matching tools. - ) : ( - visible?.map((tool) => ( - - { - void update({ - environmentId: environment.environmentId, - input: { patch: { nativeToolEnabled: { [tool.name]: enabled } } }, - }); - }} - /> - - )) - )} - - ); - }) - )} - - - + + + + Choose which built-in T3 tools agents can use across all providers. Disabled tools block + new calls immediately. Reconnect existing agent sessions to refresh their tool lists. + Calls already running are not cancelled. + + + {selectedTargets.length === 0 ? ( + Select a connected environment. + ) : ( + selectedTargets.map((environment) => { + const tools = environment.serverConfig.nativeTools; + const visible = tools?.filter((tool) => + `${tool.name} ${tool.description}`.toLowerCase().includes(query), + ); + return ( + + {tools === undefined ? ( + + Update this environment to manage T3 tools. + + ) : visible?.length === 0 ? ( + No matching tools. + ) : ( + visible?.map((tool) => ( + + { + void update({ + environmentId: environment.environmentId, + input: { patch: { nativeToolEnabled: { [tool.name]: enabled } } }, + }); + }} + /> + + )) + )} + + ); + }) + )} + + ); }