feat(mcp): merge back conversation context#8697

closed3
agents/mcp-conversations/fork
agents/mcp-conversations/merge-back
openedAug 29, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 2d ago
1 unresolved thread ยท Jump to file
Selected thread: apps/server/src/mcp/ConversationTransferMcpService.ts:511 โ€” ๐ŸŸ  **High** `mcp/ConversationTransferMcpService.ts:511` `mergeBack` allows a lower-policy caller to transfer context from a fork whose runtime or interaction mode was later escalated, bypassing the source-versus-caller ceiling enforced by `fork`. Lines 511โ€“528 validate only the parent target against `parent`, so add the same checks for `source.thread` before dispatching the merge-back. <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/ConversationTransferMcpService.ts around line 511: `mergeBack` allows a lower-policy caller to transfer context from a fork whose runtime or interaction mode was later escalated, bypassing the source-versus-caller ceiling enforced by `fork`. Lines 511โ€“528 validate only the parent target against `parent`, so add the same checks for `source.thread` before dispatching the merge-back. ``` </details> <!-- macroscope-meta: {"kind":"code_review","variant":"f57f0b61c1d8f13d673564c863408287"} -->
0 / 16 viewed16 files changedLine totals unavailable

apps/server/src/mcp/ConversationTransferMcpService.ts ยท Earlier revision d7d2ffa9 ยท right 511

Original diff
@@ -360,8 +423,169 @@ export const make = Effect.gen(function* () {
           source,
         });
       }),
+    mergeBack: (scope, input) =>
+      Effect.gen(function* () {
+        yield* requireCapability(scope);
+        const commandId = mergeBackCommandId(scope, input.clientRequestId);
+        const priorReceipt = yield* commandReceipts.getByCommandId(commandId).pipe(
+          Effect.map(
+            Option.filter(
+              (existing) =>
+                existing.status === "accepted" && existing.commandType === "thread.merge_back",
+            ),
+          ),
+          Effect.mapError((error) =>
+            failure(
+              "orchestration_error",
+              `Unable to inspect the merge-back retry receipt: ${errorMessage(error)}`,
+            ),
+          ),
+        );
+        if (Option.isSome(priorReceipt)) {
+          const caller = yield* threads
+            .getThreadProjection(scope.threadId)
+            .pipe(
+              Effect.mapError((error) =>
+                failure(
+                  "orchestration_error",
+                  `Unable to load the calling thread: ${errorMessage(error)}`,
+                ),
+              ),
+            );
+          const recordedTarget = yield* threads
+            .getThreadProjection(priorReceipt.value.threadId)
+            .pipe(
+              Effect.mapError((error) =>
+                failure(
+                  "orchestration_error",
+                  `Unable to load the accepted merge-back target: ${errorMessage(error)}`,
+                ),
+              ),
+            );
+          if (recordedTarget.thread.projectId !== caller.thread.projectId) {
+            return yield* failure(
+              "thread_not_found",
+              `The accepted merge-back target is not in the calling project.`,
+            );
+          }
+          const replayed = yield* threads
+            .dispatch({
+              type: "thread.merge_back",
+              createdBy: "agent",
+              creationSource: "mcp",
+              commandId,
+              sourceThreadId: input.sourceThreadId ?? scope.threadId,
+              targetThreadId: priorReceipt.value.threadId,
+              sourcePoint: input.sourcePoint,
+            })
+            .pipe(
+              Effect.mapError((error) =>
+                failure(
+                  "orchestration_error",
+                  `Unable to replay the merge-back receipt: ${errorMessage(error)}`,
+                ),
+              ),
+            );
+          return yield* mergeBackResultFromDispatch({
+            request: input,
+            commandId,
+            dispatched: replayed,
+          });
+        }
+
+        const { parent, target: source } = yield* loadScoped(scope, input.sourceThreadId);
+        const recordedTargetId = source.thread.lineage.parentThreadId;
+        if (source.thread.lineage.relationshipToParent !== "fork" || recordedTargetId === null) {
+          return yield* failure(
+            "invalid_request",
+            `Thread ${source.thread.id} is not a conversation fork and cannot merge back.`,
+          );
+        }
+        const requestedTargetId = input.targetThreadId ?? recordedTargetId;
+        if (requestedTargetId !== recordedTargetId) {
+          return yield* failure(
+            "invalid_request",
+            `Thread ${source.thread.id} can merge back only to its recorded parent ${recordedTargetId}.`,
+          );
+        }
+        const { target } = yield* loadScoped(scope, requestedTargetId);
M
macroscopeappBotAug 30, 2026

๐ŸŸ  High mcp/ConversationTransferMcpService.ts:511

mergeBack allows a lower-policy caller to transfer context from a fork whose runtime or interaction mode was later escalated, bypassing the source-versus-caller ceiling enforced by fork. Lines 511โ€“528 validate only the parent target against parent, so add the same checks for source.thread before dispatching the merge-back.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/ConversationTransferMcpService.ts around line 511:
2
3`mergeBack` allows a lower-policy caller to transfer context from a fork whose runtime or interaction mode was later escalated, bypassing the source-versus-caller ceiling enforced by `fork`. Lines 511โ€“528 validate only the parent target against `parent`, so add the same checks for `source.thread` before dispatching the merge-back.
J
Julius MarmingeAug 30, 2026

Fixed at b8724db4928ed0f5b96abac69baa6106021dc817. Fresh merge-back now checks the source as well as the target against both the captured and current caller ceilings inside the existing total-order source/target/caller dispatch locks. Accepted stable-key retries still replay before mutable source, target, lineage, or policy checks. Focused real MCP/V2 coverage proves source escalation is denied at the mutation boundary and that an accepted merge remains replayable after the caller narrows. Transfer service plus toolkit integration tests pass 5/5; contracts and server typechecks pass.