diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 210bb7e5ad8..f209cbbc5d5 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,15 +1,31 @@ import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; -import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EnvironmentId, + PreviewTabId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type TerminalSessionSnapshot, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); @@ -35,6 +51,8 @@ const client = McpSchema.McpServerClient.of({ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(PreviewAutomationBroker.layer), + Layer.provideMerge(McpHttpServer.OrchestrationToolkitRegistrationLive), + Layer.provideMerge(McpHttpServer.TerminalToolkitRegistrationLive), ); it("normalizes empty successful notification responses to accepted", () => { @@ -181,6 +199,23 @@ it.effect("registers annotated tools and preserves authenticated request context expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); + const browserOpenTool = server.tools.find(({ tool }) => tool.name === "browser_open"); + expect(browserOpenTool?.tool.annotations?.destructiveHint).toBe(true); + expect(browserOpenTool?.tool.annotations?.openWorldHint).toBe(true); + + const browserOpen = yield* server + .callTool({ name: "browser_open", arguments: { url: "http://example.test/" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(browserOpen.isError).toBe(false); + expect(browserOpen.structuredContent).toMatchObject({ + available: true, + tabId, + url: "http://example.test/", + }); + const status = yield* server .callTool({ name: "preview_status", arguments: {} }) .pipe( @@ -225,3 +260,214 @@ it.effect("registers annotated tools and preserves authenticated request context }), ).pipe(Effect.provide(TestLayer)), ); + +it.effect("registers orchestration and terminal tools", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const now = "2026-06-11T00:00:00.000Z"; + const projectShell: OrchestrationProjectShell = { + id: ProjectId.make("project-mcp-test"), + title: "MCP Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + scripts: [], + createdAt: now, + updatedAt: now, + }; + const baseThreadShell = ( + overrides: Partial = {}, + ): OrchestrationThreadShell => ({ + id: ThreadId.make("thread-mcp-base"), + projectId: projectShell.id, + title: "Base thread", + modelSelection: + projectShell.defaultModelSelection ?? + ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }); + let threads: OrchestrationThreadShell[] = [baseThreadShell()]; + let dispatchSequence = 0; + let openedTerminalInput: unknown = null; + let terminalWrites: string[] = []; + const terminalSnapshot: TerminalSessionSnapshot = { + threadId: threadId, + terminalId: "term-1", + cwd: "/tmp/project", + worktreePath: null, + status: "running", + pid: 12345, + history: "", + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: now, + sequence: 1, + }; + const snapshotQuery = ProjectionSnapshotQuery.ProjectionSnapshotQuery.of({ + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [projectShell], + threads, + updatedAt: now, + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: (projectId) => + Effect.succeed(projectId === projectShell.id ? Option.some(projectShell) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: (threadId) => { + const thread = threads.find((entry) => entry.id === threadId); + return Effect.succeed(thread === undefined ? Option.none() : Option.some(thread)); + }, + getThreadDetailById: () => Effect.die("unused"), + }); + const orchestrationEngine = OrchestrationEngine.OrchestrationEngineService.of({ + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatchSequence += 1; + if (command.type === "thread.create") { + const createdThread = baseThreadShell({ + id: command.threadId, + projectId: command.projectId, + title: command.title, + modelSelection: + command.modelSelection ?? + projectShell.defaultModelSelection ?? + ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + branch: command.branch, + worktreePath: command.worktreePath, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }); + threads = [ + ...threads.filter((thread) => thread.id !== command.threadId), + createdThread, + ]; + } + if (command.type === "thread.archive") { + threads = threads.map((thread) => + thread.id === command.threadId + ? { ...thread, archivedAt: now, updatedAt: now } + : thread, + ); + } + return { sequence: dispatchSequence }; + }), + streamDomainEvents: Stream.empty, + }); + const terminalManager = TerminalManager.TerminalManager.of({ + open: (input) => + Effect.sync(() => { + openedTerminalInput = input; + return terminalSnapshot; + }), + attachStream: () => Effect.die("unused"), + write: (input) => + Effect.sync(() => { + terminalWrites.push(input.data); + }), + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die("unused"), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const callTool = >( + name: string, + args: TArguments, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, snapshotQuery), + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService(TerminalManager.TerminalManager, terminalManager), + ); + + const projectsList = yield* callTool("projects_list", {}); + expect(projectsList.isError).toBe(false); + expect(projectsList.structuredContent).toMatchObject({ projects: [projectShell] }); + + const threadsList = yield* callTool("threads_list", { projectId: projectShell.id }); + expect(threadsList.isError).toBe(false); + expect(threadsList.structuredContent).toMatchObject({ threads }); + + const createdThread = yield* callTool("threads_create", { + projectId: projectShell.id, + title: "Investigate bug", + }); + expect(createdThread.isError).toBe(false); + expect(createdThread.structuredContent).toMatchObject({ + thread: { + projectId: projectShell.id, + title: "Investigate bug", + }, + }); + + const archivedThreadId = threads[0]!.id; + const archivedThread = yield* callTool("threads_archive", { + threadId: archivedThreadId, + }); + expect(archivedThread.isError).toBe(false); + expect(archivedThread.structuredContent).toMatchObject({ + thread: { + id: archivedThreadId, + archivedAt: now, + }, + }); + + const terminalRun = yield* callTool("terminal_run", { + cwd: "/tmp/project", + command: "echo hello", + }); + expect(terminalRun.isError).toBe(false); + expect(terminalRun.structuredContent).toMatchObject({ + terminalId: "term-1", + cwd: "/tmp/project", + }); + expect(openedTerminalInput).toMatchObject({ + threadId, + terminalId: "term-1", + cwd: "/tmp/project", + }); + expect(terminalWrites).toEqual(["echo hello\n"]); + + const orchestrationTool = server.tools.find(({ tool }) => tool.name === "threads_create"); + expect(orchestrationTool?.tool.annotations?.destructiveHint).toBe(true); + const terminalTool = server.tools.find(({ tool }) => tool.name === "terminal_run"); + expect(terminalTool?.tool.annotations?.destructiveHint).toBe(true); + expect(terminalTool?.tool.annotations?.openWorldHint).toBe(true); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6cde2017a9e..307ba48b25d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -22,6 +22,10 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { OrchestrationToolkitHandlersLive } from "./toolkits/orchestration/handlers.ts"; +import { OrchestrationToolkit } from "./toolkits/orchestration/tools.ts"; +import { TerminalToolkitHandlersLive } from "./toolkits/terminal/handlers.ts"; +import { TerminalToolkit } from "./toolkits/terminal/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -170,6 +174,14 @@ const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandard Layer.provide(PreviewStandardToolkitHandlersLive), ); +export const OrchestrationToolkitRegistrationLive = McpServer.toolkit(OrchestrationToolkit).pipe( + Layer.provide(OrchestrationToolkitHandlersLive), +); + +export const TerminalToolkitRegistrationLive = McpServer.toolkit(TerminalToolkit).pipe( + Layer.provide(TerminalToolkitHandlersLive), +); + const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe( Layer.provide(PreviewSnapshotToolkitHandlersLive), ); @@ -185,7 +197,8 @@ const McpTransportLive = McpServer.layerHttp({ path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe( - Layer.provideMerge(McpTransportLive), - Layer.provide(PreviewAutomationBroker.layer), -); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + OrchestrationToolkitRegistrationLive, + TerminalToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive), Layer.provide(PreviewAutomationBroker.layer)); diff --git a/apps/server/src/mcp/toolkits/orchestration/handlers.ts b/apps/server/src/mcp/toolkits/orchestration/handlers.ts new file mode 100644 index 00000000000..cb4fbb87fac --- /dev/null +++ b/apps/server/src/mcp/toolkits/orchestration/handlers.ts @@ -0,0 +1,207 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + OrchestrationDispatchCommandError, + OrchestrationGetSnapshotError, + type OrchestrationArchiveThreadInput, + type OrchestrationArchiveThreadResult, + type OrchestrationCreateThreadInput, + type OrchestrationCreateThreadResult, + type OrchestrationListProjectsResult, + type OrchestrationListThreadsInput, + type OrchestrationListThreadsResult, + ThreadId, +} from "@t3tools/contracts"; + +import * as ServerRuntimeStartup from "../../../serverRuntimeStartup.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationToolkit } from "./tools.ts"; + +const listProjects = Effect.fn("McpOrchestration.listProjects")(function* (): Effect.fn.Return< + OrchestrationListProjectsResult, + OrchestrationGetSnapshotError, + ProjectionSnapshotQuery.ProjectionSnapshotQuery +> { + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const snapshot = yield* snapshotQuery.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load projects.", + cause, + }), + ), + ); + return { projects: snapshot.projects }; +}); + +const listThreads = Effect.fn("McpOrchestration.listThreads")(function* ( + input: OrchestrationListThreadsInput, +): Effect.fn.Return< + OrchestrationListThreadsResult, + OrchestrationGetSnapshotError, + ProjectionSnapshotQuery.ProjectionSnapshotQuery +> { + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const snapshot = yield* snapshotQuery.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load threads.", + cause, + }), + ), + ); + return { threads: snapshot.threads.filter((thread) => thread.projectId === input.projectId) }; +}); + +const createThread = Effect.fn("McpOrchestration.createThread")(function* ( + input: OrchestrationCreateThreadInput, +): Effect.fn.Return< + OrchestrationCreateThreadResult, + OrchestrationDispatchCommandError | OrchestrationGetSnapshotError, + ProjectionSnapshotQuery.ProjectionSnapshotQuery | OrchestrationEngine.OrchestrationEngineService +> { + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const project = yield* snapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load project '${input.projectId}'.`, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new OrchestrationDispatchCommandError({ + message: `Project '${input.projectId}' was not found.`, + }); + } + + const createdAt = input.createdAt ?? new Date().toISOString(); + const threadId = ThreadId.make(globalThis.crypto.randomUUID()); + const modelSelection = + input.modelSelection ?? + project.value.defaultModelSelection ?? + ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(); + const runtimeMode = input.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = input.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + + yield* orchestrationEngine + .dispatch({ + type: "thread.create", + commandId: CommandId.make(`mcp:thread-create:${globalThis.crypto.randomUUID()}`), + threadId, + projectId: project.value.id, + title: input.title ?? "New thread", + modelSelection, + runtimeMode, + interactionMode, + branch: input.branch ?? null, + worktreePath: input.worktreePath ?? null, + createdAt, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create thread.", + cause, + }), + ), + ); + + const createdThread = yield* snapshotQuery.getThreadShellById(threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Thread was created but could not be reloaded from the projection.", + cause, + }), + ), + ); + if (Option.isNone(createdThread)) { + return yield* new OrchestrationGetSnapshotError({ + message: "Thread was created but could not be reloaded from the projection.", + }); + } + + return { thread: createdThread.value }; +}); + +const archiveThread = Effect.fn("McpOrchestration.archiveThread")(function* ( + input: OrchestrationArchiveThreadInput, +): Effect.fn.Return< + OrchestrationArchiveThreadResult, + OrchestrationDispatchCommandError | OrchestrationGetSnapshotError, + ProjectionSnapshotQuery.ProjectionSnapshotQuery | OrchestrationEngine.OrchestrationEngineService +> { + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const currentThread = yield* snapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread '${input.threadId}'.`, + cause, + }), + ), + ); + if (Option.isNone(currentThread)) { + return yield* new OrchestrationDispatchCommandError({ + message: `Thread '${input.threadId}' was not found.`, + }); + } + + if (currentThread.value.archivedAt !== null) { + return { thread: currentThread.value }; + } + + yield* orchestrationEngine + .dispatch({ + type: "thread.archive", + commandId: CommandId.make(`mcp:thread-archive:${globalThis.crypto.randomUUID()}`), + threadId: input.threadId, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to archive thread.", + cause, + }), + ), + ); + + const archivedThread = yield* snapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Thread was archived but could not be reloaded from the projection.", + cause, + }), + ), + ); + if (Option.isNone(archivedThread)) { + return yield* new OrchestrationGetSnapshotError({ + message: "Thread was archived but could not be reloaded from the projection.", + }); + } + + return { thread: archivedThread.value }; +}); + +const handlers = { + projects_list: () => listProjects(), + threads_list: (input) => listThreads(input), + threads_create: (input) => createThread(input), + threads_archive: (input) => archiveThread(input), +} satisfies Parameters[0]; + +export const OrchestrationToolkitHandlersLive = OrchestrationToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/orchestration/tools.ts b/apps/server/src/mcp/toolkits/orchestration/tools.ts new file mode 100644 index 00000000000..19aa8cc1e74 --- /dev/null +++ b/apps/server/src/mcp/toolkits/orchestration/tools.ts @@ -0,0 +1,77 @@ +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import { + OrchestrationArchiveThreadInput, + OrchestrationArchiveThreadResult, + OrchestrationCreateThreadInput, + OrchestrationCreateThreadResult, + OrchestrationGetSnapshotError, + OrchestrationListProjectsResult, + OrchestrationListThreadsInput, + OrchestrationListThreadsResult, + OrchestrationDispatchCommandError, +} from "@t3tools/contracts"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +const dependencies = [ + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + OrchestrationEngine.OrchestrationEngineService, +]; + +const readOnlyTool = (tool: T): T => + tool + .annotate(Tool.Readonly, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.Destructive, false) as T; + +const mutatingTool = (tool: T): T => tool.annotate(Tool.Destructive, true) as T; + +export const ProjectsListTool = readOnlyTool( + Tool.make("projects_list", { + description: "List all projects visible in the current workspace.", + success: OrchestrationListProjectsResult, + failure: OrchestrationGetSnapshotError, + dependencies, + }).annotate(Tool.Title, "List projects"), +); + +export const ThreadsListTool = readOnlyTool( + Tool.make("threads_list", { + description: "List all threads belonging to one project.", + parameters: OrchestrationListThreadsInput, + success: OrchestrationListThreadsResult, + failure: OrchestrationGetSnapshotError, + dependencies, + }).annotate(Tool.Title, "List project threads"), +); + +export const ThreadsCreateTool = mutatingTool( + Tool.make("threads_create", { + description: + "Create a new thread in the requested project and return the created shell summary.", + parameters: OrchestrationCreateThreadInput, + success: OrchestrationCreateThreadResult, + failure: Schema.Union([OrchestrationDispatchCommandError, OrchestrationGetSnapshotError]), + dependencies, + }).annotate(Tool.Title, "Create thread"), +); + +export const ThreadsArchiveTool = mutatingTool( + Tool.make("threads_archive", { + description: "Archive a thread and return its updated shell summary.", + parameters: OrchestrationArchiveThreadInput, + success: OrchestrationArchiveThreadResult, + failure: Schema.Union([OrchestrationDispatchCommandError, OrchestrationGetSnapshotError]), + dependencies, + }).annotate(Tool.Title, "Archive thread"), +); + +export const OrchestrationToolkit = Toolkit.make( + ProjectsListTool, + ThreadsListTool, + ThreadsCreateTool, + ThreadsArchiveTool, +); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 6013b1cac9e..69f82ec4354 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -38,6 +38,12 @@ const handlers = { show: input.show ?? true, reuseExistingTab: input.reuseExistingTab ?? true, }), + browser_open: (input) => + invoke("open", { + ...input, + show: input.show ?? true, + reuseExistingTab: input.reuseExistingTab ?? true, + }), preview_navigate: (input) => invoke("navigate", input, input.timeoutMs), preview_snapshot: () => invoke("snapshot", {}), preview_click: (input) => invoke("click", input, input.timeoutMs).pipe(Effect.as(null)), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index fd2fedbb369..25e912c4773 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -58,6 +58,19 @@ export const PreviewOpenTool = browserTool( .annotate(Tool.Destructive, false), ); +export const BrowserOpenTool = browserTool( + Tool.make("browser_open", { + description: + "Open the internal browser for the scoped thread and navigate to a webpage, optionally reusing the current tab.", + parameters: PreviewAutomationOpenInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Open webpage in browser") + .annotate(Tool.Destructive, false), +); + export const PreviewNavigateTool = safeBrowserTool( Tool.make("preview_navigate", { description: @@ -167,6 +180,7 @@ export const PreviewRecordingStopTool = safeBrowserTool( export const PreviewToolkit = Toolkit.make( PreviewStatusTool, PreviewOpenTool, + BrowserOpenTool, PreviewNavigateTool, PreviewSnapshotTool, PreviewClickTool, @@ -182,6 +196,7 @@ export const PreviewToolkit = Toolkit.make( export const PreviewStandardToolkit = Toolkit.make( PreviewStatusTool, PreviewOpenTool, + BrowserOpenTool, PreviewNavigateTool, PreviewClickTool, PreviewTypeTool, diff --git a/apps/server/src/mcp/toolkits/terminal/handlers.ts b/apps/server/src/mcp/toolkits/terminal/handlers.ts new file mode 100644 index 00000000000..14bd319c276 --- /dev/null +++ b/apps/server/src/mcp/toolkits/terminal/handlers.ts @@ -0,0 +1,46 @@ +import * as Effect from "effect/Effect"; + +import { + DEFAULT_TERMINAL_ID, + type TerminalOpenInput, + type TerminalRunInput, +} from "@t3tools/contracts"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as TerminalManager from "../../../terminal/Manager.ts"; +import { TerminalToolkit } from "./tools.ts"; + +const runTerminalCommand = Effect.fn("McpTerminal.runTerminalCommand")(function* ( + input: TerminalRunInput, +): Effect.fn.Return< + import("@t3tools/contracts").TerminalSessionSnapshot, + import("@t3tools/contracts").TerminalError, + McpInvocationContext.McpInvocationContext | TerminalManager.TerminalManager +> { + const invocation = yield* McpInvocationContext.McpInvocationContext; + const terminalManager = yield* TerminalManager.TerminalManager; + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const openInput: TerminalOpenInput = { + threadId: invocation.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.cols !== undefined ? { cols: input.cols } : {}), + ...(input.rows !== undefined ? { rows: input.rows } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + }; + + const snapshot = yield* terminalManager.open(openInput); + yield* terminalManager.write({ + threadId: invocation.threadId, + terminalId, + data: `${input.command}\n`, + }); + return snapshot; +}); + +const handlers = { + terminal_run: (input) => runTerminalCommand(input), +} satisfies Parameters[0]; + +export const TerminalToolkitHandlersLive = TerminalToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/terminal/tools.ts b/apps/server/src/mcp/toolkits/terminal/tools.ts new file mode 100644 index 00000000000..5f3756e6cc7 --- /dev/null +++ b/apps/server/src/mcp/toolkits/terminal/tools.ts @@ -0,0 +1,24 @@ +import { Tool, Toolkit } from "effect/unstable/ai"; + +import { TerminalRunInput, TerminalSessionSnapshot } from "@t3tools/contracts"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as TerminalManager from "../../../terminal/Manager.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext, TerminalManager.TerminalManager]; + +const destructiveTool = (tool: T): T => + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; + +export const TerminalRunTool = destructiveTool( + Tool.make("terminal_run", { + description: + "Open the scoped thread's built-in terminal, send one shell command, and return the session snapshot.", + parameters: TerminalRunInput, + success: TerminalSessionSnapshot, + failure: TerminalManager.TerminalError, + dependencies, + }).annotate(Tool.Title, "Run terminal command"), +); + +export const TerminalToolkit = Toolkit.make(TerminalRunTool); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index b46a4ce1ba3..f682deb3c16 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -9,6 +9,15 @@ For browser work, first call \`preview_status\`. If no automation-capable previe Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. `; +const T3_CODE_OPERATIONAL_TOOL_INSTRUCTIONS = ` + +## T3 Code operational tools + +The \`t3-code\` MCP server also exposes \`browser_open\` for opening a webpage in the internal browser, \`terminal_run\` for opening and running a command in the built-in terminal, and \`projects_list\`, \`threads_list\`, \`threads_create\`, and \`threads_archive\` for project/thread orchestration. + +Prefer the dedicated tool names above when the user asks to open a webpage, run a terminal command, or manage threads and projects directly. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -130,6 +139,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} +${T3_CODE_OPERATIONAL_TOOL_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default @@ -144,4 +154,5 @@ The \`request_user_input\` tool is unavailable in Default mode. If you call it w In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} +${T3_CODE_OPERATIONAL_TOOL_INSTRUCTIONS} `; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 06b7dd99bd4..f585d3fdd15 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -159,6 +159,9 @@ describe("T3 browser developer instructions", () => { NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); + NodeAssert.match(instructions, /browser_open/); + NodeAssert.match(instructions, /terminal_run/); + NodeAssert.match(instructions, /threads_create/); NodeAssert.match(instructions, /Do not switch to global browser skills/); } }); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917b..de7baecd49f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -418,6 +418,102 @@ export const OrchestrationShellSnapshot = Schema.Struct({ }); export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; +export const OrchestrationListProjectsResult = Schema.Struct({ + projects: Schema.Array(OrchestrationProjectShell).annotate({ + description: "Projects visible to the current workspace.", + }), +}).annotate({ + description: "Result payload for listing all projects.", +}); +export type OrchestrationListProjectsResult = typeof OrchestrationListProjectsResult.Type; + +export const OrchestrationListThreadsInput = Schema.Struct({ + projectId: ProjectId.annotate({ + description: "Project to filter threads by.", + }), +}).annotate({ + description: "Input payload for listing all threads in a project.", +}); +export type OrchestrationListThreadsInput = typeof OrchestrationListThreadsInput.Type; + +export const OrchestrationListThreadsResult = Schema.Struct({ + threads: Schema.Array(OrchestrationThreadShell).annotate({ + description: "Threads belonging to the requested project.", + }), +}).annotate({ + description: "Result payload for listing project threads.", +}); +export type OrchestrationListThreadsResult = typeof OrchestrationListThreadsResult.Type; + +const ThreadCreateTitle = TrimmedNonEmptyString.annotate({ + description: "Optional human-readable thread title.", +}); +const ThreadCreateBranch = TrimmedNonEmptyString.annotate({ + description: "Optional git branch to associate with the thread.", +}); +const ThreadCreateWorktreePath = TrimmedNonEmptyString.annotate({ + description: "Optional worktree path for the thread.", +}); + +export const OrchestrationCreateThreadInput = Schema.Struct({ + projectId: ProjectId.annotate({ + description: "Project to create the thread under.", + }), + title: Schema.optional(ThreadCreateTitle), + modelSelection: Schema.optional( + ModelSelection.annotate({ + description: "Optional model selection for the new thread.", + }), + ), + runtimeMode: Schema.optional( + RuntimeMode.annotate({ + description: "Optional runtime mode for the new thread.", + }), + ), + interactionMode: Schema.optional( + ProviderInteractionMode.annotate({ + description: "Optional interaction mode for the new thread.", + }), + ), + branch: Schema.optional(Schema.NullOr(ThreadCreateBranch)), + worktreePath: Schema.optional(Schema.NullOr(ThreadCreateWorktreePath)), + createdAt: Schema.optional( + IsoDateTime.annotate({ + description: "Optional creation timestamp in ISO-8601 format.", + }), + ), +}).annotate({ + description: "Input payload for creating a new thread.", +}); +export type OrchestrationCreateThreadInput = typeof OrchestrationCreateThreadInput.Type; + +export const OrchestrationCreateThreadResult = Schema.Struct({ + thread: OrchestrationThreadShell.annotate({ + description: "The created thread shell.", + }), +}).annotate({ + description: "Result payload for creating a thread.", +}); +export type OrchestrationCreateThreadResult = typeof OrchestrationCreateThreadResult.Type; + +export const OrchestrationArchiveThreadInput = Schema.Struct({ + threadId: ThreadId.annotate({ + description: "Thread to archive.", + }), +}).annotate({ + description: "Input payload for archiving a thread.", +}); +export type OrchestrationArchiveThreadInput = typeof OrchestrationArchiveThreadInput.Type; + +export const OrchestrationArchiveThreadResult = Schema.Struct({ + thread: OrchestrationThreadShell.annotate({ + description: "The archived thread shell.", + }), +}).annotate({ + description: "Result payload for archiving a thread.", +}); +export type OrchestrationArchiveThreadResult = typeof OrchestrationArchiveThreadResult.Type; + export const OrchestrationShellStreamEvent = Schema.Union([ Schema.Struct({ kind: Schema.Literal("project-upserted"), diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index a3c8e37e7f9..4356e6b30ef 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -90,6 +90,45 @@ export const TerminalCloseInput = Schema.Struct({ }); export type TerminalCloseInput = typeof TerminalCloseInput.Type; +export const TerminalRunInput = Schema.Struct({ + terminalId: Schema.optional( + TerminalIdSchema.annotate({ + description: "Terminal session id to reuse; defaults to term-1.", + }), + ), + cwd: TrimmedNonEmptyStringSchema.annotate({ + description: "Working directory for the command, for example the project root.", + }), + worktreePath: Schema.optional( + Schema.NullOr( + TrimmedNonEmptyStringSchema.annotate({ + description: "Optional worktree path for the terminal session.", + }), + ), + ), + cols: Schema.optional( + TerminalColsSchema.annotate({ + description: "Optional terminal width in columns.", + }), + ), + rows: Schema.optional( + TerminalRowsSchema.annotate({ + description: "Optional terminal height in rows.", + }), + ), + env: Schema.optional( + TerminalEnvSchema.annotate({ + description: "Optional environment variables for the terminal process.", + }), + ), + command: TrimmedNonEmptyStringSchema.annotate({ + description: "Shell command to execute, without a trailing newline.", + }), +}).annotate({ + description: "Input payload for opening a terminal and running a command.", +}); +export type TerminalRunInput = typeof TerminalRunInput.Type; + export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]); export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;