From e9f46336751044c7b7c970a168f60aa6cfe44576 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 10:47:20 -0500 Subject: [PATCH 1/9] feat(server): add worktree_handoff MCP tool for in-session worktree handoff Adds a worktree toolkit to the t3-code MCP server so an agent can move its own thread into a new git worktree mid-conversation. The tool: - creates the worktree branch from a base ref (defaults to the branch currently checked out in the project workspace), optionally starting from the remote-tracking commit of that ref (defaults to the server's 'new worktrees start from origin' setting) - supports an optional explicit worktree path (defaults to the server-managed worktrees directory) - dispatches thread.meta.update so the provider session restarts inside the worktree at the next turn with the conversation preserved via the existing resumeCursor machinery - runs the project's configured setup script in the new worktree by default; setup failures are reported in the result instead of failing the handoff - fails cleanly when the thread is already attached to a worktree or the base ref cannot be resolved Introduces a 'worktree' MCP capability alongside 'preview' and new contracts (WorktreeHandoffInput/Result/Error). --- apps/server/src/mcp/McpHttpServer.ts | 11 +- apps/server/src/mcp/McpInvocationContext.ts | 4 +- apps/server/src/mcp/McpSessionRegistry.ts | 2 +- .../mcp/toolkits/worktree/handlers.test.ts | 311 ++++++++++++++++++ .../src/mcp/toolkits/worktree/handlers.ts | 210 ++++++++++++ .../server/src/mcp/toolkits/worktree/tools.ts | 42 +++ packages/contracts/src/index.ts | 1 + packages/contracts/src/worktreeHandoff.ts | 149 +++++++++ 8 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/worktree/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/worktree/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/worktree/tools.ts create mode 100644 packages/contracts/src/worktreeHandoff.ts 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..0431bcdf42e --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -0,0 +1,311 @@ +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 Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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"; +} + +const makeHarness = (options: HarnessOptions = {}) => { + const thread = options.thread === undefined ? makeThread() : options.thread; + const dispatch = vi.fn((_: unknown) => Effect.succeed({ sequence: 1 })); + 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; readonly path: string | null }) => + 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, + } as Partial as never), + 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()), + } as Partial as never), + ServerSettings.ServerSettingsService.layerTest({ + newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, + }), + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + } as Partial as never), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread, + } as Partial as never), + Layer.mock(VcsStatusBroadcaster)({ + refreshStatus, + } as Partial as never), + ).pipe(Layer.provideMerge(NodeServices.layer)); + + return { + layer, + dispatch, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + localStatus, + runForThread, + }; +}; + +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" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { + expect(exit.cause.reasons[0].error).toMatchObject({ + _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" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { + expect(exit.cause.reasons[0].error).toMatchObject({ + _tag: "WorktreeHandoffUnavailableError", + }); + } + }); + }); + + 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" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { + expect(exit.cause.reasons[0].error).toMatchObject({ + _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(); + }); + }); +}); 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..073d23fb634 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -0,0 +1,210 @@ +import { + CommandId, + WorktreeHandoffAlreadyInWorktreeError, + WorktreeHandoffInvalidRequestError, + WorktreeHandoffOperationError, + type WorktreeHandoffResult, + type WorktreeHandoffSetupScriptStatus, + WorktreeHandoffThreadNotFoundError, + WorktreeHandoffUnavailableError, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +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 * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { WorktreeToolkit } from "./tools.ts"; + +type HandoffOperation = typeof WorktreeHandoffOperationError.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: HandoffOperation) => (error: unknown) => + new WorktreeHandoffOperationError({ operation, detail: errorDetail(error) }); + +const requireWorktreeCapability = Effect.gen(function* () { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (!invocation.capabilities.has("worktree")) { + return yield* new WorktreeHandoffUnavailableError({ + capability: "worktree", + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }); + } + return invocation; +}); + +const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* (input: { + readonly branch: string; + readonly baseRef?: string | undefined; + readonly startFromOrigin?: boolean | undefined; + readonly path?: string | undefined; + readonly runSetupScript?: boolean | undefined; +}) { + const invocation = yield* requireWorktreeCapability; + 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 WorktreeHandoffThreadNotFoundError({ threadId: invocation.threadId }); + } + if (thread.worktreePath !== null && thread.worktreePath !== undefined) { + 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 WorktreeHandoffOperationError({ + operation: "resolveProject", + detail: `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, + }); + } + const projectCwd = project.workspaceRoot; + + 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("resolveBaseRef")), + )); + + 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"))); + + 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; +}); + +export const WorktreeToolkitHandlersLive = WorktreeToolkit.toLayer({ + worktree_handoff: (input) => worktreeHandoff(input), +}); + +/** Exposed for tests. */ +export const __testing = { + worktreeHandoff, +}; 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..6b9058aed66 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -0,0 +1,42 @@ +import { + WorktreeHandoffError, + WorktreeHandoffInput, + WorktreeHandoffResult, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +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, + 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 WorktreeToolkit = Toolkit.make(WorktreeHandoffTool); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..ef801d764e0 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 "./worktreeHandoff.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/worktreeHandoff.ts b/packages/contracts/src/worktreeHandoff.ts new file mode 100644 index 00000000000..6cf8f060a77 --- /dev/null +++ b/packages/contracts/src/worktreeHandoff.ts @@ -0,0 +1,149 @@ +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.annotate({ + description: + "Absolute filesystem path for the new worktree. 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 WorktreeHandoffUnavailableError extends Schema.TaggedErrorClass()( + "WorktreeHandoffUnavailableError", + { + capability: Schema.Literal("worktree"), + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `Worktree handoff is not available for this agent session.`; + } +} + +export class WorktreeHandoffThreadNotFoundError extends Schema.TaggedErrorClass()( + "WorktreeHandoffThreadNotFoundError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return `Thread '${this.threadId}' was not found for worktree handoff.`; + } +} + +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 WorktreeHandoffOperationError extends Schema.TaggedErrorClass()( + "WorktreeHandoffOperationError", + { + operation: Schema.Literals([ + "resolveThread", + "resolveProject", + "resolveBaseRef", + "fetchRemote", + "resolveRemoteTrackingCommit", + "createWorktree", + "updateThreadMetadata", + ]), + detail: Schema.String, + }, +) { + override get message(): string { + return `Worktree handoff operation '${this.operation}' failed: ${this.detail}`; + } +} + +export const WorktreeHandoffError = Schema.Union([ + WorktreeHandoffUnavailableError, + WorktreeHandoffThreadNotFoundError, + WorktreeHandoffAlreadyInWorktreeError, + WorktreeHandoffInvalidRequestError, + WorktreeHandoffOperationError, +]); +export type WorktreeHandoffError = typeof WorktreeHandoffError.Type; From 0729f6c600cebce48b52ee80e419c93e0b1a260b Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 15:58:52 -0500 Subject: [PATCH 2/9] feat(server): add worktree_status MCP tool and generalize worktree error names Adds a read-only worktree_status tool reporting the calling thread's worktree binding (attached, worktreePath, branch), the project's main workspace root, and the server default for startFromOrigin, so agents can check whether a handoff is possible or already happened before calling worktree_handoff. Renames the shared error classes to be tool-neutral now that the toolkit has more than one tool: WorktreeHandoffUnavailableError -> WorktreeCapabilityUnavailableError, WorktreeHandoffThreadNotFoundError -> WorktreeThreadNotFoundError, WorktreeHandoffOperationError -> WorktreeOperationError. Handoff-specific errors keep their names. --- .../mcp/toolkits/worktree/handlers.test.ts | 51 +++++++++++++++- .../src/mcp/toolkits/worktree/handlers.ts | 58 ++++++++++++++++--- .../server/src/mcp/toolkits/worktree/tools.ts | 19 +++++- packages/contracts/src/worktreeHandoff.ts | 54 +++++++++++++---- 4 files changed, 159 insertions(+), 23 deletions(-) diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts index 0431bcdf42e..db55c5951c1 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -281,7 +281,7 @@ describe("worktree_handoff", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error).toMatchObject({ - _tag: "WorktreeHandoffUnavailableError", + _tag: "WorktreeCapabilityUnavailableError", }); } }); @@ -309,3 +309,52 @@ describe("worktree_handoff", () => { }); }); }); + +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)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { + expect(exit.cause.reasons[0].error).toMatchObject({ + _tag: "WorktreeCapabilityUnavailableError", + }); + } + }); + }); +}); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 073d23fb634..c7f2a658d36 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -1,12 +1,13 @@ import { CommandId, + WorktreeCapabilityUnavailableError, WorktreeHandoffAlreadyInWorktreeError, WorktreeHandoffInvalidRequestError, - WorktreeHandoffOperationError, type WorktreeHandoffResult, type WorktreeHandoffSetupScriptStatus, - WorktreeHandoffThreadNotFoundError, - WorktreeHandoffUnavailableError, + WorktreeOperationError, + type WorktreeStatusResult, + WorktreeThreadNotFoundError, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -21,7 +22,7 @@ import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { WorktreeToolkit } from "./tools.ts"; -type HandoffOperation = typeof WorktreeHandoffOperationError.fields.operation.Type; +type WorktreeOperation = typeof WorktreeOperationError.fields.operation.Type; const errorDetail = (error: unknown): string => { if (error instanceof Error) return error.message; @@ -31,13 +32,13 @@ const errorDetail = (error: unknown): string => { return String(error); }; -const asOperationError = (operation: HandoffOperation) => (error: unknown) => - new WorktreeHandoffOperationError({ operation, detail: errorDetail(error) }); +const asOperationError = (operation: WorktreeOperation) => (error: unknown) => + new WorktreeOperationError({ operation, detail: errorDetail(error) }); const requireWorktreeCapability = Effect.gen(function* () { const invocation = yield* McpInvocationContext.McpInvocationContext; if (!invocation.capabilities.has("worktree")) { - return yield* new WorktreeHandoffUnavailableError({ + return yield* new WorktreeCapabilityUnavailableError({ capability: "worktree", environmentId: invocation.environmentId, threadId: invocation.threadId, @@ -68,7 +69,7 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( .getThreadDetailById(invocation.threadId) .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveThread"))); if (!thread) { - return yield* new WorktreeHandoffThreadNotFoundError({ threadId: invocation.threadId }); + return yield* new WorktreeThreadNotFoundError({ threadId: invocation.threadId }); } if (thread.worktreePath !== null && thread.worktreePath !== undefined) { return yield* new WorktreeHandoffAlreadyInWorktreeError({ @@ -81,7 +82,7 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( .getProjectShellById(thread.projectId) .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveProject"))); if (!project) { - return yield* new WorktreeHandoffOperationError({ + return yield* new WorktreeOperationError({ operation: "resolveProject", detail: `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, }); @@ -200,11 +201,50 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( 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 WorktreeOperationError({ + operation: "resolveProject", + detail: `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, + }); + } + + 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/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 6b9058aed66..70d68fe89e0 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -2,6 +2,9 @@ import { WorktreeHandoffError, WorktreeHandoffInput, WorktreeHandoffResult, + WorktreeStatusError, + WorktreeStatusInput, + WorktreeStatusResult, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import { Tool, Toolkit } from "effect/unstable/ai"; @@ -39,4 +42,18 @@ export const WorktreeHandoffTool = Tool.make("worktree_handoff", { .annotate(Tool.Idempotent, false) .annotate(Tool.OpenWorld, false); -export const WorktreeToolkit = Toolkit.make(WorktreeHandoffTool); +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.", + parameters: WorktreeStatusInput, + 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/worktreeHandoff.ts b/packages/contracts/src/worktreeHandoff.ts index 6cf8f060a77..7260d8720c9 100644 --- a/packages/contracts/src/worktreeHandoff.ts +++ b/packages/contracts/src/worktreeHandoff.ts @@ -70,8 +70,8 @@ export const WorktreeHandoffResult = Schema.Struct({ }); export type WorktreeHandoffResult = typeof WorktreeHandoffResult.Type; -export class WorktreeHandoffUnavailableError extends Schema.TaggedErrorClass()( - "WorktreeHandoffUnavailableError", +export class WorktreeCapabilityUnavailableError extends Schema.TaggedErrorClass()( + "WorktreeCapabilityUnavailableError", { capability: Schema.Literal("worktree"), environmentId: EnvironmentId, @@ -81,18 +81,18 @@ export class WorktreeHandoffUnavailableError extends Schema.TaggedErrorClass()( - "WorktreeHandoffThreadNotFoundError", +export class WorktreeThreadNotFoundError extends Schema.TaggedErrorClass()( + "WorktreeThreadNotFoundError", { threadId: ThreadId, }, ) { override get message(): string { - return `Thread '${this.threadId}' was not found for worktree handoff.`; + return `Thread '${this.threadId}' was not found.`; } } @@ -119,8 +119,8 @@ export class WorktreeHandoffInvalidRequestError extends Schema.TaggedErrorClass< } } -export class WorktreeHandoffOperationError extends Schema.TaggedErrorClass()( - "WorktreeHandoffOperationError", +export class WorktreeOperationError extends Schema.TaggedErrorClass()( + "WorktreeOperationError", { operation: Schema.Literals([ "resolveThread", @@ -130,20 +130,50 @@ export class WorktreeHandoffOperationError extends Schema.TaggedErrorClass Date: Mon, 6 Jul 2026 16:14:56 -0500 Subject: [PATCH 3/9] refactor(server): align worktree toolkit with repo conventions - rename contracts module worktreeHandoff.ts to worktree.ts now that it also holds the status schemas - type the handoff handler input with the WorktreeHandoffInput contract instead of an inline structural type - give requireWorktreeCapability an Effect.fn trace span matching requireMcpCapability - drop a dead undefined check on thread.worktreePath (contract is NullOr) - use 'satisfies Partial' for test mocks instead of double casts, and tighten the createWorktree stub signature it flagged --- .../mcp/toolkits/worktree/handlers.test.ts | 12 ++++++------ .../src/mcp/toolkits/worktree/handlers.ts | 19 ++++++++----------- packages/contracts/src/index.ts | 2 +- .../src/{worktreeHandoff.ts => worktree.ts} | 0 4 files changed, 15 insertions(+), 18 deletions(-) rename packages/contracts/src/{worktreeHandoff.ts => worktree.ts} (100%) diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts index db55c5951c1..227765e7788 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -91,7 +91,7 @@ const makeHarness = (options: HarnessOptions = {}) => { Effect.succeed({ commitSha: "abc123", remoteRefName: "origin/dev" }), ); const createWorktree = vi.fn( - (input: { readonly newRefName?: string; readonly path: string | null }) => + (input: { readonly newRefName?: string | undefined; readonly path: string | null }) => Effect.succeed({ worktree: { path: input.path ?? `/worktrees/project/${input.newRefName}`, @@ -136,13 +136,13 @@ const makeHarness = (options: HarnessOptions = {}) => { makeInvocationLayer(options.capabilities ?? new Set(["preview", "worktree"])), Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ dispatch, - } as Partial as never), + } 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()), - } as Partial as never), + } satisfies Partial), ServerSettings.ServerSettingsService.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, }), @@ -151,13 +151,13 @@ const makeHarness = (options: HarnessOptions = {}) => { fetchRemote, resolveRemoteTrackingCommit, createWorktree, - } as Partial as never), + } satisfies Partial), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread, - } as Partial as never), + } satisfies Partial), Layer.mock(VcsStatusBroadcaster)({ refreshStatus, - } as Partial as never), + } satisfies Partial), ).pipe(Layer.provideMerge(NodeServices.layer)); return { diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index c7f2a658d36..9731adf97f6 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -2,6 +2,7 @@ import { CommandId, WorktreeCapabilityUnavailableError, WorktreeHandoffAlreadyInWorktreeError, + type WorktreeHandoffInput, WorktreeHandoffInvalidRequestError, type WorktreeHandoffResult, type WorktreeHandoffSetupScriptStatus, @@ -35,7 +36,7 @@ const errorDetail = (error: unknown): string => { const asOperationError = (operation: WorktreeOperation) => (error: unknown) => new WorktreeOperationError({ operation, detail: errorDetail(error) }); -const requireWorktreeCapability = Effect.gen(function* () { +const requireWorktreeCapability = Effect.fn("mcp.requireWorktreeCapability")(function* () { const invocation = yield* McpInvocationContext.McpInvocationContext; if (!invocation.capabilities.has("worktree")) { return yield* new WorktreeCapabilityUnavailableError({ @@ -49,14 +50,10 @@ const requireWorktreeCapability = Effect.gen(function* () { return invocation; }); -const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* (input: { - readonly branch: string; - readonly baseRef?: string | undefined; - readonly startFromOrigin?: boolean | undefined; - readonly path?: string | undefined; - readonly runSetupScript?: boolean | undefined; -}) { - const invocation = yield* requireWorktreeCapability; +const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( + input: WorktreeHandoffInput, +) { + const invocation = yield* requireWorktreeCapability(); const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -71,7 +68,7 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( if (!thread) { return yield* new WorktreeThreadNotFoundError({ threadId: invocation.threadId }); } - if (thread.worktreePath !== null && thread.worktreePath !== undefined) { + if (thread.worktreePath !== null) { return yield* new WorktreeHandoffAlreadyInWorktreeError({ threadId: invocation.threadId, worktreePath: thread.worktreePath, @@ -202,7 +199,7 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( }); const worktreeStatus = Effect.fn("WorktreeToolkit.worktreeStatus")(function* () { - const invocation = yield* requireWorktreeCapability; + const invocation = yield* requireWorktreeCapability(); const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const serverSettings = yield* ServerSettings.ServerSettingsService; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ef801d764e0..4710db0594a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -25,5 +25,5 @@ export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; -export * from "./worktreeHandoff.ts"; +export * from "./worktree.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/worktreeHandoff.ts b/packages/contracts/src/worktree.ts similarity index 100% rename from packages/contracts/src/worktreeHandoff.ts rename to packages/contracts/src/worktree.ts From 744661d85974758d3245c3e65cd6284b3130a63f Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 17:19:13 -0500 Subject: [PATCH 4/9] test(server): cover worktree tool registration through the production MCP layer Boots the real McpHttpServer.layer (layerHttp transport, auth middleware, session registry) over a test HTTP server, mints a credential, and asserts tools/list includes worktree_handoff and worktree_status alongside the preview tools. --- .../toolkits/worktree/registration.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/server/src/mcp/toolkits/worktree/registration.test.ts 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..9c7cb482c28 --- /dev/null +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -0,0 +1,87 @@ +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 { 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 toolNames = Array.from(bodyText.matchAll(/"name":"([a-z_]+)"/g)).map((m) => m[1]); + expect(toolNames).toContain("preview_status"); + expect(toolNames).toContain("worktree_handoff"); + expect(toolNames).toContain("worktree_status"); + }), + ).pipe(Effect.provide(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))), +); From 6af7f99650784dc2f3c1c39f2545c9e6284eefb6 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 17:45:01 -0500 Subject: [PATCH 5/9] fix(server): give worktree_status a valid MCP input schema An explicit empty Schema.Struct({}) serializes to JSON Schema 'anyOf: [object, array]', which is not a valid MCP tool inputSchema (clients require a top-level object schema). Claude Code rejects the entire t3-code MCP server when any tool schema is invalid, so the broken worktree_status schema silently took the preview tools down with it - the server stayed 'pending' and sessions saw zero tools. Drop the explicit parameters so Tool.make defaults to Tool.EmptyParams, which serializes to a top-level object schema, remove the now-unused WorktreeStatusInput contract, and extend the registration test to assert every listed tool has a top-level object inputSchema. --- .../toolkits/worktree/registration.test.ts | 26 ++++++++++++++++++- .../server/src/mcp/toolkits/worktree/tools.ts | 6 +++-- packages/contracts/src/worktree.ts | 7 ----- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 9c7cb482c28..38cd4d9e7d4 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -4,6 +4,7 @@ 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"; @@ -78,10 +79,33 @@ it.effect("production mcp layer lists worktree tools over http", () => ), }); const bodyText = yield* listResponse.text; - const toolNames = Array.from(bodyText.matchAll(/"name":"([a-z_]+)"/g)).map((m) => m[1]); + 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 index 70d68fe89e0..da644d7bdc3 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -3,7 +3,6 @@ import { WorktreeHandoffInput, WorktreeHandoffResult, WorktreeStatusError, - WorktreeStatusInput, WorktreeStatusResult, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -45,7 +44,10 @@ export const WorktreeHandoffTool = Tool.make("worktree_handoff", { 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.", - parameters: WorktreeStatusInput, + // 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, diff --git a/packages/contracts/src/worktree.ts b/packages/contracts/src/worktree.ts index 7260d8720c9..ac760a7c30a 100644 --- a/packages/contracts/src/worktree.ts +++ b/packages/contracts/src/worktree.ts @@ -149,13 +149,6 @@ export const WorktreeHandoffError = Schema.Union([ ]); export type WorktreeHandoffError = typeof WorktreeHandoffError.Type; -/** - * Input for the `worktree_status` MCP tool. Takes no parameters; the tool - * reports on the calling agent thread. - */ -export const WorktreeStatusInput = Schema.Struct({}); -export type WorktreeStatusInput = typeof WorktreeStatusInput.Type; - export const WorktreeStatusResult = Schema.Struct({ attached: Schema.Boolean.annotate({ description: "True when this thread is already attached to a git worktree.", From a7bf0f58f3bd81ca5ce8524d2a24f9c9ed352fab Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 18:54:04 -0500 Subject: [PATCH 6/9] fix(server): address review findings on worktree toolkit - correct the operation label on the settings read in the handoff path (resolveSettings, not resolveBaseRef) - assert typed failure tags unconditionally in handler tests via an expectTypedFailure helper so defects can no longer slip past the guarded assertions --- .../mcp/toolkits/worktree/handlers.test.ts | 43 ++++++++----------- .../src/mcp/toolkits/worktree/handlers.ts | 2 +- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts index 227765e7788..b238a5ac8b2 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -171,6 +171,17 @@ const makeHarness = (options: HarnessOptions = {}) => { }; }; +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], @@ -263,13 +274,10 @@ describe("worktree_handoff", () => { }); return Effect.gen(function* () { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/second" })); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { - expect(exit.cause.reasons[0].error).toMatchObject({ - _tag: "WorktreeHandoffAlreadyInWorktreeError", - worktreePath: "/worktrees/project/existing", - }); - } + expectTypedFailure(exit, { + _tag: "WorktreeHandoffAlreadyInWorktreeError", + worktreePath: "/worktrees/project/existing", + }); expect(harness.createWorktree).not.toHaveBeenCalled(); }); }); @@ -278,12 +286,7 @@ describe("worktree_handoff", () => { const harness = makeHarness({ capabilities: new Set(["preview"]) }); return Effect.gen(function* () { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-capability" })); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { - expect(exit.cause.reasons[0].error).toMatchObject({ - _tag: "WorktreeCapabilityUnavailableError", - }); - } + expectTypedFailure(exit, { _tag: "WorktreeCapabilityUnavailableError" }); }); }); @@ -291,12 +294,7 @@ describe("worktree_handoff", () => { const harness = makeHarness({ currentBranch: null }); return Effect.gen(function* () { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/detached" })); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { - expect(exit.cause.reasons[0].error).toMatchObject({ - _tag: "WorktreeHandoffInvalidRequestError", - }); - } + expectTypedFailure(exit, { _tag: "WorktreeHandoffInvalidRequestError" }); }); }); @@ -349,12 +347,7 @@ describe("worktree_status", () => { const exit = yield* Effect.exit( __testing.worktreeStatus().pipe(Effect.provide(harness.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { - expect(exit.cause.reasons[0].error).toMatchObject({ - _tag: "WorktreeCapabilityUnavailableError", - }); - } + expectTypedFailure(exit, { _tag: "WorktreeCapabilityUnavailableError" }); }); }); }); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 9731adf97f6..7690a6cfd4c 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -109,7 +109,7 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( input.startFromOrigin ?? (yield* serverSettings.getSettings.pipe( Effect.map((settings) => settings.newWorktreesStartFromOrigin), - Effect.mapError(asOperationError("resolveBaseRef")), + Effect.mapError(asOperationError("resolveSettings")), )); let worktreeBaseRef = baseRef; From 0f37ca0b58c40baab7f41a1da1e57a4b6eae507c Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 19:04:25 -0500 Subject: [PATCH 7/9] fix(server): address bot review feedback on worktree toolkit - WorktreeOperationError now preserves the underlying failure as cause (Schema.Defect) and derives its message from the operation alone, matching the previewAutomation error conventions, instead of copying the wrapped error's message into a detail field - worktree_handoff rejects relative paths: git would create them relative to the project workspace but the raw string would be stored as the thread's worktree binding and later used as the session cwd --- .../src/mcp/toolkits/worktree/handlers.test.ts | 11 +++++++++++ .../src/mcp/toolkits/worktree/handlers.ts | 18 +++++++++++++++--- apps/server/src/mcp/toolkits/worktree/tools.ts | 2 ++ packages/contracts/src/worktree.ts | 4 ++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts index b238a5ac8b2..7eec9b0c4d4 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -290,6 +290,17 @@ describe("worktree_handoff", () => { }); }); + 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* () { diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 7690a6cfd4c..93b7107a23d 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -13,6 +13,7 @@ import { 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"; @@ -34,7 +35,7 @@ const errorDetail = (error: unknown): string => { }; const asOperationError = (operation: WorktreeOperation) => (error: unknown) => - new WorktreeOperationError({ operation, detail: errorDetail(error) }); + new WorktreeOperationError({ operation, cause: error }); const requireWorktreeCapability = Effect.fn("mcp.requireWorktreeCapability")(function* () { const invocation = yield* McpInvocationContext.McpInvocationContext; @@ -81,11 +82,20 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( if (!project) { return yield* new WorktreeOperationError({ operation: "resolveProject", - detail: `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, + cause: new Error( + `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, + ), }); } 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 @@ -216,7 +226,9 @@ const worktreeStatus = Effect.fn("WorktreeToolkit.worktreeStatus")(function* () if (!project) { return yield* new WorktreeOperationError({ operation: "resolveProject", - detail: `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, + cause: new Error( + `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, + ), }); } diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index da644d7bdc3..9670b93409e 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -6,6 +6,7 @@ import { 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"; @@ -19,6 +20,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, Crypto.Crypto, + Path.Path, OrchestrationEngine.OrchestrationEngineService, ProjectionSnapshotQuery.ProjectionSnapshotQuery, ServerSettings.ServerSettingsService, diff --git a/packages/contracts/src/worktree.ts b/packages/contracts/src/worktree.ts index ac760a7c30a..814019ea1e3 100644 --- a/packages/contracts/src/worktree.ts +++ b/packages/contracts/src/worktree.ts @@ -132,11 +132,11 @@ export class WorktreeOperationError extends Schema.TaggedErrorClass Date: Mon, 6 Jul 2026 19:14:35 -0500 Subject: [PATCH 8/9] fix(server): harden worktree_handoff against races and failed re-pointing - serialize handoffs per thread with an in-flight guard so concurrent calls cannot both pass the attachment check and create two worktrees - remove the created worktree when the thread.meta.update dispatch fails so a failed handoff leaves nothing orphaned on disk - reject relative paths at the schema level (absolute POSIX, Windows drive, or UNC) in addition to the handler check, so the input contract matches its description --- .../mcp/toolkits/worktree/handlers.test.ts | 69 +++++++++++++++++-- .../src/mcp/toolkits/worktree/handlers.ts | 32 ++++++++- packages/contracts/src/worktree.ts | 7 +- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts index 7eec9b0c4d4..80f2e2f1eb6 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.test.ts @@ -8,8 +8,10 @@ import { 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"; @@ -81,23 +83,34 @@ interface HarnessOptions { 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) => Effect.succeed({ sequence: 1 })); + 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 }) => - Effect.succeed({ - worktree: { - path: input.path ?? `/worktrees/project/${input.newRefName}`, - refName: input.newRefName ?? "detached", - }, - }), + (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({ @@ -151,6 +164,7 @@ const makeHarness = (options: HarnessOptions = {}) => { fetchRemote, resolveRemoteTrackingCommit, createWorktree, + removeWorktree, } satisfies Partial), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread, @@ -166,6 +180,7 @@ const makeHarness = (options: HarnessOptions = {}) => { fetchRemote, resolveRemoteTrackingCommit, createWorktree, + removeWorktree, localStatus, runForThread, }; @@ -290,6 +305,46 @@ describe("worktree_handoff", () => { }); }); + 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* () { diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 93b7107a23d..57e78c8313e 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -51,10 +51,31 @@ const requireWorktreeCapability = Effect.fn("mcp.requireWorktreeCapability")(fun 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; @@ -160,7 +181,16 @@ const worktreeHandoff = Effect.fn("WorktreeToolkit.worktreeHandoff")(function* ( branch: worktree.worktree.refName, worktreePath, }) - .pipe(Effect.mapError(asOperationError("updateThreadMetadata"))); + .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) diff --git a/packages/contracts/src/worktree.ts b/packages/contracts/src/worktree.ts index 814019ea1e3..605ef37acff 100644 --- a/packages/contracts/src/worktree.ts +++ b/packages/contracts/src/worktree.ts @@ -27,9 +27,12 @@ export const WorktreeHandoffInput = Schema.Struct({ }), ), path: Schema.optional( - TrimmedNonEmptyString.annotate({ + 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. Defaults to the server-managed worktrees directory.", + "Absolute filesystem path for the new worktree. Relative paths are rejected. Defaults to the server-managed worktrees directory.", }), ), runSetupScript: Schema.optional( From a33fb248284d4da6fab6277e382ab8fc6b6da53e Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 19:18:02 -0500 Subject: [PATCH 9/9] fix(server): use a dedicated error for project-not-found in worktree tools WorktreeOperationError requires an underlying cause, and project-not-found has none; manufacturing an Error just to fill the field violated the error conventions. Add WorktreeProjectNotFoundError (threadId, projectId), symmetric with WorktreeThreadNotFoundError, and use it in both handlers. --- .../src/mcp/toolkits/worktree/handlers.ts | 17 +++++++---------- packages/contracts/src/worktree.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 57e78c8313e..07ccdd27800 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -7,6 +7,7 @@ import { type WorktreeHandoffResult, type WorktreeHandoffSetupScriptStatus, WorktreeOperationError, + WorktreeProjectNotFoundError, type WorktreeStatusResult, WorktreeThreadNotFoundError, } from "@t3tools/contracts"; @@ -101,11 +102,9 @@ const performWorktreeHandoff = Effect.fn("WorktreeToolkit.performWorktreeHandoff .getProjectShellById(thread.projectId) .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveProject"))); if (!project) { - return yield* new WorktreeOperationError({ - operation: "resolveProject", - cause: new Error( - `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, - ), + return yield* new WorktreeProjectNotFoundError({ + threadId: invocation.threadId, + projectId: thread.projectId, }); } const projectCwd = project.workspaceRoot; @@ -254,11 +253,9 @@ const worktreeStatus = Effect.fn("WorktreeToolkit.worktreeStatus")(function* () .getProjectShellById(thread.projectId) .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(asOperationError("resolveProject"))); if (!project) { - return yield* new WorktreeOperationError({ - operation: "resolveProject", - cause: new Error( - `Project '${thread.projectId}' was not found for thread '${invocation.threadId}'.`, - ), + return yield* new WorktreeProjectNotFoundError({ + threadId: invocation.threadId, + projectId: thread.projectId, }); } diff --git a/packages/contracts/src/worktree.ts b/packages/contracts/src/worktree.ts index 605ef37acff..15fabe8d15d 100644 --- a/packages/contracts/src/worktree.ts +++ b/packages/contracts/src/worktree.ts @@ -99,6 +99,18 @@ export class WorktreeThreadNotFoundError 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", { @@ -146,6 +158,7 @@ export class WorktreeOperationError extends Schema.TaggedErrorClass