From 134607d447407be94a69aaf344f7afa4d9f6b82d Mon Sep 17 00:00:00 2001 From: sidreyas Date: Tue, 10 Nov 2026 16:14:28 +0530 Subject: [PATCH] fix(server): MCP can no longer delete the built-in No project A forced t3_project_delete on the scratch project deleted every thread in it, pinned and running ones included, while the server recreates the project on the next scratch launch anyway. Refuse it with invalid_request. Fixes #33766 Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.test.ts | 41 ++++++++++++++++++- .../src/mcp/toolkits/project/handlers.ts | 8 ++++ apps/server/src/mcp/toolkits/project/tools.ts | 6 ++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index b77df3ac2075e..810436712a873 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -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 = { @@ -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), @@ -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 = []; + 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: [] }); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index ab27b6b5bc4b4..f60542aef60a6 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -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, { diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 56799959f6cd8..14d2fb2efbc02 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -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,