diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index dc8edc58a17f..72e8b320898c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -12,6 +12,8 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import packageJson from "../../package.json" with { type: "json" }; 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 { ThreadToolkit } from "./toolkits/thread/tools.ts"; import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts"; import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts"; @@ -239,6 +241,10 @@ export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit Layer.provide(WorktreeMcpService.layer), ); +export const PreviewControlsRegistrationLive = McpServer.toolkit(PreviewControlsToolkit).pipe( + Layer.provide(PreviewControlsHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -250,5 +256,6 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, ThreadToolkitRegistrationLive, + 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 d4e06ea0f2a4..9b1f246ecf47 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -11,12 +11,19 @@ import * as McpHttpServer from "../McpHttpServer.ts"; import { McpInvocationContext, type McpInvocationScope } from "../McpInvocationContext.ts"; import { OrchestratorToolkit } from "./orchestrator/tools.ts"; import { PreviewToolkit } from "./preview/tools.ts"; +import { PreviewControlsToolkit } from "./previewControls/tools.ts"; import { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; it("publishes unique tool names with object-root inputs", () => { const names = new Set(); - for (const toolkit of [OrchestratorToolkit, PreviewToolkit, WorktreeToolkit, ThreadToolkit]) { + for (const toolkit of [ + OrchestratorToolkit, + PreviewToolkit, + WorktreeToolkit, + ThreadToolkit, + PreviewControlsToolkit, + ]) { for (const tool of Object.values(toolkit.tools)) { expect(names.has(tool.name)).toBe(false); names.add(tool.name); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts new file mode 100644 index 000000000000..8ab4ebcd2490 --- /dev/null +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -0,0 +1,41 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Preview from "../../../preview/Manager.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { requireMcpCapability } from "../../McpInvocationContext.ts"; +import { unavailable } from "../../threadAccess.ts"; +import { PreviewControlsToolkit } from "./tools.ts"; + +const access = Effect.gen(function* () { + const scope = yield* requireMcpCapability("preview"); + const settings = yield* ServerSettings.ServerSettingsService; + const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); + if (!current.enableAgentBrowserAccess) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "Agent browser access is disabled.", + }); + return { scope, manager: yield* Preview.PreviewManager }; +}); +export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ + t3_preview_list: (input) => + Effect.gen(function* () { + const { scope, manager } = yield* access; + const result = yield* manager.list({ threadId: scope.threadId }); + const start = input.cursor ?? 0; + const end = start + (input.limit ?? 20); + return { + ...result, + sessions: result.sessions.slice(start, end), + nextCursor: end < result.sessions.length ? end : null, + }; + }), + t3_preview_close: (input) => + Effect.gen(function* () { + const { scope, manager } = yield* access; + yield* manager + .close({ threadId: scope.threadId, tabId: input.tabId }) + .pipe(Effect.mapError(unavailable)); + return {}; + }), +}); diff --git a/apps/server/src/mcp/toolkits/previewControls/tools.ts b/apps/server/src/mcp/toolkits/previewControls/tools.ts new file mode 100644 index 000000000000..6efda4d6690b --- /dev/null +++ b/apps/server/src/mcp/toolkits/previewControls/tools.ts @@ -0,0 +1,41 @@ +import { + NonNegativeInt, + OrchestratorMcpFailure, + PreviewAutomationUnavailableError, + PreviewListResult, + PreviewTabId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import { PreviewManager } from "../../../preview/Manager.ts"; +import { ServerSettingsService } from "../../../serverSettings.ts"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; + +const shared = { + failure: Schema.Union([OrchestratorMcpFailure, PreviewAutomationUnavailableError]), + failureMode: "return" as const, + dependencies: [McpInvocationContext, PreviewManager, ServerSettingsService], +}; +export const PreviewListTool = Tool.make("t3_preview_list", { + ...shared, + description: + "List this thread's preview tabs. Pages reflect the current server state and may shift as tabs change.", + parameters: Schema.Struct({ + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))), + }), + success: Schema.Struct({ + ...PreviewListResult.fields, + nextCursor: Schema.NullOr(NonNegativeInt), + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); +export const PreviewCloseTool = Tool.make("t3_preview_close", { + ...shared, + description: + "Close one preview tab owned by this thread through the normal server/host tab lifecycle. This does not wait for renderer cleanup.", + parameters: Schema.Struct({ tabId: PreviewTabId }), + success: Schema.Struct({}), +}).annotate(Tool.Destructive, true); +export const PreviewControlsToolkit = Toolkit.make(PreviewListTool, PreviewCloseTool); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c565b72b45ab..19ae9843bfaa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,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 { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; @@ -584,6 +585,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { const readOnlyToolNames = [ ...Object.values(OrchestratorToolkit.tools), ...Object.values(ThreadToolkit.tools), + ...Object.values(PreviewControlsToolkit.tools), ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 754da19e1226..95caa303f3b3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -812,6 +812,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_pending_request_read", "mcp__t3-code__t3_thread_configuration", "mcp__t3-code__t3_thread_transfers", + "mcp__t3-code__t3_preview_list", "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 32251e9ba35f..ed640f369b13 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -69,6 +69,8 @@ const T3_MCP_TOOLS: Record< t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, + t3_preview_list: { displayName: "List preview tabs" }, + t3_preview_close: { displayName: "Close a preview tab" }, preview_status: { displayName: "Get preview browser status" }, preview_open: { displayName: "Open a page in the preview browser" }, preview_navigate: { displayName: "Navigate the preview browser" },