Skip to content
8 changes: 8 additions & 0 deletions apps/server/src/mcp/McpToolAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const ProbeToolkit = Toolkit.make(
Tool.make("reads", probe),
Tool.make("reads_as_caller", probe),
Tool.make("acts_as_caller", probe),
Tool.make("acts_as_full_access_caller", probe),
Tool.make("writes", probe),
Tool.make("writes_with_capability", probe),
Tool.make("writes_threads", {
Expand All @@ -135,6 +136,7 @@ const probeHandlers: McpToolAccess.Handlers<typeof ProbeToolkit.tools> = {
reads: McpToolAccess.reads(() => ran),
reads_as_caller: McpToolAccess.readsAsCaller(() => ran),
acts_as_caller: McpToolAccess.actsAsCaller(() => ran),
acts_as_full_access_caller: McpToolAccess.actsAsFullAccessCaller(() => ran),
writes: McpToolAccess.writes(() => ran),
writes_with_capability: McpToolAccess.writesWithCapability(() => ran),
writes_threads: McpToolAccess.writesThreads(
Expand Down Expand Up @@ -181,6 +183,7 @@ export const refusedAtCompileTime = () => {
reads: unchecked,
reads_as_caller: unchecked,
acts_as_caller: unchecked,
acts_as_full_access_caller: unchecked,
writes: unchecked,
writes_with_capability: unchecked,
writes_threads: unchecked,
Expand Down Expand Up @@ -263,6 +266,11 @@ it.effect.each([
["acts_as_caller", archivedBackground, {}, "parent_not_active"],
["acts_as_caller", unknownOwnerBackground, {}, "parent_not_active"],
["acts_as_caller", fullAccessClient, {}, "thread_credential_required"],
["acts_as_full_access_caller", fullAccess, {}, "ran"],
["acts_as_full_access_caller", supervised, {}, "capability_denied"],
["acts_as_full_access_caller", plan, {}, "capability_denied"],
["acts_as_full_access_caller", ended, {}, "parent_not_active"],
["acts_as_full_access_caller", fullAccessClient, {}, "thread_credential_required"],

["writes", supervised, {}, "ran"],
["writes", ended, {}, "parent_not_active"],
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,19 @@ export const either = <P, A, E, R, A2, E2, R2>(
const fullAccessRequired =
"Changing projects or environment settings needs a live full-access/default calling thread or a full-access client.";

/** The calling thread picks a folder its next turn can write, so this needs the bar for adding a project. */
export const actsAsFullAccessCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
declare(
(params: P) =>
requireThreadCaller.pipe(
Effect.andThen(writingCaller),
Effect.tap((caller) => assertNotLinked(caller, "change projects or environment settings")),
Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)),
Effect.flatMap(() => handle(params)),
),
undefined,
);

/** Recheck after waits before writing; caller modes may have changed. */
export const writesEnvironment = <P, A, E, R>(
handle: (
Expand Down
75 changes: 40 additions & 35 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ import {
runtimeModeRank,
taskPrompt,
} from "./delegatedTaskModes.ts";
import { isLiveCallerShell, threadNotFound } from "./threadAccess.ts";
import { isLiveCallerShell, readInCurrentProject, threadNotFound } from "./threadAccess.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
Expand Down Expand Up @@ -1134,22 +1134,20 @@ const make = Effect.gen(function* () {
projectId: OrchestrationV2ThreadProjection["thread"]["projectId"],
threadId: ThreadId,
) =>
threadManagement
.getProjectThreadRecords(
{ projectId, threadId },
[
"runs",
"messages",
"subagents",
"providerThreads",
"providerTurns",
"runtimeRequests",
"contextTransfers",
"turnItems",
],
{ turnItemTypes: [], messageRoles: ["user"] },
)
.pipe(Effect.mapError(threadManagementFailure));
threadManagement.getProjectThreadRecords(
{ projectId, threadId },
[
"runs",
"messages",
"subagents",
"providerThreads",
"providerTurns",
"runtimeRequests",
"contextTransfers",
"turnItems",
],
{ turnItemTypes: [], messageRoles: ["user"] },
);

const loadCaller = Effect.fn("OrchestratorMcpService.loadCaller")(function* (
scope: McpInvocationScope,
Expand Down Expand Up @@ -1208,11 +1206,11 @@ const make = Effect.gen(function* () {
return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`);
}
// Native wait owns its history reads; keep legacy import and project validation here.
return yield* includeHistory
? loadProjectThread(shell.projectId, threadId)
: threadManagement
.getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])
.pipe(Effect.mapError(threadManagementFailure));
return yield* readInCurrentProject(threadManagement, threadId, shell.projectId, (projectId) =>
includeHistory
? loadProjectThread(projectId, threadId)
: threadManagement.getProjectThreadRecords({ projectId, threadId }, []),
).pipe(Effect.mapError(threadManagementFailure));
});

const loadScopedThread = Effect.fn("OrchestratorMcpService.loadScopedThread")(function* (
Expand Down Expand Up @@ -1289,20 +1287,27 @@ const make = Effect.gen(function* () {
`Thread ${threadId} is no longer available.`,
);
}
const input = { projectId: shell.projectId, threadId };
// Transfers are needed only to acknowledge a caller's child-task result.
const records =
parent === undefined
? threadManagement
.getProjectThreadRecords(input, ["runs", "providerTurns", "runtimeRequests"])
.pipe(Effect.map((target) => ({ ...target, contextTransfers: [] })))
: threadManagement.getProjectThreadRecords(input, [
"runs",
"providerTurns",
"runtimeRequests",
"contextTransfers",
]);
const target = yield* records.pipe(Effect.mapError(threadManagementFailure));
const target = yield* readInCurrentProject(
threadManagement,
threadId,
shell.projectId,
(projectId) =>
parent === undefined
? threadManagement
.getProjectThreadRecords({ projectId, threadId }, [
"runs",
"providerTurns",
"runtimeRequests",
])
.pipe(Effect.map((target) => ({ ...target, contextTransfers: [] })))
: threadManagement.getProjectThreadRecords({ projectId, threadId }, [
"runs",
"providerTurns",
"runtimeRequests",
"contextTransfers",
]),
).pipe(Effect.mapError(threadManagementFailure));
return { parent, target, shell } as const;
});

Expand Down
165 changes: 164 additions & 1 deletion apps/server/src/mcp/WorktreeMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ProviderInstanceId,
ThreadId,
WorktreeMcpHandoffInput,
ScheduledTaskError,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
Expand All @@ -19,6 +20,7 @@ import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";

Expand All @@ -32,6 +34,7 @@ import {
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
Expand Down Expand Up @@ -117,6 +120,10 @@ interface HarnessOptions {
readonly deleteLocalBranchFails?: boolean;
readonly createWorktreeGate?: Effect.Effect<void>;
readonly existsFails?: boolean;
readonly subagentStatus?: string;
readonly destinationProjectExists?: boolean;
readonly moveContinuationFailure?: string;
readonly moveTasksFails?: boolean;
}

const makeHarness = (options: HarnessOptions = {}) => {
Expand Down Expand Up @@ -176,7 +183,11 @@ const makeHarness = (options: HarnessOptions = {}) => {
return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" }));
}
return id === threadId && thread !== null
? Effect.succeed(makeProjection(thread))
? Effect.succeed({
...makeProjection(thread),
subagents:
options.subagentStatus === undefined ? [] : [{ status: options.subagentStatus }],
} as unknown as OrchestrationV2ThreadProjection)
: Effect.fail(new OrchestratorProjectionError({ threadId: id }));
});
const sendToThread = vi.fn((_: unknown) => {
Expand Down Expand Up @@ -204,6 +215,31 @@ const makeHarness = (options: HarnessOptions = {}) => {
: Option.none(),
),
);
const bootstrap = vi.fn((input: { readonly projectId: ProjectId; readonly title: string }) =>
Effect.succeed({
project: {
...project,
id: options.destinationProjectExists ? ProjectId.make("project-existing") : input.projectId,
title: input.title,
},
created: options.destinationProjectExists !== true,
}),
);
const moveThreadToProject = vi.fn((input: { readonly afterMove: Effect.Effect<void> }) =>
input.afterMove.pipe(
Effect.as({
movesWhenTurnEnds: options.moveContinuationFailure === undefined,
...(options.moveContinuationFailure === undefined
? {}
: { continuationFailure: options.moveContinuationFailure }),
}),
),
);
const moveThreadTasks = vi.fn((_: unknown) =>
options.moveTasksFails
? Effect.fail(new ScheduledTaskError({ message: "simulated" }))
: Effect.void,
);
const removeWorktree = vi.fn((_: unknown) =>
options.removeWorktreeFails
? (Effect.fail("simulated worktree removal failure") as never)
Expand Down Expand Up @@ -299,10 +335,15 @@ const makeHarness = (options: HarnessOptions = {}) => {
dispatch,
getThreadRecords,
sendToThread,
moveThreadToProject: moveThreadToProject as never,
} satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
Layer.mock(ProjectService.ProjectService)({
getById,
bootstrap: bootstrap as never,
} satisfies Partial<ProjectService.ProjectService["Service"]>),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
moveThreadTasks: moveThreadTasks as never,
}),
ServerSettings.layerTest({
newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false,
...(options.projectNewWorktreesStartFromOrigin === undefined
Expand Down Expand Up @@ -362,6 +403,9 @@ const makeHarness = (options: HarnessOptions = {}) => {
deleteLocalBranch,
localStatus,
runForThread,
bootstrap,
moveThreadToProject,
moveThreadTasks,
};
};

Expand Down Expand Up @@ -397,6 +441,125 @@ const runStatus = (harness: ReturnType<typeof makeHarness>) =>

const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput);

describe("t3_thread_move", () => {
const moveCapabilities = new Set(["worktree", "orchestration"] as const);
const makeFolder = Effect.fn("makeFolder")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
return yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-thread-move-" });
});
const runMove = (
harness: ReturnType<typeof makeHarness>,
input: Parameters<WorktreeMcpService.WorktreeMcpService["Service"]["moveToFolder"]>[1],
) =>
WorktreeMcpService.WorktreeMcpService.pipe(
Effect.flatMap((service) => service.moveToFolder(harness.scope, input)),
Effect.provide(harness.layer),
);

it.effect("registers the folder as a project and defers the move and its continuation", () =>
Effect.scoped(
Effect.gen(function* () {
const folder = yield* makeFolder();
const harness = makeHarness({ capabilities: moveCapabilities });
const result = yield* runMove(harness, { path: folder, continuationPrompt: "Carry on." });

expect(result).toMatchObject({
projectCreated: true,
workspaceRoot: folder,
movesWhenTurnEnds: true,
});
expect(harness.moveThreadToProject).toHaveBeenCalledWith(
expect.objectContaining({
threadId,
projectId: result.projectId,
continuation: expect.objectContaining({ text: "Carry on." }),
}),
);
expect(harness.moveThreadTasks).toHaveBeenCalledWith({
threadIds: [threadId],
projectId: result.projectId,
});
}),
).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("reuses the project already rooted at the folder", () =>
Effect.scoped(
Effect.gen(function* () {
const folder = yield* makeFolder();
const harness = makeHarness({
capabilities: moveCapabilities,
destinationProjectExists: true,
});
const result = yield* runMove(harness, { path: folder });

expect(result.projectCreated).toBe(false);
expect(result.projectId).toBe(ProjectId.make("project-existing"));
expect(harness.moveThreadToProject).toHaveBeenCalledWith(
expect.not.objectContaining({ continuation: expect.anything() }),
);
}),
).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("reports a moved thread whose tasks or continuation could not follow", () =>
Effect.scoped(
Effect.gen(function* () {
const folder = yield* makeFolder();
const harness = makeHarness({
capabilities: moveCapabilities,
moveContinuationFailure: "provider disabled",
moveTasksFails: true,
});
const result = yield* runMove(harness, { path: folder, continuationPrompt: "Carry on." });

expect(result.movesWhenTurnEnds).toBe(false);
expect(result.note).toContain("was not queued (provider disabled)");
}),
).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("refuses a move it cannot honor before changing anything", () =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const folder = yield* makeFolder();
const file = path.join(folder, "file.txt");
yield* fileSystem.writeFileString(file, "");
const cases = [
{ input: "relative/folder", code: "invalid_request" },
{ input: path.join(folder, "missing"), code: "invalid_request" },
{ input: file, code: "invalid_request" },
{
input: folder,
code: "invalid_request",
options: { thread: { worktreePath: folder } },
},
{ input: folder, code: "invalid_request", options: { subagentStatus: "running" } },
{ input: folder, code: "invalid_request", options: { thread: { archivedAt: "now" } } },
{
input: folder,
code: "capability_denied",
options: { capabilities: new Set(["worktree"] as const) },
},
] satisfies ReadonlyArray<{
readonly input: string;
readonly code: string;
readonly options?: HarnessOptions;
}>;
for (const { input, code, options } of cases) {
const harness = makeHarness({ capabilities: moveCapabilities, ...options });
const exit = yield* Effect.exit(runMove(harness, { path: input }));
expectTypedFailure(exit, { code });
expect(harness.bootstrap).not.toHaveBeenCalled();
expect(harness.moveThreadToProject).not.toHaveBeenCalled();
}
}),
).pipe(Effect.provide(NodeServices.layer)),
);
});

describe("t3_worktree_handoff", () => {
it.effect("reports a continuation failure without failing the handoff", () => {
const harness = makeHarness({ continuation: "fails" });
Expand Down
Loading
Loading