diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b8ab00673db12..f6ce461b18e2d 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -91,6 +91,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"; @@ -202,6 +203,11 @@ const SETTINGS_SCREENS = { linking: "settings/labs", options: settingsPage("Labs"), }), + SettingsNativeTools: createNativeStackScreen({ + screen: SettingsNativeToolsRouteScreen, + linking: "settings/tools", + options: settingsPage("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..48f41903a7a78 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsNativeToolsRouteScreen.tsx @@ -0,0 +1,95 @@ +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 { useSettingsEnvironmentMenu } 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 environmentMenu = useSettingsEnvironmentMenu(); + 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 418e4286502da..d31abbd5d1962 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -254,6 +254,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 eadd9c46fff7b..1180ebc356d83 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", "SettingsVoice", "SettingsFollowUp", diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 4d02bd9789b1d..159a09ff283b5 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -77,6 +77,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/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a34dbc534f59a..8f85a1bce2000 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -310,6 +310,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 8f922c2786cba..ac1fbfbdd2c9a 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"; @@ -21,6 +23,8 @@ 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"; @@ -53,6 +57,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"); @@ -916,7 +930,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", () => @@ -1069,7 +1083,7 @@ it.effect("admits provider and OAuth client credentials and points only clients ), ).toEqual([]); }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ).pipe(Effect.provide(layerHttpTest)), ); it.effect("accepts case-insensitive MCP bearer schemes and diagnoses rejected credentials", () => { @@ -1165,7 +1179,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 })), ), ), @@ -1283,6 +1297,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. @@ -1291,7 +1313,131 @@ 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.layerMcpTransport( + McpHttpServer.toolkitRegistration( + Tools, + McpToolAccess.toLayer(Tools, { + enabled: McpToolAccess.reads(() => Effect.succeed("available")), + disabled: McpToolAccess.reads(() => Effect.die("Disabled tool ran.")), + }), + ), + ).pipe( + 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", () => @@ -1381,7 +1527,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( @@ -1526,5 +1672,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 f838412f14f8c..45428f2441ef3 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -199,10 +199,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), - ); const authenticateRequest = Effect.fn("McpHttpServer.authenticateRequest")(function* ( request: HttpServerRequest.HttpServerRequest, ) { @@ -233,9 +229,10 @@ 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.getMcpToolSettings.pipe(Effect.orDie); + const linkedEnvironments = settings.linkedEnvironmentsEnabled; if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff; - return { ...limited, linkedEnvironments }; + return { ...limited, linkedEnvironments, nativeToolEnabled: settings.nativeToolEnabled }; }); // Untraced: a span here would wrap the tool call; the HTTP server span already covers it. return Effect.fnUntraced(function* (httpEffect) { @@ -1034,6 +1031,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)); +}); + /** Each tool call gets a span named for its tool; arguments stay out, as they carry prompts and IDs. */ const withToolSpans = Effect.fn("McpHttpServer.withToolSpans")(function* ( registration: Effect.Effect, @@ -1115,9 +1135,11 @@ export const toolkitRegistration = , EX, handlers: McpToolAccess.HandlersLayer, ) => Layer.effectDiscard( - withToolAnalytics( - withToolSpans( - withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + withToolAvailability( + withToolAnalytics( + withToolSpans( + withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + ), ), ), ).pipe( @@ -1134,7 +1156,7 @@ const imageToolRegistration = , A, E, R, register: Effect.Effect, handlers: McpToolAccess.HandlersLayer, ) => - Layer.effectDiscard(withToolAnalytics(withToolSpans(register))).pipe( + Layer.effectDiscard(withToolAvailability(withToolAnalytics(withToolSpans(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 f8788917da059..2d3326e6b7858 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>; } /** @@ -172,6 +173,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/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index f3c83eca70646..c26673ac8b8bf 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -642,6 +642,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/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 8cdd52d202f12..971aa9339cf1f 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -14,6 +14,7 @@ import { DeviceService } from "../device/DeviceService.ts"; import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import { nativeToolCatalog } from "../mcp/nativeToolCatalog.ts"; import { PreviewAutomationBroker } from "../mcp/PreviewAutomationBroker.ts"; import { CLAUDE_PROVIDER, @@ -129,6 +130,9 @@ it.effect("only request_secret asks Claude Code to load it up front", () => 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/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5c53f76237b6a..01e14a1df8ad0 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -285,6 +285,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.test.ts b/apps/server/src/serverSettings.test.ts index 347b43dd6f44c..017c2454cb2c6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -168,7 +168,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" 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; @@ -176,6 +176,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" 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"] }, @@ -184,11 +185,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" 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/serverSettings.ts b/apps/server/src/serverSettings.ts index 251becba12f6c..a3b339762d7f0 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -250,6 +250,11 @@ function ensureProviderInstanceMutationAllowed( return Effect.void; } +const selectMcpToolSettings = (settings: ServerSettings) => ({ + linkedEnvironmentsEnabled: settings.linkedEnvironmentsEnabled, + nativeToolEnabled: settings.nativeToolEnabled, +}); + export class ServerSettingsService extends Context.Service< ServerSettingsService, { @@ -262,6 +267,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; @@ -324,6 +335,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)), @@ -938,6 +950,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(); @@ -1367,6 +1391,7 @@ const make = Effect.gen(function* () { start, ready: Deferred.await(startedDeferred), getSettings: getMaterializedSettings, + getMcpToolSettings, updateSettings: (patch) => updateAndPersistSettings(patch, (current) => ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8a8c4e1be719c..2bd764b66c3e0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -119,6 +119,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"; @@ -1970,6 +1971,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.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 new file mode 100644 index 0000000000000..33df64ddb199a --- /dev/null +++ b/apps/web/src/components/settings/NativeToolsSettings.tsx @@ -0,0 +1,74 @@ +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({ scope: "environment" }); + 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 e175fbc439f22..bf6ed9b1930df 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -96,6 +96,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 f7492035874da..04a2d72b8285c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -25,6 +25,7 @@ export type SettingsPath = | "/settings/diffs" | "/settings/keybindings" | "/settings/snap-shot" + | "/settings/tools" | "/settings/providers" | "/settings/threads" | "/settings/browser" @@ -100,6 +101,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", @@ -138,6 +140,7 @@ export const SETTINGS_NAV_GROUPS: ReadonlyArray<{ heading: "Agents", paths: [ "/settings/providers", + "/settings/tools", "/settings/threads", "/settings/browser", "/settings/devices", @@ -224,6 +227,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", @@ -1337,6 +1347,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly void }) => - run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); + run(planScopedSettingsPatch(writeScope, environments, patch), options?.onFailed); } export function useClearScopedSettings() { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1b93b3966b4ef..5d9bed36282f7 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as ChatNewRouteImport } from './routes/_chat.new' import { Route as ThreadsEnvironmentIdThreadIdRouteImport } from './routes/threads.$environmentId.$threadId' import { Route as ChatSettingsWorktreesRouteImport } from './routes/_chat.settings.worktrees' import { Route as ChatSettingsVoiceRouteImport } from './routes/_chat.settings.voice' +import { Route as ChatSettingsToolsRouteImport } from './routes/_chat.settings.tools' import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings.threads' import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' import { Route as ChatSettingsSourceControlRouteImport } from './routes/_chat.settings.source-control' @@ -133,6 +134,11 @@ const ChatSettingsVoiceRoute = ChatSettingsVoiceRouteImport.update({ path: '/voice', getParentRoute: () => ChatSettingsRoute, } as any) +const ChatSettingsToolsRoute = ChatSettingsToolsRouteImport.update({ + id: '/tools', + path: '/tools', + getParentRoute: () => ChatSettingsRoute, +} as any) const ChatSettingsThreadsRoute = ChatSettingsThreadsRouteImport.update({ id: '/threads', path: '/threads', @@ -314,6 +320,7 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute '/settings/threads': typeof ChatSettingsThreadsRoute + '/settings/tools': typeof ChatSettingsToolsRoute '/settings/voice': typeof ChatSettingsVoiceRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute @@ -358,6 +365,7 @@ export interface FileRoutesByTo { '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute '/settings/threads': typeof ChatSettingsThreadsRoute + '/settings/tools': typeof ChatSettingsToolsRoute '/settings/voice': typeof ChatSettingsVoiceRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute @@ -404,6 +412,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/voice': typeof ChatSettingsVoiceRoute '/_chat/settings/worktrees': typeof ChatSettingsWorktreesRoute '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute @@ -450,6 +459,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/settings/storage' | '/settings/threads' + | '/settings/tools' | '/settings/voice' | '/settings/worktrees' | '/threads/$environmentId/$threadId' @@ -494,6 +504,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/settings/storage' | '/settings/threads' + | '/settings/tools' | '/settings/voice' | '/settings/worktrees' | '/threads/$environmentId/$threadId' @@ -539,6 +550,7 @@ export interface FileRouteTypes { | '/_chat/settings/source-control' | '/_chat/settings/storage' | '/_chat/settings/threads' + | '/_chat/settings/tools' | '/_chat/settings/voice' | '/_chat/settings/worktrees' | '/threads/$environmentId/$threadId' @@ -670,6 +682,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsVoiceRouteImport 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' @@ -888,6 +907,7 @@ interface ChatSettingsRouteChildren { ChatSettingsSourceControlRoute: typeof ChatSettingsSourceControlRoute ChatSettingsStorageRoute: typeof ChatSettingsStorageRoute ChatSettingsThreadsRoute: typeof ChatSettingsThreadsRoute + ChatSettingsToolsRoute: typeof ChatSettingsToolsRoute ChatSettingsVoiceRoute: typeof ChatSettingsVoiceRoute ChatSettingsWorktreesRoute: typeof ChatSettingsWorktreesRoute } @@ -918,6 +938,7 @@ const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { ChatSettingsSourceControlRoute: ChatSettingsSourceControlRoute, ChatSettingsStorageRoute: ChatSettingsStorageRoute, ChatSettingsThreadsRoute: ChatSettingsThreadsRoute, + ChatSettingsToolsRoute: ChatSettingsToolsRoute, ChatSettingsVoiceRoute: ChatSettingsVoiceRoute, 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 228c33f246e2d..cfe4387352332 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -25,6 +25,18 @@ For an app that is not listed, choose **Something else…**, add a remote MCP server with OAuth in that app, and paste the URL. Apps that require their own client ID cannot connect. +## 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 40a1aa8b2ca86..5d4f95852db14 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 cd112d84bf9f4..b28412717e01d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1331,6 +1331,9 @@ export const CloudSpeechCredentials = Schema.Struct({ }); 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( @@ -1749,6 +1752,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 })), ),