feat(mcp): expose project service operations#10563

merged3
agents/mcp-thin/scheduled-tasks
agents/mcp-thin/projects
openedSep 7, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
2 unresolved threads ยท Jump to file
Selected thread: apps/server/src/mcp/toolkits/project/handlers.ts:29 โ€” ๐Ÿ”ด **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. <details> <summary>๐Ÿš€ Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> ```text In file @apps/server/src/mcp/toolkits/project/handlers.ts around line 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. ``` </details> <!-- macroscope-meta: {"kind":"code_review","variant":"f57f0b61c1d8f13d673564c863408287"} -->
0 / 7 viewed7 files changedLine totals unavailable

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;
M
macroscopeappBotSep 7, 2026

๐Ÿ”ด 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:
text
1In file @apps/server/src/mcp/toolkits/project/handlers.ts around line 29:
2
3These 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.
J
Julius MarmingeSep 7, 2026

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

M
macroscopeappBotSep 7, 2026

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:

ts
1projects.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

J
Julius MarmingeSep 7, 2026

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.