feat(mcp): merge back conversation context#8697
1 unresolved thread ยท Jump to file
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);๐ 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:
| 1 | In 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. |
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.