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,