feat(mcp): expose project service operations#10563
2 unresolved threads ยท Jump to file
- apps/server/src/mcp/toolkits/project/handlers.ts:29 ยท ๐ด **Critical** `project/handlers.ts:29` These handlers expose every project to a caller: `t3_proje
- apps/server/src/mcp/toolkits/project/handlers.ts:47โ73 ยท `create`, `update`, `delete`, and `cloneRepository` map their whole error channel through `unavailab
apps/server/src/mcp/toolkits/project/handlers.ts ยท Earlier revision 530d0eb9 ยท right 29
Original diff
@@ -0,0 +1,75 @@
+import { OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Project from "../../../project/ProjectService.ts";
+import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
+import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";
+import { ProjectToolkit } from "./tools.ts";
+
+const access = Effect.gen(function* () {
+ yield* readCaller();
+ return yield* Project.ProjectService;
+});
+const mutation = Effect.gen(function* () {
+ const { caller } = yield* readMutationCaller();
+ if (
+ caller.archivedAt !== null ||
+ caller.runtimeMode !== "full-access" ||
+ caller.interactionMode !== "default"
+ )
+ return yield* new OrchestratorMcpFailure({
+ code: "capability_denied",
+ message: "Project changes require a live full-access/default calling thread.",
+ });
+ return yield* Project.ProjectService;
+});
+export const ProjectHandlersLive = ProjectToolkit.toLayer({
+ t3_project_list: (input) =>
+ Effect.gen(function* () {
+ const projects = yield* access;๐ด Critical project/handlers.ts:29
These handlers expose every project to a caller: t3_project_list returns the global snapshot, while t3_project_read, t3_project_update, and t3_project_delete accept arbitrary projectId values. Because access discards caller.projectId, a thread authenticated for project A can read, modify, or delete project B; retain and enforce caller.projectId when listing and targeting projects.
๐ Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/mcp/toolkits/project/handlers.ts around line 29: |
| 2 | |
| 3 | These handlers expose every project to a caller: `t3_project_list` returns the global snapshot, while `t3_project_read`, `t3_project_update`, and `t3_project_delete` accept arbitrary `projectId` values. Because `access` discards `caller.projectId`, a thread authenticated for project A can read, modify, or delete project B; retain and enforce `caller.projectId` when listing and targeting projects. |
Environment-wide project management is intentional, not a missed current-project filter. The requested feature includes listing/managing projects and launching into another selected project, and the new tool descriptions explicitly say "in this environment". McpInvocationScope carries an environment/session capability, not a per-project ACL; caller.projectId remains the scope for ordinary thread tools in readThread. Project mutations separately require a live full-access/default caller through readMutationCaller. Restricting every project operation to caller.projectId would remove the requested project-management functionality. This PR does not claim a per-project credential authorization model.
apps/server/src/mcp/toolkits/project/handlers.ts ยท Earlier revision 530d0eb9 ยท right 47โ73
Original diff
@@ -0,0 +1,75 @@
+import { OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Project from "../../../project/ProjectService.ts";
+import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
+import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";
+import { ProjectToolkit } from "./tools.ts";
+
+const access = Effect.gen(function* () {
+ yield* readCaller();
+ return yield* Project.ProjectService;
+});
+const mutation = Effect.gen(function* () {
+ const { caller } = yield* readMutationCaller();
+ if (
+ caller.archivedAt !== null ||
+ caller.runtimeMode !== "full-access" ||
+ caller.interactionMode !== "default"
+ )
+ return yield* new OrchestratorMcpFailure({
+ code: "capability_denied",
+ message: "Project changes require a live full-access/default calling thread.",
+ });
+ return yield* Project.ProjectService;
+});
+export const ProjectHandlersLive = ProjectToolkit.toLayer({
+ t3_project_list: (input) =>
+ Effect.gen(function* () {
+ const projects = yield* access;
+ const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));
+ const rows = snapshot.projects.filter((project) => project.deletedAt === null);
+ const start = input.cursor ?? 0,
+ end = start + (input.limit ?? 20);
+ return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };
+ }),
+ t3_project_read: (input) =>
+ Effect.gen(function* () {
+ const projects = yield* access;
+ const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable));
+ if (Option.isNone(result))
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: "The project was not found.",
+ });
+ return result.value;
+ }),
+ t3_project_create: (input) =>
+ Effect.gen(function* () {
+ const projects = yield* mutation;
+ const commandId = yield* newCommandId();
+ return yield* projects
+ .create({ ...input, commandId, projectId: ProjectId.make(commandId) })
+ .pipe(Effect.mapError(unavailable));
+ }),
+ t3_project_update: (input) =>
+ Effect.gen(function* () {
+ const projects = yield* mutation;
+ return yield* projects
+ .update({ ...input, commandId: yield* newCommandId() })
+ .pipe(Effect.mapError(unavailable));
+ }),
+ t3_project_delete: (input) =>
+ Effect.gen(function* () {
+ const projects = yield* mutation;
+ return yield* projects
+ .delete({ ...input, commandId: yield* newCommandId() })
+ .pipe(Effect.mapError(unavailable));
+ }),
+ t3_project_clone: (input) =>
+ Effect.gen(function* () {
+ yield* mutation;
+ const repositories = yield* Repositories.SourceControlRepositoryService;
+ return yield* repositories.cloneRepository(input).pipe(Effect.mapError(unavailable));apps/server/src/mcp/toolkits/project/handlers.ts ยท right 47โ73
create, update, delete, and cloneRepository map their whole error channel through unavailable(), so every failure reaches the caller as orchestration_error / "The operation could not be completed." That erases the structured domain errors these calls are documented to produce โ ProjectConflictError ("an existing registered workspace is rejected"), ProjectNotEmptyError ("Nonempty projects require force=true"), ProjectNotFoundError, and the destination-path variants of SourceControlRepositoryError โ leaving the agent unable to distinguish a bad request from an infrastructure fault.
Consider mapping the known tags where the context is known and reserving unavailable() for the genuinely unknown/lower-level failure (ProjectOperationError), the way threadManagementFailure in OrchestratorMcpService.ts does:
| 1 | projects.create({ ...input, commandId, projectId: ProjectId.make(commandId) }).pipe( |
| 2 | Effect.catchTags({ |
| 3 | ProjectNotFoundError: (error) => |
| 4 | Effect.fail(new OrchestratorMcpFailure({ code: "invalid_request", message: error.message })), |
| 5 | ProjectConflictError: (error) => |
| 6 | Effect.fail(new OrchestratorMcpFailure({ code: "invalid_request", message: error.message })), |
| 7 | ProjectNotEmptyError: (error) => |
| 8 | Effect.fail(new OrchestratorMcpFailure({ code: "invalid_request", message: error.message })), |
| 9 | ProjectOperationError: () => Effect.fail(unavailable()), |
| 10 | }), |
| 11 | ) |
The same applies to the clone handler, where SourceControlRepositoryError already carries a caller-safe detail.
Posted via Macroscope โ Effect Service Conventions
Fixed in cbb362ac5458cc1951a41f5a381a467b971b39d9. Known ProjectNotFound/Conflict/NotEmpty errors now return invalid_request with fixed actionable messages, while ProjectOperationError remains the bounded generic service failure. Clone returns SourceControlRepositoryError.detail, whose production constructors use safe fixed explanations; nested causes are never serialized. No service error classes or lifecycle logic changed. Composed shared tests 6/6, server typecheck, targeted lint/format/diff checks pass.