diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 72e8b320898c..180f40c4e1ba 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,6 +14,8 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import { PreviewControlsHandlersLive } from "./toolkits/previewControls/handlers.ts"; +import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; +import { EnvironmentHandlersLive } from "./toolkits/environment/handlers.ts"; import { ThreadToolkit } from "./toolkits/thread/tools.ts"; import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts"; import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts"; @@ -245,6 +247,10 @@ export const PreviewControlsRegistrationLive = McpServer.toolkit(PreviewControls Layer.provide(PreviewControlsHandlersLive), ); +export const EnvironmentRegistrationLive = McpServer.toolkit(EnvironmentToolkit).pipe( + Layer.provide(EnvironmentHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -256,6 +262,7 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, ThreadToolkitRegistrationLive, + EnvironmentRegistrationLive, PreviewControlsRegistrationLive, WorktreeToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 9b1f246ecf47..82b9dcb0ae01 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,6 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; @@ -12,6 +17,8 @@ import { McpInvocationContext, type McpInvocationScope } from "../McpInvocationC import { OrchestratorToolkit } from "./orchestrator/tools.ts"; import { PreviewToolkit } from "./preview/tools.ts"; import { PreviewControlsToolkit } from "./previewControls/tools.ts"; +import { EnvironmentToolkit } from "./environment/tools.ts"; +import * as EnvironmentHandlers from "./environment/handlers.ts"; import { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; @@ -22,6 +29,7 @@ it("publishes unique tool names with object-root inputs", () => { PreviewToolkit, WorktreeToolkit, ThreadToolkit, + EnvironmentToolkit, PreviewControlsToolkit, ]) { for (const tool of Object.values(toolkit.tools)) { @@ -108,3 +116,21 @@ it.effect("returns a bounded public failure without serializing storage causes", ), ), ); + +it("keeps MCP preference output allowlisted and Unicode-bounded", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + privateCredential: "must-not-escape", + sourceControlWritingStyle: { + ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle, + customInstructions: "🙂".repeat(4001), + }, + }; + const result = EnvironmentHandlers.preferences(settings); + expect(result).not.toHaveProperty("privateCredential"); + expect(result).not.toHaveProperty("providers"); + expect(result.sourceControlWritingStyle).toMatchObject({ + customInstructions: "🙂".repeat(4000), + truncated: true, + }); +}); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts new file mode 100644 index 000000000000..91715668b295 --- /dev/null +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -0,0 +1,79 @@ +import { OrchestratorMcpFailure, type ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Environment from "../../../environment/ServerEnvironment.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as Settings from "../../../serverSettings.ts"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; +import { EnvironmentToolkit } from "./tools.ts"; + +export function preferences(settings: ServerSettings) { + const { + defaultThreadEnvMode, + newWorktreesStartFromOrigin, + enableProviderUpdateChecks, + backgroundActivity, + sourceControlWritingStyle, + } = settings; + const characters = Array.from(sourceControlWritingStyle.customInstructions); + return { + defaultThreadEnvMode, + newWorktreesStartFromOrigin, + enableProviderUpdateChecks, + backgroundActivity: { profile: backgroundActivity.profile }, + sourceControlWritingStyle: { + ...sourceControlWritingStyle, + customInstructions: characters.slice(0, 4000).join(""), + truncated: characters.length > 4000, + }, + }; +} +const access = (writable = false) => + Effect.gen(function* () { + const context = yield* writable ? readMutationCaller() : readCaller(); + const environment = yield* Environment.ServerEnvironment; + const descriptor = yield* environment.getDescriptor; + if (descriptor.environmentId !== context.scope.environmentId) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "This credential belongs to another environment.", + }); + return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; + }); +export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({ + t3_environment_read: () => + Effect.gen(function* () { + const { descriptor, settings } = yield* access(); + const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + serverVersion: descriptor.serverVersion, + platform: descriptor.platform, + preferences: preferences(current), + }; + }), + t3_environment_preferences_update: (patch) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + return yield* executor.withLock( + scope.threadId, + Effect.gen(function* () { + const { caller, settings } = yield* access(true); + if ( + caller.archivedAt !== null || + caller.runtimeMode !== "full-access" || + caller.interactionMode !== "default" + ) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "Preference updates require a live full-access/default thread.", + }); + return preferences( + yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), + ); + }), + ); + }), +}); diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts new file mode 100644 index 000000000000..14c8235a7e5c --- /dev/null +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -0,0 +1,67 @@ +import { + BackgroundActivityProfile, + BackgroundActivityProfileSelection, + ExecutionEnvironmentDescriptor, + OrchestratorMcpFailure, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import { ServerEnvironment } from "../../../environment/ServerEnvironment.ts"; +import { ThreadCommandExecutor } from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts"; +import { ServerSettingsService } from "../../../serverSettings.ts"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; + +export const PreferenceFields = { + defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode, + newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin, + enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks, + backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }), + sourceControlWritingStyle: Schema.Struct({ + mode: Schema.String, + followChangeRequestTemplates: Schema.Boolean, + customInstructions: Schema.String, + truncated: Schema.Boolean, + }), +}; +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext, + ThreadManagementService, + ServerEnvironment, + ServerSettingsService, + ThreadCommandExecutor, + ], +}; +export const EnvironmentReadTool = Tool.make("t3_environment_read", { + ...shared, + description: + "Read this server's identity and selected environment preferences. Provider/model availability is exposed by orchestrator_capabilities. Writing instructions are limited to 4,000 characters.", + success: Schema.Struct({ + environmentId: ExecutionEnvironmentDescriptor.fields.environmentId, + label: Schema.String, + serverVersion: Schema.String, + platform: ExecutionEnvironmentDescriptor.fields.platform, + preferences: Schema.Struct(PreferenceFields), + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); +export const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update", { + ...shared, + description: + "Update selected environment-wide preferences through normal settings persistence and notifications. Requires a live full-access/default calling thread. Omitted fields are preserved; empty customInstructions clears them.", + parameters: Schema.Struct({ + defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, + newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, + enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks, + backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })), + sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle, + }), + success: Schema.Struct(PreferenceFields), +}).annotate(Tool.Destructive, true); +export const EnvironmentToolkit = Toolkit.make(EnvironmentReadTool, EnvironmentPreferencesTool); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 19ae9843bfaa..c193bd8ca405 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -46,6 +46,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; +import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; @@ -585,6 +586,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { const readOnlyToolNames = [ ...Object.values(OrchestratorToolkit.tools), ...Object.values(ThreadToolkit.tools), + ...Object.values(EnvironmentToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 95caa303f3b3..706063fb2dff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -813,6 +813,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_configuration", "mcp__t3-code__t3_thread_transfers", "mcp__t3-code__t3_preview_list", + "mcp__t3-code__t3_environment_read", "mcp__t3-code__t3_queue_list", "mcp__t3-code__t3_queue_read", ]; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index ed640f369b13..29c5cd0ef4b9 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -71,6 +71,8 @@ const T3_MCP_TOOLS: Record< t3_worktree_status: { displayName: "Get thread worktree status" }, t3_preview_list: { displayName: "List preview tabs" }, t3_preview_close: { displayName: "Close a preview tab" }, + t3_environment_read: { displayName: "Read environment preferences" }, + t3_environment_preferences_update: { displayName: "Update environment preferences" }, preview_status: { displayName: "Get preview browser status" }, preview_open: { displayName: "Open a page in the preview browser" }, preview_navigate: { displayName: "Navigate the preview browser" },