Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const clientLaunchHarness = (input: {
readonly hostedAppUrl?: string;
/** The orchestrator refuses the new thread's mode, as under a managed ceiling. */
readonly refusedByCeiling?: true;
readonly isScratchProject?: true;
}) => {
const projectId = ProjectId.make("project:client-target");
const modelSelection = {
Expand Down Expand Up @@ -159,7 +160,10 @@ const clientLaunchHarness = (input: {
),
}),
Layer.mock(PeerForwarding.PeerForwarding)({}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
namedProjectsRoot: "/projects",
isScratchProject: (id) => Effect.succeed(input.isScratchProject === true && id === projectId),
}),
DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)),
).pipe(
Layer.provideMerge(GitVcsDriver.layer),
Expand Down Expand Up @@ -665,6 +669,41 @@ it.effect("deleting a project through MCP also discards its clone record", () =>
}),
);

it.effect("a forced MCP delete refuses the built-in No project and keeps its threads", () =>
Effect.gen(function* () {
const deleted: Array<ProjectId> = [];
const harness = clientLaunchHarness({
runtimeModeCeiling: "full-access",
launched: [],
isScratchProject: true,
});
const dependencies = Layer.merge(
harness.dependencies,
Layer.mergeAll(
Layer.mock(Project.ProjectService)({
delete: (input) =>
Effect.sync(() => void deleted.push(input.projectId)).pipe(
Effect.andThen(Effect.die("deleted")),
),
}),
Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({}),
),
);
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);

const refused = yield* toolkit
.handle("t3_project_delete", { projectId: harness.projectId, force: true })
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));

expect(refused.at(-1)?.result).toMatchObject({ code: "invalid_request" });
expect(deleted).toEqual([]);
}),
);

it.effect("an update the project refuses tells the agent why", () =>
Effect.gen(function* () {
const harness = clientLaunchHarness({ runtimeModeCeiling: "full-access", launched: [] });
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
),
t3_project_delete: McpToolAccess.writesEnvironment((input) =>
Effect.gen(function* () {
const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
// The server recreates No project on demand, so deleting it only deletes every thread in it.
if (yield* folders.isScratchProject(input.projectId))
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message:
"The built-in No project cannot be deleted. Delete or archive its threads individually instead.",
});
const projects = yield* Project.ProjectService;
const clones = yield* ProjectCloneTracker.ProjectCloneTracker;
return yield* mutateProjectWithClone(projects, clones, {
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", {
projectId: ProjectId,
force: Schema.optionalKey(Schema.Boolean),
}),
dependencies: [...shared.dependencies, ProjectCloneTracker.ProjectCloneTracker],
dependencies: [
...shared.dependencies,
ProjectCloneTracker.ProjectCloneTracker,
ManagedProjectFolders.ManagedProjectFolders,
],
}).annotate(Tool.Destructive, true);
const ProjectCloneTool = Tool.make("t3_project_clone", {
...shared,
Expand Down
Loading