diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6774731a73e..0d215bde21f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -22,6 +22,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; +import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -208,10 +210,17 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe( + Layer.provide(WorktreeToolkitHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + WorktreeToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index b13bf2d312e..7696b1f285f 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -7,7 +7,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "worktree"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; @@ -25,7 +25,7 @@ export class McpInvocationContext extends Context.Service< >()("t3/mcp/McpInvocationContext") {} export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( - capability: McpCapability, + capability: "preview", ) { const invocation = yield* McpInvocationContext; if (!invocation.capabilities.has(capability)) { diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 67c4f2f0ff0..882bd0e1e08 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -114,7 +114,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set(["preview", "worktree"]), issuedAt, expiresAt, }; diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts new file mode 100644 index 00000000000..80f2e2f1eb6 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + type OrchestrationProjectShell, + type OrchestrationThread, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { __testing } from "./handlers.ts"; + +const environmentId = EnvironmentId.make("environment-worktree-test"); +const threadId = ThreadId.make("thread-worktree-test"); +const projectId = ProjectId.make("project-worktree-test"); +const workspaceRoot = "/repo/project"; + +const makeInvocationLayer = (capabilities: ReadonlySet) => + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + threadId, + providerSessionId: "provider-session-worktree-test", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + capabilities, + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, + }); + +const makeThread = (overrides: Partial = {}): OrchestrationThread => + ({ + id: threadId, + projectId, + title: "Worktree test thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-5", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + }) as OrchestrationThread; + +const projectShell: OrchestrationProjectShell = { + id: projectId, + title: "Worktree test project", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +} as OrchestrationProjectShell; + +interface HarnessOptions { + readonly thread?: OrchestrationThread | null; + readonly capabilities?: ReadonlySet; + readonly currentBranch?: string | null; + readonly newWorktreesStartFromOrigin?: boolean; + readonly setupScript?: "started" | "no-script" | "fails"; + readonly dispatchFails?: boolean; + readonly createWorktreeGate?: Effect.Effect; +} + +const makeHarness = (options: HarnessOptions = {}) => { + const thread = options.thread === undefined ? makeThread() : options.thread; + const dispatch = vi.fn((_: unknown) => + options.dispatchFails + ? (Effect.fail("simulated dispatch failure") as never) + : Effect.succeed({ sequence: 1 }), + ); + const removeWorktree = vi.fn((_: unknown) => Effect.void); + const fetchRemote = vi.fn((_: unknown) => Effect.void); + const resolveRemoteTrackingCommit = vi.fn((_: unknown) => + Effect.succeed({ commitSha: "abc123", remoteRefName: "origin/dev" }), + ); + const createWorktree = vi.fn( + (input: { readonly newRefName?: string | undefined; readonly path: string | null }) => + (options.createWorktreeGate ?? Effect.void).pipe( + Effect.andThen( + Effect.succeed({ + worktree: { + path: input.path ?? `/worktrees/project/${input.newRefName}`, + refName: input.newRefName ?? "detached", + }, + }), + ), + ), + ); + const localStatus = vi.fn((_: unknown) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: options.currentBranch === undefined ? "dev" : options.currentBranch, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + ); + const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub")); + const runForThread = vi.fn((input: { readonly worktreePath: string }) => { + switch (options.setupScript ?? "started") { + case "no-script": + return Effect.succeed({ status: "no-script" } as const); + case "fails": + return Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptProjectNotFoundError({ + threadId, + worktreePath: input.worktreePath, + }), + ); + default: + return Effect.succeed({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-terminal", + cwd: input.worktreePath, + } as const); + } + }); + + const layer = Layer.mergeAll( + makeInvocationLayer(options.capabilities ?? new Set(["preview", "worktree"])), + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + dispatch, + } satisfies Partial), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getThreadDetailById: (id) => + Effect.succeed(id === threadId && thread ? Option.some(thread) : Option.none()), + getProjectShellById: (id) => + Effect.succeed(id === projectId ? Option.some(projectShell) : Option.none()), + } satisfies Partial), + ServerSettings.ServerSettingsService.layerTest({ + newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, + }), + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + removeWorktree, + } satisfies Partial), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread, + } satisfies Partial), + Layer.mock(VcsStatusBroadcaster)({ + refreshStatus, + } satisfies Partial), + ).pipe(Layer.provideMerge(NodeServices.layer)); + + return { + layer, + dispatch, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + removeWorktree, + localStatus, + runForThread, + }; +}; + +const expectTypedFailure = (exit: Exit.Exit, expected: object): void => { + if (!Exit.isFailure(exit)) { + expect.fail(`Expected a failure exit, got: ${JSON.stringify(exit)}`); + } + const reason = exit.cause.reasons[0]; + if (reason?._tag !== "Fail") { + expect.fail(`Expected a typed Fail cause, got: ${reason?._tag ?? "no reason"}`); + } + expect(reason.error).toMatchObject(expected); +}; + +const runHandoff = ( + harness: ReturnType, + input: Parameters[0], +) => __testing.worktreeHandoff(input).pipe(Effect.provide(harness.layer)); + +describe("worktree_handoff", () => { + it.effect("creates a worktree from the current branch and re-points the thread", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const result = yield* runHandoff(harness, { branch: "feature/handoff" }); + + expect(result.branch).toBe("feature/handoff"); + expect(result.baseRef).toBe("dev"); + expect(result.startedFromOrigin).toBe(false); + expect(result.worktreePath).toBe("/worktrees/project/feature/handoff"); + expect(result.setupScript).toMatchObject({ status: "started", scriptName: "Setup" }); + + expect(harness.fetchRemote).not.toHaveBeenCalled(); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "dev", + newRefName: "feature/handoff", + baseRefName: "dev", + path: null, + }); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "thread.meta.update", + threadId, + branch: "feature/handoff", + worktreePath: "/worktrees/project/feature/handoff", + }), + ); + expect(harness.runForThread).toHaveBeenCalledWith({ + threadId, + projectId, + projectCwd: workspaceRoot, + worktreePath: "/worktrees/project/feature/handoff", + }); + }); + }); + + it.effect("starts from origin and honors explicit baseRef and path", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const result = yield* runHandoff(harness, { + branch: "feature/from-origin", + baseRef: "dev", + startFromOrigin: true, + path: "/custom/worktree/location", + runSetupScript: false, + }); + + expect(harness.fetchRemote).toHaveBeenCalledWith({ + cwd: workspaceRoot, + remoteName: "origin", + }); + expect(harness.resolveRemoteTrackingCommit).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "dev", + fallbackRemoteName: "origin", + }); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "abc123", + newRefName: "feature/from-origin", + baseRefName: "dev", + path: "/custom/worktree/location", + }); + expect(harness.localStatus).not.toHaveBeenCalled(); + expect(harness.runForThread).not.toHaveBeenCalled(); + expect(result.worktreePath).toBe("/custom/worktree/location"); + expect(result.startedFromOrigin).toBe(true); + expect(result.setupScript).toEqual({ status: "skipped" }); + }); + }); + + it.effect("uses the server setting for startFromOrigin when unspecified", () => { + const harness = makeHarness({ newWorktreesStartFromOrigin: true }); + return Effect.gen(function* () { + const result = yield* runHandoff(harness, { branch: "feature/settings-origin" }); + expect(result.startedFromOrigin).toBe(true); + expect(harness.fetchRemote).toHaveBeenCalled(); + }); + }); + + it.effect("fails when the thread is already attached to a worktree", () => { + const harness = makeHarness({ + thread: makeThread({ worktreePath: "/worktrees/project/existing" }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/second" })); + expectTypedFailure(exit, { + _tag: "WorktreeHandoffAlreadyInWorktreeError", + worktreePath: "/worktrees/project/existing", + }); + expect(harness.createWorktree).not.toHaveBeenCalled(); + }); + }); + + it.effect("fails when the worktree capability is missing", () => { + const harness = makeHarness({ capabilities: new Set(["preview"]) }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-capability" })); + expectTypedFailure(exit, { _tag: "WorktreeCapabilityUnavailableError" }); + }); + }); + + it.effect("serializes concurrent handoffs for the same thread", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const harness = makeHarness({ createWorktreeGate: Deferred.await(gate) }); + + // First handoff acquires the per-thread guard and blocks on the gate. + const first = yield* Effect.forkChild( + Effect.exit(runHandoff(harness, { branch: "feature/race-1" })), + ); + yield* Effect.yieldNow; + + // Second handoff for the same thread must be refused while the first + // is still in flight. + const second = yield* Effect.exit(runHandoff(harness, { branch: "feature/race-2" })); + expectTypedFailure(second, { _tag: "WorktreeHandoffInvalidRequestError" }); + + yield* Deferred.succeed(gate, undefined); + const firstExit = yield* Fiber.join(first); + expect(Exit.isSuccess(firstExit)).toBe(true); + expect(harness.createWorktree).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("removes the created worktree when the thread update fails", () => { + const harness = makeHarness({ dispatchFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-fails" })); + expectTypedFailure(exit, { + _tag: "WorktreeOperationError", + operation: "updateThreadMetadata", + }); + expect(harness.createWorktree).toHaveBeenCalledTimes(1); + expect(harness.removeWorktree).toHaveBeenCalledWith({ + cwd: workspaceRoot, + path: "/worktrees/project/feature/dispatch-fails", + force: true, + }); + }); + }); + + it.effect("rejects a relative path", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runHandoff(harness, { branch: "feature/relative-path", path: "worktrees/nested" }), + ); + expectTypedFailure(exit, { _tag: "WorktreeHandoffInvalidRequestError" }); + expect(harness.createWorktree).not.toHaveBeenCalled(); + }); + }); + + it.effect("fails when baseRef is omitted and HEAD is detached", () => { + const harness = makeHarness({ currentBranch: null }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/detached" })); + expectTypedFailure(exit, { _tag: "WorktreeHandoffInvalidRequestError" }); + }); + }); + + it.effect("reports setup script failure without failing the handoff", () => { + const harness = makeHarness({ setupScript: "fails" }); + return Effect.gen(function* () { + const result = yield* runHandoff(harness, { branch: "feature/setup-fails" }); + expect(result.setupScript.status).toBe("failed"); + expect(harness.dispatch).toHaveBeenCalled(); + }); + }); +}); + +describe("worktree_status", () => { + it.effect("reports an unattached thread", () => { + const harness = makeHarness({ newWorktreesStartFromOrigin: true }); + return Effect.gen(function* () { + const result = yield* __testing.worktreeStatus().pipe(Effect.provide(harness.layer)); + expect(result).toEqual({ + attached: false, + worktreePath: null, + branch: null, + projectWorkspaceRoot: workspaceRoot, + defaultStartFromOrigin: true, + }); + }); + }); + + it.effect("reports an attached thread's worktree and branch", () => { + const harness = makeHarness({ + thread: makeThread({ + worktreePath: "/worktrees/project/existing", + branch: "feature/existing", + }), + }); + return Effect.gen(function* () { + const result = yield* __testing.worktreeStatus().pipe(Effect.provide(harness.layer)); + expect(result).toMatchObject({ + attached: true, + worktreePath: "/worktrees/project/existing", + branch: "feature/existing", + defaultStartFromOrigin: false, + }); + }); + }); + + it.effect("fails when the worktree capability is missing", () => { + const harness = makeHarness({ capabilities: new Set(["preview"]) }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + __testing.worktreeStatus().pipe(Effect.provide(harness.layer)), + ); + expectTypedFailure(exit, { _tag: "WorktreeCapabilityUnavailableError" }); + }); + }); +}); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts new file mode 100644 index 00000000000..07ccdd27800 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -0,0 +1,286 @@ +import { + CommandId, + WorktreeCapabilityUnavailableError, + WorktreeHandoffAlreadyInWorktreeError, + type WorktreeHandoffInput, + WorktreeHandoffInvalidRequestError, + type WorktreeHandoffResult, + type WorktreeHandoffSetupScriptStatus, + WorktreeOperationError, + WorktreeProjectNotFoundError, + type WorktreeStatusResult, + WorktreeThreadNotFoundError, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { WorktreeToolkit } from "./tools.ts"; + +type WorktreeOperation = typeof WorktreeOperationError.fields.operation.Type; + +const errorDetail = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + return String(error); +}; + +const asOperationError = (operation: WorktreeOperation) => (error: unknown) => + new WorktreeOperationError({ operation, cause: error }); + +const requireWorktreeCapability = Effect.fn("mcp.requireWorktreeCapability")(function* () { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (!invocation.capabilities.has("worktree")) { + return yield* new WorktreeCapabilityUnavailableError({ + capability: "worktree", + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }); + } + return invocation; +}); + +// Serializes handoffs per thread: two concurrent calls could otherwise both +// pass the worktreePath === null check and each create a worktree, leaving +// one untracked on disk. Module-level state is safe here for the same reason +// it is in McpProviderSession: the server process hosts a single MCP server. +const handoffThreadsInFlight = new Set(); + +const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( + input: WorktreeHandoffInput, +) { + const invocation = yield* requireWorktreeCapability(); + if (handoffThreadsInFlight.has(invocation.threadId)) { + return yield* new WorktreeHandoffInvalidRequestError({ + detail: `A worktree handoff is already in progress for thread '${invocation.threadId}'.`, + }); + } + handoffThreadsInFlight.add(invocation.threadId); + return yield* performWorktreeHandoff(invocation, input).pipe( + Effect.ensuring(Effect.sync(() => handoffThreadsInFlight.delete(invocation.threadId))), + ); +}); + +const performWorktreeHandoff = Effect.fn("WorktreeToolkit.performWorktreeHandoff")(function* ( + invocation: McpInvocationContext.McpInvocationScope, + input: WorktreeHandoffInput, +) { + const crypto = yield* Crypto.Crypto; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + const thread = yield* projectionSnapshotQuery + .getThreadDetailById(invocation.threadId) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveThread"))); + if (!thread) { + return yield* new WorktreeThreadNotFoundError({ threadId: invocation.threadId }); + } + if (thread.worktreePath !== null) { + return yield* new WorktreeHandoffAlreadyInWorktreeError({ + threadId: invocation.threadId, + worktreePath: thread.worktreePath, + }); + } + + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveProject"))); + if (!project) { + return yield* new WorktreeProjectNotFoundError({ + threadId: invocation.threadId, + projectId: thread.projectId, + }); + } + const projectCwd = project.workspaceRoot; + + const path = yield* Path.Path; + if (input.path !== undefined && !path.isAbsolute(input.path)) { + return yield* new WorktreeHandoffInvalidRequestError({ + detail: `path must be an absolute filesystem path, got '${input.path}'. A relative path would be created relative to the project workspace but stored verbatim as the thread's worktree binding.`, + }); + } + + let baseRef = input.baseRef; + if (baseRef === undefined) { + const localStatus = yield* gitWorkflow + .localStatus({ cwd: projectCwd }) + .pipe(Effect.mapError(asOperationError("resolveBaseRef"))); + if (!localStatus.isRepo) { + return yield* new WorktreeHandoffInvalidRequestError({ + detail: `Project workspace '${projectCwd}' is not a git repository.`, + }); + } + if (localStatus.refName === null) { + return yield* new WorktreeHandoffInvalidRequestError({ + detail: + "Could not determine the current branch of the project workspace (detached HEAD?). Pass baseRef explicitly.", + }); + } + baseRef = localStatus.refName; + } + + const startFromOrigin = + input.startFromOrigin ?? + (yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.newWorktreesStartFromOrigin), + Effect.mapError(asOperationError("resolveSettings")), + )); + + let worktreeBaseRef = baseRef; + if (startFromOrigin) { + yield* gitWorkflow + .fetchRemote({ cwd: projectCwd, remoteName: "origin" }) + .pipe(Effect.mapError(asOperationError("fetchRemote"))); + const resolvedRemoteBase = yield* gitWorkflow + .resolveRemoteTrackingCommit({ + cwd: projectCwd, + refName: baseRef, + fallbackRemoteName: "origin", + }) + .pipe(Effect.mapError(asOperationError("resolveRemoteTrackingCommit"))); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } + + const worktree = yield* gitWorkflow + .createWorktree({ + cwd: projectCwd, + refName: worktreeBaseRef, + newRefName: input.branch, + baseRefName: baseRef, + path: input.path ?? null, + }) + .pipe(Effect.mapError(asOperationError("createWorktree"))); + const worktreePath = worktree.worktree.path; + + const commandId = yield* crypto.randomUUIDv4.pipe( + Effect.map((uuid) => CommandId.make(`server:mcp-worktree-handoff:${uuid}`)), + Effect.orDie, + ); + yield* orchestrationEngine + .dispatch({ + type: "thread.meta.update", + commandId, + threadId: invocation.threadId, + branch: worktree.worktree.refName, + worktreePath, + }) + .pipe( + Effect.mapError(asOperationError("updateThreadMetadata")), + // The worktree was already created; if the thread cannot be re-pointed + // at it, remove it again so a failed handoff leaves nothing behind. + Effect.catch((error) => + gitWorkflow + .removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }) + .pipe(Effect.ignoreCause({ log: true }), Effect.andThen(Effect.fail(error))), + ), + ); + + yield* vcsStatusBroadcaster + .refreshStatus(worktreePath) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + let setupScript: WorktreeHandoffSetupScriptStatus = { status: "skipped" }; + if (input.runSetupScript ?? true) { + setupScript = yield* setupScriptRunner + .runForThread({ + threadId: invocation.threadId, + projectId: thread.projectId, + projectCwd, + worktreePath, + }) + .pipe( + Effect.map( + (result): WorktreeHandoffSetupScriptStatus => + result.status === "started" + ? { + status: "started", + scriptName: result.scriptName, + terminalId: result.terminalId, + } + : { status: "no-script" }, + ), + Effect.catch( + (error: unknown): Effect.Effect => + Effect.logWarning("worktree handoff setup script failed", { + threadId: invocation.threadId, + worktreePath, + detail: errorDetail(error), + }).pipe(Effect.as({ status: "failed", detail: errorDetail(error) } as const)), + ), + ); + } + + const result: WorktreeHandoffResult = { + worktreePath, + branch: worktree.worktree.refName, + baseRef, + startedFromOrigin: startFromOrigin, + setupScript, + note: "Handoff recorded. The agent session restarts inside the worktree at the start of the next turn with the conversation preserved; finish the current turn normally.", + }; + return result; +}); + +const worktreeStatus = Effect.fn("WorktreeToolkit.worktreeStatus")(function* () { + const invocation = yield* requireWorktreeCapability(); + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; + + const thread = yield* projectionSnapshotQuery + .getThreadDetailById(invocation.threadId) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveThread"))); + if (!thread) { + return yield* new WorktreeThreadNotFoundError({ threadId: invocation.threadId }); + } + + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveProject"))); + if (!project) { + return yield* new WorktreeProjectNotFoundError({ + threadId: invocation.threadId, + projectId: thread.projectId, + }); + } + + const defaultStartFromOrigin = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.newWorktreesStartFromOrigin), + Effect.mapError(asOperationError("resolveSettings")), + ); + + const result: WorktreeStatusResult = { + attached: thread.worktreePath !== null, + worktreePath: thread.worktreePath, + branch: thread.branch, + projectWorkspaceRoot: project.workspaceRoot, + defaultStartFromOrigin, + }; + return result; +}); + +export const WorktreeToolkitHandlersLive = WorktreeToolkit.toLayer({ + worktree_handoff: (input) => worktreeHandoff(input), + worktree_status: () => worktreeStatus(), +}); + +/** Exposed for tests. */ +export const __testing = { + worktreeHandoff, + worktreeStatus, +}; diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts new file mode 100644 index 00000000000..38cd4d9e7d4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -0,0 +1,111 @@ +import { expect, it } from "@effect/vitest"; +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; + +import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; +import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; + +const StubServicesLive = Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({}), + ServerSettings.ServerSettingsService.layerTest({}), + Layer.mock(GitWorkflowService.GitWorkflowService)({}), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), + Layer.mock(VcsStatusBroadcaster)({}), +); + +it.effect("production mcp layer lists worktree tools over http", () => + Effect.scoped( + Effect.gen(function* () { + const routes = McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)); + yield* HttpRouter.serve(routes, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed("environment-scratch" as never), + }), + ), + Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(StubServicesLive), + Layer.build, + ); + + const registry = McpSessionRegistry.issueActiveMcpCredential({ + threadId: ThreadId.make("thread-scratch"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }); + const credential = yield* registry; + expect(credential).toBeDefined(); + + const httpClient = yield* HttpClient.HttpClient; + const auth = credential!.config.authorizationHeader; + const initResponse = yield* httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + authorization: auth, + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"scratch","version":"1.0.0"}}}`, + "application/json", + ), + }); + expect(initResponse.status).toBe(200); + const sessionId = initResponse.headers["mcp-session-id"]; + + const listResponse = yield* httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + authorization: auth, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, + "application/json", + ), + }); + const bodyText = yield* listResponse.text; + const ToolsListPayload = Schema.fromJsonString( + Schema.Struct({ + result: Schema.Struct({ + tools: Schema.Array( + Schema.Struct({ + name: Schema.String, + inputSchema: Schema.Struct({ type: Schema.optional(Schema.String) }), + }), + ), + }), + }), + ); + const payload = yield* Schema.decodeUnknownEffect(ToolsListPayload)( + bodyText.match(/\{.*\}/s)![0], + ); + const tools = payload.result.tools; + const toolNames = tools.map((tool) => tool.name); + expect(toolNames).toContain("preview_status"); + expect(toolNames).toContain("worktree_handoff"); + expect(toolNames).toContain("worktree_status"); + + // MCP requires every tool input schema to be a top-level object schema. + // A non-object schema (e.g. the anyOf produced by an empty + // Schema.Struct({})) makes clients reject the entire server. + for (const tool of tools) { + expect(tool.inputSchema.type, `inputSchema.type of ${tool.name}`).toBe("object"); + } + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts new file mode 100644 index 00000000000..9670b93409e --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -0,0 +1,63 @@ +import { + WorktreeHandoffError, + WorktreeHandoffInput, + WorktreeHandoffResult, + WorktreeStatusError, + WorktreeStatusResult, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Path from "effect/Path"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; +import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; +import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + Crypto.Crypto, + Path.Path, + OrchestrationEngine.OrchestrationEngineService, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ServerSettings.ServerSettingsService, + GitWorkflowService.GitWorkflowService, + ProjectSetupScriptRunner.ProjectSetupScriptRunner, + VcsStatusBroadcaster.VcsStatusBroadcaster, +]; + +export const WorktreeHandoffTool = Tool.make("worktree_handoff", { + description: + "Move this agent thread into a new git worktree. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. The session restarts inside the worktree at the start of the next turn with the conversation preserved, so call this only when the remaining work should happen on a dedicated branch. Fails if the thread is already attached to a worktree.", + parameters: WorktreeHandoffInput, + success: WorktreeHandoffResult, + failure: WorktreeHandoffError, + dependencies, +}) + .annotate(Tool.Title, "Hand off thread to a git worktree") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +export const WorktreeStatusTool = Tool.make("worktree_status", { + description: + "Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for worktree_handoff's startFromOrigin. Call this before worktree_handoff to check whether a handoff is possible or has already happened.", + // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes + // to a top-level `type: "object"` JSON Schema. An explicit empty + // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a + // valid MCP tool input schema and makes clients reject the whole server. + success: WorktreeStatusResult, + failure: WorktreeStatusError, + dependencies, +}) + .annotate(Tool.Title, "Get thread worktree status") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const WorktreeToolkit = Toolkit.make(WorktreeHandoffTool, WorktreeStatusTool); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..4710db0594a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -25,4 +25,5 @@ export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; +export * from "./worktree.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/worktree.ts b/packages/contracts/src/worktree.ts new file mode 100644 index 00000000000..15fabe8d15d --- /dev/null +++ b/packages/contracts/src/worktree.ts @@ -0,0 +1,189 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** + * Input for the `worktree_handoff` MCP tool. + * + * Creates a git worktree for the calling agent thread and re-points the + * thread at it. The provider session restarts inside the worktree at the + * start of the next turn, resuming the conversation. + */ +export const WorktreeHandoffInput = Schema.Struct({ + branch: TrimmedNonEmptyString.annotate({ + description: "Branch name to create for the worktree (e.g. 'feature/my-change').", + }), + baseRef: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Branch or ref the worktree branch starts from. Defaults to the branch currently checked out in the project workspace.", + }), + ), + startFromOrigin: Schema.optional( + Schema.Boolean.annotate({ + description: + "Fetch origin and start the worktree branch from the remote-tracking commit of baseRef instead of the local ref. Defaults to the server's 'new worktrees start from origin' setting.", + }), + ), + path: Schema.optional( + TrimmedNonEmptyString.check( + // Absolute POSIX (/...), Windows drive (C:\ or C:/), or UNC (\\host). + Schema.isPattern(/^(?:[A-Za-z]:[\\/]|[\\/])/), + ).annotate({ + description: + "Absolute filesystem path for the new worktree. Relative paths are rejected. Defaults to the server-managed worktrees directory.", + }), + ), + runSetupScript: Schema.optional( + Schema.Boolean.annotate({ + description: + "Run the project's configured setup script in the new worktree after handoff. Defaults to true.", + }), + ), +}); +export type WorktreeHandoffInput = typeof WorktreeHandoffInput.Type; + +export const WorktreeHandoffSetupScriptStatus = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("started"), + scriptName: TrimmedNonEmptyString, + terminalId: TrimmedNonEmptyString, + }), + Schema.Struct({ + status: Schema.Literal("no-script"), + }), + Schema.Struct({ + status: Schema.Literal("skipped"), + }), + Schema.Struct({ + status: Schema.Literal("failed"), + detail: Schema.String, + }), +]); +export type WorktreeHandoffSetupScriptStatus = typeof WorktreeHandoffSetupScriptStatus.Type; + +export const WorktreeHandoffResult = Schema.Struct({ + worktreePath: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, + baseRef: TrimmedNonEmptyString, + startedFromOrigin: Schema.Boolean, + setupScript: WorktreeHandoffSetupScriptStatus, + note: Schema.String, +}); +export type WorktreeHandoffResult = typeof WorktreeHandoffResult.Type; + +export class WorktreeCapabilityUnavailableError extends Schema.TaggedErrorClass()( + "WorktreeCapabilityUnavailableError", + { + capability: Schema.Literal("worktree"), + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `Worktree tools are not available for this agent session.`; + } +} + +export class WorktreeThreadNotFoundError extends Schema.TaggedErrorClass()( + "WorktreeThreadNotFoundError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return `Thread '${this.threadId}' was not found.`; + } +} + +export class WorktreeProjectNotFoundError extends Schema.TaggedErrorClass()( + "WorktreeProjectNotFoundError", + { + threadId: ThreadId, + projectId: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Project '${this.projectId}' was not found for thread '${this.threadId}'.`; + } +} + +export class WorktreeHandoffAlreadyInWorktreeError extends Schema.TaggedErrorClass()( + "WorktreeHandoffAlreadyInWorktreeError", + { + threadId: ThreadId, + worktreePath: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Thread '${this.threadId}' is already attached to worktree '${this.worktreePath}'.`; + } +} + +export class WorktreeHandoffInvalidRequestError extends Schema.TaggedErrorClass()( + "WorktreeHandoffInvalidRequestError", + { + detail: Schema.String, + }, +) { + override get message(): string { + return `Worktree handoff request is invalid: ${this.detail}`; + } +} + +export class WorktreeOperationError extends Schema.TaggedErrorClass()( + "WorktreeOperationError", + { + operation: Schema.Literals([ + "resolveThread", + "resolveProject", + "resolveBaseRef", + "fetchRemote", + "resolveRemoteTrackingCommit", + "createWorktree", + "updateThreadMetadata", + "resolveSettings", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Worktree operation '${this.operation}' failed.`; + } +} + +export const WorktreeHandoffError = Schema.Union([ + WorktreeCapabilityUnavailableError, + WorktreeThreadNotFoundError, + WorktreeProjectNotFoundError, + WorktreeHandoffAlreadyInWorktreeError, + WorktreeHandoffInvalidRequestError, + WorktreeOperationError, +]); +export type WorktreeHandoffError = typeof WorktreeHandoffError.Type; + +export const WorktreeStatusResult = Schema.Struct({ + attached: Schema.Boolean.annotate({ + description: "True when this thread is already attached to a git worktree.", + }), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + branch: Schema.NullOr(TrimmedNonEmptyString), + projectWorkspaceRoot: TrimmedNonEmptyString.annotate({ + description: "Root of the project's main workspace checkout.", + }), + defaultStartFromOrigin: Schema.Boolean.annotate({ + description: "Server default used by worktree_handoff when startFromOrigin is omitted.", + }), +}); +export type WorktreeStatusResult = typeof WorktreeStatusResult.Type; + +export const WorktreeStatusError = Schema.Union([ + WorktreeCapabilityUnavailableError, + WorktreeThreadNotFoundError, + WorktreeProjectNotFoundError, + WorktreeOperationError, +]); +export type WorktreeStatusError = typeof WorktreeStatusError.Type;