feat(mcp): expose project thread launch service#10565

merged3
agents/mcp-thin/attachments
agents/mcp-thin/project-launches
openedSep 7, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago
4 unresolved threads ยท Jump to file
0 / 3 viewed3 files changedLine totals unavailable

apps/server/src/mcp/toolkits/project/handlers.ts ยท Earlier revision 4ae96cb0 ยท right 29โ€“46

Original diff
@@ -24,6 +26,60 @@ const mutation = Effect.gen(function* () {
   return yield* Project.ProjectService;
 });
 export const ProjectHandlersLive = ProjectToolkit.toLayer({
+  t3_thread_launch: (input) =>
+    Effect.gen(function* () {
+      const { caller } = yield* readMutationCaller();
+      if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
+        return yield* new OrchestratorMcpFailure({
+          code: "capability_denied",
+          message: "Project launches require a full-access/default calling thread.",
+        });
+      const launch = yield* Launch.ThreadLaunchService;
+      const commandId = yield* newCommandId();
+      const threadId = ThreadId.make(commandId);
+      const messageId = MessageId.make(commandId);
+      const attachments = input.attachments ?? [];
+      if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
+        return yield* new OrchestratorMcpFailure({
+          code: "invalid_request",
+          message: "A new thread accepts only pending attachment uploads.",
+        });

apps/server/src/mcp/toolkits/project/handlers.ts ยท right 29โ€“46

M
macroscopeappBotSep 7, 2026

This handler adds backend behavior that lives only here and not in ThreadLaunchService: the full-access/default capability gate, the pending-upload-only attachment rule, and the caller-inheritance defaults for projectId/modelSelection/runtimeMode/interactionMode/workspaceStrategy. None of it is covered by a test โ€” consider a focused test alongside toolkits/core.test.ts, which already exercises a toolkit handler through Toolkit.toLayer with a test layer for ThreadManagementService, so the launch service can be substituted the same way without mocking the launch logic itself.

Posted via Macroscope โ€” Effect Service Conventions

J
Julius MarmingeSep 7, 2026

The maintainer explicitly requested shared MCP tests and existing service tests instead of per-tool suites. This wrapper delegates execution/provisioning to ThreadLaunchService, uses the shared live caller helper, restricts project launch to full-access/default, and only accepts pending uploads for a new thread. Default inheritance is explicit nullish forwarding; the returned run is found by the exact generated initial message ID, never by the latest run. These lines have been source-reviewed; no concrete faulty behavior was identified. Launch execution/partial acceptance remain the service responsibility and its tests; the MCP core/real integration coverage stays shared.

M
macroscopeappBotSep 7, 2026

The delegation boundary is clear, but the capability gate, pending-upload rejection, and inheritance are handler-owned. Current shared integration coverage does not exercise uploads or inherited defaults, so the test-coverage concern remains.

apps/server/src/mcp/toolkits/project/handlers.ts ยท Earlier revision 4ae96cb0 ยท right 29โ€“46

Original diff
@@ -24,6 +26,60 @@ const mutation = Effect.gen(function* () {
   return yield* Project.ProjectService;
 });
 export const ProjectHandlersLive = ProjectToolkit.toLayer({
+  t3_thread_launch: (input) =>
+    Effect.gen(function* () {
+      const { caller } = yield* readMutationCaller();
+      if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
+        return yield* new OrchestratorMcpFailure({
+          code: "capability_denied",
+          message: "Project launches require a full-access/default calling thread.",
+        });
+      const launch = yield* Launch.ThreadLaunchService;
+      const commandId = yield* newCommandId();
+      const threadId = ThreadId.make(commandId);
+      const messageId = MessageId.make(commandId);
+      const attachments = input.attachments ?? [];
+      if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
+        return yield* new OrchestratorMcpFailure({
+          code: "invalid_request",
+          message: "A new thread accepts only pending attachment uploads.",
+        });

apps/server/src/mcp/toolkits/project/handlers.ts ยท right 29โ€“46

M
macroscopeappBotSep 7, 2026

This handler introduces new backend behavior that nothing exercises: the full-access/default capability gate, the pending-upload-only attachment rejection, the caller inheritance of projectId/modelSelection/runtimeMode/interactionMode, and the runId/status lookup keyed by messageId. Consider adding a focused test (e.g. toolkits/project/handlers.test.ts, in the style of toolkits/preview/handlers.test.ts / toolkits/worktree/registration.test.ts) that provides a test layer for ThreadLaunchService only and asserts the denied-caller path, the non-pending attachment rejection, and the inherited defaults reaching launch.

Posted via Macroscope โ€” Effect Service Conventions

J
Julius MarmingeSep 7, 2026

The maintainer explicitly requested shared MCP tests and existing service tests instead of per-tool suites. This wrapper delegates execution/provisioning to ThreadLaunchService, uses the shared live caller helper, restricts project launch to full-access/default, and only accepts pending uploads for a new thread. Default inheritance is explicit nullish forwarding; the returned run is found by the exact generated initial message ID, never by the latest run. These lines have been source-reviewed; no concrete faulty behavior was identified. Launch execution/partial acceptance remain the service responsibility and its tests; the MCP core/real integration coverage stays shared.

apps/server/src/mcp/toolkits/project/handlers.ts ยท Earlier revision 6e7c612f ยท right 72โ€“89

Original diff
@@ -24,6 +26,60 @@ const mutation = Effect.gen(function* () {
   return yield* Project.ProjectService;
 });
 export const ProjectHandlersLive = ProjectToolkit.toLayer({
+  t3_thread_launch: (input) =>
+    Effect.gen(function* () {
+      const { caller } = yield* readMutationCaller();
+      if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
+        return yield* new OrchestratorMcpFailure({
+          code: "capability_denied",
+          message: "Project launches require a full-access/default calling thread.",
+        });
+      const launch = yield* Launch.ThreadLaunchService;
+      const commandId = yield* newCommandId();
+      const threadId = ThreadId.make(commandId);
+      const messageId = MessageId.make(commandId);
+      const attachments = input.attachments ?? [];
+      if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
+        return yield* new OrchestratorMcpFailure({
+          code: "invalid_request",
+          message: "A new thread accepts only pending attachment uploads.",
+        });
+      const claimed = yield* Claims.claimPendingAttachments({ threadId, attachments }).pipe(
+        Effect.mapError(unavailable),
+      );
+      const result = yield* launch
+        .launch({
+          commandId,
+          threadId,
+          projectId: input.projectId ?? caller.projectId,
+          title: input.title,
+          modelSelection: input.modelSelection ?? caller.modelSelection,
+          runtimeMode: input.runtimeMode ?? caller.runtimeMode,
+          interactionMode: input.interactionMode ?? caller.interactionMode,
+          workspaceStrategy: input.workspaceStrategy ?? { type: "root" },
+          ...(input.message === undefined && attachments.length === 0
+            ? {}
+            : {
+                initialMessage: {
+                  messageId,
+                  text: input.message ?? "",
+                  attachments: claimed.attachments,
+                },
+              }),
+          createdBy: "agent",
+          creationSource: "mcp",
+        })
+        .pipe(Effect.mapError(unavailable));

apps/server/src/mcp/toolkits/project/handlers.ts ยท right 72โ€“89

M
macroscopeappBotSep 7, 2026

๐ŸŸก Medium project/handlers.ts:72

A failed launch.launch leaves the thread-scoped attachment copies orphaned, so repeated failed launches accumulate files while the pending originals remain retryable. The handler maps the launch error without releasing claimed.claimedPaths; release those paths on launch failure before returning the error.

diff
1- .pipe(Effect.mapError(unavailable));
2+ .pipe(
3+ Effect.tapError(() => Claims.releaseClaimedAttachments(claimed.claimedPaths)),
4+ Effect.mapError(unavailable),
5+ );
๐Ÿš€ 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 72:
2
3A failed `launch.launch` leaves the thread-scoped attachment copies orphaned, so repeated failed launches accumulate files while the pending originals remain retryable. The handler maps the launch error without releasing `claimed.claimedPaths`; release those paths on launch failure before returning the error.
J
Julius MarmingeSep 7, 2026

The proposed blanket cleanup is unsafe at this boundary. In the unchanged ThreadLaunchService, message.dispatch commits the attachment references at lines 520-534, then getThreadProjection at lines 548-550 can still fail; the later preparation re-read can also fail. A launch error therefore does not prove those copies are unused. Releasing claimedPaths in tapError can delete bytes referenced by an accepted durable message. This wrapper deliberately retains copies on an uncertain outcome, as stated in the PR limits. Cleanup that distinguishes proven pre-acceptance failure from partial acceptance belongs in the shared launch service as a separate change; no unconditional release is being added here.

apps/server/src/mcp/toolkits/project/handlers.ts ยท Earlier revision 6c9a47b8 ยท right 58โ€“60

Original diff
@@ -35,6 +37,60 @@ const mutation = Effect.gen(function* () {
   return yield* Project.ProjectService;
 });
 export const ProjectHandlersLive = ProjectToolkit.toLayer({
+  t3_thread_launch: (input) =>
+    Effect.gen(function* () {
+      const { caller } = yield* readMutationCaller();
+      if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
+        return yield* new OrchestratorMcpFailure({
+          code: "capability_denied",
+          message: "Project launches require a full-access/default calling thread.",
+        });
+      const launch = yield* Launch.ThreadLaunchService;
+      const commandId = yield* newCommandId();
+      const threadId = ThreadId.make(commandId);
+      const messageId = MessageId.make(commandId);
+      const attachments = input.attachments ?? [];
+      if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
+        return yield* new OrchestratorMcpFailure({
+          code: "invalid_request",
+          message: "A new thread accepts only pending attachment uploads.",
+        });
+      const claimed = yield* Claims.claimPendingAttachments({ threadId, attachments }).pipe(
+        Effect.mapError(unavailable),
+      );

apps/server/src/mcp/toolkits/project/handlers.ts ยท right 58โ€“60

M
macroscopeappBotSep 7, 2026

unavailable discards the structured AttachmentClaimError, so caller-actionable claim reasons ("attachment not found", "stored size does not match", "attachment type does not match the upload") collapse into "The operation could not be completed." The sibling claim site (toolkits/attachment/handlers.ts) and ws.ts launchThread both preserve error.message; consider mapping this failure where its context is known rather than through the generic helper.

suggestion
1 const claimed = yield* Claims.claimPendingAttachments({ threadId, attachments }).pipe(
2 Effect.mapError(
3 (error) =>
4 new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }),
5 ),
6 );

Posted via Macroscope โ€” Effect Service Conventions

J
Julius MarmingeSep 7, 2026

Fixed in 530b178333. The launch wrapper now preserves AttachmentClaimError.message, matching the send wrapper and existing WS boundary, while excluding its raw cause. The public code remains orchestration_error because this typed channel also covers operational copy/stat failures. Final shared core + real MCP integration 7/7, server typecheck and targeted lint/fmt/diff pass. No claim or launch service behavior changed.