From 76d6c7cd8f8ed3aa31e0ab389ad3716ce4e87297 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:53:39 -0700 Subject: [PATCH 01/50] feat(server): /mcp narrows a client's modes from T3-Mode-Limit An OAuth client on /mcp is capped by the access it was approved with. When another environment calls on behalf of one of its agents, that agent may run under narrower modes, and the target has no way to know. /mcp now reads `T3-Mode-Limit: /` from OAuth clients and caps the request at the narrower of the two. The header can only narrow: a broader one changes nothing, and a malformed one is refused with 400. The client's interaction mode can now be capped too; before, it was always default. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.test.ts | 145 ++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 35 ++++- apps/server/src/mcp/McpInvocationContext.ts | 54 ++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 7 +- apps/server/src/mcp/threadAccess.ts | 5 +- 5 files changed, 232 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 90c4af4d6e0de..0a4238d6c138a 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,5 +1,6 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -8,6 +9,8 @@ import { OrchestratorMcpFailure, PreviewTabId, ProviderInstanceId, + ProviderInteractionMode, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -1178,3 +1181,145 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect( + "narrows an OAuth client's modes to the T3-Mode-Limit it sends, and never widens them", + () => + Effect.scoped( + Effect.gen(function* () { + const clientToken = "client-payload.client-signature"; + const clientScope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "client:linked", + thread: undefined, + client: { sessionId: "linked", label: "T3 Code ยท laptop", access: "auto" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + // Starts a thread at the modes the gate hands it, so the outcome is those modes. + const StartsToolkit = Toolkit.make( + Tool.make("starts", { + description: "Reports the modes a started thread would run with.", + parameters: Schema.Struct({ + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), + success: Schema.Struct({ modes: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], + }), + ); + const serverLayer = McpHttpServer.toolkitRegistration( + StartsToolkit, + McpToolAccess.toLayer(StartsToolkit, { + starts: McpToolAccess.startsThreads( + (input) => input, + (_, modes) => + Effect.succeed({ modes: `${modes.runtimeMode}/${modes.interactionMode}` }), + ), + }), + ).pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide( + Layer.succeed(McpHttpServer.McpClientAuthenticator, { + authenticate: (request) => + Effect.succeed( + request.headers.authorization === `Bearer ${clientToken}` + ? clientScope + : undefined, + ), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Struct({ text: Schema.String })), + }), + }), + ), + ); + const decodeText = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([ + Schema.Struct({ modes: Schema.String }), + Schema.Struct({ code: Schema.String }), + ]), + ), + ); + /** Starts a thread as the client, with `limit` as T3-Mode-Limit; the HTTP status or the outcome. */ + const start = (limit: string | undefined, args: Record = {}) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${clientToken}`, + ...(limit === undefined ? {} : { "t3-mode-limit": limit }), + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + if (initialize.status !== 200) return initialize.status; + const response = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "starts", arguments: args }, + }), + "application/json", + ), + }); + const body = yield* decodeOutcome(yield* response.text); + const outcome = yield* decodeText( + body.result.content.map((part) => part.text).join(""), + ); + return "code" in outcome ? outcome.code : outcome.modes; + }); + + // Approved for auto: without the header the client keeps its ceiling. + expect(yield* start(undefined)).toBe("auto/default"); + // The header narrows both modes, and asking above it is refused. + expect(yield* start("approval-required/plan")).toBe("approval-required/plan"); + expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe( + "runtime_mode_escalation_denied", + ); + expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe( + "interaction_mode_escalation_denied", + ); + // A broader header changes nothing: the approved ceiling still holds. + expect(yield* start("full-access/default")).toBe("auto/default"); + expect(yield* start("full-access/default", { runtimeMode: "full-access" })).toBe( + "runtime_mode_escalation_denied", + ); + // A malformed header is refused before anything runs. + expect(yield* start("auto")).toBe(400); + expect(yield* start("auto/default/extra")).toBe(400); + expect(yield* start("root/default")).toBe(400); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 2f19cc8cecf49..c7bb29cad815c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -16,7 +16,9 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { OrchestratorMcpFailure, PreviewAutomationError, + ProviderInteractionMode, type RunId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -153,6 +155,35 @@ export const normalizeMcpHttpResponse = ( // Session tokens are `.`; registry tokens are a bare base64url secret. const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes("."); +const decodeModeLimit = Schema.decodeUnknownOption( + Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode }), +); +const parseModeLimit = (value: string) => { + const [runtimeMode, interactionMode, ...rest] = value.trim().split("/"); + return rest.length === 0 ? decodeModeLimit({ runtimeMode, interactionMode }) : Option.none(); +}; + +const invalidModeLimit = HttpServerResponse.jsonUnsafe( + { + error: "invalid_mode_limit", + message: "T3-Mode-Limit must be /, e.g. auto/default.", + }, + { status: 400, headers: { "cache-control": "no-store" } }, +); + +/** Applies a client's requested mode limit; undefined when the header is malformed. */ +const withModeLimit = ( + invocation: McpInvocationContext.McpInvocationScope, + request: HttpServerRequest.HttpServerRequest, +): McpInvocationContext.McpInvocationScope | undefined => { + const header = request.headers[McpInvocationContext.MODE_LIMIT_HEADER]; + if (header === undefined || invocation.client === undefined) return invocation; + const limit = parseModeLimit(header); + return Option.isNone(limit) + ? undefined + : { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } }; +}; + const makeMcpAuthMiddleware = Effect.gen(function* () { const registry = yield* McpSessionRegistry.McpSessionRegistry; const clients = yield* Effect.serviceOption(McpClientAuthenticator); @@ -178,8 +209,10 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token), }); } + const limited = withModeLimit(invocation, request); + if (limited === undefined) return invalidModeLimit; return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, limited), Effect.map(normalizeMcpHttpResponse), ); }) satisfies McpAuthMiddleware; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03c..06b22f1d726dc 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -5,6 +5,7 @@ import { OrchestratorMcpFailure, PreviewAutomationUnavailableError, type ProviderInstanceId, + type ProviderInteractionMode, type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; @@ -30,14 +31,59 @@ export interface McpClientCaller { readonly sessionId: string; readonly label: string; readonly access: AuthMcpClientAccess; + /** + * Narrower modes the client asked for on this request (`T3-Mode-Limit`): + * another environment passing on the limits of the agent it calls for. + */ + readonly narrowedTo?: ClientModes | undefined; } /** - * A read-only client never reaches a write (`McpToolAccess` refuses it first), - * so it maps to the lowest mode rather than to nothing. + * `T3-Mode-Limit: /` lets an OAuth client cap + * a request below what it was approved with: another environment passes on + * the limits of the agent it calls for. It can only narrow (see + * `clientModeCeiling`), so a client gains nothing by sending a broad one. */ -export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => - client === undefined || client.access === "read-only" ? "approval-required" : client.access; +export const MODE_LIMIT_HEADER = "t3-mode-limit"; + +export interface ClientModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +const runtimeModeRank: Record = { + "approval-required": 0, + "auto-accept-edits": 1, + auto: 2, + "full-access": 3, +}; +const interactionModeRank: Record = { plan: 0, default: 1 }; + +/** + * The modes a client caller's writes are capped at: what it was approved + * with, narrowed by what it asked for on this request. A read-only client + * never reaches a write (`McpToolAccess` refuses it first), so it maps to the + * lowest runtime mode rather than to nothing. + */ +export const clientModeCeiling = (client: McpClientCaller | undefined): ClientModes => { + const approved: ClientModes = { + runtimeMode: + client === undefined || client.access === "read-only" ? "approval-required" : client.access, + interactionMode: "default", + }; + const asked = client?.narrowedTo; + if (asked === undefined) return approved; + return { + runtimeMode: + runtimeModeRank[asked.runtimeMode] < runtimeModeRank[approved.runtimeMode] + ? asked.runtimeMode + : approved.runtimeMode, + interactionMode: + interactionModeRank[asked.interactionMode] < interactionModeRank[approved.interactionMode] + ? asked.interactionMode + : approved.interactionMode, + }; +}; /** * A thread caller's omitted target falls back to its own thread; a client diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5e028855c7f4c..acba153b7bda7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -99,7 +99,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { - clientRuntimeModeCeiling, + clientModeCeiling, type McpInvocationScope, type McpThreadInvocationScope, requireThreadScope, @@ -1075,10 +1075,7 @@ const make = Effect.gen(function* () { if (scope.thread === undefined) { return { parent: undefined, - limits: { - runtimeMode: clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, + limits: clientModeCeiling(scope.client), } as const; } const parent = yield* loadProjection(scope.thread.threadId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index be70ecf1807fb..1833c379b505d 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -68,10 +68,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { scope, threads, caller: undefined, - limits: { - runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - }, + limits: McpInvocationContext.clientModeCeiling(scope.client), } satisfies Caller; } const caller = yield* threads From 41974697a33418dac4a97523b409db298b53372d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:22:08 -0700 Subject: [PATCH 02/50] feat(server): agents use linked environments An agent can now work on a linked environment's threads through the same T3 MCP tools. t3_project_list, orchestrator_capabilities, t3_thread_launch, t3_thread_list, t3_thread_read, t3_thread_send, t3_thread_wait and t3_thread_interrupt take an optional environmentId; t3_environment_links lists the links. Each tool's own access declaration still runs here first, so a read-only or ended caller is refused before anything leaves. The forwarded call carries the caller's modes in T3-Mode-Limit, and the linked environment holds it to those and the link's access. Retry keys are hashed with the caller's namespace, since every caller here shares the link's one session there. A forwarded wait goes out in pieces of at most 50 s so no route drops it idle. A remote launch cannot carry attachments yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.ts | 2 +- apps/server/src/mcp/McpToolAccess.ts | 7 +- apps/server/src/mcp/OrchestratorMcpService.ts | 39 ++ ...OrchestratorMcpToolkit.integration.test.ts | 3 + apps/server/src/mcp/toolkits/core.test.ts | 6 + .../src/mcp/toolkits/orchestrator/handlers.ts | 120 +++-- .../src/mcp/toolkits/orchestrator/tools.ts | 44 +- .../src/mcp/toolkits/project/handlers.test.ts | 5 + .../src/mcp/toolkits/project/handlers.ts | 31 +- apps/server/src/mcp/toolkits/project/tools.ts | 10 +- .../toolkits/worktree/registration.test.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 1 + apps/server/src/peer/PeerForwarding.test.ts | 476 ++++++++++++++++++ apps/server/src/peer/PeerForwarding.ts | 197 ++++++++ apps/server/src/peer/PeerLinks.test.ts | 195 ++----- apps/server/src/peer/PeerLinks.testkit.ts | 176 +++++++ apps/server/src/server.ts | 8 +- docs/internals/remote.md | 31 +- packages/client-runtime/src/t3ToolSummary.ts | 3 + packages/contracts/src/orchestratorMcp.ts | 49 ++ .../src/server/orchestrationInstructions.ts | 3 +- packages/shared/src/t3McpToolPresentation.ts | 5 + 22 files changed, 1202 insertions(+), 211 deletions(-) create mode 100644 apps/server/src/peer/PeerForwarding.test.ts create mode 100644 apps/server/src/peer/PeerForwarding.ts create mode 100644 apps/server/src/peer/PeerLinks.testkit.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index c7bb29cad815c..7eca3616d3ff3 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1063,7 +1063,7 @@ export const layerEnvironmentToolkit = toolkitRegistration( EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); -const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); +export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 5cc8dc0c38b8f..c9084feac33c5 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -125,13 +125,16 @@ export const writesWithCapability = (handle: (params: P) => Effect.E declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); export const writesThreads = ( - threads: (params: P) => ReadonlyArray, + threads: ( + params: P, + scope: McpInvocationContext.McpInvocationScope, + ) => ReadonlyArray, handle: (params: P) => Effect.Effect, ) => declare((params: P) => Effect.gen(function* () { const caller = yield* writingCaller; - for (const threadId of threads(params)) { + for (const threadId of threads(params, caller.scope)) { if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; const target = yield* caller.threads .getThreadShell(threadId) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index acba153b7bda7..b1c50b1dc309b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2770,6 +2770,45 @@ const make = Effect.gen(function* () { yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const key = yield* requestKey(input.clientRequestId); + // A peer from before `stop` drops it when decoding, so it interrupts + // the run instead, as this call did then. + if (input.stop === true) { + const commandId = stableCommandId({ scope, requestKey: key, operation: "thread-stop" }); + // The cause goes to the log; the caller learns only that the stop failed. + const stopFailed = (error: unknown) => + Effect.logWarning("orchestrator-mcp.thread-stop-failed", { + threadId: input.threadId, + error, + }).pipe( + Effect.andThen( + Effect.fail( + failure("orchestration_error", `Unable to stop thread ${input.threadId}.`), + ), + ), + ); + const stopped = yield* threadManagement + .dispatch({ + type: "thread.stop", + commandId, + threadId: input.threadId, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }) + .pipe(Effect.catch(stopFailed)); + // A retry with the same clientRequestId repeats only the stops that failed. + yield* threadManagement + .stopDelegatedTasks({ threadId: input.threadId, commandId, reason: input.reason }) + .pipe(Effect.catch(stopFailed)); + // The run this stop interrupted, from its own events, so a retry + // reports the same run even if another has started since. + const interrupted = stopped.storedEvents.find( + (stored) => stored.event.type === "run.updated", + )?.event.runId; + return { + threadId: input.threadId, + runId: interrupted ?? null, + status: interrupted === undefined ? "no_active_run" : "interrupt_requested", + } satisfies OrchestratorMcpThreadInterruptResult; + } const result = yield* threadManagement .interruptThread({ projectId: target.thread.projectId, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index b125361cd94b6..b059488b5938a 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -85,6 +85,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as PeerForwarding from "../peer/PeerForwarding.ts"; import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -729,6 +730,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(layerProviderRegistry), Layer.provide(ServerSettings.layerTest()), Layer.provide(layerScheduledTaskStub), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide( Layer.mock(ProjectService.ProjectService)({ @@ -4407,6 +4409,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(layerProviderRegistry), Layer.provide(ServerSettings.layerTest()), Layer.provide(layerUnusedScheduledTaskStub), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provideMerge( SecretRequests.layer.pipe( diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index b3bd302303f41..3ec6662dfd814 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -60,6 +60,7 @@ import * as EnvironmentHandlers from "./environment/handlers.ts"; import { ProjectToolkit } from "./project/tools.ts"; import { AttachmentToolkit } from "./attachment/tools.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; +import * as PeerForwarding from "../../peer/PeerForwarding.ts"; import { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; import { DeviceToolkit } from "./device/tools.ts"; @@ -90,6 +91,7 @@ const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadR const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ); it("publishes unique tool names with reference-free object-root inputs", () => { @@ -718,6 +720,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), ), @@ -773,6 +776,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode ), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ), ), ), @@ -857,6 +861,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ), ), ), @@ -947,6 +952,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), ), diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7c05b330047cf..38b57178e4b28 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -1,20 +1,46 @@ -import { OrchestratorMcpFailure } from "@t3tools/contracts"; -import { OrchestratorToolkit } from "./tools.ts"; +import { type EnvironmentId, OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import type * as Tool from "effect/ai/Tool"; + +import { OrchestratorToolkit } from "./tools.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import { readCaller } from "../../threadAccess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; +const { tools } = OrchestratorToolkit; + +/** + * Runs `here` in this environment, or the same tool in the linked + * environment `input.environmentId` names. The tool's declaration has already + * checked the caller here either way. + */ +const routed = ( + tool: T, + input: Tool.Parameters & { readonly environmentId?: EnvironmentId | undefined }, + here: Effect.Effect, +) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + if (PeerForwarding.remoteTarget(scope, input.environmentId) === undefined) return yield* here; + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.route(scope, tool, input, here); + }); + const handlers = { - orchestrator_capabilities: McpToolAccess.reads(() => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.capabilities(scope); - }), + orchestrator_capabilities: McpToolAccess.reads((input) => + routed( + tools.orchestrator_capabilities, + input, + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.capabilities(scope); + }), + ), ), delegate_task: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { @@ -102,18 +128,26 @@ const handlers = { }), ), t3_thread_list: McpToolAccess.reads((input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.listThreads(scope, input); - }), + routed( + tools.t3_thread_list, + input, + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.listThreads(scope, input); + }), + ), ), t3_thread_read: McpToolAccess.reads((input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.readThread(scope, input); - }), + routed( + tools.t3_thread_read, + input, + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.readThread(scope, input); + }), + ), ), t3_thread_update: McpToolAccess.writesThreads( (input) => [input.threadId], @@ -125,29 +159,55 @@ const handlers = { }), ), t3_thread_send: McpToolAccess.writesThreads( - (input) => [input.threadId], + // A thread in a linked environment is checked there, where it lives. + // This environment's own id names a thread here, which is checked here. + (input, scope) => + PeerForwarding.remoteTarget(scope, input.environmentId) === undefined ? [input.threadId] : [], (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.sendToThread(scope, input); - }), + routed( + tools.t3_thread_send, + input, + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.sendToThread(scope, input); + }), + ), ), t3_thread_wait: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; + const target = PeerForwarding.remoteTarget(scope, input.environmentId); + if (target !== undefined) { + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.waitForThread(scope, target, input); + } const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.waitForThread(scope, input); }), ), + t3_environment_links: McpToolAccess.reads(() => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.links(scope); + }), + ), t3_thread_interrupt: McpToolAccess.writesThreads( - (input) => [input.threadId], + // A thread in a linked environment is checked there, where it lives. + // This environment's own id names a thread here, which is checked here. + (input, scope) => + PeerForwarding.remoteTarget(scope, input.environmentId) === undefined ? [input.threadId] : [], (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.interruptThread(scope, input); - }), + routed( + tools.t3_thread_interrupt, + input, + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.interruptThread(scope, input); + }), + ), ), } satisfies McpToolAccess.Handlers; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 91187bc2a1311..1e27f7511dc03 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,6 +1,8 @@ import { + OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, + OrchestratorMcpEnvironmentLinksResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskInput, @@ -35,6 +37,7 @@ import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; @@ -44,6 +47,8 @@ const dependencies = [ ThreadManagementService.ThreadManagementService, OrchestratorMcpService.OrchestratorMcpService, ]; +/** Tools that also run in a linked environment, when `environmentId` names one. */ +const forwardingDependencies = [...dependencies, PeerForwarding.PeerForwarding]; const threadMetadataDependencies = [ McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, @@ -52,11 +57,12 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy. Pass environmentId to list a linked environment's providers instead.", + parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies, + dependencies: forwardingDependencies, }) .annotate(Tool.Title, "Get orchestration capabilities") .annotate(Tool.Readonly, true) @@ -192,12 +198,12 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Pass environmentId to list a linked environment's threads; projectId is then required. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title. Name a thread in a linked environment by title instead: its link would open here.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies, + dependencies: forwardingDependencies, }) .annotate(Tool.Title, "List T3 threads") .annotate(Tool.Readonly, true) @@ -206,12 +212,12 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "Read durable state and a paginated timeline from any T3 thread in this environment, or in a linked one with environmentId. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title. Name a thread in a linked environment by title instead: its link would open here.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies, + dependencies: forwardingDependencies, }) .annotate(Tool.Title, "Read a T3 thread") .annotate(Tool.Strict, true) @@ -234,12 +240,12 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to any T3 thread in this environment, or in a linked one with environmentId. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies, + dependencies: forwardingDependencies, }) .annotate(Tool.Title, "Send to a T3 thread") .annotate(Tool.Destructive, true) @@ -247,12 +253,12 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a T3 thread run to reach a terminal durable state. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", + "Wait for a T3 thread run to reach a terminal durable state, here or in a linked environment with environmentId. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies, + dependencies: forwardingDependencies, }) .annotate(Tool.Title, "Wait for a T3 thread") .annotate(Tool.Readonly, true) @@ -261,16 +267,29 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", { const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { description: - "Request interruption of a running turn in any T3 thread in this environment. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", + "Request interruption of a running turn in any T3 thread in this environment, or in a linked one with environmentId. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. stop=true stops the thread as its Stop button does instead: it also holds queued messages, ends pull request watches and stops the tasks it delegated. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadInterruptInput, success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies, + dependencies: forwardingDependencies, }) .annotate(Tool.Title, "Interrupt a T3 thread") .annotate(Tool.Destructive, true); +const EnvironmentLinksTool = Tool.make("t3_environment_links", { + description: + "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to t3_project_list, t3_thread_launch, t3_thread_list, t3_thread_read, t3_thread_send, t3_thread_wait, t3_thread_interrupt, or orchestrator_capabilities to act there. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", + success: OrchestratorMcpEnvironmentLinksResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: forwardingDependencies, +}) + .annotate(Tool.Title, "List linked environments") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + export const OrchestratorToolkit = Toolkit.make( OrchestratorCapabilitiesTool, DelegateTaskTool, @@ -289,4 +308,5 @@ export const OrchestratorToolkit = Toolkit.make( ThreadSendTool, ThreadWaitTool, ThreadInterruptTool, + EnvironmentLinksTool, ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index f0cb413ca756a..c814753f39698 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -22,6 +22,7 @@ import * as ServerConfig from "../../../config.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -79,6 +80,7 @@ it.effect("attributes a launched thread's first message to the calling thread", }, }), Layer.mock(Project.ProjectService)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, @@ -151,6 +153,7 @@ it.effect("launches a scratch thread into the Scratch project", () => }, }), Layer.mock(Project.ProjectService)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }), @@ -247,6 +250,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () projectId === createdProjectId ? Option.some(createdProject) : Option.none(), ), }), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", createNamedProject: (input) => @@ -356,6 +360,7 @@ const clientLaunchHarness = (input: { : Option.none(), ), }), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), NodeServices.layer, ).pipe( diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 3c148ed5669cb..c4fb7020b8f06 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,6 @@ import { CommandId, + type EnvironmentId, MessageId, ThreadId, OrchestratorMcpFailure, @@ -14,7 +15,9 @@ import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -56,13 +59,34 @@ const access = Effect.gen(function* () { yield* readCaller(); return yield* Project.ProjectService; }); + +/** The linked environment a call names, or nothing for this one. */ +const remoteTarget = (input: { readonly environmentId?: EnvironmentId | undefined }) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => ({ + scope, + target: PeerForwarding.remoteTarget(scope, input.environmentId), + })), + ); export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_thread_launch: McpToolAccess.startsThreads( (input) => input, (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { + const { scope, target } = yield* remoteTarget(input); + if (target !== undefined) { + // The call carries the caller's modes, so omitted modes inherit + // them there too, capped by the link's access. + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.call( + scope, + ProjectToolkit.tools.t3_thread_launch, + target, + input, + ); + } const context = yield* readCaller(); - const { caller, scope } = context; + const { caller } = context; // A retry with the same key replays the first launch: the ids derive // from the caller and the key, and the launch service replays a // command it already accepted. @@ -198,6 +222,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { + const { scope, target } = yield* remoteTarget(input); + if (target !== undefined) { + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_list, target, input); + } const projects = yield* access; const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); const rows = snapshot.projects.filter((project) => project.deletedAt === null); diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 1634f98a6e4f9..679808a916ea0 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -14,6 +14,7 @@ import { ProjectUpdatePayload, ProjectId, OrchestratorMcpClientRequestId, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -28,6 +29,7 @@ import { Tool, Toolkit } from "effect/ai"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -46,8 +48,9 @@ const shared = { const ProjectListTool = Tool.make("t3_project_list", { ...shared, description: - "List registered projects in this environment. Pages use the current project snapshot and may shift between calls.", + "List registered projects in this environment, or in a linked one with environmentId. Pages use the current project snapshot and may shift between calls.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), }), @@ -55,6 +58,7 @@ const ProjectListTool = Tool.make("t3_project_list", { projects: Schema.Array(Project), nextCursor: Schema.NullOr(NonNegativeInt), }), + dependencies: [...shared.dependencies, PeerForwarding.PeerForwarding], }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); @@ -105,8 +109,9 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. Without a key, after errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. Name a thread in a linked environment by title instead: its link would open here. Without a key, after errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with. Pass environmentId to launch in a linked environment; projectId and modelSelection then name that environment\'s project and providers (t3_project_list and orchestrator_capabilities with the same environmentId list them), and attachments are not supported.', parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional(ProjectId), scratch: Schema.optional( Schema.Boolean.annotate({ @@ -148,6 +153,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + PeerForwarding.PeerForwarding, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 48c48f6199663..93f0ecc336fe4 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -35,6 +35,7 @@ import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; const layerStubServices = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({}), @@ -44,6 +45,7 @@ const layerStubServices = Layer.mergeAll( Layer.mock(ProviderRegistry.ProviderRegistry)({}), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 53c71b9eed71a..b67ba231ab357 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1425,6 +1425,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_search", "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", + "mcp__t3-code__t3_environment_links", "mcp__t3-code__t3_queue_list", "mcp__t3-code__t3_queue_read", "mcp__t3-code__html_preview", diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts new file mode 100644 index 0000000000000..6fa6c6aa192ba --- /dev/null +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -0,0 +1,476 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + type OrchestrationV2Run, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ServerConfig from "../config.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { liveThreadShell } from "../mcp/McpToolAccess.testkit.ts"; +import * as PeerForwarding from "./PeerForwarding.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts"; + +const laptop = descriptorOf("environment-laptop", "Laptop"); +const box = descriptorOf("environment-box", "Box"); +const boxProject = ProjectId.make("project:box"); +const boxThread = ThreadId.make("thread:box-auto"); +const boxRun = RunId.make("run:box-auto"); + +/** What B's thread service was asked to do. */ +interface Seen { + readonly sends: ReadonlyArray; + readonly waits: ReadonlyArray; + readonly launches: ReadonlyArray; + /** Commands dispatched and delegated-task stops, as ` `. */ + readonly stops: ReadonlyArray; +} +const nothingSeen: Seen = { sends: [], waits: [], launches: [], stops: [] }; + +/** + * B's threads: one `auto` thread in `boxProject`, whose run finishes on the + * third wait. Every send and wait is recorded. + */ +const boxThreads = (seen: Ref.Ref) => { + const shell = { ...liveThreadShell(boxThread, { runtimeMode: "auto" }), projectId: boxProject }; + const run = (status: OrchestrationV2Run["status"]) => + ({ id: boxRun, status }) as unknown as OrchestrationV2Run; + const projection = { thread: shell, runs: [run("running")], runtimeRequests: [] } as never; + return Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(threadId === boxThread ? shell : null), + getProjectThreadRecords: () => Effect.succeed(projection), + getThreadRecords: () => Effect.succeed(projection), + listProjectThreads: () => Effect.succeed([shell] as never), + sendToThread: (input) => + Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe( + Effect.as({ run: run("running"), delivery: "started" } as never), + ), + dispatch: (command) => + Ref.update(seen, (current) => ({ + ...current, + stops: [ + ...current.stops, + `${command.type} ${"threadId" in command ? command.threadId : ""}`, + ], + })).pipe( + // The stop interrupts the thread's running run. + Effect.as({ + sequence: 0, + storedEvents: [ + { + sequence: 1, + commandId: command.commandId, + event: { type: "run.updated", runId: boxRun }, + }, + ], + } as never), + ), + stopDelegatedTasks: (input) => + Ref.update(seen, (current) => ({ + ...current, + stops: [...current.stops, `delegated-tasks.stop ${input.threadId}`], + })), + waitForThread: (input) => + Ref.updateAndGet(seen, (current) => ({ ...current, waits: [...current.waits, input] })).pipe( + Effect.map(({ waits }) => ({ + threadId: input.threadId, + run: run(waits.length < 3 ? "running" : "completed"), + timedOut: waits.length < 3, + })), + ), + }); +}; + +/** B launches whatever it is asked to, and records it. */ +const boxLaunches = (seen: Ref.Ref) => + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (input) => + Ref.update(seen, (current) => ({ ...current, launches: [...current.launches, input] })).pipe( + Effect.as({ + threadId: input.threadId, + projection: { + thread: { + id: input.threadId, + projectId: input.projectId, + modelSelection: input.modelSelection, + title: input.title, + }, + runs: [], + }, + resumed: false, + } as unknown as ThreadLaunch.ThreadLaunchResult), + ), + }); + +/** The orchestrator and project toolkits on B's real `/mcp`, behind its real OAuth. */ +const serveBox = (seen: Ref.Ref) => + servePeer( + box, + Layer.merge( + McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerProjectRegistration, + ).pipe( + Layer.provide(NodeCrypto.layer), + Layer.provide(boxThreads(seen)), + Layer.provide(boxLaunches(seen)), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-box-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(NodeServices.layer), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + ), + ); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "peer-forwarding-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "peer-forwarding-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +/** + * Environment A: its own orchestrator toolkit with forwarding, and a thread + * caller whose modes the test picks. Its local threads never matter here. + */ +const makeLaptop = Effect.gen(function* () { + const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); + const layerHere = Layer.merge( + McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerProjectRegistration, + ).pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-laptop-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(NodeServices.layer), + Layer.provide(PeerForwarding.layer), + Layer.provide(Layer.succeedContext(linking)), + Layer.provide(NodeCrypto.layer), + Layer.provideMerge( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed( + liveThreadShell( + threadId, + threadId === ThreadId.make("thread:laptop-plan") + ? { runtimeMode: "full-access", interactionMode: "plan" } + : { runtimeMode: "full-access" }, + ), + ), + }), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.fresh, + ); + const here = yield* Layer.build(layerHere); + const server = Context.get(here, McpServer.McpServer); + const scopeOf = ( + caller: string, + capabilities: ReadonlySet = new Set(["orchestration"]), + ): McpInvocationContext.McpInvocationScope => ({ + environmentId: laptop.environmentId, + requestNamespace: `provider-session:${caller}`, + thread: { + threadId: ThreadId.make(caller), + providerSessionId: `provider-session:${caller}`, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities, + issuedAt: 1, + }); + const call = ( + caller: string, + name: string, + args: Record, + capabilities?: ReadonlySet, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + scopeOf(caller, capabilities), + ), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + return { links: Context.get(linking, PeerLinks.PeerLinks), call }; +}); + +const failureOf = (result: McpSchema.CallToolResult) => { + const text = result.content[0]; + return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; +}; + +it.effect("an agent here works on a linked environment's threads within its own modes", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + + const listed = yield* a.call("thread:laptop-full", "t3_environment_links", {}); + expect(listed.structuredContent).toMatchObject({ + environmentId: laptop.environmentId, + links: [ + { environmentId: box.environmentId, label: "Box", status: "reachable", access: "auto" }, + ], + }); + + const threads = yield* a.call("thread:laptop-full", "t3_thread_list", { + environmentId: box.environmentId, + projectId: boxProject, + }); + expect(threads.isError).toBe(false); + expect(threads.structuredContent).toMatchObject({ + projectId: boxProject, + threads: [{ threadId: boxThread, runtimeMode: "auto" }], + }); + + // A full-access agent here is held to the link's `auto` there, which + // the box's `auto` thread is within. + const sent = yield* a.call("thread:laptop-full", "t3_thread_send", { + environmentId: box.environmentId, + threadId: boxThread, + message: "Carry on there.", + clientRequestId: "send-1", + }); + expect(sent.isError).toBe(false); + expect(sent.structuredContent).toMatchObject({ threadId: boxThread, delivery: "started" }); + + // A plan-mode agent here may not steer that thread there: the box + // refuses it, because the call carries the agent's own modes. + const planned = yield* a.call("thread:laptop-plan", "t3_thread_send", { + environmentId: box.environmentId, + threadId: boxThread, + message: "Change course.", + }); + expect(failureOf(planned)).toMatchObject({ code: "interaction_mode_escalation_denied" }); + expect((yield* Ref.get(seen)).sends).toHaveLength(1); + + // stop=true stops the thread there as its Stop button does: the whole + // thread and the tasks it delegated, not just the running turn. + const stopped = yield* a.call("thread:laptop-full", "t3_thread_interrupt", { + environmentId: box.environmentId, + threadId: boxThread, + stop: true, + clientRequestId: "stop-1", + }); + expect(stopped.structuredContent).toMatchObject({ + threadId: boxThread, + runId: boxRun, + status: "interrupt_requested", + }); + expect((yield* Ref.get(seen)).stops).toEqual([ + `thread.stop ${boxThread}`, + `delegated-tasks.stop ${boxThread}`, + ]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("keeps a forwarded wait short and its retries scoped to the caller", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + + const waited = yield* a.call("thread:laptop-full", "t3_thread_wait", { + environmentId: box.environmentId, + threadId: boxThread, + timeoutMs: 600_000, + }); + expect(waited.structuredContent).toEqual({ + threadId: boxThread, + runId: boxRun, + status: "completed", + timedOut: false, + }); + // Three pieces of at most 50 s each, the later ones pinned to the run + // the first one found. + expect( + (yield* Ref.get(seen)).waits.map(({ timeoutMs, runId }) => [timeoutMs, runId]), + ).toEqual([ + [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, undefined], + [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun], + [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun], + ]); + + const send = (caller: string, clientRequestId: string) => + a.call(caller, "t3_thread_send", { + environmentId: box.environmentId, + threadId: boxThread, + message: "Once.", + clientRequestId, + }); + yield* send("thread:laptop-full", "same-key"); + yield* send("thread:laptop-full", "same-key"); + yield* send("thread:laptop-other", "same-key"); + const [first, retried, other] = (yield* Ref.get(seen)).sends.map((input) => input.commandId); + // The box sees every caller here as the one link, so the key is scoped + // to its caller before it leaves: a retry matches, another agent's same + // key does not. + expect(retried).toBe(first); + expect(other).not.toBe(first); + expect(first).not.toContain("same-key"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("launches in a linked environment with the caller's modes, retry-safe", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "full-access"); + const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }; + const launch = (caller: string, args: Record = {}) => + a.call(caller, "t3_thread_launch", { + environmentId: box.environmentId, + projectId: boxProject, + title: "Run the suite there", + modelSelection: codex, + message: "Run the suite.", + clientRequestId: "launch-1", + ...args, + }); + + const first = yield* launch("thread:laptop-plan"); + expect(first.isError).toBe(false); + expect(first.structuredContent).toMatchObject({ + projectId: boxProject, + modelSelection: codex, + }); + const retried = yield* launch("thread:laptop-plan"); + const [launched, relaunched] = (yield* Ref.get(seen)).launches; + // The thread there runs with the caller's own modes here, below the + // link's full access, and a retry asks for the same thread. + expect(launched).toMatchObject({ runtimeMode: "full-access", interactionMode: "plan" }); + expect(relaunched?.threadId).toBe(launched?.threadId); + expect(retried.structuredContent).toEqual(first.structuredContent); + + // An escalation past the caller's own modes is refused here. + const escalated = yield* launch("thread:laptop-plan", { interactionMode: "default" }); + expect(failureOf(escalated)).toMatchObject({ code: "interaction_mode_escalation_denied" }); + // A pending upload lives here, so it cannot go along. + yield* Ref.set(b.bearers, []); + const withUpload = yield* launch("thread:laptop-full", { + clientRequestId: "launch-2", + attachments: [ + { type: "image", id: "upload-1", name: "a.png", mimeType: "image/png", sizeBytes: 1 }, + ], + }); + expect(failureOf(withUpload)).toMatchObject({ code: "invalid_request" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + expect((yield* Ref.get(seen)).launches).toHaveLength(2); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("refuses what a linked environment cannot take before anything leaves", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + yield* Ref.set(b.bearers, []); + + const unknown = yield* a.call("thread:laptop-full", "t3_thread_read", { + environmentId: EnvironmentId.make("environment-elsewhere"), + threadId: boxThread, + }); + expect(failureOf(unknown)).toMatchObject({ code: "invalid_request" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + // The box sees only the link's credential, so one that may not control + // threads here may not reach them there either. + const previewOnly = new Set(["preview"]); + for (const [name, args] of [ + ["t3_environment_links", {}], + ["t3_project_list", { environmentId: box.environmentId }], + ["t3_thread_list", { environmentId: box.environmentId, projectId: boxProject }], + ["t3_thread_read", { environmentId: box.environmentId, threadId: boxThread }], + ["t3_thread_wait", { environmentId: box.environmentId, threadId: boxThread }], + [ + "t3_thread_send", + { environmentId: box.environmentId, threadId: boxThread, message: "Carry on there." }, + ], + [ + "t3_thread_launch", + { + environmentId: box.environmentId, + projectId: boxProject, + title: "Start there", + modelSelection: { instanceId: "codex", model: "gpt-5" }, + message: "Start there.", + }, + ], + ] as const) { + const refused = yield* a.call("thread:laptop-full", name, args, previewOnly); + expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" }); + } + expect(yield* Ref.get(b.bearers)).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts new file mode 100644 index 0000000000000..4d26e1b09568d --- /dev/null +++ b/apps/server/src/peer/PeerForwarding.ts @@ -0,0 +1,197 @@ +import { + type EnvironmentId, + OrchestratorMcpFailure, + type OrchestratorMcpEnvironmentLinksResult, + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Tool from "effect/ai/Tool"; + +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { readCaller, unavailable } from "../mcp/threadAccess.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +/** + * A peer's `/mcp` holds a wait open for its whole timeout without sending + * anything, and T3 Connect's edge drops a request idle for about 100 s, so a + * forwarded wait goes out in pieces no longer than this. + */ +export const FORWARDED_WAIT_CHUNK_MS = 50_000; +const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; +const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; + +type Scope = McpInvocationContext.McpInvocationScope; + +/** + * Runs T3 MCP tools in a linked environment for an agent here. The tool's own + * declaration checks the agent here first; the linked environment then holds + * the call to the link's access, narrowed to the agent's modes, which every + * forwarded call carries. + */ +export class PeerForwarding extends Context.Service< + PeerForwarding, + { + /** Runs `tool` in the linked environment `environmentId` with `params`. */ + readonly call: ( + scope: Scope, + tool: T, + environmentId: EnvironmentId, + params: Tool.Parameters, + ) => Effect.Effect, OrchestratorMcpFailure>; + /** + * Runs `here` unless `params.environmentId` names a linked environment, in + * which case `tool` runs there with the same parameters. + */ + readonly route: ( + scope: Scope, + tool: T, + params: Tool.Parameters & { readonly environmentId?: EnvironmentId | undefined }, + here: Effect.Effect, + ) => Effect.Effect, E | OrchestratorMcpFailure, R>; + /** `t3_thread_wait` there, in pieces short enough to survive any route. */ + readonly waitForThread: ( + scope: Scope, + environmentId: EnvironmentId, + input: OrchestratorMcpThreadWaitInput, + ) => Effect.Effect; + /** The environments this one links to, as an agent sees them. */ + readonly links: ( + scope: Scope, + ) => Effect.Effect; + } +>()("t3/peer/PeerForwarding") {} + +/** The linked environment a call names, or nothing when it names this one. */ +export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) => + environmentId === scope.environmentId ? undefined : environmentId; + +const make = Effect.gen(function* () { + const peers = yield* PeerMcpClient.PeerMcpClient; + const links = yield* PeerLinks.PeerLinks; + const crypto = yield* Crypto.Crypto; + const threads = yield* ThreadManagement.ThreadManagementService; + + /** + * The caller's own limits here, which travel with every call it forwards. + * The peer sees only the link's credential, so a credential that may not + * control threads here is refused here. + */ + const callerLimits = (scope: Scope) => + readCaller().pipe( + Effect.map((caller) => caller.limits), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(ThreadManagement.ThreadManagementService, threads), + ); + + /** + * Every caller here reaches the peer as the one link session, so a retry + * key is scoped to its caller before it leaves: two agents here reusing a + * key never collide there, and one agent retrying gets its first result. + */ + const scopedRequestId = (namespace: string, clientRequestId: string) => + crypto.digest("SHA-256", new TextEncoder().encode(`${namespace}\n${clientRequestId}`)).pipe( + Effect.map((digest) => `fwd:${Hex.encode(digest)}`), + Effect.orDie, + ); + + /** Tool and contract schemas are plain data: they encode and decode without services. */ + const forward = ( + scope: Scope, + toolName: string, + parameters: Schema.Top, + success: Schema.Top, + environmentId: EnvironmentId, + params: unknown, + ) => + Effect.gen(function* () { + const limits = yield* callerLimits(scope); + const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( + params, + ).pipe(Effect.mapError(unavailable))) as Record; + const { environmentId: _target, ...args } = encoded; + // A pending upload lives here, so it cannot go with the call. + if (Array.isArray(args.attachments) && args.attachments.length > 0) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A launch in a linked environment cannot carry attachments yet.", + }); + } + if (typeof args.clientRequestId === "string") { + args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); + } + return yield* peers.call({ + environmentId, + tool: toolName, + arguments: args, + limits, + success: success as Schema.Decoder, + }); + }); + + const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => + forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); + + const route: PeerForwarding["Service"]["route"] = (scope, tool, params, here) => { + const target = remoteTarget(scope, params.environmentId); + return target === undefined ? here : call(scope, tool, target, params); + }; + + const waitForThread: PeerForwarding["Service"]["waitForThread"] = (scope, environmentId, input) => + Effect.gen(function* () { + let remaining = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + let runId = input.runId; + while (true) { + const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); + const result = yield* forward( + scope, + "t3_thread_wait", + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, + environmentId, + { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk }, + ); + remaining -= chunk; + if (!result.timedOut || remaining <= 0) return result; + // Later pieces wait for the run the first one picked, not a newer one. + runId = result.runId ?? undefined; + } + }); + + const listLinks: PeerForwarding["Service"]["links"] = (scope) => + Effect.gen(function* () { + yield* callerLimits(scope); + const listed = yield* links.list.pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), + ), + ); + return { + environmentId: scope.environmentId, + links: listed.map((link) => ({ + environmentId: link.environmentId, + label: link.label, + status: link.status, + access: link.access, + expiresAt: DateTime.formatIso(link.expiresAt), + lastError: link.lastError, + })), + }; + }); + + return PeerForwarding.of({ call, route, waitForThread, links: listLinks }); +}); + +export const layer = Layer.effect(PeerForwarding, make); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 47b872b3772c9..8a6f1ccfba0f2 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,11 +1,5 @@ import { NodeHttpServer } from "@effect/platform-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - EnvironmentHttpApi, - EnvironmentId, - type ExecutionEnvironmentDescriptor, - OrchestratorMcpFailure, -} from "@t3tools/contracts"; +import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -16,42 +10,19 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { Tool, Toolkit } from "effect/ai"; -import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/http"; -import * as HttpApi from "effect/http-api/HttpApi"; -import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import * as AuthHttp from "../auth/http.ts"; -import * as McpOAuth from "../auth/McpOAuth.ts"; -import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as ServerHttp from "../http.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; -import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; -import * as Sqlite from "../persistence/Sqlite.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import { + descriptorOf, + layerLinkingEnvironment, + servePeer, + type ServedPeer, +} from "./PeerLinks.testkit.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; -class PeerTestApi extends HttpApi.make("environment") - .add(EnvironmentHttpApi.groups.metadata) - .add(EnvironmentHttpApi.groups.mcpOAuth) {} - -const descriptorOf = ( - environmentId: string, - label: string, - capabilities: Partial = {}, -): ExecutionEnvironmentDescriptor => ({ - environmentId: EnvironmentId.make(environmentId), - label, - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, -}); - /** What B's probe saw: the calling client and the modes it may start work with. */ const Probe = Schema.Struct({ caller: Schema.String, modes: Schema.String }); @@ -71,126 +42,37 @@ const ProbeToolkit = Toolkit.make( }), ); -/** - * Environment B as it runs: its descriptor, MCP OAuth, and `/mcp` behind the - * real client authenticator, on a real socket. The descriptor can be swapped, - * as when B's address comes to answer as another environment. - */ -const serveB = (initial: ExecutionEnvironmentDescriptor) => - Effect.gen(function* () { - const descriptor = yield* Ref.make(initial); - const bearers = yield* Ref.make>([]); - const probes = yield* Ref.make(0); - const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), - getDescriptor: Ref.get(descriptor), - }); - const authContext = yield* EnvironmentAuth.layer.pipe( - Layer.provide(Sqlite.layerMemory), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge(ServerEnvironment.layerIdentity), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-b-" })), - Layer.provideMerge(NodeServices.layer), - Layer.fresh, - Layer.build, - ); - // Every bearer B receives, on any route, so a test can show one was never sent. - const layerRecordBearers = HttpRouter.middleware( - (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.url.startsWith("/.well-known/t3/environment")) { - yield* Ref.update(probes, (count) => count + 1); - } - const authorization = request.headers.authorization; - if (authorization !== undefined) { - yield* Ref.update(bearers, (seen) => [...seen, authorization]); - } - return yield* httpEffect; - }), - { global: true }, - ); - const layerRoutes = Layer.mergeAll( - HttpApiBuilder.layer(PeerTestApi).pipe( - Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), - Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), - Layer.provide(AuthHttp.layerAuthenticatedAuth), - ), - McpHttpServer.toolkitRegistration( - ProbeToolkit, - McpToolAccess.toLayer(ProbeToolkit, { - probe: McpToolAccess.reads(() => - McpInvocationContext.McpInvocationContext.pipe( - Effect.map((scope) => { - const modes = McpInvocationContext.clientModeCeiling(scope.client); - return { - caller: scope.client?.label ?? "none", - modes: `${modes.runtimeMode}/${modes.interactionMode}`, - }; - }), - ), - ), - refuse: McpToolAccess.reads(() => - Effect.fail( - new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), - ), - ), - }), - ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), - Layer.provide( - Layer.mock(McpSessionRegistry.McpSessionRegistry)({ - resolve: () => Effect.succeed(undefined), - }), - ), - Layer.provide(McpOAuth.layerMcpClientAuthenticator), - ), - ).pipe( - Layer.provide(layerRecordBearers), - Layer.provide(layerEnvironment), - Layer.provide(Layer.succeedContext(authContext)), - ); - yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( - Layer.build, - ); - const address = (yield* HttpServer.HttpServer).address; - if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); - return { - url: `http://127.0.0.1:${address.port}`, - auth: Context.get(authContext, EnvironmentAuth.EnvironmentAuth), - descriptor, - bearers, - /** How often its descriptor was asked for, as a probe of whether it answers. */ - probes, - }; - }); +const probeHandlers = McpToolAccess.toLayer(ProbeToolkit, { + probe: McpToolAccess.reads(() => + McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => { + const modes = McpInvocationContext.clientModeCeiling(scope.client); + return { + caller: scope.client?.label ?? "none", + modes: `${modes.runtimeMode}/${modes.interactionMode}`, + }; + }), + ), + ), + refuse: McpToolAccess.reads(() => + Effect.fail( + new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), + ), + ), +}); -/** Environment A: its own database and secret store, reaching B over plain HTTP. */ +const serveB = (descriptor: ExecutionEnvironmentDescriptor) => + servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers)); + +/** Environment A, which links to B. */ const makeA = (descriptor: ExecutionEnvironmentDescriptor) => - Effect.gen(function* () { - const context = yield* PeerMcpClient.layer.pipe( - Layer.provideMerge(PeerLinks.layer), - Layer.provide( - Layer.mergeAll( - Sqlite.layerMemory, - ServerSecretStore.layer, - FetchHttpClient.layer, - Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Effect.succeed(descriptor.environmentId), - getDescriptor: Effect.succeed(descriptor), - }), - ), - ), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-a-" })), - Layer.provide(NodeServices.layer), - Layer.fresh, - Layer.build, - ); - return { + layerLinkingEnvironment(descriptor).pipe( + Layer.build, + Effect.map((context) => ({ links: Context.get(context, PeerLinks.PeerLinks), peer: Context.get(context, PeerMcpClient.PeerMcpClient), - }; - }); + })), + ); const laptop = descriptorOf("environment-laptop", "Laptop"); const box = descriptorOf("environment-box", "Box"); @@ -207,14 +89,7 @@ const probe = ( success: Probe, }); -const linkedSessions = (b: Effect.Success>) => - b.auth - .listSessions() - .pipe( - Effect.map((sessions) => - sessions.filter((session) => session.client.label?.startsWith("T3 Code ยท ")), - ), - ); +const linkedSessions = (b: ServedPeer) => b.linkedSessions; it.effect("links with a pairing code, and the peer holds its agents to the limits they carry", () => Effect.scoped( diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts new file mode 100644 index 0000000000000..7425e3587eae1 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -0,0 +1,176 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentHttpApi, + EnvironmentId, + type AuthMcpClientAccess, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../auth/http.ts"; +import * as McpOAuth from "../auth/McpOAuth.ts"; +import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerHttp from "../http.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +class PeerTestApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.metadata) + .add(EnvironmentHttpApi.groups.mcpOAuth) {} + +export const descriptorOf = ( + environmentId: string, + label: string, + capabilities: Partial = {}, +): ExecutionEnvironmentDescriptor => ({ + environmentId: EnvironmentId.make(environmentId), + label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, +}); + +/** + * The environment a link points at, as it runs: its descriptor, MCP OAuth, + * and `/mcp` with `tools` behind the real client authenticator, on a real + * socket. The descriptor can be swapped, as when its address comes to answer + * as another environment, and every bearer it receives is recorded. + */ +export const servePeer = ( + initial: ExecutionEnvironmentDescriptor, + tools: Layer.Layer, +) => + Effect.gen(function* () { + const descriptor = yield* Ref.make(initial); + const bearers = yield* Ref.make>([]); + const probes = yield* Ref.make(0); + const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), + getDescriptor: Ref.get(descriptor), + }); + const authContext = yield* EnvironmentAuth.layer.pipe( + Layer.provide(Sqlite.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerEnvironment.layerIdentity), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-peer-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + Layer.build, + ); + const layerRecordBearers = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.url.startsWith("/.well-known/t3/environment")) { + yield* Ref.update(probes, (count) => count + 1); + } + const authorization = request.headers.authorization; + if (authorization !== undefined) { + yield* Ref.update(bearers, (seen) => [...seen, authorization]); + } + return yield* httpEffect; + }), + { global: true }, + ); + const layerRoutes = Layer.mergeAll( + HttpApiBuilder.layer(PeerTestApi).pipe( + Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), + Layer.provide(AuthHttp.layerAuthenticatedAuth), + ), + tools.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide(McpOAuth.layerMcpClientAuthenticator), + ), + ).pipe( + Layer.provide(layerRecordBearers), + Layer.provide(layerEnvironment), + Layer.provide(Layer.succeedContext(authContext)), + ); + // Fresh, so the peer never shares a toolkit or MCP server with the + // environment that links to it in the same test. + yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.fresh, + Layer.build, + ); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + const url = `http://127.0.0.1:${address.port}`; + const auth = Context.get(authContext, EnvironmentAuth.EnvironmentAuth); + return { + url, + auth, + descriptor, + bearers, + /** How often its descriptor was asked for, as a probe of whether it answers. */ + probes, + /** The sessions linking created there, which its Connections lists. */ + linkedSessions: auth + .listSessions() + .pipe( + Effect.map((sessions) => + sessions.filter((session) => session.client.label?.startsWith("T3 Code ยท ")), + ), + ), + }; + }); + +export type ServedPeer = Effect.Success>; + +/** + * The environment that links: its own database and secret store, reaching + * peers over plain HTTP. Provide it to anything built on its links. + */ +export const layerLinkingEnvironment = (descriptor: ExecutionEnvironmentDescriptor) => + PeerMcpClient.layer.pipe( + Layer.provideMerge(PeerLinks.layer), + Layer.provide( + Layer.mergeAll( + Sqlite.layerMemory, + ServerSecretStore.layer, + FetchHttpClient.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-here-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + ); + +/** Links `links` to `peer` with a fresh pairing code from it. */ +export const linkTo = ( + links: PeerLinks.PeerLinks["Service"], + peer: ServedPeer, + access: AuthMcpClientAccess = "auto", +) => + peer.auth + .issuePairingCredential() + .pipe( + Effect.flatMap((pairing) => + links.link({ url: peer.url, pairingCode: pairing.credential, access }), + ), + ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e3809450589d8..373a96a887172 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,7 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as PeerForwarding from "./peer/PeerForwarding.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -725,7 +726,12 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), // Links to other environments, shared by Settings (WebSocket) and agents (/mcp). - Layer.provide(PeerMcpClient.layer.pipe(Layer.provideMerge(PeerLinks.layer))), + Layer.provide( + PeerForwarding.layer.pipe( + Layer.provideMerge(PeerMcpClient.layer), + Layer.provideMerge(PeerLinks.layer), + ), + ), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), Layer.provide(layerCommandReadiness), diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 5fcec03bbaf89..e70dda632d88e 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -11,7 +11,8 @@ not introduce another execution model. See An environment keeps its ID across server restarts and endpoint changes. Saved connections are local to a client profile; the server's identity and state are not. A repository identity can correlate clones across environments, but never -routes work between them. A project and its threads belong to one environment. +routes work between them. A project and its threads belong to one environment; +an agent reaches another environment's threads only through a peer link. The canonical key follows the `upstream` remote when one exists, so pull request features target the repository a fork tracks. A fork also reports its own `origin`, and clients group and label by that, so a fork never collapses into a @@ -57,6 +58,34 @@ learned route still has to answer as this environment before it is used. GitHub routing trust covers the whole route list. Adding or changing a route revokes it; reordering does not, because the same addresses remain trusted. +## Peer links + +A [peer link](../../apps/server/src/peer/PeerLinks.ts) lets one environment's +agents work in another. The linking environment signs in to the other's `/mcp` +as an outside MCP client, with a pairing code from it, and keeps that session's +token in its own secret store. The other environment needs nothing new: it +sees one more OAuth client, lists it in Connections, and revokes it there. +Unlinking on the linking side only forgets the token. + +- **Every forwarded call carries the calling agent's modes** in + `T3-Mode-Limit`, which the receiving `/mcp` only ever narrows the client's + approved access with. A plan-mode agent therefore cannot start full-access + work through a full-access link. The tool's own access declaration also + runs on the linking side first, so a read-only or ended caller is refused + before anything is sent. +- **The token goes only to an address that proves it is the linked + environment**: its descriptor must report the linked environment id. A LAN + address that now belongs to another machine never receives it. +- **All callers on one side share the link's single session on the other**, + so idempotency keys are hashed with the caller's namespace before they + leave. Without that, two agents reusing a key would collide on the far side. +- **A forwarded wait is split into calls of at most 50 s**, because the + receiving `/mcp` sends nothing while a wait is open and T3 Connect's edge + drops idle requests after about 100 s. + +The link is routing, not isolation: an agent the link starts runs as the +receiving environment's user, inside the limits above. + ## Hosted web is a client The hosted web app stores its connection catalog in the browser and connects diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index df655c1d6c218..966a823b5f27a 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -333,6 +333,9 @@ export function summarizeT3ToolCalls( case "environment-update": label = phrase("Updated", "update", `environment preferences ${times}`); break; + case "environment-links": + label = phrase("Listed", "list", `linked environments ${times}`); + break; case "attachment-prepare": label = phrase( "Prepared", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 4032f3df0af5f..f63a2b4325120 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -2,8 +2,10 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AuthMcpClientAccess } from "./auth.ts"; import { ContextTransferId, + EnvironmentId, IsoDateTime, MessageId, NodeId, @@ -309,7 +311,20 @@ const OrchestratorMcpProjectTarget = Schema.optional( }), ); +/** + * A linked environment to act in instead of this one. The tools that take it + * forward the call there, where the link's access and the caller's own modes + * both apply. + */ +export const OrchestratorMcpEnvironmentTarget = Schema.optional( + EnvironmentId.annotate({ + description: + "A linked environment to do this in; t3_environment_links lists them. Omit for this environment. Ids (projects, threads, runs) then belong to that environment.", + }), +); + export const OrchestratorMcpThreadListInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), @@ -368,6 +383,7 @@ export const OrchestratorMcpThreadListResult = Schema.Struct({ export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListResult.Type; export const OrchestratorMcpThreadReadInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, itemId: Schema.optional(TurnItemId), textOffset: Schema.optional(NonNegativeInt), @@ -456,6 +472,7 @@ export const OrchestratorMcpThreadReadResult = Schema.Struct({ export type OrchestratorMcpThreadReadResult = typeof OrchestratorMcpThreadReadResult.Type; export const OrchestratorMcpThreadSendInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, message: OrchestratorMcpPrompt, mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), @@ -474,6 +491,7 @@ export const OrchestratorMcpThreadSendResult = Schema.Struct({ export type OrchestratorMcpThreadSendResult = typeof OrchestratorMcpThreadSendResult.Type; export const OrchestratorMcpThreadWaitInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), @@ -489,9 +507,16 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; export const OrchestratorMcpThreadInterruptInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, runId: Schema.optional(RunId), reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), + /** + * Stop the thread as its Stop button does instead: interrupt its running + * turn whichever run that is, hold its queued messages, end its pull request + * watches and stop every task it delegated. `runId` is then not used. + */ + stop: Schema.optional(Schema.Boolean), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpThreadInterruptInput = typeof OrchestratorMcpThreadInterruptInput.Type; @@ -525,6 +550,30 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ }); export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; +export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, +}); +export type OrchestratorMcpCapabilitiesInput = typeof OrchestratorMcpCapabilitiesInput.Type; + +export const OrchestratorMcpEnvironmentLink = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + /** Whether it answered just now. An expired link must be linked again there. */ + status: Schema.Literals(["reachable", "unreachable", "expired"]), + /** The most the linked environment lets this one's agents do there. */ + access: AuthMcpClientAccess, + expiresAt: IsoDateTime, + lastError: Schema.NullOr(Schema.String), +}); +export type OrchestratorMcpEnvironmentLink = typeof OrchestratorMcpEnvironmentLink.Type; + +export const OrchestratorMcpEnvironmentLinksResult = Schema.Struct({ + environmentId: EnvironmentId, + links: Schema.Array(OrchestratorMcpEnvironmentLink), +}); +export type OrchestratorMcpEnvironmentLinksResult = + typeof OrchestratorMcpEnvironmentLinksResult.Type; + export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ parentThreadId: Schema.NullOr(ThreadId), inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index a79ee19cfff5f..989b80e06f9e9 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,8 +14,9 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. +- \`t3_environment_links\` lists the user's other T3 Code environments that this one is linked to, such as another machine. To work there, pass that \`environmentId\` to \`t3_project_list\`, \`orchestrator_capabilities\`, \`t3_thread_launch\`, \`t3_thread_list\`, \`t3_thread_read\`, \`t3_thread_send\`, \`t3_thread_wait\`, or \`t3_thread_interrupt\`; project, thread, run, and model ids then belong to that environment. Work there runs within your own modes and the link's access. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. -- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`, +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. Name a thread in a linked environment by title instead: its link would open here.`, `### Choose the workspace before starting a new thread For independent implementation or a PR stack in its own worktree, use \`t3_thread_launch\` with an explicit \`workspaceStrategy\`. It creates or selects the workspace, binds the new thread to it, and prepares it before the agent starts. Put the task in \`message\`, not \`prompt\`: diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 8541958758db3..3f2f1c15f75e6 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -50,6 +50,7 @@ export type T3McpToolSummaryAction = | "project-delete" | "project-clone" | "environment-read" + | "environment-links" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -309,6 +310,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Read", "Reading", "Read", "environment preferences"], "environment-read", ), + t3_environment_links: tool( + ["List", "Listing", "Listed", "linked environments"], + "environment-links", + ), t3_environment_preferences_update: tool( ["Update", "Updating", "Updated", "environment preferences"], "environment-update", From 566933f69d6c0dd4eac4117e69e8a0cb21c2f734 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:45:34 -0700 Subject: [PATCH 03/50] feat(server): agents create and clone projects in a linked environment t3_project_create and t3_project_clone take environmentId, so an agent can bring a repository to a machine before launching or delegating work there. Both still need full access here, and the linked environment holds the call to the link's access. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.ts | 21 +++- apps/server/src/mcp/toolkits/project/tools.ts | 17 +++- apps/server/src/peer/PeerForwarding.test.ts | 98 ++++++++++++++++++- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index c4fb7020b8f06..f96bf6386141e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -247,8 +247,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { return result.value; }), ), - t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => + t3_project_create: McpToolAccess.writesEnvironment((params) => Effect.gen(function* () { + const { scope, target } = yield* remoteTarget(params); + if (target !== undefined) { + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.call( + scope, + ProjectToolkit.tools.t3_project_create, + target, + params, + ); + } + const { environmentId: _here, workspaceRoot, ...input } = params; const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { // Project creation records no model default (only an update does), so @@ -307,8 +318,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { .pipe(Effect.mapError(projectFailure)); }), ), - t3_project_clone: McpToolAccess.writesEnvironment((input) => + t3_project_clone: McpToolAccess.writesEnvironment((params) => Effect.gen(function* () { + const { scope, target } = yield* remoteTarget(params); + if (target !== undefined) { + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_clone, target, params); + } + const { environmentId: _here, ...input } = params; const repositories = yield* Repositories.SourceControlRepositoryService; return yield* repositories.cloneRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 679808a916ea0..d42dd00ce124e 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -73,13 +73,18 @@ const ProjectReadTool = Tool.make("t3_project_read", { const ProjectCreateTool = Tool.make("t3_project_create", { ...shared, description: - "Register a project directory through the existing project service. Set createWorkspaceRootIfMissing to create a directory. Omit workspaceRoot to start a new project from just its title: the app makes a Git repository for it in its own projects folder, with a README, an icon, and a first commit (commitError says why a commit failed; the project exists either way). Each call creates a new request; an existing registered workspace is rejected. Clone separately with t3_project_clone when needed.", + "Register a project directory through the existing project service. Set createWorkspaceRootIfMissing to create a directory. Omit workspaceRoot to start a new project from just its title: the app makes a Git repository for it in its own projects folder, with a README, an icon, and a first commit (commitError says why a commit failed; the project exists either way). Each call creates a new request; an existing registered workspace is rejected. Clone separately with t3_project_clone when needed. Pass environmentId to create it in a linked environment; workspaceRoot and defaultModelSelection then name that environment's paths and providers.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, ...ProjectCreatePayload.fields, workspaceRoot: Schema.optional(ProjectCreatePayload.fields.workspaceRoot), }), success: Schema.Struct({ ...Project.fields, commitError: Schema.optional(Schema.String) }), - dependencies: [...shared.dependencies, ManagedProjectFolders.ManagedProjectFolders], + dependencies: [ + ...shared.dependencies, + ManagedProjectFolders.ManagedProjectFolders, + PeerForwarding.PeerForwarding, + ], }).annotate(Tool.Destructive, true); const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, @@ -96,12 +101,16 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, description: - "Clone a repository using the app's source-control service. This only clones; register the returned cwd with t3_project_create. An existing destination is not adopted or removed on failure.", - parameters: SourceControlCloneRepositoryInput, + "Clone a repository using the app's source-control service. This only clones; register the returned cwd with t3_project_create. An existing destination is not adopted or removed on failure. Pass environmentId to clone in a linked environment, then register it there with t3_project_create and the same environmentId.", + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...SourceControlCloneRepositoryInput.fields, + }), success: SourceControlCloneRepositoryResult, dependencies: [ ...shared.dependencies, SourceControlRepositoryService.SourceControlRepositoryService, + PeerForwarding.PeerForwarding, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 6fa6c6aa192ba..e4258f8ad1737 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, type OrchestrationV2Run, + type SourceControlCloneRepositoryInput, ProjectId, ProviderInstanceId, RunId, @@ -46,10 +47,19 @@ interface Seen { readonly sends: ReadonlyArray; readonly waits: ReadonlyArray; readonly launches: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly clones: ReadonlyArray; /** Commands dispatched and delegated-task stops, as ` `. */ readonly stops: ReadonlyArray; } -const nothingSeen: Seen = { sends: [], waits: [], launches: [], stops: [] }; +const nothingSeen: Seen = { + sends: [], + waits: [], + launches: [], + projects: [], + clones: [], + stops: [], +}; /** * B's threads: one `auto` thread in `boxProject`, whose run finishes on the @@ -126,6 +136,39 @@ const boxLaunches = (seen: Ref.Ref) => ), }); +/** B clones and registers whatever it is asked to, and records it. */ +const boxProjects = (seen: Ref.Ref) => + Layer.merge( + Layer.mock(ProjectService.ProjectService)({ + create: (input) => + Ref.update(seen, (current) => ({ + ...current, + projects: [...current.projects, input], + })).pipe( + Effect.as({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-18T00:00:00.000Z", + updatedAt: "2026-10-18T00:00:00.000Z", + deletedAt: null, + }), + ), + }), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: (input) => + Ref.update(seen, (current) => ({ ...current, clones: [...current.clones, input] })).pipe( + Effect.as({ + cwd: input.destinationPath, + remoteUrl: "https://github.com/pingdotgg/t3code.git", + repository: null, + }), + ), + }), + ); + /** The orchestrator and project toolkits on B's real `/mcp`, behind its real OAuth. */ const serveBox = (seen: Ref.Ref) => servePeer( @@ -141,7 +184,7 @@ const serveBox = (seen: Ref.Ref) => Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(boxProjects(seen)), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-box-" }).pipe( Layer.provide(NodeServices.layer), @@ -151,7 +194,6 @@ const serveBox = (seen: Ref.Ref) => Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), @@ -427,6 +469,56 @@ it.effect("launches in a linked environment with the caller's modes, retry-safe" ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("brings a repository to a linked environment, only through full access", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + const clone = { repository: "pingdotgg/t3code", destinationPath: "/home/box/t3code" }; + const register = { title: "t3code", workspaceRoot: "/home/box/t3code" }; + + yield* linkTo(a.links, b, "full-access"); + const cloned = yield* a.call("thread:laptop-full", "t3_project_clone", { + environmentId: box.environmentId, + ...clone, + }); + expect(cloned.structuredContent).toMatchObject({ cwd: clone.destinationPath }); + const created = yield* a.call("thread:laptop-full", "t3_project_create", { + environmentId: box.environmentId, + ...register, + }); + expect(created.structuredContent).toMatchObject(register); + expect((yield* Ref.get(seen)).clones).toEqual([clone]); + expect((yield* Ref.get(seen)).projects).toMatchObject([register]); + + // A plan-mode agent here may not, and nothing leaves. + yield* Ref.set(b.bearers, []); + const planned = yield* a.call("thread:laptop-plan", "t3_project_create", { + environmentId: box.environmentId, + ...register, + }); + expect(failureOf(planned)).toMatchObject({ code: "capability_denied" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + // Nor may a full-access agent through a link with less: the box holds it. + yield* linkTo(a.links, b, "auto"); + for (const [name, args] of [ + ["t3_project_clone", clone], + ["t3_project_create", register], + ] as const) { + const refused = yield* a.call("thread:laptop-full", name, { + environmentId: box.environmentId, + ...args, + }); + expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" }); + } + expect((yield* Ref.get(seen)).clones).toHaveLength(1); + expect((yield* Ref.get(seen)).projects).toHaveLength(1); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("refuses what a linked environment cannot take before anything leaves", () => Effect.scoped( Effect.gen(function* () { From 6152622adc27258c06c4f9f5aa3b9f9a2064b9bb Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:24:48 -0700 Subject: [PATCH 04/50] feat(server): t3_thread_launch takes clientRequestId A launch had no retry key, so an agent (or another environment calling on its behalf) that lost the response could only guess whether the thread was created, and a retry started a second one. t3_thread_launch now takes an optional clientRequestId. The command, thread and message ids derive from the caller and the key, and the launch service already replays a command it accepted, so a retry returns the first thread. Without a key every launch is new, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/project/handlers.test.ts | 38 +++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 20 ++++++++-- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../ThreadLaunchService.test.ts | 34 +++++++++++++++++ .../orchestration-v2/ThreadLaunchService.ts | 17 ++++++++- packages/contracts/src/orchestratorMcp.ts | 2 +- .../src/server/orchestrationInstructions.ts | 2 +- 7 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 29a8305358011..f0cb413ca756a 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -402,6 +402,44 @@ it.effect("a client launches at its ceiling with the project's default model", ( }), ); +it.effect("a launch retried with the same clientRequestId replays the first one", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = (clientRequestId: string | undefined) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + message: "Fix the bug", + ...(clientRequestId === undefined ? {} : { clientRequestId }), + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + yield* launch("fix-1"); + yield* launch("fix-1"); + yield* launch("fix-2"); + yield* launch(undefined); + yield* launch(undefined); + const ids = launched.map((input) => [input.commandId, input.threadId]); + // The same key sends the same command and thread ids, which the launch + // service replays instead of starting a second thread. + expect(ids[0]).toEqual(ids[1]); + expect(ids[0]?.[0]).toContain("client%3Asession-1"); + expect(ids[2]).not.toEqual(ids[0]); + // Without a key every launch is new. + expect(ids[3]).not.toEqual(ids[4]); + }), +); + it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 912d6ad127fc8..3c148ed5669cb 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,4 +1,10 @@ -import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + ThreadId, + OrchestratorMcpFailure, + ProjectId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -56,8 +62,16 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller } = context; - const commandId = yield* newCommandId(); + const { caller, scope } = context; + // A retry with the same key replays the first launch: the ids derive + // from the caller and the key, and the launch service replays a + // command it already accepted. + const commandId = + input.clientRequestId === undefined + ? yield* newCommandId() + : CommandId.make( + `mcp:launch:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(input.clientRequestId)}`, + ); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); const attachments = input.attachments ?? []; diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 2114565d8201e..1634f98a6e4f9 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -13,6 +13,7 @@ import { ProjectCreatePayload, ProjectUpdatePayload, ProjectId, + OrchestratorMcpClientRequestId, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -104,7 +105,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. Without a key, after errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -130,6 +131,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), ), attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }), success: Schema.Struct({ threadId: ThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 2d8d3cccf3d5e..da6db1c982b31 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2448,6 +2448,40 @@ it.effect("replays an accepted launch after the project disables the instance", }).pipe(Effect.provide(harness.layer)); }); +it.effect("an agent's launch never starts work above the modes it asks for", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + // The first launch, at full access, creates the thread without a message. + const created = yield* launches.launch( + launchInput({ command: "command:launch:narrowed-retry", thread: "thread:narrowed" }), + ); + // The same key comes back from a narrower agent, now carrying a message, + // as a retry or a concurrent launch that lost the create would. + const failed = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:narrowed-retry", + thread: "thread:narrowed", + message: "Run this", + }), + runtimeMode: "approval-required", + interactionMode: "plan", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.flip); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } + assert.equal(failed.operation, "dispatch-message"); + const projection = yield* threads.getThreadProjection(created.threadId); + assert.equal(projection.runs.length, 0); + assert.equal(projection.messages.length, 0); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index f1c3d432a10be..d1d00777c2d52 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -49,6 +49,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type * as Orchestrator from "./Orchestrator.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; export type ThreadLaunchWorkspaceStrategy = @@ -940,7 +941,6 @@ const make = Effect.gen(function* () { if (project.id !== input.projectId) { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } - let runId: RunId | null = null; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { @@ -973,7 +973,20 @@ const make = Effect.gen(function* () { createdBy: input.createdBy, creationSource: input.creationSource, }) - .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); + .pipe( + // An agent's launch may land on a thread a replay, or a + // concurrent launch with the same key, created with broader modes + // than it asks for. The orchestrator refuses the message under + // the thread's lock. The user's own clients launch into their + // own drafts, whatever modes those hold. + input.createdBy === "agent" + ? Effect.provideService(DispatchModeLimit, { + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }) + : (effect) => effect, + Effect.mapError(mapError(input, "dispatch-message", threadId)), + ); const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7d643be910789..4032f3df0af5f 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -50,7 +50,7 @@ const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120 const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ description: "Optional concise display title.", }); -const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( +export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( Schema.isMaxLength(256), ).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index f06aacd6a3e15..a79ee19cfff5f 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -28,7 +28,7 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO \`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. -\`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist.`, +Pass a \`clientRequestId\` to \`t3_thread_launch\` and reuse it when retrying: the same key returns the thread the first call created. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance.`, `Tool names may include a harness-normalized MCP prefix, such as \`mcp__t3_code__delegate_task\`; the semantics are the same. Some harnesses attach optional MCP servers lazily: if an initial tool-catalog scan does not show T3 tools, do not conclude that cross-provider delegation is unavailable. Make one bounded direct attempt using the known T3 tool name on the next tool step. In Codex code mode, for example, call \`tools.mcp__t3_code__orchestrator_capabilities({})\` before reporting that the capability is absent. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying tools that accept them. ACP fallback: some ACP agents accept the injected MCP server but fail to expose its tools. When the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call the same tools through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset when T3 runs as a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`. This is the supported T3 transport fallback, not an ordinary shell-based substitute for delegation. From e13f1d4556882544f8d0d68766d19cb6db0528f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:23:30 -0700 Subject: [PATCH 05/50] feat(server): link to another environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An environment can now sign in to another one's /mcp as an outside agent, with a pairing code from that environment, without a browser. It drives the peer's existing MCP OAuth flow server-side, keeps the token in the server secret store and the rest in peer_environment_links (migration 059). The peer lists the link as "T3 Code ยท

= ( + params: P, +) => Effect.Effect>; +let remoteCheckOf: (declaration: Declaration) => RemoteCheck | undefined; + type CheckedHandlerOf = D extends Declaration ? Handler : never; interface CheckedHandler extends Struct.Lambda { @@ -62,13 +78,20 @@ let handlersLayer: , EX, RX>( */ export class Declaration { readonly #handle: Handler; - private constructor(token: typeof builtHere, handle: Handler) { + readonly #remote: RemoteCheck | undefined; + private constructor( + token: typeof builtHere, + handle: Handler, + remote: RemoteCheck | undefined, + ) { refuseOutsideConstruction(token); this.#handle = handle; + this.#remote = remote; } static { - declare = (handle) => new Declaration(builtHere, handle); + declare = (handle, remote) => new Declaration(builtHere, handle, remote); checkedHandler = Struct.lambda((declaration) => declaration.#handle); + remoteCheckOf = (declaration) => declaration.#remote; } } @@ -101,63 +124,73 @@ const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( ); export const reads = (handle: (params: P) => Effect.Effect) => - declare((params: P) => handle(params)); + declare( + (params: P) => handle(params), + () => Effect.void, + ); export const readsAsCaller = (handle: (params: P) => Effect.Effect) => - declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params)))); + declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params))), undefined); export const actsAsCaller = (handle: (params: P) => Effect.Effect) => - declare((params: P) => - requireThreadCaller.pipe( - Effect.flatMap(() => writingCaller), - Effect.flatMap(() => handle(params)), - ), + declare( + (params: P) => + requireThreadCaller.pipe( + Effect.flatMap(() => writingCaller), + Effect.flatMap(() => handle(params)), + ), + undefined, ); export const writes = (handle: (params: P) => Effect.Effect) => - declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); + declare( + (params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params))), + () => orchestratingCaller, + ); /** * Unlike `writes` it does not need thread control; the handler checks its * capability, as the capability toolkits do. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => - declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); + declare( + (params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params))), + () => writingCaller, + ); export const writesThreads = ( - threads: ( - params: P, - scope: McpInvocationContext.McpInvocationScope, - ) => ReadonlyArray, + threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, ) => - declare((params: P) => - Effect.gen(function* () { - const caller = yield* writingCaller; - for (const threadId of threads(params, caller.scope)) { - if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; - const target = yield* caller.threads - .getThreadShell(threadId) - .pipe(Effect.mapError(unavailable)); - if (target !== null && target.deletedAt === null) { - yield* assertTargetWithinLimits(caller.limits, target); + declare( + (params: P) => + Effect.gen(function* () { + const caller = yield* writingCaller; + for (const threadId of threads(params)) { + if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; + const target = yield* caller.threads + .getThreadShell(threadId) + .pipe(Effect.mapError(unavailable)); + if (target !== null && target.deletedAt === null) { + yield* assertTargetWithinLimits(caller.limits, target); + } } - } - // The target's user can raise its modes after the check above; the - // orchestrator checks again under the thread's lock and records its - // refusal here, since handlers wrap dispatch errors their own way. - const refused = yield* Ref.make(undefined); - return yield* handle(params).pipe( - Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), - Effect.catch((error) => - Effect.flatMap(Ref.get(refused), (refusal) => - Effect.fail( - refusal === undefined ? error : escalationDenied(refusal), + // The target's user can raise its modes after the check above; the + // orchestrator checks again under the thread's lock and records its + // refusal here, since handlers wrap dispatch errors their own way. + const refused = yield* Ref.make(undefined); + return yield* handle(params).pipe( + Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), + Effect.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined ? error : escalationDenied(refusal), + ), ), ), - ), - ); - }), + ); + }), + () => writingCaller, ); const escalationDenied = (refusal: DispatchModeRefusal) => @@ -180,21 +213,23 @@ export const startsThreads = ( readonly interactionMode?: ProviderInteractionMode | undefined; }, handle: (params: P, modes: StartedModes) => Effect.Effect, -) => - declare((params: P) => - Effect.gen(function* () { - const { limits } = yield* writingCaller; - const requested = modes(params); - const started: StartedModes = { - runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), - interactionMode: yield* resolveInteractionMode( - limits.interactionMode, - requested.interactionMode, - ), - }; - return yield* handle(params, started); - }), +) => { + const started = Effect.fn("McpToolAccess.started")(function* (params: P) { + const { limits } = yield* writingCaller; + const requested = modes(params); + return { + runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), + interactionMode: yield* resolveInteractionMode( + limits.interactionMode, + requested.interactionMode, + ), + } satisfies StartedModes; + }); + return declare( + (params: P) => Effect.flatMap(started(params), (modes) => handle(params, modes)), + started, ); +}; const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; @@ -212,7 +247,10 @@ export const writesEnvironment = ( const check = orchestratingCaller.pipe( Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), ); - return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check)))); + return declare( + (params: P) => check.pipe(Effect.flatMap(() => handle(params, check))), + () => check, + ); }; type CheckServices = Effect.Services; @@ -249,14 +287,73 @@ export class HandlersLayer, EX = never, R const checkedHandlers = (declarations: Declarations): Handlers => Struct.map(declarations, checkedHandler); +/** + * A tool runs in a linked environment when its parameters take this target + * as `environmentId`. Other tools may name an environment for their own + * reasons, such as the one to link or to move a thread to. + */ +const takesLinkedEnvironment = (tool: Tool.Any) => { + const parameters: unknown = tool.parametersSchema; + return ( + Predicate.hasProperty(parameters, "fields") && + Predicate.hasProperty(parameters.fields, "environmentId") && + parameters.fields.environmentId === OrchestratorMcpEnvironmentTarget + ); +}; + +const linksUnavailable = new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "This environment cannot reach linked environments.", +}); + +/** + * Every tool that takes a linked environment runs there the same way: its + * declaration checks the caller here, and the same call goes to the other + * environment, which checks the rest. The handler here never sees + * `environmentId`, so it cannot leak into what it writes. + */ +const routed = Effect.fn("McpToolAccess.routed")(function* >( + toolkit: Toolkit.Toolkit, + declarations: Handlers, + checked: Toolkit.HandlersFrom, +) { + // Optional so toolkits that never leave this environment build without it. + const forwarding = yield* Effect.serviceOption(PeerForwarding.PeerForwarding); + const handlers: Record = { ...checked }; + for (const [name, declaration] of Object.entries>(declarations)) { + const tool = toolkit.tools[name]; + if (tool === undefined || !takesLinkedEnvironment(tool)) continue; + const remote = remoteCheckOf(declaration); + if (remote === undefined) + return yield* Effect.die( + `${name} takes a linked environment, but it acts as its calling thread, which is here.`, + ); + const here = handlers[name] as ( + params: object, + ) => Effect.Effect>; + handlers[name] = ({ environmentId, ...params }: { readonly environmentId?: EnvironmentId }) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const target = PeerForwarding.remoteTarget(scope, environmentId); + if (target === undefined) return yield* here(params); + yield* (remote as RemoteCheck)(params); + if (Option.isNone(forwarding)) return yield* linksUnavailable; + return yield* forwarding.value.call(scope, tool, target, { ...params, environmentId }); + }); + } + return handlers as Toolkit.HandlersFrom; +}); + export const toLayer = , EX = never, RX = never>( toolkit: Toolkit.Toolkit, build: Handlers | Effect.Effect, EX, RX>, -): HandlersLayer> => - handlersLayer( +): HandlersLayer> => { + // Checked as soon as they are given, so a forged declaration fails here. + const withDeclarations = (declarations: Handlers) => + routed(toolkit, declarations, checkedHandlers(declarations)); + return handlersLayer( toolkit.toLayer( - Effect.isEffect(build) - ? Effect.map(build, checkedHandlers>) - : checkedHandlers(build), + Effect.isEffect(build) ? Effect.flatMap(build, withDeclarations) : withDeclarations(build), ), ); +}; diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index e441174900ce1..d2e9a51bb124a 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -2,6 +2,7 @@ import { BackgroundActivityProfile, BackgroundActivityProfileSelection, ExecutionEnvironmentDescriptor, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ServerSettings, ServerSettingsPatch, @@ -41,6 +42,7 @@ const EnvironmentReadTool = Tool.make("t3_environment_read", { ...shared, description: "Read this server's identity and selected environment preferences. Provider/model availability is exposed by orchestrator_capabilities. Writing instructions are limited to 4,000 characters.", + parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget }), success: Schema.Struct({ environmentId: ExecutionEnvironmentDescriptor.fields.environmentId, label: Schema.String, @@ -56,6 +58,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update" description: "Update selected environment-wide preferences through normal settings persistence and notifications. Requires a live full-access/default calling thread. Omitted fields are preserved; empty customInstructions clears them.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks, diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 38b57178e4b28..2d24d869695e2 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -1,8 +1,6 @@ -import { type EnvironmentId, OrchestratorMcpFailure } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import type * as Tool from "effect/ai/Tool"; - +import { OrchestratorMcpFailure } from "@t3tools/contracts"; import { OrchestratorToolkit } from "./tools.ts"; +import * as Effect from "effect/Effect"; import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import { readCaller } from "../../threadAccess.ts"; @@ -11,36 +9,13 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; -const { tools } = OrchestratorToolkit; - -/** - * Runs `here` in this environment, or the same tool in the linked - * environment `input.environmentId` names. The tool's declaration has already - * checked the caller here either way. - */ -const routed = ( - tool: T, - input: Tool.Parameters & { readonly environmentId?: EnvironmentId | undefined }, - here: Effect.Effect, -) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - if (PeerForwarding.remoteTarget(scope, input.environmentId) === undefined) return yield* here; - const forwarding = yield* PeerForwarding.PeerForwarding; - return yield* forwarding.route(scope, tool, input, here); - }); - const handlers = { - orchestrator_capabilities: McpToolAccess.reads((input) => - routed( - tools.orchestrator_capabilities, - input, - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.capabilities(scope); - }), - ), + orchestrator_capabilities: McpToolAccess.reads(() => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.capabilities(scope); + }), ), delegate_task: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { @@ -128,26 +103,18 @@ const handlers = { }), ), t3_thread_list: McpToolAccess.reads((input) => - routed( - tools.t3_thread_list, - input, - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.listThreads(scope, input); - }), - ), + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.listThreads(scope, input); + }), ), t3_thread_read: McpToolAccess.reads((input) => - routed( - tools.t3_thread_read, - input, - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.readThread(scope, input); - }), - ), + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.readThread(scope, input); + }), ), t3_thread_update: McpToolAccess.writesThreads( (input) => [input.threadId], @@ -159,29 +126,17 @@ const handlers = { }), ), t3_thread_send: McpToolAccess.writesThreads( - // A thread in a linked environment is checked there, where it lives. - // This environment's own id names a thread here, which is checked here. - (input, scope) => - PeerForwarding.remoteTarget(scope, input.environmentId) === undefined ? [input.threadId] : [], + (input) => [input.threadId], (input) => - routed( - tools.t3_thread_send, - input, - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.sendToThread(scope, input); - }), - ), + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.sendToThread(scope, input); + }), ), t3_thread_wait: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; - const target = PeerForwarding.remoteTarget(scope, input.environmentId); - if (target !== undefined) { - const forwarding = yield* PeerForwarding.PeerForwarding; - return yield* forwarding.waitForThread(scope, target, input); - } const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.waitForThread(scope, input); }), @@ -194,20 +149,13 @@ const handlers = { }), ), t3_thread_interrupt: McpToolAccess.writesThreads( - // A thread in a linked environment is checked there, where it lives. - // This environment's own id names a thread here, which is checked here. - (input, scope) => - PeerForwarding.remoteTarget(scope, input.environmentId) === undefined ? [input.threadId] : [], + (input) => [input.threadId], (input) => - routed( - tools.t3_thread_interrupt, - input, - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.interruptThread(scope, input); - }), - ), + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.interruptThread(scope, input); + }), ), } satisfies McpToolAccess.Handlers; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index b8f735069d31c..0857847295125 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -80,6 +80,7 @@ describe("orchestrator MCP tool guidance", () => { assert.equal(schema.type, "object"); assert.hasAllKeys(schema.properties ?? {}, [ + "environmentId", "threadId", "action", "title", diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 1e27f7511dc03..96fdda9e7fc84 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -9,6 +9,7 @@ import { OrchestratorMcpDelegateTaskResult, OrchestratorMcpDeleteScheduledTaskInput, OrchestratorMcpDeleteScheduledTaskResult, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpRequestSecretInput, OrchestratorMcpRequestSecretResult, OrchestratorMcpFailure, @@ -47,8 +48,8 @@ const dependencies = [ ThreadManagementService.ThreadManagementService, OrchestratorMcpService.OrchestratorMcpService, ]; -/** Tools that also run in a linked environment, when `environmentId` names one. */ -const forwardingDependencies = [...dependencies, PeerForwarding.PeerForwarding]; +/** Listing this environment's links. */ +const linkDependencies = [...dependencies, PeerForwarding.PeerForwarding]; const threadMetadataDependencies = [ McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, @@ -62,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies, }) .annotate(Tool.Title, "Get orchestration capabilities") .annotate(Tool.Readonly, true) @@ -111,7 +112,10 @@ const TaskCancelTool = Tool.make("task_cancel", { const WorkflowStopTool = Tool.make("t3_workflow_stop", { description: "Stop one running Claude workflow in a T3 thread. Use the workflow coordinator's subagentId returned by t3_thread_read with view='activity'. Stops the whole workflow, including its phases and agents. Does not stop the parent thread or other workflows. Individual workflow phases and agents cannot be stopped separately.", - parameters: OrchestrationV2StopWorkflowInput, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...OrchestrationV2StopWorkflowInput.fields, + }), success: Schema.Struct({}), failure: OrchestratorMcpFailure, failureMode: "return", @@ -203,7 +207,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies, }) .annotate(Tool.Title, "List T3 threads") .annotate(Tool.Readonly, true) @@ -217,7 +221,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies, }) .annotate(Tool.Title, "Read a T3 thread") .annotate(Tool.Strict, true) @@ -228,7 +232,10 @@ const ThreadReadTool = Tool.make("t3_thread_read", { export const ThreadUpdateTool = Tool.make("t3_thread_update", { description: "Update metadata for a thread. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", - parameters: ThreadMetadataMcpUpdateInput, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...ThreadMetadataMcpUpdateInput.fields, + }), success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -245,7 +252,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies, }) .annotate(Tool.Title, "Send to a T3 thread") .annotate(Tool.Destructive, true) @@ -258,7 +265,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", { success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies, }) .annotate(Tool.Title, "Wait for a T3 thread") .annotate(Tool.Readonly, true) @@ -272,18 +279,18 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies, }) .annotate(Tool.Title, "Interrupt a T3 thread") .annotate(Tool.Destructive, true); const EnvironmentLinksTool = Tool.make("t3_environment_links", { description: - "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to t3_project_list, t3_thread_launch, t3_thread_list, t3_thread_read, t3_thread_send, t3_thread_wait, t3_thread_interrupt, or orchestrator_capabilities to act there. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", + "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to any tool that takes environmentId, such as t3_thread_launch, t3_thread_send, t3_pending_request_respond, t3_queue_list or t3_project_list, to act there; delegate_task takes it as target.environmentId. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", success: OrchestratorMcpEnvironmentLinksResult, failure: OrchestratorMcpFailure, failureMode: "return", - dependencies: forwardingDependencies, + dependencies: linkDependencies, }) .annotate(Tool.Title, "List linked environments") .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index f96bf6386141e..43e7daaa16aa5 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,6 +1,5 @@ import { CommandId, - type EnvironmentId, MessageId, ThreadId, OrchestratorMcpFailure, @@ -15,7 +14,6 @@ import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; -import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -59,32 +57,12 @@ const access = Effect.gen(function* () { yield* readCaller(); return yield* Project.ProjectService; }); - -/** The linked environment a call names, or nothing for this one. */ -const remoteTarget = (input: { readonly environmentId?: EnvironmentId | undefined }) => - McpInvocationContext.McpInvocationContext.pipe( - Effect.map((scope) => ({ - scope, - target: PeerForwarding.remoteTarget(scope, input.environmentId), - })), - ); export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_thread_launch: McpToolAccess.startsThreads( (input) => input, (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { - const { scope, target } = yield* remoteTarget(input); - if (target !== undefined) { - // The call carries the caller's modes, so omitted modes inherit - // them there too, capped by the link's access. - const forwarding = yield* PeerForwarding.PeerForwarding; - return yield* forwarding.call( - scope, - ProjectToolkit.tools.t3_thread_launch, - target, - input, - ); - } + const scope = yield* McpInvocationContext.McpInvocationContext; const context = yield* readCaller(); const { caller } = context; // A retry with the same key replays the first launch: the ids derive @@ -222,11 +200,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { - const { scope, target } = yield* remoteTarget(input); - if (target !== undefined) { - const forwarding = yield* PeerForwarding.PeerForwarding; - return yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_list, target, input); - } const projects = yield* access; const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); const rows = snapshot.projects.filter((project) => project.deletedAt === null); @@ -247,19 +220,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { return result.value; }), ), - t3_project_create: McpToolAccess.writesEnvironment((params) => + t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => Effect.gen(function* () { - const { scope, target } = yield* remoteTarget(params); - if (target !== undefined) { - const forwarding = yield* PeerForwarding.PeerForwarding; - return yield* forwarding.call( - scope, - ProjectToolkit.tools.t3_project_create, - target, - params, - ); - } - const { environmentId: _here, workspaceRoot, ...input } = params; const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { // Project creation records no model default (only an update does), so @@ -318,14 +280,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { .pipe(Effect.mapError(projectFailure)); }), ), - t3_project_clone: McpToolAccess.writesEnvironment((params) => + t3_project_clone: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - const { scope, target } = yield* remoteTarget(params); - if (target !== undefined) { - const forwarding = yield* PeerForwarding.PeerForwarding; - return yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_clone, target, params); - } - const { environmentId: _here, ...input } = params; const repositories = yield* Repositories.SourceControlRepositoryService; return yield* repositories.cloneRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index d42dd00ce124e..5b98ffd1a305b 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -29,7 +29,6 @@ import { Tool, Toolkit } from "effect/ai"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; -import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -58,7 +57,6 @@ const ProjectListTool = Tool.make("t3_project_list", { projects: Schema.Array(Project), nextCursor: Schema.NullOr(NonNegativeInt), }), - dependencies: [...shared.dependencies, PeerForwarding.PeerForwarding], }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); @@ -66,7 +64,10 @@ const ProjectReadTool = Tool.make("t3_project_read", { ...shared, description: "Read a registered project in this environment, including its workspace and saved scripts.", - parameters: Schema.Struct({ projectId: ProjectId }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + }), }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); @@ -80,23 +81,27 @@ const ProjectCreateTool = Tool.make("t3_project_create", { workspaceRoot: Schema.optional(ProjectCreatePayload.fields.workspaceRoot), }), success: Schema.Struct({ ...Project.fields, commitError: Schema.optional(Schema.String) }), - dependencies: [ - ...shared.dependencies, - ManagedProjectFolders.ManagedProjectFolders, - PeerForwarding.PeerForwarding, - ], + dependencies: [...shared.dependencies, ManagedProjectFolders.ManagedProjectFolders], }).annotate(Tool.Destructive, true); const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, description: "Update a registered project's settings. Omitted fields are preserved. Uses the same project service as the app.", - parameters: Schema.Struct({ projectId: ProjectId, ...ProjectUpdatePayload.fields }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + ...ProjectUpdatePayload.fields, + }), }).annotate(Tool.Destructive, true); const ProjectDeleteTool = Tool.make("t3_project_delete", { ...shared, description: "Delete a project using the existing project deletion lifecycle. Nonempty projects require force=true. This does not delete the repository directory or promise a deleted-thread count.", - parameters: Schema.Struct({ projectId: ProjectId, force: Schema.optionalKey(Schema.Boolean) }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + force: Schema.optionalKey(Schema.Boolean), + }), }).annotate(Tool.Destructive, true); const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, @@ -110,7 +115,6 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { dependencies: [ ...shared.dependencies, SourceControlRepositoryService.SourceControlRepositoryService, - PeerForwarding.PeerForwarding, ], }) .annotate(Tool.Destructive, true) @@ -162,7 +166,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, - PeerForwarding.PeerForwarding, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index a3a75eabaa385..4595578e1e50e 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -1,5 +1,6 @@ import { McpCapabilityUnavailableError, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, PositiveInt, PullRequestReviewGuide, @@ -35,6 +36,7 @@ const REGISTER_EVERY_PR = "Register every pull request you open for this thread, including each layer of a stack, right after creating it."; export const PullRequestTargetInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional( ThreadId.annotate({ description: "Thread to act on. Omit for this thread.", @@ -300,6 +302,7 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { description: `List the pull requests linked to a thread (omit threadId for this thread) with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional( ThreadId.annotate({ description: "Thread to list. Omit for this thread." }), ), @@ -379,6 +382,7 @@ const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", { description: "Write review guides for up to 20 pull requests, the tour a maintainer reads in a pull request's Guide tab. Each run reads its change in a temporary checkout and takes minutes, so this returns once the runs are under way; read a guide with read_pull_request_guide to see when it is written. A guide for the same head that is already running is joined, not restarted; a pull request whose guide is current is written again. Each pull request is read through the project in this environment whose checkout has that repository. Fails while review guides are off on this server.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, pullRequests: Schema.Array(GuideTarget).check(Schema.isMinLength(1), Schema.isMaxLength(20)), }), success: WritePullRequestGuidesResult, @@ -394,7 +398,10 @@ const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", { const ReadPullRequestGuideTool = Tool.make("read_pull_request_guide", { description: "Read a pull request's stored review guide and its run: run.Running while one is being written, run.Failed with the reason when the last one failed, guide null when none was written. stale is true after a push the guide has not caught up with. Never starts a run.", - parameters: GuideTarget, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...GuideTarget.fields, + }), success: ReadPullRequestGuideResult, failure: PullRequestToolError, dependencies: guideDependencies, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 21750cbb6eaae..72bb262542f34 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpEnvironmentTarget, ScheduledTaskId, ScheduledTask, OrchestrationSearchThreadsInput, @@ -32,6 +33,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), action: Schema.Literals([ "pin", @@ -61,7 +63,11 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { .annotate(Tool.Title, "Organize a thread") .annotate(Tool.Destructive, true); -const queueTarget = { threadId: Schema.optional(ThreadId), queuedRunId: RunId }; +const queueTarget = { + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + queuedRunId: RunId, +}; const commandTool = { success: OrchestrationV2DispatchCommandResult, failure: OrchestratorMcpFailure, @@ -82,6 +88,7 @@ const QueueListTool = Tool.make("t3_queue_list", { description: "List queued messages in delivery order. Results are a live offset page; use t3_thread_read for full thread history.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), @@ -127,7 +134,11 @@ const QueuePromoteTool = Tool.make("t3_queue_promote_to_steer", { parameters: Schema.Struct({ ...queueTarget, targetRunId: RunId }), }).annotate(Tool.Destructive, true); -const requestTarget = { threadId: Schema.optional(ThreadId), requestId: RuntimeRequestId }; +const requestTarget = { + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + requestId: RuntimeRequestId, +}; const question = Schema.Struct({ id: Schema.String, header: Schema.String, @@ -152,7 +163,10 @@ const PendingRequestListTool = Tool.make("t3_pending_request_list", { ...commandTool, description: "List pending user questions in a thread. Omit threadId for this thread. Approval requests are not included.", - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + }), success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }), }) .annotate(Tool.Readonly, true) @@ -179,7 +193,10 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { ...commandTool, description: "Read a thread's provider/model selection and modes. Omit threadId for this thread. orchestrator_capabilities lists available providers and models.", - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + }), success: Schema.Struct({ threadId: ThreadId, modelSelection: ModelSelection, @@ -194,6 +211,7 @@ const ThreadConfigureTool = Tool.make("t3_thread_configure", { description: "Set a thread's provider, model and options with the existing selection command. Omit threadId for this thread. This does not change permission modes. Use orchestrator_capabilities to choose a selection.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), modelSelection: ModelSelection, }), @@ -205,6 +223,7 @@ const ThreadForkTool = Tool.make("t3_thread_fork", { description: "Fork a thread from a stable run or checkpoint using the existing fork command. Omit threadId to fork this thread. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), sourcePoint: OrchestrationV2ThreadForkSourcePoint, title: Schema.optional(TrimmedNonEmptyString), @@ -216,6 +235,7 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { description: "Merge context from a thread back to a related thread in the same project. Omit sourceThreadId to merge from this thread. Existing lineage and transfer rules apply.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, sourceThreadId: Schema.optional(ThreadId), targetThreadId: ThreadId, sourcePoint: OrchestrationV2ThreadForkSourcePoint, @@ -225,7 +245,10 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { const ThreadTransfersTool = Tool.make("t3_thread_transfers", { ...commandTool, description: "Read context transfer status for a thread. Omit threadId for this thread.", - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + }), success: Schema.Struct({ transfers: Schema.Array( Schema.Struct({ @@ -245,6 +268,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { description: "Search active thread titles and content with the app's existing bounded search. Matches are limited to one project (projectId, else the calling thread's project) out of the global top matches, so this may return fewer than limit. A caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, ...OrchestrationSearchThreadsInput.fields, projectId: Schema.optional(ProjectId), }), @@ -258,7 +282,10 @@ const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { ...commandTool, description: "Run a scheduled task now through the existing scheduler. Requires a full-access/default caller. Returns the thread this run dispatched to, or null if dispatch failed. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.", - parameters: Schema.Struct({ taskId: ScheduledTaskId }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + taskId: ScheduledTaskId, + }), success: Schema.Struct({ taskId: ScheduledTaskId, threadId: ScheduledTask.fields.threadId, diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 61d36c91747b8..e01c663191f6c 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -1,5 +1,6 @@ import { WorktreeMcpFailure, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, VcsListRefsInput, VcsListRefsResult, @@ -62,6 +63,7 @@ const WorktreeListTool = Tool.make("t3_worktree_list", { description: "List branch refs and their associated checkout paths for a thread's workspace (omit threadId for this thread) using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), query: VcsListRefsInput.fields.query, cursor: VcsListRefsInput.fields.cursor, diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index e4258f8ad1737..ff4206c817921 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -20,6 +20,7 @@ import { McpSchema, McpServer } from "effect/ai"; import * as ServerConfig from "../config.ts"; import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -173,12 +174,14 @@ const boxProjects = (seen: Ref.Ref) => const serveBox = (seen: Ref.Ref) => servePeer( box, - Layer.merge( + Layer.mergeAll( McpHttpServer.layerOrchestratorToolkit, McpHttpServer.layerProjectRegistration, + McpHttpServer.layerThreadToolkit, ).pipe( Layer.provide(NodeCrypto.layer), Layer.provide(boxThreads(seen)), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(boxLaunches(seen)), Layer.provide( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), @@ -219,11 +222,13 @@ const mcpClient = McpSchema.McpServerClient.of({ */ const makeLaptop = Effect.gen(function* () { const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); - const layerHere = Layer.merge( + const layerHere = Layer.mergeAll( McpHttpServer.layerOrchestratorToolkit, McpHttpServer.layerProjectRegistration, + McpHttpServer.layerThreadToolkit, ).pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), Layer.provide( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), @@ -250,6 +255,10 @@ const makeLaptop = Effect.gen(function* () { : { runtimeMode: "full-access" }, ), ), + getProjectThreadRecords: ({ threadId }) => + Effect.succeed({ + thread: liveThreadShell(threadId, { runtimeMode: "full-access" }), + } as never), }), ), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), @@ -519,6 +528,39 @@ it.effect("brings a repository to a linked environment, only through full access ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("runs any tool that takes a linked environment there", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + + const there = yield* a.call("thread:laptop-full", "t3_thread_configuration", { + environmentId: box.environmentId, + threadId: boxThread, + }); + expect(there.structuredContent).toMatchObject({ threadId: boxThread, runtimeMode: "auto" }); + const archived = yield* a.call("thread:laptop-full", "t3_thread_organize", { + environmentId: box.environmentId, + threadId: boxThread, + action: "archive", + }); + expect(archived.isError).toBe(false); + expect((yield* Ref.get(seen)).stops).toEqual([`thread.archive ${boxThread}`]); + + // This environment's own id keeps the call here. + const own = yield* a.call("thread:laptop-full", "t3_thread_configuration", { + environmentId: laptop.environmentId, + }); + expect(own.structuredContent).toMatchObject({ + threadId: "thread:laptop-full", + runtimeMode: "full-access", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("refuses what a linked environment cannot take before anything leaves", () => Effect.scoped( Effect.gen(function* () { @@ -548,6 +590,10 @@ it.effect("refuses what a linked environment cannot take before anything leaves" "t3_thread_send", { environmentId: box.environmentId, threadId: boxThread, message: "Carry on there." }, ], + [ + "t3_thread_organize", + { environmentId: box.environmentId, threadId: boxThread, action: "archive" }, + ], [ "t3_thread_launch", { diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 4d26e1b09568d..09963c76c6417 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -40,23 +40,16 @@ type Scope = McpInvocationContext.McpInvocationScope; export class PeerForwarding extends Context.Service< PeerForwarding, { - /** Runs `tool` in the linked environment `environmentId` with `params`. */ + /** + * Runs `tool` in the linked environment `environmentId` with `params`. A + * `t3_thread_wait` goes in pieces short enough to survive any route. + */ readonly call: ( scope: Scope, tool: T, environmentId: EnvironmentId, params: Tool.Parameters, ) => Effect.Effect, OrchestratorMcpFailure>; - /** - * Runs `here` unless `params.environmentId` names a linked environment, in - * which case `tool` runs there with the same parameters. - */ - readonly route: ( - scope: Scope, - tool: T, - params: Tool.Parameters & { readonly environmentId?: EnvironmentId | undefined }, - here: Effect.Effect, - ) => Effect.Effect, E | OrchestratorMcpFailure, R>; /** `t3_thread_wait` there, in pieces short enough to survive any route. */ readonly waitForThread: ( scope: Scope, @@ -104,73 +97,74 @@ const make = Effect.gen(function* () { ); /** Tool and contract schemas are plain data: they encode and decode without services. */ - const forward = ( + const forward = Effect.fn("PeerForwarding.forward")(function* ( scope: Scope, toolName: string, parameters: Schema.Top, success: Schema.Top, environmentId: EnvironmentId, params: unknown, - ) => - Effect.gen(function* () { - const limits = yield* callerLimits(scope); - const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( - params, - ).pipe(Effect.mapError(unavailable))) as Record; - const { environmentId: _target, ...args } = encoded; - // A pending upload lives here, so it cannot go with the call. - if (Array.isArray(args.attachments) && args.attachments.length > 0) { - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "A launch in a linked environment cannot carry attachments yet.", - }); - } - if (typeof args.clientRequestId === "string") { - args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); - } - return yield* peers.call({ - environmentId, - tool: toolName, - arguments: args, - limits, - success: success as Schema.Decoder, + ) { + const limits = yield* callerLimits(scope); + const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( + params, + ).pipe(Effect.mapError(unavailable))) as Record; + const { environmentId: _target, ...args } = encoded; + // A pending upload lives here, so it cannot go with the call. + if (Array.isArray(args.attachments) && args.attachments.length > 0) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A launch in a linked environment cannot carry attachments yet.", }); + } + if (typeof args.clientRequestId === "string") { + args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); + } + return yield* peers.call({ + environmentId, + tool: toolName, + arguments: args, + limits, + success: success as Schema.Decoder, }); + }); - const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => - forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); - - const route: PeerForwarding["Service"]["route"] = (scope, tool, params, here) => { - const target = remoteTarget(scope, params.environmentId); - return target === undefined ? here : call(scope, tool, target, params); - }; - - const waitForThread: PeerForwarding["Service"]["waitForThread"] = (scope, environmentId, input) => - Effect.gen(function* () { - let remaining = Math.min( - MAX_WAIT_TIMEOUT_MS, - Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + const waitForThread: PeerForwarding["Service"]["waitForThread"] = Effect.fn( + "PeerForwarding.waitForThread", + )(function* (scope, environmentId, input) { + let remaining = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + let runId = input.runId; + while (true) { + const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); + const result = yield* forward( + scope, + "t3_thread_wait", + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, + environmentId, + { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk }, ); - let runId = input.runId; - while (true) { - const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); - const result = yield* forward( + remaining -= chunk; + if (!result.timedOut || remaining <= 0) return result; + // Later pieces wait for the run the first one picked, not a newer one. + runId = result.runId ?? undefined; + } + }); + + const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => + tool.name === "t3_thread_wait" + ? (waitForThread( scope, - "t3_thread_wait", - OrchestratorMcpThreadWaitInput, - OrchestratorMcpThreadWaitResult, environmentId, - { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk }, - ); - remaining -= chunk; - if (!result.timedOut || remaining <= 0) return result; - // Later pieces wait for the run the first one picked, not a newer one. - runId = result.runId ?? undefined; - } - }); + params as OrchestratorMcpThreadWaitInput, + ) as Effect.Effect, OrchestratorMcpFailure>) + : forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); - const listLinks: PeerForwarding["Service"]["links"] = (scope) => - Effect.gen(function* () { + const listLinks: PeerForwarding["Service"]["links"] = Effect.fn("PeerForwarding.listLinks")( + function* (scope) { yield* callerLimits(scope); const listed = yield* links.list.pipe( Effect.mapError( @@ -189,9 +183,10 @@ const make = Effect.gen(function* () { lastError: link.lastError, })), }; - }); + }, + ); - return PeerForwarding.of({ call, route, waitForThread, links: listLinks }); + return PeerForwarding.of({ call, waitForThread, links: listLinks }); }); export const layer = Layer.effect(PeerForwarding, make); diff --git a/docs/internals/remote.md b/docs/internals/remote.md index e70dda632d88e..5148ea22f1cfa 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -67,12 +67,17 @@ token in its own secret store. The other environment needs nothing new: it sees one more OAuth client, lists it in Connections, and revokes it there. Unlinking on the linking side only forgets the token. +- **A tool runs in a linked environment when its parameters take + `OrchestratorMcpEnvironmentTarget`** as `environmentId`. + [`McpToolAccess.toLayer`](../../apps/server/src/mcp/McpToolAccess.ts) sends + such a call there once the tool's access declaration has checked the caller, + so a read-only or ended caller is refused before anything is sent. The + thread or project the call names is checked where it lives. A tool that acts + as the calling thread itself cannot take a linked environment. - **Every forwarded call carries the calling agent's modes** in `T3-Mode-Limit`, which the receiving `/mcp` only ever narrows the client's approved access with. A plan-mode agent therefore cannot start full-access - work through a full-access link. The tool's own access declaration also - runs on the linking side first, so a read-only or ended caller is refused - before anything is sent. + work through a full-access link. - **The token goes only to an address that proves it is the linked environment**: its descriptor must report the linked environment id. A LAN address that now belongs to another machine never receives it. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index f63a2b4325120..5a7e28d8fd7ed 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -596,6 +596,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, prompt: OrchestratorMcpPrompt.annotate({ description: "Prompt executed on every scheduled run.", @@ -639,6 +640,7 @@ export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional( ProjectId.annotate({ description: @@ -656,6 +658,7 @@ export type OrchestratorMcpListScheduledTasksResult = typeof OrchestratorMcpListScheduledTasksResult.Type; export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, scheduledTaskId: ScheduledTaskId, prompt: Schema.optional(OrchestratorMcpPrompt), title: Schema.optional(OrchestratorMcpTitle), @@ -705,6 +708,7 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSecretResult.Type; export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, scheduledTaskId: ScheduledTaskId, }); export type OrchestratorMcpDeleteScheduledTaskInput = diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 989b80e06f9e9..bc3631700626b 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,7 +14,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. -- \`t3_environment_links\` lists the user's other T3 Code environments that this one is linked to, such as another machine. To work there, pass that \`environmentId\` to \`t3_project_list\`, \`orchestrator_capabilities\`, \`t3_thread_launch\`, \`t3_thread_list\`, \`t3_thread_read\`, \`t3_thread_send\`, \`t3_thread_wait\`, or \`t3_thread_interrupt\`; project, thread, run, and model ids then belong to that environment. Work there runs within your own modes and the link's access. +- \`t3_environment_links\` lists the user's other T3 Code environments that this one is linked to, such as another machine. To work there, pass that \`environmentId\` to any T3 tool that takes one, such as \`t3_thread_launch\`, \`t3_thread_send\`, \`t3_pending_request_respond\`, \`t3_queue_list\`, or \`t3_project_list\`; project, thread, run, and model ids then belong to that environment, so pass \`threadId\` explicitly. Work there runs within your own modes and the link's access. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. - To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. Name a thread in a linked environment by title instead: its link would open here.`, `### Choose the workspace before starting a new thread From 3b721d1a38b1241950e7305b2049d89b440ef9ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:07:07 +0000 Subject: [PATCH 07/50] fix(server): replay accepted launches before mutable preflights (cherry picked from commit c60c49fdf2222e51b5df3b003c1c77667eca8cce) --- .../src/mcp/toolkits/project/handlers.test.ts | 1 + .../src/mcp/toolkits/project/handlers.ts | 18 ++- .../ThreadLaunchService.test.ts | 133 ++++++++++++++++-- .../orchestration-v2/ThreadLaunchService.ts | 48 ++++++- 4 files changed, 187 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index c04b4db67f281..05980bde92807 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -55,6 +55,7 @@ const clientLaunchHarness = (input: { }), Layer.mock(ThreadManagement.ThreadManagementService)({}), Layer.mock(ThreadLaunch.ThreadLaunchService)({ + getAcceptedLaunch: () => Effect.succeed(Option.none()), checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void), launch: (launch) => { input.launched.push(launch); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 3c148ed5669cb..8aec288496920 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -102,12 +102,25 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), )).projectId : yield* resolveProjectId(context, input.projectId); + // Accepted creates keep their workspace and model even if refs or + // project defaults change. Message intake still handles their replay. + const acceptedLaunch = + input.clientRequestId === undefined + ? undefined + : Option.getOrUndefined( + yield* ThreadLaunch.ThreadLaunchService.pipe( + Effect.flatMap((launches) => + launches.getAcceptedLaunch({ commandId, threadId, projectId }), + ), + Effect.mapError(unavailable), + ), + ); const readProject = Project.ProjectService.pipe( Effect.flatMap((projects) => projects.getById(projectId)), Effect.mapError(unavailable), Effect.map(Option.getOrUndefined), ); - if (input.workspaceStrategy?.type === "existing_worktree") { + if (acceptedLaunch === undefined && input.workspaceStrategy?.type === "existing_worktree") { const project = yield* readProject; if (project === undefined) return yield* new OrchestratorMcpFailure({ @@ -119,7 +132,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { // A launch is accepted before its worktree is provisioned, so a base // ref that cannot resolve is refused while the agent can still act on it. const strategy = input.workspaceStrategy; - if (strategy?.type === "worktree") + if (acceptedLaunch === undefined && strategy?.type === "worktree") yield* ThreadLaunch.ThreadLaunchService.pipe( Effect.flatMap((launches) => launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }), @@ -137,6 +150,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), ); const modelSelection = + acceptedLaunch?.modelSelection ?? input.modelSelection ?? caller?.modelSelection ?? (yield* readProject)?.defaultModelSelection ?? diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f7208eb3452dc..1eefa9d94508d 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -14,6 +14,7 @@ import { ComposerContextId, type ChatAttachment, CommandId, + EnvironmentId, DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, @@ -58,6 +59,10 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as ProjectHandlers from "../mcp/toolkits/project/handlers.ts"; +import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; @@ -113,6 +118,7 @@ const claudeAdapter = { } as ProviderAdapter.ProviderAdapterV2["Service"]; interface HarnessOptions { + readonly getProject?: ProjectService.ProjectService["Service"]["getById"]; readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; readonly adapters?: ReadonlyArray; readonly managedFolders?: Layer.Layer; @@ -178,14 +184,16 @@ function makeHarness(options: HarnessOptions = {}) { bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), - getById: (id) => - Effect.succeed( - id === projectId - ? Option.some(project) - : id === otherProjectId - ? Option.some(otherProject) - : Option.none(), - ), + getById: + options.getProject ?? + ((id) => + Effect.succeed( + id === projectId + ? Option.some(project) + : id === otherProjectId + ? Option.some(otherProject) + : Option.none(), + )), getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), snapshot: Effect.die("unused"), getShell: () => Effect.die("unused"), @@ -1386,6 +1394,114 @@ it.effect("replays a server-allocated launch", () => }), ); +it.effect("MCP replays an accepted launch after its base and project default disappear", () => + Effect.gen(function* () { + const setupEntered = yield* Deferred.make(); + const allowSetup = yield* Deferred.make(); + let changed = false; + const harness = makeHarness({ + getProject: (id) => + Effect.sync(() => + id === projectId + ? Option.some({ ...project, defaultModelSelection: changed ? null : modelSelection }) + : id === otherProjectId + ? Option.some(otherProject) + : Option.none(), + ), + hasCommit: ({ refName }) => Effect.sync(() => refName === "HEAD" || !changed), + git: { hasRefNamed: () => Effect.succeed(false) }, + runSetup: () => + Deferred.succeed(setupEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowSetup)), + Effect.as({ status: "no-script" as const }), + ), + }); + const context = McpInvocationContext.McpInvocationContext.of({ + environmentId: EnvironmentId.make("launch-retry"), + requestNamespace: "client:launch-retry", + thread: undefined, + client: { sessionId: "launch-retry", label: "Test", access: "full-access" }, + issuedAt: 0, + capabilities: new Set(["orchestration" as const]), + }); + const dependencies = Layer.mergeAll( + harness.layer, + NodeCrypto.layer, + NodeServices.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, context), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-launch-retry-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide(McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer)), + ); + const input = { + title: "Only once", + projectId, + clientRequestId: "lost-response", + message: "Do the work", + workspaceStrategy: { type: "worktree" as const, baseRef: "temporary-base" }, + }; + const launch = (params: Parameters>[1]) => + toolkit.handle("t3_thread_launch", params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((results) => results.at(-1)?.result), + ); + const first = yield* launch(input); + assert.isDefined(first); + assert.notProperty(first, "code"); + yield* Deferred.await(setupEntered); + changed = true; + + assert.deepEqual(yield* launch(input), first); + assert.deepEqual(yield* launch({ ...input, modelSelection: claudeModelSelection }), first); + assert.include(yield* launch({ ...input, projectId: otherProjectId }), { + code: "orchestration_error", + }); + assert.include( + yield* launch(input).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...context, + client: { ...context.client!, access: "read-only" }, + }), + ), + { code: "capability_denied" }, + ); + assert.include(yield* launch({ ...input, clientRequestId: "fresh" }), { + code: "invalid_request", + }); + assert.include( + yield* launch({ + ...input, + clientRequestId: "fresh-root", + workspaceStrategy: { type: "root" }, + }), + { code: "invalid_request" }, + ); + const threadId = ThreadId.make("mcp:launch:client%3Alaunch-retry:lost-response"); + const threads = yield* ThreadManagement.ThreadManagementService; + const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.messages.length, 1); + assert.equal(projection.runs.length, 1); + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.runSetup.mock.calls.length, 1); + yield* Deferred.succeed(allowSetup, undefined); + yield* threads.streamStoredEventsFrom({ threadId }).pipe( + Stream.filter( + (stored) => + stored.commandId === CommandId.make(`${threadId}:release`) && + stored.event.type === "run.updated", + ), + Stream.runHead, + ); + }).pipe(Effect.provide(dependencies)); + }), +); + it.effect("replays an accepted launch after the project disables the instance", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1800,6 +1916,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }), ), ), + getAcceptedLaunch: launches.getAcceptedLaunch, checkWorktreeBase: launches.checkWorktreeBase, retryPreparation: launches.retryPreparation, prepareRun: launches.prepareRun, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index d1d00777c2d52..ebc1e362b11bd 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -11,6 +11,7 @@ import { type OrchestrationV2CreationSource, type OrchestrationV2ProviderThreadNativeMetadata, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type ProviderDriverKind, type ProviderInteractionMode, ProjectId, @@ -167,6 +168,9 @@ export class ThreadLaunchService extends Context.Service< ThreadLaunchResult, ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError >; + readonly getAcceptedLaunch: ( + input: Pick, + ) => Effect.Effect, ThreadLaunchError>; /** * `launch` accepts such a launch and reports the failure inside the * thread; a caller that needs it before a thread exists checks first. @@ -215,7 +219,11 @@ const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void)); const mapError = - (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => + ( + input: Pick, + operation: ThreadLaunchError["operation"], + threadId?: ThreadId, + ) => (cause: unknown) => new ThreadLaunchError({ operation, @@ -225,11 +233,39 @@ const make = Effect.gen(function* () { cause, }); - const readReceipt = (input: ThreadLaunchInput, commandId: CommandId) => + const readReceipt = ( + input: Pick, + commandId: CommandId, + ) => receipts .getByCommandId(commandId) .pipe(Effect.mapError(mapError(input, "read-receipt", input.threadId))); + const getAcceptedLaunch: ThreadLaunchService["Service"]["getAcceptedLaunch"] = Effect.fn( + "ThreadLaunchService.getAcceptedLaunch", + )(function* (input) { + const receipt = yield* readReceipt(input, input.commandId); + if (Option.isNone(receipt)) return Option.none(); + if ( + receipt.value.status !== "accepted" || + receipt.value.commandType !== "thread.create" || + (input.threadId !== undefined && input.threadId !== receipt.value.threadId) + ) { + return yield* mapError(input, "read-receipt")("This command cannot be replayed as a launch."); + } + const shell = yield* threads + .getThreadShell(receipt.value.threadId) + .pipe(Effect.mapError(mapError(input, "create-thread", receipt.value.threadId))); + if (shell === null || shell.projectId !== input.projectId) { + return yield* mapError( + input, + "resolve-project", + receipt.value.threadId, + )("The accepted launch does not belong to this project."); + } + return Option.some(shell); + }); + const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(function* ( input: ThreadLaunchInput, threadId: ThreadId, @@ -1125,7 +1161,13 @@ const make = Effect.gen(function* () { ); }; - return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); + return ThreadLaunchService.of({ + launch, + getAcceptedLaunch, + checkWorktreeBase, + retryPreparation, + prepareRun, + }); }); export const layer = Layer.effect(ThreadLaunchService, make); From a615bda9053c839d15c0f2cb45bbddbc54b707f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:25:55 +0000 Subject: [PATCH 08/50] fix(server): validate worktree membership on launch retries (cherry picked from commit 1fdb652e7d088394ce0ddecf0d4d04c6e3dc23e4) --- .../src/mcp/toolkits/project/handlers.ts | 6 +-- .../ThreadLaunchService.test.ts | 45 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 8aec288496920..32da8b12b6da7 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -102,8 +102,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), )).projectId : yield* resolveProjectId(context, input.projectId); - // Accepted creates keep their workspace and model even if refs or - // project defaults change. Message intake still handles their replay. + // Accepted creates skip mutable base/default-model preflights. + // Message intake still handles their replay and preparation. const acceptedLaunch = input.clientRequestId === undefined ? undefined @@ -120,7 +120,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { Effect.mapError(unavailable), Effect.map(Option.getOrUndefined), ); - if (acceptedLaunch === undefined && input.workspaceStrategy?.type === "existing_worktree") { + if (input.workspaceStrategy?.type === "existing_worktree") { const project = yield* readProject; if (project === undefined) return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 1eefa9d94508d..f300adde9c53e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1394,7 +1394,7 @@ it.effect("replays a server-allocated launch", () => }), ); -it.effect("MCP replays an accepted launch after its base and project default disappear", () => +it.effect("MCP replays accepted launches and checks worktree membership", () => Effect.gen(function* () { const setupEntered = yield* Deferred.make(); const allowSetup = yield* Deferred.make(); @@ -1429,7 +1429,9 @@ it.effect("MCP replays an accepted launch after its base and project default dis NodeCrypto.layer, NodeServices.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, context), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + listWorktreePaths: () => Effect.succeed([project.workspaceRoot, "/repo-worktrees/feature"]), + }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-launch-retry-" }).pipe( Layer.provide(NodeServices.layer), ), @@ -1451,12 +1453,47 @@ it.effect("MCP replays an accepted launch after its base and project default dis Stream.runCollect, Effect.map((results) => results.at(-1)?.result), ); + const fs = yield* FileSystem.FileSystem; + const outsidePath = yield* fs.makeTempDirectoryScoped({ prefix: "launch-unregistered-" }); + const outsideStrategy = { type: "existing_worktree" as const, worktreePath: outsidePath }; + const refusal = { + code: "invalid_request", + message: + "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.", + }; + const idleInput = { + title: "Idle accepted launch", + projectId, + clientRequestId: "idle-retry", + workspaceStrategy: { type: "root" as const }, + }; + assert.include(yield* launch(idleInput), { runId: null, status: null }); + assert.include( + yield* launch({ + ...idleInput, + message: "Run outside the project", + workspaceStrategy: outsideStrategy, + }), + refusal, + ); + const threads = yield* ThreadManagement.ThreadManagementService; + const idle = yield* threads.getThreadProjection( + ThreadId.make("mcp:launch:client%3Alaunch-retry:idle-retry"), + ); + assert.equal(idle.messages.length, 0); + assert.equal(idle.runs.length, 0); + assert.equal(idle.thread.worktreePath, null); + assert.equal(harness.createWorktree.mock.calls.length, 0); + assert.equal(harness.runSetup.mock.calls.length, 0); + const first = yield* launch(input); assert.isDefined(first); assert.notProperty(first, "code"); yield* Deferred.await(setupEntered); changed = true; + assert.include(yield* launch({ ...input, workspaceStrategy: outsideStrategy }), refusal); + assert.deepEqual(yield* launch(input), first); assert.deepEqual(yield* launch({ ...input, modelSelection: claudeModelSelection }), first); assert.include(yield* launch({ ...input, projectId: otherProjectId }), { @@ -1483,10 +1520,12 @@ it.effect("MCP replays an accepted launch after its base and project default dis { code: "invalid_request" }, ); const threadId = ThreadId.make("mcp:launch:client%3Alaunch-retry:lost-response"); - const threads = yield* ThreadManagement.ThreadManagementService; const projection = yield* threads.getThreadProjection(threadId); assert.equal(projection.messages.length, 1); assert.equal(projection.runs.length, 1); + assert.equal(projection.runs[0]?.status, "preparing"); + assert.deepEqual(projection.runs[0]?.workspacePreparation, input.workspaceStrategy); + assert.equal(projection.thread.worktreePath, "/repo-worktrees/feature"); assert.equal(harness.createWorktree.mock.calls.length, 1); assert.equal(harness.runSetup.mock.calls.length, 1); yield* Deferred.succeed(allowSetup, undefined); From 662caf2d1191b82d481f98787d7f40a8be0e1c61 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:40:03 +0000 Subject: [PATCH 09/50] fix(server): preserve accepted launch preparation and limits (cherry picked from commit ebba131607e20705643a10a79e2afc483962af00) --- .../src/orchestration-v2/Orchestrator.ts | 27 ++++++++++ .../ThreadLaunchService.test.ts | 52 +++++++++++++++++-- .../orchestration-v2/ThreadLaunchService.ts | 24 ++++++--- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7094be95897d6..8b9cd9d038032 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -11527,6 +11527,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if ( + command.type === "message.dispatch" && + command.dispatchMode?.type === "defer_start" && + (yield* DispatchModeLimit) !== undefined + ) { + const runCreated = storedEvents.find( + (stored) => stored.event.type === "run.created", + )?.event; + if (runCreated?.type === "run.created") { + const current = yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + // Replaying this receipt can resume workspace preparation, so it must + // respect the caller's current ceiling just like a fresh dispatch. + if ( + current.runs.some( + (run) => run.id === runCreated.payload.id && run.status === "preparing", + ) + ) { + yield* refuseAboveDispatchModeLimit(command, command.threadId, current.thread); + } + } + } if (command.type === "queue.resume") { yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); } diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f300adde9c53e..842889df21b8b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1444,6 +1444,7 @@ it.effect("MCP replays accepted launches and checks worktree membership", () => title: "Only once", projectId, clientRequestId: "lost-response", + runtimeMode: "approval-required" as const, message: "Do the work", workspaceStrategy: { type: "worktree" as const, baseRef: "temporary-base" }, }; @@ -1520,7 +1521,24 @@ it.effect("MCP replays accepted launches and checks worktree membership", () => { code: "invalid_request" }, ); const threadId = ThreadId.make("mcp:launch:client%3Alaunch-retry:lost-response"); + assert.equal((yield* threads.getThreadShell(threadId))?.runtimeMode, "approval-required"); + yield* threads.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("owner:raise-launch-modes"), + threadId, + runtimeMode: "full-access", + }); + assert.include( + yield* launch(input).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...context, + client: { ...context.client!, access: "approval-required" }, + }), + ), + { code: "orchestration_error" }, + ); const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.thread.runtimeMode, "full-access"); assert.equal(projection.messages.length, 1); assert.equal(projection.runs.length, 1); assert.equal(projection.runs[0]?.status, "preparing"); @@ -1537,6 +1555,15 @@ it.effect("MCP replays accepted launches and checks worktree membership", () => ), Stream.runHead, ); + assert.include( + yield* launch(input).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...context, + client: { ...context.client!, access: "approval-required" }, + }), + ), + { threadId, status: "starting" }, + ); }).pipe(Effect.provide(dependencies)); }), ); @@ -1830,19 +1857,38 @@ it.effect("schedules an accepted preparing message exactly once across concurren text: "Resume preparation", attachments: [], modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start" }, + dispatchMode: { type: "defer_start", workspaceStrategy: input.workspaceStrategy }, createdBy: input.createdBy, creationSource: input.creationSource, }); const preparing = yield* threads.getThreadProjection(input.threadId); assert.equal(preparing.runs[0]?.status, "preparing"); - const [first, second] = yield* Effect.all([launches.launch(input), launches.launch(input)], { + const retry = { ...input, workspaceStrategy: { type: "root" as const } }; + const [first, second] = yield* Effect.all([launches.launch(retry), launches.launch(retry)], { concurrency: "unbounded", }); - yield* Deferred.await(setupEntered); + const prepared = yield* Effect.raceFirst( + Deferred.await(setupEntered).pipe(Effect.as(true)), + threads.streamStoredEventsFrom({ threadId: input.threadId }).pipe( + Stream.filter( + (stored) => + stored.commandId === CommandId.make(input.commandId + ":release") && + stored.event.type === "run.updated", + ), + Stream.runHead, + Effect.as(false), + ), + ); + assert.isTrue(prepared, "the accepted worktree setup must run before releasing the run"); assert.isTrue(first.resumed); assert.isTrue(second.resumed); + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.runSetup.mock.calls[0]?.[0].worktreePath, "/repo-worktrees/feature"); + assert.deepEqual( + (yield* threads.getThreadProjection(input.threadId)).runs[0]?.workspacePreparation, + input.workspaceStrategy, + ); assert.equal(harness.runSetup.mock.calls.length, 1); yield* Deferred.succeed(allowSetup, undefined); }).pipe(Effect.provide(harness.layer)); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index ebc1e362b11bd..892a457368cea 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -1039,9 +1039,8 @@ const make = Effect.gen(function* () { const projection = yield* threads .getThreadProjection(threadId) .pipe(Effect.mapError(mapError(input, "create-thread", threadId))); - const runIsPreparing = - runId !== null && - projection.runs.some((run) => run.id === runId && run.status === "preparing"); + const acceptedRun = projection.runs.find((run) => run.id === runId); + const runIsPreparing = acceptedRun?.status === "preparing"; const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing; const boundWorktreePath = projection.thread.worktreePath; const preparationStrategy: ThreadLaunchWorkspaceStrategy = @@ -1068,11 +1067,20 @@ const make = Effect.gen(function* () { Effect.mapError(mapError(input, "update-thread", threadId)), ); if (preparationStillRequired) { - yield* schedulePreparation( - { ...input, workspaceStrategy: preparationStrategy }, - threadId, - runId, - ); + if (acceptedRun?.workspacePreparation !== undefined) { + yield* scheduleRunPreparation( + { commandId: input.commandId, threadId, runId: acceptedRun.id }, + projection, + acceptedRun, + acceptedRun.workspacePreparation, + ); + } else { + yield* schedulePreparation( + { ...input, workspaceStrategy: preparationStrategy }, + threadId, + runId, + ); + } } else { yield* releasePreparation(input.commandId); } From 9621c6452c6c05c7ac7f8bb80fe6a0a537c2b14a Mon Sep 17 00:00:00 2001 From: zainmitchell <337498276+zainmitchell@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:53:52 -0500 Subject: [PATCH 10/50] docs(server): describe t3_thread_launch retry keys The launch section said launch has no retry key, but t3_thread_launch accepts clientRequestId and replays the first accepted launch for the same key within the caller's MCP session. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 452eb16e6f6b0da66852985735bee334d16951d4) --- docs/orchestration-v2/orchestrator-mcp-server.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 44aa2492a8282..2cfaf22af3bc4 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -356,7 +356,9 @@ Pass the task in `message`. Project, model, and modes inherit when omitted; workspace does not. `scratch: true` launches without a project, in a folder of its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with `startFromOrigin: false`. The new thread may not run with broader runtime or -interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before +interaction modes than the caller. Reusing a `clientRequestId` within the same +MCP session returns the launch it first accepted instead of launching again. +Without a key, inspect existing threads after a failed or lost response before launching again. `create_threads` remains the batch option for a shared checkout. ### `t3_thread_list` From 8bc5fbe2ba3a9d0f45a85b754e9a414b5226b754 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:44:00 +0000 Subject: [PATCH 11/50] fix(server): handle peer response and cleanup failures Ports the reviewed #21672 fix onto the original peer-link code: read the event-stream answer matching the request id, forget a link only after its token is removed (and never a newer link), and keep link errors free of the entered address and peer-supplied text. The tests are rewritten for this branch's harness; the plain-http guidance keeps this branch's wording. (cherry picked from commit 84a4f73b9ec721840534d00b15084f7c997a7441) --- apps/server/src/peer/PeerLinks.test.ts | 189 ++++++++++++++++++++++++- apps/server/src/peer/PeerLinks.ts | 32 +++-- apps/server/src/peer/PeerMcpClient.ts | 39 +++-- 3 files changed, 233 insertions(+), 27 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 47b872b3772c9..5c54f611ca676 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -8,15 +8,23 @@ import { } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { Tool, Toolkit } from "effect/ai"; -import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/http"; +import { + FetchHttpClient, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/http"; import * as HttpApi from "effect/http-api/HttpApi"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -81,6 +89,12 @@ const serveB = (initial: ExecutionEnvironmentDescriptor) => const descriptor = yield* Ref.make(initial); const bearers = yield* Ref.make>([]); const probes = yield* Ref.make(0); + // Answers tool calls as an event stream with other messages ahead of the answer. + const sse = yield* Ref.make(false); + // Canned answers by path, standing in for a peer's own refusals. + const replies = yield* Ref.make>( + new Map(), + ); const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), getDescriptor: Ref.get(descriptor), @@ -106,7 +120,32 @@ const serveB = (initial: ExecutionEnvironmentDescriptor) => if (authorization !== undefined) { yield* Ref.update(bearers, (seen) => [...seen, authorization]); } - return yield* httpEffect; + const reply = (yield* Ref.get(replies)).get(request.url); + if (reply !== undefined) + return HttpServerResponse.jsonUnsafe(reply.body, { status: reply.status }); + const response = yield* httpEffect; + if ( + !(yield* Ref.get(sse)) || + request.url !== "/mcp" || + response.status !== 200 || + response.body._tag !== "Uint8Array" + ) + return response; + const answer = new TextDecoder().decode(response.body.body); + const split = answer.indexOf(",") + 1; + return HttpServerResponse.text( + [ + 'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}', + "", + 'data: {"jsonrpc":"2.0","id":999,"error":{"code":-1,"message":"other request"}}', + "", + `data: ${answer.slice(0, split)}`, + `data: ${answer.slice(split)}`, + "", + "", + ].join("\r\n"), + { contentType: "text/event-stream" }, + ); }), { global: true }, ); @@ -160,20 +199,34 @@ const serveB = (initial: ExecutionEnvironmentDescriptor) => auth: Context.get(authContext, EnvironmentAuth.EnvironmentAuth), descriptor, bearers, + sse, + replies, /** How often its descriptor was asked for, as a probe of whether it answers. */ probes, }; }); /** Environment A: its own database and secret store, reaching B over plain HTTP. */ -const makeA = (descriptor: ExecutionEnvironmentDescriptor) => +const makeA = ( + descriptor: ExecutionEnvironmentDescriptor, + beforeSecretRemove: Effect.Effect = Effect.void, +) => Effect.gen(function* () { + const layerSecrets = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.ServerSecretStore, (secrets) => + ServerSecretStore.ServerSecretStore.of({ + ...secrets, + remove: (name) => beforeSecretRemove.pipe(Effect.andThen(secrets.remove(name))), + }), + ), + ).pipe(Layer.provide(ServerSecretStore.layer)); const context = yield* PeerMcpClient.layer.pipe( Layer.provideMerge(PeerLinks.layer), Layer.provide( Layer.mergeAll( Sqlite.layerMemory, - ServerSecretStore.layer, + layerSecrets, FetchHttpClient.layer, Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(descriptor.environmentId), @@ -395,3 +448,131 @@ it.effect( }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("reads a tool's answer from an event stream carrying other messages first", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + const answered = yield* probe(a); + + yield* Ref.set(b.sse, true); + expect(yield* probe(a)).toEqual(answered); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect( + "keeps a link and its token when forgetting it fails, and never forgets a newer link", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const removal = yield* Ref.make<"fail" | "pause" | null>(null); + const removalEntered = yield* Deferred.make(); + const allowRemoval = yield* Deferred.make(); + const a = yield* makeA( + laptop, + Ref.get(removal).pipe( + Effect.flatMap((action) => + action === "fail" + ? Effect.fail( + new ServerSecretStore.SecretStoreRemoveError({ + resource: "test credential", + cause: new Error("removal failed"), + }), + ) + : action === "pause" + ? Deferred.succeed(removalEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowRemoval)), + ) + : Effect.void, + ), + ), + ); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + const linked = yield* a.links.get(box.environmentId); + + yield* Ref.set(removal, "fail"); + expect((yield* a.links.unlink(box.environmentId).pipe(Effect.flip)).reason).toBe("storage"); + expect(yield* a.links.get(box.environmentId)).toEqual(linked); + expect(yield* probe(a)).toEqual({ caller: "T3 Code ยท Laptop", modes: "auto/default" }); + + // Linking again while an unlink is forgetting the old token keeps the new link. + yield* Ref.set(removal, "pause"); + const unlinking = yield* a.links.unlink(box.environmentId).pipe(Effect.forkScoped); + yield* Deferred.await(removalEntered); + yield* Ref.set(removal, null); + const repairing = yield* b.auth.issuePairingCredential(); + const relinked = yield* a.links.link({ + url: b.url, + pairingCode: repairing.credential, + access: "approval-required", + }); + yield* Deferred.succeed(allowRemoval, undefined); + expect(yield* Fiber.join(unlinking)).toBe(false); + expect(yield* a.links.get(box.environmentId)).toEqual(Option.some(relinked)); + expect(yield* probe(a)).toEqual({ + caller: "T3 Code ยท Laptop", + modes: "approval-required/default", + }); + expect(yield* a.links.unlink(box.environmentId)).toBe(true); + expect(yield* a.links.get(box.environmentId)).toEqual(Option.none()); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("never repeats an address's credentials or a peer's own text in a link error", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + for (const url of [ + "file://username:password@example.test/path?token=private", + "http://username:password@192.168.1.20:3773/path?token=private", + ]) { + const invalid = yield* a.links + .link({ url, pairingCode: "unused", access: "auto" }) + .pipe(Effect.flip); + expect(invalid.message).not.toMatch(/username|password|private|example\.test/); + } + const peerText = "PRIVATE-PEER-RESPONSE\n".repeat(200); + for (const [path, body, message] of [ + [ + "/oauth/mcp/register", + { error: "invalid_client_metadata", error_description: peerText }, + "The peer refused to register this environment. Check its connection settings.", + ], + [ + "/oauth/mcp/decision", + { _tag: "AuthMcpApprovalError", message: peerText }, + "The peer refused link approval. Check the pairing code and try again.", + ], + [ + "/oauth/mcp/token", + { error: "invalid_grant", error_description: peerText }, + "The peer refused the sign-in token. Create a new pairing code and try again.", + ], + [ + "/oauth/mcp/decision", + { redirectTo: "http://localhost/?error_description=" + encodeURIComponent(peerText) }, + "The peer did not approve the link. Check the pairing code and try again.", + ], + ] as const) { + yield* Ref.set( + b.replies, + new Map([[path, { status: "redirectTo" in body ? 200 : 400, body }]]), + ); + const code = yield* b.auth.issuePairingCredential(); + const failed = yield* a.links + .link({ url: b.url, pairingCode: code.credential, access: "auto" }) + .pipe(Effect.flip); + expect(failed.reason).toBe("pairing_rejected"); + expect(failed.message).toBe(message); + } + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 0586e24623658..634936b3fcee2 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -116,7 +116,7 @@ const peerOrigin = (url: string) => { return Effect.fail( new PeerLinkError({ reason: "unreachable", - message: `${url} is not an http or https address.`, + message: "The peer address must use http or https.", }), ); } @@ -128,7 +128,8 @@ const peerOrigin = (url: string) => { return Effect.fail( new PeerLinkError({ reason: "unreachable", - message: `${url} is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.`, + message: + "The peer address uses plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.", }), ); } @@ -200,7 +201,9 @@ const make = Effect.gen(function* () { Effect.fail( new PeerLinkError({ reason: "pairing_rejected", - message: `The peer refused to register this environment: ${error.message}`, + message: + "The peer refused to register this environment. Check its connection settings.", + cause: error, }), ), ), @@ -230,7 +233,11 @@ const make = Effect.gen(function* () { Effect.catchTags({ AuthMcpApprovalError: (error) => Effect.fail( - new PeerLinkError({ reason: "pairing_rejected", message: error.message }), + new PeerLinkError({ + reason: "pairing_rejected", + message: "The peer refused link approval. Check the pairing code and try again.", + cause: error, + }), ), }), ); @@ -245,9 +252,7 @@ const make = Effect.gen(function* () { if (code === null || redirect.searchParams.get("state") !== state) { return yield* new PeerLinkError({ reason: "pairing_rejected", - message: - redirect.searchParams.get("error_description") ?? - "The peer did not approve the link. Check the pairing code and try again.", + message: "The peer did not approve the link. Check the pairing code and try again.", }); } return yield* client.mcpOAuth @@ -266,7 +271,9 @@ const make = Effect.gen(function* () { Effect.fail( new PeerLinkError({ reason: "pairing_rejected", - message: `The peer refused the sign-in: ${error.message}`, + message: + "The peer refused the sign-in token. Create a new pairing code and try again.", + cause: error, }), ), ), @@ -405,13 +412,14 @@ const make = Effect.gen(function* () { const unlink: PeerLinks["Service"]["unlink"] = Effect.fn("PeerLinks.unlink")( function* (environmentId) { + const row = yield* readRow(environmentId); + if (Option.isNone(row)) return false; + yield* secrets.remove(row.value.secret_name).pipe(Effect.catch(failStorage)); const deleted = yield* sql<{ secret_name: string }>` - DELETE FROM peer_environment_links WHERE environment_id = ${environmentId} + DELETE FROM peer_environment_links + WHERE environment_id = ${environmentId} AND secret_name = ${row.value.secret_name} RETURNING secret_name `.pipe(Effect.catch(failStorage)); - for (const row of deleted) { - yield* secrets.remove(row.secret_name).pipe(Effect.catch(failStorage)); - } return deleted.length > 0; }, ); diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 11b222e570d13..30adbbdc86aee 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -46,6 +46,7 @@ export class PeerMcpClient extends Context.Service< >()("t3/peer/PeerMcpClient") {} const ToolCallResponse = Schema.Struct({ + id: Schema.Number, result: Schema.optional( Schema.Struct({ content: Schema.Array( @@ -57,7 +58,9 @@ const ToolCallResponse = Schema.Struct({ ), error: Schema.optional(Schema.Struct({ code: Schema.Number, message: Schema.String })), }); -const decodeToolCallResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(ToolCallResponse)); +const ToolCallResponseJson = Schema.fromJsonString(ToolCallResponse); +const decodeToolCallResponse = Schema.decodeUnknownEffect(ToolCallResponseJson); +const decodeToolCallResponseOption = Schema.decodeUnknownOption(ToolCallResponseJson); const decodeFailure = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ code: OrchestratorMcpFailure.fields.code, message: Schema.String }), @@ -76,15 +79,29 @@ const fromLinkError = (error: PeerLinkError) => message: error.message, }); -/** The body of an MCP response: JSON, or a single server-sent event carrying JSON. */ -const responseJson = (contentType: string | undefined, text: string) => - contentType?.includes("text/event-stream") === true - ? text - .split("\n") - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice("data:".length).trim()) - .join("") - : text; +/** + * The body of an MCP response: JSON, or the server-sent event answering + * `requestId`. A peer may stream notifications ahead of the answer. + */ +const responseJson = (contentType: string | undefined, text: string, requestId: number) => { + if (contentType?.includes("text/event-stream") !== true) return text; + for (const event of text.replace(/\r\n?/g, "\n").split("\n\n")) { + const data = event + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).replace(/^ /, "")) + .join("\n"); + const decoded = decodeToolCallResponseOption(data); + if ( + decoded._tag === "Some" && + decoded.value.id === requestId && + (decoded.value.result !== undefined || decoded.value.error !== undefined) + ) { + return data; + } + } + return ""; +}; const make = Effect.gen(function* () { const links = yield* PeerLinks.PeerLinks; @@ -189,7 +206,7 @@ const make = Effect.gen(function* () { }); } const body = yield* decodeToolCallResponse( - responseJson(response.headers["content-type"], yield* response.text), + responseJson(response.headers["content-type"], yield* response.text, 1), ).pipe( Effect.mapError( () => From 2149e29059eb0a3e4f0451646f77dd93c0d444be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 22:02:51 -0500 Subject: [PATCH 12/50] fix(server): keep forwarded stop retries on their original work Ports the reviewed Stop fixes from #21673 onto the original forwarding layer and current main: - A delegated Stop walks only the children recorded through the original Stop's receipt sequence, so a retry never reaches tasks delegated later. - A child Stop carries that bound internally (`throughSequence`); it stops a run only if the run already existed by then, and Stop receipts record the global sequence so the bound covers other threads. - `t3_thread_interrupt` with `stop` reports the run its interrupt request reached, not a run that a queue hold or disposal updated. Upstream made `thread.stop` public; the bound stays on the internal variant only, as in the reviewed #22448 integration, so clients cannot set it. Upstream's orphan cancellation and pruned ThreadStop cases are kept. Adapted from 6c0e5cfaae1c347ff6042a72bc350fb5806616c5, 6be8a58da (correct delegated stop receipt bounds), a39f5f935 (comment) and the Stop cases of 001ecbed8. --- apps/server/src/mcp/OrchestratorMcpService.ts | 21 +- apps/server/src/orchestration-v2/EventSink.ts | 13 + .../src/orchestration-v2/Orchestrator.ts | 44 ++- .../ThreadManagementService.ts | 61 +++- .../src/orchestration-v2/ThreadStop.test.ts | 318 +++++++++++++++--- apps/server/src/peer/PeerForwarding.test.ts | 6 +- packages/contracts/src/orchestrationV2.ts | 11 + 7 files changed, 406 insertions(+), 68 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ae958786e772b..af58b87592d53 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2821,15 +2821,24 @@ const make = Effect.gen(function* () { ...(input.reason === undefined ? {} : { reason: input.reason }), }) .pipe(Effect.catch(stopFailed)); - // A retry with the same clientRequestId repeats only the stops that failed. yield* threadManagement .stopDelegatedTasks({ threadId: input.threadId, commandId, reason: input.reason }) .pipe(Effect.catch(stopFailed)); - // The run this stop interrupted, from its own events, so a retry - // reports the same run even if another has started since. - const interrupted = stopped.storedEvents.find( - (stored) => stored.event.type === "run.updated", - )?.event.runId; + // Queue holds and completion disposal also update runs. Only the + // original interrupt request identifies the run Stop reached. + const request = stopped.storedEvents.find( + (stored) => + stored.event.type === "turn-item.updated" && + stored.event.payload.type === "run_interrupt_request" && + (stored.event.payload.providerTurnId !== null || + stopped.storedEvents.some( + (updated) => + updated.event.type === "run.updated" && + updated.event.runId === stored.event.runId && + updated.event.payload.status === "interrupted", + )), + ); + const interrupted = request?.event.runId; return { threadId: input.threadId, runId: interrupted ?? null, diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index d2bd8a7ac0315..1a8ef48cfe2b3 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -198,6 +198,11 @@ export interface EventSinkV2Shape { readonly latestSequence: (input?: { readonly threadId?: ThreadId; }) => Effect.Effect; + readonly read: (input: { + readonly threadId: ThreadId; + readonly throughSequence: number; + readonly eventType: OrchestrationV2DomainEvent["type"]; + }) => Stream.Stream; readonly readByCommandId: (input: { readonly commandId: CommandId; }) => Stream.Stream; @@ -957,6 +962,14 @@ const layerBase: Layer.Layer< }), ), ), + read: (input) => + eventStore + .read(input) + .pipe( + Stream.mapError( + (cause) => new EventSinkStreamError({ threadId: input.threadId, cause }), + ), + ), readByCommandId: (input) => eventStore.readByCommandId(input).pipe( Stream.mapError( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8b9cd9d038032..a9e4004c86848 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -383,6 +383,12 @@ export interface OrchestratorV2Shape { readonly getThreadEventSequence: ( threadId: ThreadId, ) => Effect.Effect; + /** Committed history at a fixed sequence, without subscribing to live events. */ + readonly readStoredEvents: (input: { + readonly threadId: ThreadId; + readonly throughSequence: number; + readonly eventType: OrchestrationV2DomainEvent["type"]; + }) => Stream.Stream; readonly streamStoredEvents: Stream.Stream; readonly streamStoredEventsFrom: (input?: { readonly threadId?: ThreadId; @@ -9979,7 +9985,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( - command: Extract, + // Client Stops fit this internal shape without its retry bound. + command: Extract, events: Ref.Ref>, effects: Ref.Ref>, ) { @@ -9995,6 +10002,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (projection.thread.deletedAt !== null) return undefined; const latestRun = projection.runs.findLast((run) => run.status !== "queued"); + if (command.throughSequence !== undefined && latestRun !== undefined) { + // A queued run already belonged to Stop even if it started before the delegated walk. + const existed = yield* eventSink + .read({ + threadId: command.threadId, + throughSequence: command.throughSequence, + eventType: "run.created", + }) + .pipe( + Stream.runFold( + () => false, + (found, stored) => found || stored.event.runId === latestRun.id, + ), + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (!existed) return undefined; + // Partial retries keep queue/watch changes thread-wide while the original run is current. + } const target = projection.runs.findLast( (run) => @@ -11633,7 +11660,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A settle that ended nothing still records its receipt: a replayed Stop // effect then finds it instead of settling work that appeared since. const resultSequence = yield* Effect.gen(function* () { - const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) }); + // Stop receipts also bound other threads; other commands keep their thread cursor. + const sequence = yield* eventSink.latestSequence( + command.type === "thread.stop" ? undefined : { threadId: commandThreadId(command) }, + ); yield* commandReceipts.insertIfAbsent({ commandId: command.commandId, threadId: commandThreadId(command), @@ -12051,6 +12081,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio eventSink .latestSequence({ threadId }) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + readStoredEvents: (input) => + eventSink + .read(input) + .pipe(Stream.mapError((cause) => new OrchestratorDomainEventStreamError({ cause }))), streamStoredEvents: eventSink.stream().pipe( Stream.mapError( (cause) => @@ -12207,6 +12241,12 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + readStoredEvents: () => + Stream.fail( + new OrchestratorDomainEventStreamError({ + cause: "Orchestration V2 live runtime is not configured.", + }), + ), streamStoredEventsFrom: () => Stream.fail( new OrchestratorDomainEventStreamError({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe1..ea0de29b814e9 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -43,6 +43,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -1099,24 +1100,66 @@ const make = Effect.gen(function* () { const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn( "ThreadManagementService.stopDelegatedTasks", )(function* (input) { - const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); + const receipt = yield* receipts + .getByCommandId(input.commandId) + .pipe( + Effect.mapError( + (cause) => + new Orchestrator.OrchestratorProjectionError({ threadId: input.threadId, cause }), + ), + ); + return yield* stopDelegatedTasksThrough( + input, + Option.isSome(receipt) ? receipt.value.resultSequence : undefined, + ); + }); + + const stopDelegatedTasksThrough: ( + input: Parameters[0], + throughSequence: number | undefined, + ) => ReturnType = Effect.fn( + "ThreadManagementService.stopDelegatedTasksThrough", + )(function* (input, throughSequence) { + // A retry must not stop tasks delegated after the original Stop, including + // when an earlier walk stopped some children and failed on another. Follow-up + // tasks reuse a child thread, so the set walks its subtree only once. + const childThreadIds = new Set( + throughSequence === undefined + ? (yield* orchestrator.getThreadRecords(input.threadId, ["subagents"])).subagents.flatMap( + (task) => + task.origin === "app_owned" && task.childThreadId !== null + ? [task.childThreadId] + : [], + ) + : yield* orchestrator + .readStoredEvents({ + threadId: input.threadId, + throughSequence, + eventType: "subagent.updated", + }) + .pipe( + Stream.filterMap((stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.origin === "app_owned" && + stored.event.payload.childThreadId !== null + ? Result.succeed(stored.event.payload.childThreadId) + : Result.failVoid, + ), + Stream.runCollect, + ), + ); const failures: Array< Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError > = []; - const stoppedChildThreadIds = new Set(); - for (const task of subagents) { - if (task.origin !== "app_owned" || task.childThreadId === null) continue; - const threadId = task.childThreadId; - // Follow-up tasks reuse a child thread; walk its subtree only once. - if (stoppedChildThreadIds.has(threadId)) continue; - stoppedChildThreadIds.add(threadId); + for (const threadId of childThreadIds) { yield* dispatch({ type: "thread.stop", commandId: CommandId.make(`${input.commandId}:stop:${threadId}`), threadId, + ...(throughSequence === undefined ? {} : { throughSequence }), ...(input.reason === undefined ? {} : { reason: input.reason }), }).pipe( - Effect.andThen(stopDelegatedTasks({ ...input, threadId })), + Effect.andThen(stopDelegatedTasksThrough({ ...input, threadId }, throughSequence)), Effect.catch((error) => Effect.logWarning("Unable to stop a delegated task", { parentThreadId: input.threadId, diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 74da4d674d792..38325b6628a05 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1,8 +1,13 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { CommandId, ContextTransferId, EventId, + EnvironmentId, MessageId, NodeId, type OrchestrationV2DomainEvent, @@ -24,13 +29,23 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; import * as EventStore from "./EventStore.ts"; +import * as EventSink from "./EventSink.ts"; import * as TestClock from "effect/testing/TestClock"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts"; +import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as ThreadLaunch from "./ThreadLaunchService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as EffectWorker from "./EffectWorker.ts"; @@ -50,24 +65,42 @@ const adapter = { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("Runs here never reach a provider"), } as ProviderAdapter.ProviderAdapterV2["Service"]; -const layerDatabase = SqlitePersistence.layerMemory; -const layerTest = ThreadManagementService.layer.pipe( - Layer.provideMerge( - Layer.mergeAll( - layerDatabase, - ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), - EventStore.layer.pipe(Layer.provide(layerDatabase)), - EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), - ProviderReplayHarness.layerWithRegistry( - { name: "thread-stop" }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { databaseLayer: layerDatabase, runEffectWorker: false }, +const fileDatabase = (dbPath: string) => + SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer)); +const makeLayerTest = ( + layerDatabase: ReturnType = SqlitePersistence.layerMemory, +) => + ThreadManagementService.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EventStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "thread-stop" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), ), ), - ), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), -); + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); +const layerTest = makeLayerTest(); + +const layerMcp = (layerDatabase: ReturnType) => + OrchestratorMcpService.layer.pipe( + Layer.provideMerge(makeLayerTest(layerDatabase)), + Layer.provide(NodeCrypto.layer), + Layer.provide(ProviderAdapterRegistry.layerFromAdapters([adapter])), + Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ); const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2)); @@ -498,7 +531,7 @@ it.effect("Stop still stops the thread when a delegated child cannot be read", ( it.effect("Stop ends watches, holds queues, and stops the delegated tasks under the thread", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; + const sink = yield* EventSink.EventSinkV2; const threads = yield* ThreadManagementService.ThreadManagementService; const sql = yield* SqlClient.SqlClient; const parentThreadId = ThreadId.make("thread:stop-parent"); @@ -508,16 +541,21 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(childThreadId, 2)))); const grandchildThreadId = yield* delegate(childThreadId, "grandchild task"); yield* send(childThreadId, "child follow-up", "queue_after_active"); + yield* send(childThreadId, "child queued after follow-up", "queue_after_active"); const now = yield* DateTime.now; const firstRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; - yield* projections.apply({ - id: EventId.make("event:stop-parent:first-run-completed"), - type: "run.updated", - threadId: parentThreadId, - runId: firstRun.id, - occurredAt: now, - payload: { ...firstRun, status: "completed", completedAt: now }, + yield* sink.write({ + events: [ + { + id: EventId.make("event:stop-parent:first-run-completed"), + type: "run.updated", + threadId: parentThreadId, + runId: firstRun.id, + occurredAt: now, + payload: { ...firstRun, status: "completed", completedAt: now }, + }, + ], }); yield* send(parentThreadId, "second", "start_immediately"); const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!; @@ -545,15 +583,44 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under "delegated-tasks.stop", ); + const childRun = (yield* orchestrator.getThreadProjection(childThreadId)).runs[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make("event:stop-child:first-run-completed"), + type: "run.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume-child-before-stop-walk"), + threadId: childThreadId, + }); + assert.deepEqual((yield* threadState(childThreadId)).runs, ["completed", "starting", "queued"]); + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); assert.deepEqual(yield* threadState(childThreadId), { - runs: ["interrupted", "queued:held"], + runs: ["completed", "interrupted", "queued:held"], watched: [], }); assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]); yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); - assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]); + assert.deepEqual((yield* threadState(childThreadId)).runs, [ + "completed", + "interrupted", + "queued:held", + ]); + + yield* send(parentThreadId, "new work", "start_immediately"); + const newChild = yield* delegate(parentThreadId, "new task after Stop"); + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); + assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]); }).pipe(Effect.provide(layerTest)), ); @@ -1025,40 +1092,75 @@ it.effect.each([ }).pipe(Effect.provide(layerTest)), ); -it.effect("a delegated task that cannot be stopped fails the walk after its siblings stop", () => +it.effect("a partial delegated Stop retries only the original children that failed", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; const threads = yield* ThreadManagementService.ThreadManagementService; const parentThreadId = ThreadId.make("thread:stop-partial"); yield* createWatchingThread(parentThreadId, 6); yield* send(parentThreadId, "work", "start_immediately"); - const childThreadId = yield* delegate(parentThreadId, "stoppable task"); - const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; - yield* projections.apply({ - id: EventId.make("event:stop-partial:missing-task"), - type: "subagent.updated", - threadId: parentThreadId, - runId: task.runId!, - nodeId: NodeId.make("node:stop-partial:missing"), - providerInstanceId: instanceId, - occurredAt: yield* DateTime.now, - payload: { - ...task, - id: NodeId.make("node:stop-partial:missing"), - childThreadId: ThreadId.make("thread:stop-partial:missing"), - startedAt: DateTime.subtract(task.startedAt!, { hours: 1 }), - }, - }); - - const walked = yield* Effect.exit( - threads.stopDelegatedTasks({ + const rootRefused = yield* threads + .dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-root-above-limit"), threadId: parentThreadId, - commandId: CommandId.make("stop-partial"), + }) + .pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: "approval-required", + interactionMode: "default", + }), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(rootRefused)); + assert.deepEqual((yield* threadState(parentThreadId)).runs, ["starting"]); + const blockedChild = yield* delegate(parentThreadId, "default task"); + const advancedChild = yield* delegate(parentThreadId, "default task that advances"); + const stoppedChild = yield* delegate(parentThreadId, "plan task"); + const blockedGrandchild = yield* delegate(stoppedChild, "broader grandchild"); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("approval-child"), + threadId: stoppedChild, + runtimeMode: "approval-required", + }); + yield* orchestrator.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("plan-child"), + threadId: stoppedChild, + interactionMode: "plan", + }); + const commandId = CommandId.make("stop-partial"); + yield* orchestrator.dispatch({ type: "thread.stop", commandId, threadId: parentThreadId }); + const walked = yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId }).pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: "approval-required", + interactionMode: "plan", }), + Effect.exit, ); assert.isTrue(Exit.isFailure(walked)); - assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(stoppedChild)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(blockedChild)).runs, ["starting"]); + assert.deepEqual((yield* threadState(blockedGrandchild)).runs, ["starting"]); + + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("independent-child-stop"), + threadId: advancedChild, + }); + yield* send(advancedChild, "new work before failed Stop retry", "start_immediately"); + yield* send(stoppedChild, "new child work", "start_immediately"); + const newGrandchild = yield* delegate(stoppedChild, "new grandchild"); + yield* send(parentThreadId, "new parent work", "start_immediately"); + const newChild = yield* delegate(parentThreadId, "new child"); + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId }); + assert.deepEqual((yield* threadState(blockedChild)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(blockedGrandchild)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(stoppedChild)).runs, ["interrupted", "starting"]); + assert.deepEqual((yield* threadState(advancedChild)).runs, ["interrupted", "starting"]); + assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]); + assert.deepEqual((yield* threadState(newGrandchild)).runs, ["starting"]); }).pipe(Effect.provide(layerTest)), ); @@ -1380,3 +1482,119 @@ it.effect.each(["missing session", "completed", "failed", "cancelled"] as const) ); }).pipe(Effect.provide(layerTest)), ); + +it.effect("an MCP Stop replays its original result after the database and services reopen", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-stop-replay-" }); + const database = fileDatabase(path.join(dir, "statev2.sqlite")); + const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("stop-replay"), + requestNamespace: "client:stop-replay", + capabilities: new Set(["orchestration"]), + issuedAt: 1, + thread: undefined, + client: { sessionId: "stop-replay", label: "Stop replay", access: "full-access" }, + }; + const threadId = ThreadId.make("thread:mcp-stop-replay"); + const stop = { threadId, stop: true, clientRequestId: "stop" }; + const first = yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const idle = ThreadId.make("thread:mcp-stop-idle"); + yield* createWatchingThread(idle, 102); + yield* send(idle, "finished work", "start_immediately"); + yield* send(idle, "held queue", "queue_after_active"); + const idleProjection = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(idle); + const idleRun = idleProjection.runs[0]!; + const now = yield* DateTime.now; + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make("event:mcp-stop-idle:completed"), + type: "run.updated", + threadId: idle, + runId: idleRun.id, + occurredAt: now, + payload: { ...idleRun, status: "completed", completedAt: now }, + }, + { + id: EventId.make("event:mcp-stop-idle:held"), + type: "run.updated", + threadId: idle, + runId: idleProjection.runs[1]!.id, + occurredAt: now, + payload: { ...idleProjection.runs[1]!, queueHeld: true }, + }, + ], + }); + assert.deepEqual( + yield* mcp.interruptThread(scope, { + ...stop, + threadId: idle, + clientRequestId: "idle-stop", + }), + { + threadId: idle, + runId: null, + status: "no_active_run", + }, + ); + yield* createWatchingThread(threadId, 101); + const sent = yield* mcp.sendToThread(scope, { + threadId, + message: "first", + clientRequestId: "send", + }); + const childThreadId = yield* delegate(threadId, "original child"); + yield* delegate(childThreadId, "original grandchild"); + const stopped = yield* mcp.interruptThread(scope, stop); + assert.equal(stopped.runId, sent.runId); + yield* send(childThreadId, "child work after parent stopped", "start_immediately"); + const store = yield* EventStore.EventStoreV2; + const childSequence = yield* store.latestSequence({ threadId: childThreadId }); + const noopStop = { ...stop, clientRequestId: "already-stopped-parent" }; + const noopStopped = yield* mcp.interruptThread(scope, noopStop); + assert.deepEqual(noopStopped, { threadId, runId: null, status: "no_active_run" }); + assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "interrupted"]); + const sql = yield* SqlClient.SqlClient; + const [noopReceipt] = yield* sql<{ command_id: string; result_sequence: number }>` + SELECT command_id, result_sequence FROM orchestration_command_receipts + WHERE aggregate_id = ${threadId} AND command_type = 'thread.stop' + ORDER BY result_sequence DESC LIMIT 1 + `; + assert.isAtLeast(noopReceipt!.result_sequence, childSequence); + assert.deepEqual( + yield* store + .readByCommandId({ commandId: CommandId.make(noopReceipt!.command_id) }) + .pipe(Stream.runCollect), + [], + ); + return { sent, stopped, childThreadId, noopStop, noopStopped }; + }).pipe(Effect.provide(layerMcp(database).pipe(Layer.fresh))); + + yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const replay = yield* mcp.sendToThread(scope, { + threadId, + message: "first", + clientRequestId: "send", + }); + assert.equal(replay.runId, first.sent.runId); + assert.equal((yield* orchestrator.getThreadProjection(threadId)).runs.length, 1); + yield* send(threadId, "after restart", "start_immediately"); + const newChild = yield* delegate(threadId, "child after restart"); + yield* send(first.childThreadId, "child work after restart", "start_immediately"); + assert.deepEqual(yield* mcp.interruptThread(scope, first.noopStop), first.noopStopped); + assert.deepEqual((yield* threadState(first.childThreadId)).runs, [ + "interrupted", + "interrupted", + "starting", + ]); + assert.deepEqual(yield* mcp.interruptThread(scope, stop), first.stopped); + assert.deepEqual((yield* threadState(threadId)).runs, ["interrupted", "starting"]); + assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]); + }).pipe(Effect.provide(layerMcp(database).pipe(Layer.fresh))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index e4abdf9d436f0..7511fc7f2d60c 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -96,7 +96,11 @@ const boxThreads = (seen: Ref.Ref) => { { sequence: 1, commandId: command.commandId, - event: { type: "run.updated", runId: boxRun }, + event: { + type: "turn-item.updated", + runId: boxRun, + payload: { type: "run_interrupt_request", providerTurnId: "turn:box" }, + }, }, ], } as never), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a60f7b7c513e5..76822f7ac013f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3049,6 +3049,17 @@ const OrchestrationV2InternalCommand = Schema.Union([ }), ), }), + /** + * Internal so no client can bound a Stop retry to earlier history; client + * Stops use the public `OrchestrationV2Command` variant. + */ + Schema.Struct({ + type: Schema.Literal("thread.stop"), + commandId: CommandId, + threadId: ThreadId, + reason: Schema.optional(Schema.String), + throughSequence: Schema.optional(NonNegativeInt), + }), /** * Internal so no client can mark a request saved without the value being * stored. From ee56407496ec79a27b915918687b743f614b72d5 Mon Sep 17 00:00:00 2001 From: zainmitchell <337498276+zainmitchell@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:17:30 -0500 Subject: [PATCH 13/50] test(contracts): pin that dispatched Stops drop the internal retry bound Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 248791f26bf21f6a22354c87fed2c8cf117291ba) --- packages/contracts/src/rpc.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 718cd3e50f84d..ced58ca806906 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -35,6 +35,28 @@ describe("WebSocket RPC contracts", () => { ).toBe(true); }); + it("drops a client-supplied retry bound from a dispatched Stop", () => { + const dispatchCommand = WsRpcGroup.requests.get(ORCHESTRATION_V2_WS_METHODS.dispatchCommand); + if (dispatchCommand === undefined) throw new Error("dispatchCommand is not registered"); + const decode = Schema.decodeUnknownSync(dispatchCommand.payloadSchema); + + const decoded = decode({ + type: "thread.stop", + commandId: "stop-1", + threadId: "thread-1", + reason: "User stopped the thread.", + throughSequence: 0, + }); + + expect(decoded).not.toHaveProperty("throughSequence"); + expect(decoded).toStrictEqual({ + type: "thread.stop", + commandId: "stop-1", + threadId: "thread-1", + reason: "User stopped the thread.", + }); + }); + it("lets the server pick a new worktree's folder", () => { const createWorktree = WsRpcGroup.requests.get(WS_METHODS.vcsCreateWorktree); if (createWorktree === undefined) throw new Error("vcs.createWorktree is not registered"); From a42e9295d4e4879684d200eec8509f5321aee3e8 Mon Sep 17 00:00:00 2001 From: zainmitchell <337498276+zainmitchell@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:18:52 -0500 Subject: [PATCH 14/50] fix(server): a queue cancelled before it ran no longer steers Stop Stop took the newest non-queued run as the run it reaches. A queued run cancelled before it started is non-queued, so after one: - a delegated Stop retry checked its receipt bound against that cancelled run and skipped the original run, active or finished; - Stop on settled background work marked the cancelled run, leaving the run whose agent owns the work free to start a late watch. Stop and the background-work check in run.interrupt now skip a run that was cancelled before it started. The run.created bound is unchanged, so a run queued before the original Stop still belongs to it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit cc4df41858b478d821ce6fa2539e8a931d99dae8) --- .../src/orchestration-v2/Orchestrator.ts | 13 +- .../src/orchestration-v2/ThreadStop.test.ts | 191 ++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a9e4004c86848..e7776bfa93c35 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -565,6 +565,15 @@ function hasLiveRun(projection: Pick): ); } +/** A queued run cancelled before it started owns no work, so Stop never reaches it. */ +function latestDequeuedRun( + runs: ReadonlyArray, +): OrchestrationV2Run | undefined { + return runs.findLast( + (run) => run.status !== "queued" && !(run.status === "cancelled" && run.startedAt === null), + ); +} + function withPullRequestWatch( link: ThreadPullRequestLink, watch: ThreadPullRequestWatch | undefined, @@ -9528,7 +9537,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? undefined : projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId); const hasBackgroundWork = - run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id && + run?.id === latestDequeuedRun(projection.runs)?.id && derivePendingBackgroundWork({ latestRun: run, providerThreads: projection.providerThreads, @@ -10001,7 +10010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); if (projection.thread.deletedAt !== null) return undefined; - const latestRun = projection.runs.findLast((run) => run.status !== "queued"); + const latestRun = latestDequeuedRun(projection.runs); if (command.throughSequence !== undefined && latestRun !== undefined) { // A queued run already belonged to Stop even if it started before the delegated walk. const existed = yield* eventSink diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 38325b6628a05..c5cbcb9d614ce 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1164,6 +1164,197 @@ it.effect("a partial delegated Stop retries only the original children that fail }).pipe(Effect.provide(layerTest)), ); +it.effect.each([ + { + name: "an active run past a queue cancelled since", + afterStop: ["queue", "cancel"], + expected: { runs: ["interrupted", "cancelled"], watched: [] }, + }, + { + name: "a finished run past a queue cancelled since", + afterStop: ["queue", "cancel", "complete"], + expected: { runs: ["completed", "cancelled"], watched: [] }, + }, + { + name: "a run queued before Stop that started since", + beforeStop: ["queue"], + afterStop: ["complete", "resume"], + expected: { runs: ["completed", "interrupted"], watched: [] }, + }, + { + name: "a run queued before Stop that started after newer work finished", + beforeStop: ["queue"], + afterStop: ["complete", "start", "complete", "resume"], + expected: { runs: ["completed", "completed", "interrupted"], watched: [] }, + }, + { + // Partial retries keep queue/watch changes thread-wide while the original run is current. + name: "the original run and the queue and watch added since", + afterStop: ["queue", "watch"], + expected: { runs: ["interrupted", "queued:held"], watched: [] }, + }, + { + name: "nothing when only newer work started", + afterStop: ["complete", "start"], + expected: { runs: ["completed", "starting"], watched: [40] }, + }, + { + name: "nothing when newer work finished past a queue cancelled since", + afterStop: ["complete", "start", "queue", "cancel", "complete"], + expected: { runs: ["completed", "completed", "cancelled"], watched: [40] }, + }, +] as const)("a Stop retry reaches $name", ({ beforeStop = [], afterStop, expected }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make( + `thread:stop-retry-bound:${afterStop.join(",")}:${beforeStop.join(",")}`, + ); + let step = 0; + const apply = (action: (typeof beforeStop)[number] | (typeof afterStop)[number]) => + Effect.gen(function* () { + step++; + if (action === "queue") + return yield* send(threadId, `queued ${step}`, "queue_after_active"); + if (action === "start") return yield* send(threadId, `new ${step}`, "start_immediately"); + if (action === "watch") return yield* watch(threadId, 41); + const runs = (yield* orchestrator.getThreadProjection(threadId)).runs; + if (action === "resume") + return yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`resume:${step}`), + threadId, + }); + if (action === "cancel") + return yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`cancel:${step}`), + threadId, + runId: runs.findLast((run) => run.status === "queued")!.id, + }); + const now = yield* DateTime.now; + const run = runs.findLast((candidate) => candidate.status === "starting")!; + yield* projections.apply({ + id: EventId.make(`event:stop-retry-bound:${step}`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", startedAt: now, completedAt: now }, + }); + }); + yield* createWatchingThread(threadId, 40); + yield* send(threadId, "original", "start_immediately"); + for (const action of beforeStop) yield* apply(action); + // The original Stop's receipt bound; its walk failed before reaching this thread. + const throughSequence = yield* orchestrator.getThreadEventSequence(threadId); + for (const action of afterStop) yield* apply(action); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("retry-stop"), + threadId, + throughSequence, + }); + assert.deepEqual(yield* threadState(threadId), { + runs: [...expected.runs], + watched: [...expected.watched], + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("thread.stop interrupts background work past a queue cancelled before it ran", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:stop-background-cancelled-queue"); + yield* createWatchingThread(threadId, 42); + yield* send(threadId, "work", "start_immediately"); + yield* send(threadId, "queued", "queue_after_active"); + const [run, queued] = (yield* orchestrator.getThreadProjection(threadId)).runs; + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel-before-background"), + threadId, + runId: queued!.id, + }); + const now = yield* DateTime.now; + const providerTurnId = ProviderTurnId.make("provider-turn:stop-background"); + yield* projections.apply({ + id: EventId.make("event:stop-background:turn"), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId: run!.providerThreadId!, + nodeId: run!.rootNodeId!, + runAttemptId: run!.activeAttemptId!, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + }); + const backgroundId = TurnItemId.make("item:stop-background"); + yield* projections.apply({ + id: EventId.make("event:stop-background:item"), + type: "turn-item.updated", + threadId, + runId: run!.id, + occurredAt: now, + payload: { + id: backgroundId, + threadId, + runId: run!.id, + nodeId: run!.rootNodeId!, + providerThreadId: run!.providerThreadId!, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: "Background command", + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: "test background work", + }, + }); + yield* projections.apply({ + id: EventId.make("event:stop-background:completed"), + type: "run.updated", + threadId, + runId: run!.id, + occurredAt: now, + payload: { ...run!, status: "completed", startedAt: now, completedAt: now }, + }); + + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-background"), + threadId, + }); + const stopped = yield* orchestrator.getThreadProjection(threadId); + assert.notInclude( + ["pending", "running", "waiting"], + stopped.turnItems.find((item) => item.id === backgroundId)?.status, + ); + assert.deepEqual( + stopped.turnItems + .filter((item) => item.type === "run_interrupt_request") + .map((item) => item.runId), + [run!.id], + ); + assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(threadId, 43)))); + assert.deepEqual(yield* threadState(threadId), { + runs: ["completed", "cancelled"], + watched: [], + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect("thread.stop marks a turn it cannot interrupt so a late agent watch is refused", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 65f0893b471482190404b51b6f6d46be3d60b483 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 09:21:25 +0000 Subject: [PATCH 15/50] fix(server): reject local secret refs in remote schedules A secretRef names a secret stored in this environment and works once, so a schedule or schedule update forwarded to a linked environment that carries one is refused here before anything is sent. The scheduler tool guidance says so, and the linked-environments guidance no longer claims delegate_task takes an environmentId. Upstream's dedupe and filter guidance is kept. (cherry picked from commit 8845874ac with conflicts resolved against upstream's scheduler descriptions and the original forwarding layer) --- .../src/mcp/toolkits/orchestrator/tools.ts | 6 +-- apps/server/src/peer/PeerForwarding.test.ts | 38 +++++++++++++++++++ apps/server/src/peer/PeerForwarding.ts | 18 +++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 51896525c77fb..3d1e74357f93d 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -126,7 +126,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10โ€“3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10โ€“3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. Secret references work only in this environment; to configure a signed webhook in a linked environment, use a thread there. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -153,7 +153,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10โ€“3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10โ€“3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. To set or replace signature.secretRef in a linked environment, use a thread there; secret references work only in this environment. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -286,7 +286,7 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { const EnvironmentLinksTool = Tool.make("t3_environment_links", { description: - "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to any tool that takes environmentId, such as t3_thread_launch, t3_thread_send, t3_pending_request_respond, t3_queue_list or t3_project_list, to act there; delegate_task takes it as target.environmentId. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", + "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to any tool that takes environmentId, such as t3_thread_launch, t3_thread_send, t3_pending_request_respond, t3_queue_list or t3_project_list, to act there. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", success: OrchestratorMcpEnvironmentLinksResult, failure: OrchestratorMcpFailure, failureMode: "return", diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 7511fc7f2d60c..8a9d1d0cda6f2 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -576,6 +576,44 @@ it.effect("refuses what a linked environment cannot take before anything leaves" yield* linkTo(a.links, b, "auto"); yield* Ref.set(b.bearers, []); + const probes = yield* Ref.get(b.probes); + for (const name of ["schedule_task", "update_scheduled_task"]) { + const args = + name === "schedule_task" + ? { projectId: boxProject, prompt: "Handle {{body.action}}." } + : { scheduledTaskId: "scheduled-task:box" }; + const signed = yield* a.call("thread:laptop-full", name, { + ...args, + environmentId: box.environmentId, + schedule: { + type: "webhook", + signature: { + header: "x-hub-signature-256", + encoding: "hex", + prefix: "sha256=", + secretRef: "secret:local-only", + }, + }, + }); + expect(yield* Ref.get(b.probes)).toBe(probes); + expect(yield* Ref.get(b.bearers)).toEqual([]); + expect(failureOf(signed)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("signature.secretRef"), + }); + expect(failureOf(signed).message).toContain("destination environment"); + + const ordinary = yield* a.call("thread:laptop-full", name, { + ...args, + environmentId: EnvironmentId.make("environment-elsewhere"), + schedule: { type: "interval", everyMs: 3_600_000 }, + }); + expect(failureOf(ordinary)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("not linked"), + }); + } + const unknown = yield* a.call("thread:laptop-full", "t3_thread_read", { environmentId: EnvironmentId.make("environment-elsewhere"), threadId: boxThread, diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 09963c76c6417..634b1e8566dcd 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -29,6 +29,14 @@ export const FORWARDED_WAIT_CHUNK_MS = 50_000; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; +const hasScheduleSecretRef = Schema.is( + Schema.Struct({ + schedule: Schema.Struct({ + signature: Schema.Struct({ secretRef: Schema.String }), + }), + }), +); + type Scope = McpInvocationContext.McpInvocationScope; /** @@ -117,6 +125,16 @@ const make = Effect.gen(function* () { message: "A launch in a linked environment cannot carry attachments yet.", }); } + if ( + (toolName === "schedule_task" || toolName === "update_scheduled_task") && + hasScheduleSecretRef(args) + ) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "A schedule in a linked environment cannot use signature.secretRef: secret references are local to this server and work once. Configure the webhook signing secret from a thread in the destination environment instead.", + }); + } if (typeof args.clientRequestId === "string") { args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); } From 820e789c3d08b51612a1f244bd829561c8808bbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 22:09:34 -0500 Subject: [PATCH 16/50] fix(server): preserve forwarded tool validation and codecs `t3_thread_update` built its linked-environment parameters by spreading the metadata input's fields, which dropped its struct-level checks: an invalid action combination was forwarded and ran in the linked environment. Its parameters now keep those checks, so the call is refused here before anything leaves. Forwarded parameters and results also go through the tools' JSON codecs. Tests port to the current seams: upstream deleted tools.test.ts, so the validation case now runs through PeerForwarding's real forwarding path. The replacement's testkit and Stop-test parts are covered elsewhere in this stack. (cherry picked from commit 001ecbed8a0214b4d3788563a3a3d01488841bbc) --- .../src/mcp/toolkits/orchestrator/tools.ts | 8 ++-- apps/server/src/peer/PeerForwarding.test.ts | 44 ++++++++++++++++++- apps/server/src/peer/PeerForwarding.ts | 9 ++-- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 3d1e74357f93d..35e44a4541239 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -232,10 +232,10 @@ const ThreadReadTool = Tool.make("t3_thread_read", { const ThreadUpdateTool = Tool.make("t3_thread_update", { description: "Update metadata for a thread. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", - parameters: Schema.Struct({ - environmentId: OrchestratorMcpEnvironmentTarget, - ...ThreadMetadataMcpUpdateInput.fields, - }), + parameters: ThreadMetadataMcpUpdateInput.mapFields( + (fields) => ({ environmentId: OrchestratorMcpEnvironmentTarget, ...fields }), + { unsafePreserveChecks: true }, + ), success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, failureMode: "return", diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 8a9d1d0cda6f2..5ad0180dc3e6f 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, type OrchestrationV2Run, type SourceControlCloneRepositoryInput, + type ModelSelection, ProjectId, ProviderInstanceId, RunId, @@ -53,6 +54,7 @@ interface Seen { readonly clones: ReadonlyArray; /** Commands dispatched and delegated-task stops, as ` `. */ readonly stops: ReadonlyArray; + readonly selections: ReadonlyArray; } const nothingSeen: Seen = { sends: [], @@ -61,6 +63,7 @@ const nothingSeen: Seen = { projects: [], clones: [], stops: [], + selections: [], }; /** @@ -68,7 +71,15 @@ const nothingSeen: Seen = { * third wait. Every send and wait is recorded. */ const boxThreads = (seen: Ref.Ref) => { - const shell = { ...liveThreadShell(boxThread, { runtimeMode: "auto" }), projectId: boxProject }; + const shell = { + ...liveThreadShell(boxThread, { runtimeMode: "auto" }), + projectId: boxProject, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + options: [{ id: "reasoning", value: "high" }], + }, + }; const run = (status: OrchestrationV2Run["status"]) => ({ id: boxRun, status }) as unknown as OrchestrationV2Run; const projection = { thread: shell, runs: [run("running")], runtimeRequests: [] } as never; @@ -84,6 +95,10 @@ const boxThreads = (seen: Ref.Ref) => { dispatch: (command) => Ref.update(seen, (current) => ({ ...current, + selections: + command.type === "thread.model-selection.set" + ? [...current.selections, command.modelSelection] + : current.selections, stops: [ ...current.stops, `${command.type} ${"threadId" in command ? command.threadId : ""}`, @@ -555,6 +570,33 @@ it.effect("runs any tool that takes a linked environment there", () => expect(archived.isError).toBe(false); expect((yield* Ref.get(seen)).stops).toEqual([`thread.archive ${boxThread}`]); + const selection = { + instanceId: "codex", + model: "gpt-5", + options: [{ id: "reasoning", value: "high" }], + }; + const configured = yield* a.call("thread:laptop-full", "t3_thread_configure", { + environmentId: box.environmentId, + threadId: boxThread, + modelSelection: { provider: "codex", model: "gpt-5", options: { reasoning: "high" } }, + }); + expect(configured.isError).toBe(false); + expect((yield* Ref.get(seen)).selections).toEqual([selection]); + expect(there.structuredContent).toHaveProperty("modelSelection", selection); + + // The tool's own checks hold here, before anything leaves. + yield* Ref.set(b.bearers, []); + const mismatched = yield* a + .call("thread:laptop-full", "t3_thread_update", { + environmentId: box.environmentId, + threadId: boxThread, + action: "regenerate_title", + title: "Requested title", + }) + .pipe(Effect.flip); + expect(mismatched.message).toContain("regenerate_title does not accept title"); + expect(yield* Ref.get(b.bearers)).toEqual([]); + // This environment's own id keeps the call here. const own = yield* a.call("thread:laptop-full", "t3_thread_configuration", { environmentId: laptop.environmentId, diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 634b1e8566dcd..5b097068d04e2 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -114,9 +114,10 @@ const make = Effect.gen(function* () { params: unknown, ) { const limits = yield* callerLimits(scope); - const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( - params, - ).pipe(Effect.mapError(unavailable))) as Record; + const jsonParameters = Schema.toCodecJson(parameters as Schema.Encoder); + const encoded = (yield* Schema.encodeUnknownEffect(jsonParameters)(params).pipe( + Effect.mapError(unavailable), + )) as Record; const { environmentId: _target, ...args } = encoded; // A pending upload lives here, so it cannot go with the call. if (Array.isArray(args.attachments) && args.attachments.length > 0) { @@ -143,7 +144,7 @@ const make = Effect.gen(function* () { tool: toolName, arguments: args, limits, - success: success as Schema.Decoder, + success: Schema.toCodecJson(success as Schema.Decoder), }); }); From d868b6296c8a24bbc150ebb4d96d264316206099 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:53:39 -0700 Subject: [PATCH 17/50] feat(server): /mcp narrows a client's modes from T3-Mode-Limit An OAuth client on /mcp is capped by the access it was approved with. When another environment calls on behalf of one of its agents, that agent may run under narrower modes, and the target has no way to know. /mcp now reads `T3-Mode-Limit: /` from OAuth clients and caps the request at the narrower of the two. The header can only narrow: a broader one changes nothing, and a malformed one is refused with 400. The client's interaction mode can now be capped too; before, it was always default. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.test.ts | 145 ++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 35 ++++- apps/server/src/mcp/McpInvocationContext.ts | 54 ++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 7 +- apps/server/src/mcp/threadAccess.ts | 5 +- 5 files changed, 232 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 85ccc3ab926e4..497a28f7c666e 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,5 +1,6 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -8,6 +9,8 @@ import { OrchestratorMcpFailure, PreviewTabId, ProviderInstanceId, + ProviderInteractionMode, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -809,3 +812,145 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect( + "narrows an OAuth client's modes to the T3-Mode-Limit it sends, and never widens them", + () => + Effect.scoped( + Effect.gen(function* () { + const clientToken = "client-payload.client-signature"; + const clientScope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "client:linked", + thread: undefined, + client: { sessionId: "linked", label: "T3 Code ยท laptop", access: "auto" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + // Starts a thread at the modes the gate hands it, so the outcome is those modes. + const StartsToolkit = Toolkit.make( + Tool.make("starts", { + description: "Reports the modes a started thread would run with.", + parameters: Schema.Struct({ + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), + success: Schema.Struct({ modes: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], + }), + ); + const serverLayer = McpHttpServer.toolkitRegistration( + StartsToolkit, + McpToolAccess.toLayer(StartsToolkit, { + starts: McpToolAccess.startsThreads( + (input) => input, + (_, modes) => + Effect.succeed({ modes: `${modes.runtimeMode}/${modes.interactionMode}` }), + ), + }), + ).pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide( + Layer.succeed(McpHttpServer.McpClientAuthenticator, { + authenticate: (request) => + Effect.succeed( + request.headers.authorization === `Bearer ${clientToken}` + ? clientScope + : undefined, + ), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Struct({ text: Schema.String })), + }), + }), + ), + ); + const decodeText = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([ + Schema.Struct({ modes: Schema.String }), + Schema.Struct({ code: Schema.String }), + ]), + ), + ); + /** Starts a thread as the client, with `limit` as T3-Mode-Limit; the HTTP status or the outcome. */ + const start = (limit: string | undefined, args: Record = {}) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${clientToken}`, + ...(limit === undefined ? {} : { "t3-mode-limit": limit }), + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + if (initialize.status !== 200) return initialize.status; + const response = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "starts", arguments: args }, + }), + "application/json", + ), + }); + const body = yield* decodeOutcome(yield* response.text); + const outcome = yield* decodeText( + body.result.content.map((part) => part.text).join(""), + ); + return "code" in outcome ? outcome.code : outcome.modes; + }); + + // Approved for auto: without the header the client keeps its ceiling. + expect(yield* start(undefined)).toBe("auto/default"); + // The header narrows both modes, and asking above it is refused. + expect(yield* start("approval-required/plan")).toBe("approval-required/plan"); + expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe( + "runtime_mode_escalation_denied", + ); + expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe( + "interaction_mode_escalation_denied", + ); + // A broader header changes nothing: the approved ceiling still holds. + expect(yield* start("full-access/default")).toBe("auto/default"); + expect(yield* start("full-access/default", { runtimeMode: "full-access" })).toBe( + "runtime_mode_escalation_denied", + ); + // A malformed header is refused before anything runs. + expect(yield* start("auto")).toBe(400); + expect(yield* start("auto/default/extra")).toBe(400); + expect(yield* start("root/default")).toBe(400); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5c6c4fece44bb..b6b22bcb7c303 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -16,7 +16,9 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { OrchestratorMcpFailure, PreviewAutomationError, + ProviderInteractionMode, type RunId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -153,6 +155,35 @@ const normalizeMcpHttpResponse = ( // Session tokens are `.`; registry tokens are a bare base64url secret. const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes("."); +const decodeModeLimit = Schema.decodeUnknownOption( + Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode }), +); +const parseModeLimit = (value: string) => { + const [runtimeMode, interactionMode, ...rest] = value.trim().split("/"); + return rest.length === 0 ? decodeModeLimit({ runtimeMode, interactionMode }) : Option.none(); +}; + +const invalidModeLimit = HttpServerResponse.jsonUnsafe( + { + error: "invalid_mode_limit", + message: "T3-Mode-Limit must be /, e.g. auto/default.", + }, + { status: 400, headers: { "cache-control": "no-store" } }, +); + +/** Applies a client's requested mode limit; undefined when the header is malformed. */ +const withModeLimit = ( + invocation: McpInvocationContext.McpInvocationScope, + request: HttpServerRequest.HttpServerRequest, +): McpInvocationContext.McpInvocationScope | undefined => { + const header = request.headers[McpInvocationContext.MODE_LIMIT_HEADER]; + if (header === undefined || invocation.client === undefined) return invocation; + const limit = parseModeLimit(header); + return Option.isNone(limit) + ? undefined + : { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } }; +}; + const makeMcpAuthMiddleware = Effect.gen(function* () { const registry = yield* McpSessionRegistry.McpSessionRegistry; const clients = yield* Effect.serviceOption(McpClientAuthenticator); @@ -178,8 +209,10 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token), }); } + const limited = withModeLimit(invocation, request); + if (limited === undefined) return invalidModeLimit; return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, limited), Effect.map(normalizeMcpHttpResponse), ); }) satisfies McpAuthMiddleware; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03c..06b22f1d726dc 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -5,6 +5,7 @@ import { OrchestratorMcpFailure, PreviewAutomationUnavailableError, type ProviderInstanceId, + type ProviderInteractionMode, type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; @@ -30,14 +31,59 @@ export interface McpClientCaller { readonly sessionId: string; readonly label: string; readonly access: AuthMcpClientAccess; + /** + * Narrower modes the client asked for on this request (`T3-Mode-Limit`): + * another environment passing on the limits of the agent it calls for. + */ + readonly narrowedTo?: ClientModes | undefined; } /** - * A read-only client never reaches a write (`McpToolAccess` refuses it first), - * so it maps to the lowest mode rather than to nothing. + * `T3-Mode-Limit: /` lets an OAuth client cap + * a request below what it was approved with: another environment passes on + * the limits of the agent it calls for. It can only narrow (see + * `clientModeCeiling`), so a client gains nothing by sending a broad one. */ -export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => - client === undefined || client.access === "read-only" ? "approval-required" : client.access; +export const MODE_LIMIT_HEADER = "t3-mode-limit"; + +export interface ClientModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +const runtimeModeRank: Record = { + "approval-required": 0, + "auto-accept-edits": 1, + auto: 2, + "full-access": 3, +}; +const interactionModeRank: Record = { plan: 0, default: 1 }; + +/** + * The modes a client caller's writes are capped at: what it was approved + * with, narrowed by what it asked for on this request. A read-only client + * never reaches a write (`McpToolAccess` refuses it first), so it maps to the + * lowest runtime mode rather than to nothing. + */ +export const clientModeCeiling = (client: McpClientCaller | undefined): ClientModes => { + const approved: ClientModes = { + runtimeMode: + client === undefined || client.access === "read-only" ? "approval-required" : client.access, + interactionMode: "default", + }; + const asked = client?.narrowedTo; + if (asked === undefined) return approved; + return { + runtimeMode: + runtimeModeRank[asked.runtimeMode] < runtimeModeRank[approved.runtimeMode] + ? asked.runtimeMode + : approved.runtimeMode, + interactionMode: + interactionModeRank[asked.interactionMode] < interactionModeRank[approved.interactionMode] + ? asked.interactionMode + : approved.interactionMode, + }; +}; /** * A thread caller's omitted target falls back to its own thread; a client diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index edaa8777b422e..df2ace9501ef7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -104,7 +104,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { - clientRuntimeModeCeiling, + clientModeCeiling, type McpInvocationScope, type McpThreadInvocationScope, requireThreadScope, @@ -1158,10 +1158,7 @@ const make = Effect.gen(function* () { if (scope.thread === undefined) { return { parent: undefined, - limits: { - runtimeMode: clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, + limits: clientModeCeiling(scope.client), } as const; } const parent = yield* loadProjection(scope.thread.threadId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ba8dc4c3e5765..9cc010a02cb88 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -68,10 +68,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { scope, threads, caller: undefined, - limits: { - runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - }, + limits: McpInvocationContext.clientModeCeiling(scope.client), } satisfies Caller; } const caller = yield* threads From 1aa278ba2891974d3f1afd8ef83e16d0b55ad5cf Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:24:48 -0700 Subject: [PATCH 18/50] feat(server): t3_thread_launch takes clientRequestId A launch had no retry key, so an agent (or another environment calling on its behalf) that lost the response could only guess whether the thread was created, and a retry started a second one. t3_thread_launch now takes an optional clientRequestId. The command, thread and message ids derive from the caller and the key, and the launch service already replays a command it accepted, so a retry returns the first thread. Without a key every launch is new, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/project/handlers.test.ts | 91 ++++++++++++++++++- .../src/mcp/toolkits/project/handlers.ts | 31 ++++++- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../ThreadLaunchService.test.ts | 34 +++++++ .../orchestration-v2/ThreadLaunchService.ts | 17 +++- packages/contracts/src/orchestratorMcp.ts | 2 +- .../src/server/orchestrationInstructions.ts | 2 +- 7 files changed, 169 insertions(+), 12 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 4c22393bf4761..7819574bffce7 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -6,6 +6,7 @@ import { type Project as ProjectRecord, type ModelSelection, type RuntimeMode, + type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -43,6 +44,10 @@ const clientLaunchHarness = (input: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus", }; + // The project's own default model, which a test can clear between launches. + const projectDefaults: { model: ModelSelection | null } = { + model: input.overrideModel === undefined ? modelSelection : null, + }; const layerDependencies = Layer.mergeAll( ServerSettings.layerTest({ ...(input.defaultRuntimeMode === undefined @@ -70,7 +75,22 @@ const clientLaunchHarness = (input: { issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), - Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ThreadManagement.ThreadManagementService)({ + // A thread exists once a launch started it. + getThreadShell: (threadId) => { + const launch = input.launched.find((candidate) => candidate.threadId === threadId); + return Effect.succeed( + launch === undefined + ? null + : ({ + id: launch.threadId, + projectId: launch.projectId, + modelSelection: launch.modelSelection, + deletedAt: null, + } as unknown as OrchestrationV2ThreadShell), + ); + }, + }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void), launch: (launch) => { @@ -96,7 +116,7 @@ const clientLaunchHarness = (input: { ? Option.some({ id, workspaceRoot: input.workspaceRoot ?? "/projects/client-target", - defaultModelSelection: input.overrideModel === undefined ? modelSelection : null, + defaultModelSelection: projectDefaults.model, } as unknown as ProjectRecord) : Option.none(), ), @@ -113,7 +133,7 @@ const clientLaunchHarness = (input: { ), Layer.provideMerge(NodeServices.layer), ); - return { projectId, modelSelection, dependencies: layerDependencies }; + return { projectId, modelSelection, projectDefaults, dependencies: layerDependencies }; }; it.effect( @@ -197,6 +217,71 @@ it.effect( }), ); +it.effect("a launch retried with the same clientRequestId replays the first one", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = (clientRequestId: string | undefined) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + message: "Fix the bug", + ...(clientRequestId === undefined ? {} : { clientRequestId }), + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + yield* launch("fix-1"); + yield* launch("fix-1"); + yield* launch("fix-2"); + yield* launch(undefined); + yield* launch(undefined); + const ids = launched.map((input) => [input.commandId, input.threadId]); + // The same key sends the same command and thread ids, which the launch + // service replays instead of starting a second thread. + expect(ids[0]).toEqual(ids[1]); + expect(ids[0]?.[0]).toContain("client%3Asession-1"); + expect(ids[2]).not.toEqual(ids[0]); + // Without a key every launch is new. + expect(ids[3]).not.toEqual(ids[4]); + }), +); + +it.effect("a retried launch replays after the project loses its default model", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, modelSelection, projectDefaults, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = toolkit + .handle("t3_thread_launch", { title: "Fix", projectId, clientRequestId: "fix-1" }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + yield* launch; + projectDefaults.model = null; + const retried = (yield* launch).at(-1)?.result; + expect(retried).toMatchObject({ projectId, modelSelection }); + expect(launched.map((input) => input.threadId)).toEqual([ + launched[0]?.threadId, + launched[0]?.threadId, + ]); + }), +); + it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 380a1b52ec963..44efc66971148 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,4 +1,10 @@ -import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + ThreadId, + OrchestratorMcpFailure, + ProjectId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -59,8 +65,16 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller } = context; - const commandId = yield* newCommandId(); + const { caller, scope } = context; + // A retry with the same key replays the first launch: the ids derive + // from the caller and the key, and the launch service replays a + // command it already accepted. + const commandId = + input.clientRequestId === undefined + ? yield* newCommandId() + : CommandId.make( + `mcp:launch:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(input.clientRequestId)}`, + ); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); const attachments = input.attachments ?? []; @@ -125,11 +139,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }), ), ); + // A retry of an accepted launch keeps the model it started with, even if + // the project's defaults changed since, and the launch service replays it. + const launched = + input.clientRequestId === undefined + ? null + : yield* context.threads.getThreadShell(threadId).pipe(Effect.mapError(unavailable)); + const launchedModel = + launched === null || launched.deletedAt !== null ? undefined : launched.modelSelection; // A caller outside a thread has nothing to inherit, so it starts with the // project's effective defaults, as the composer does. Its mode stays // within the ceiling it was approved with. const projectDefaults = - caller === undefined + caller === undefined && launchedModel === undefined ? yield* ServerSettings.ServerSettingsService.pipe( Effect.flatMap((settings) => settings.getSettings), Effect.mapError(unavailable), @@ -143,6 +165,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { : undefined; const modelSelection = input.modelSelection ?? + launchedModel ?? caller?.modelSelection ?? projectDefaults?.defaultModelSelection ?? undefined; diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 2114565d8201e..1634f98a6e4f9 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -13,6 +13,7 @@ import { ProjectCreatePayload, ProjectUpdatePayload, ProjectId, + OrchestratorMcpClientRequestId, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -104,7 +105,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. Without a key, after errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -130,6 +131,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), ), attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }), success: Schema.Struct({ threadId: ThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 0391412fe5f9b..f7208eb3452dc 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1423,6 +1423,40 @@ it.effect("replays an accepted launch after the project disables the instance", }).pipe(Effect.provide(harness.layer)); }); +it.effect("an agent's launch never starts work above the modes it asks for", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + // The first launch, at full access, creates the thread without a message. + const created = yield* launches.launch( + launchInput({ command: "command:launch:narrowed-retry", thread: "thread:narrowed" }), + ); + // The same key comes back from a narrower agent, now carrying a message, + // as a retry or a concurrent launch that lost the create would. + const failed = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:narrowed-retry", + thread: "thread:narrowed", + message: "Run this", + }), + runtimeMode: "approval-required", + interactionMode: "plan", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.flip); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } + assert.equal(failed.operation, "dispatch-message"); + const projection = yield* threads.getThreadProjection(created.threadId); + assert.equal(projection.runs.length, 0); + assert.equal(projection.messages.length, 0); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index f1c3d432a10be..d1d00777c2d52 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -49,6 +49,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type * as Orchestrator from "./Orchestrator.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; export type ThreadLaunchWorkspaceStrategy = @@ -940,7 +941,6 @@ const make = Effect.gen(function* () { if (project.id !== input.projectId) { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } - let runId: RunId | null = null; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { @@ -973,7 +973,20 @@ const make = Effect.gen(function* () { createdBy: input.createdBy, creationSource: input.creationSource, }) - .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); + .pipe( + // An agent's launch may land on a thread a replay, or a + // concurrent launch with the same key, created with broader modes + // than it asks for. The orchestrator refuses the message under + // the thread's lock. The user's own clients launch into their + // own drafts, whatever modes those hold. + input.createdBy === "agent" + ? Effect.provideService(DispatchModeLimit, { + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }) + : (effect) => effect, + Effect.mapError(mapError(input, "dispatch-message", threadId)), + ); const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index c962c3975c17d..d37b88fde9333 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -46,7 +46,7 @@ const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120 const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ description: "Optional concise display title.", }); -const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( +export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( Schema.isMaxLength(256), ).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 88053dee9b546..2bb2b2b0ec6e5 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -28,7 +28,7 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO \`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. -\`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist.`, +Pass a \`clientRequestId\` to \`t3_thread_launch\` and reuse it when retrying: the same key returns the thread the first call created. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance.`, `Tool names may include a harness-normalized MCP prefix, such as \`mcp__t3_code__delegate_task\`; the semantics are the same. Some harnesses attach optional MCP servers lazily: if an initial tool-catalog scan does not show T3 tools, do not conclude that cross-provider delegation is unavailable. Make one bounded direct attempt using the known T3 tool name on the next tool step. In Codex code mode, for example, call \`tools.mcp__t3_code__orchestrator_capabilities({})\` before reporting that the capability is absent. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying tools that accept them. ACP fallback: some ACP agents accept the injected MCP server but fail to expose its tools. When the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call the same tools through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset when T3 runs as a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`. This is the supported T3 transport fallback, not an ordinary shell-based substitute for delegation. From 16800ae68138cd7043505e35a84f9f4d88f79c4e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:23:30 -0700 Subject: [PATCH 19/50] feat(server): link to another environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An environment can now sign in to another one's /mcp as an outside agent, with a pairing code from that environment, without a browser. It drives the peer's existing MCP OAuth flow server-side, keeps the token in the server secret store and the rest in peer_environment_links (migration 059). The peer lists the link as "T3 Code ยท , E | OrchestratorMcpFailure, R>; + /** `t3_thread_wait` there, in pieces short enough to survive any route. */ + readonly waitForThread: ( + scope: Scope, + environmentId: EnvironmentId, + input: OrchestratorMcpThreadWaitInput, + ) => Effect.Effect; + /** The environments this one links to, as an agent sees them. */ + readonly links: ( + scope: Scope, + ) => Effect.Effect; + } +>()("t3/peer/PeerForwarding") {} + +/** The linked environment a call names, or nothing when it names this one. */ +export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) => + environmentId === scope.environmentId ? undefined : environmentId; + +const make = Effect.gen(function* () { + const peers = yield* PeerMcpClient.PeerMcpClient; + const links = yield* PeerLinks.PeerLinks; + const crypto = yield* Crypto.Crypto; + const threads = yield* ThreadManagement.ThreadManagementService; + + /** + * The caller's own limits here, which travel with every call it forwards. + * The peer sees only the link's credential, so a credential that may not + * control threads here is refused here. + */ + const callerLimits = (scope: Scope) => + readCaller().pipe( + Effect.map((caller) => caller.limits), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(ThreadManagement.ThreadManagementService, threads), + ); + + /** + * Every caller here reaches the peer as the one link session, so a retry + * key is scoped to its caller before it leaves: two agents here reusing a + * key never collide there, and one agent retrying gets its first result. + */ + const scopedRequestId = (namespace: string, clientRequestId: string) => + crypto.digest("SHA-256", new TextEncoder().encode(`${namespace}\n${clientRequestId}`)).pipe( + Effect.map((digest) => `fwd:${Hex.encode(digest)}`), + Effect.orDie, + ); + + /** Tool and contract schemas are plain data: they encode and decode without services. */ + const forward = ( + scope: Scope, + toolName: string, + parameters: Schema.Top, + success: Schema.Top, + environmentId: EnvironmentId, + params: unknown, + ) => + Effect.gen(function* () { + const limits = yield* callerLimits(scope); + const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( + params, + ).pipe(Effect.mapError(unavailable))) as Record; + const { environmentId: _target, ...args } = encoded; + // A pending upload lives here, so it cannot go with the call. + if (Array.isArray(args.attachments) && args.attachments.length > 0) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A launch in a linked environment cannot carry attachments yet.", + }); + } + if (typeof args.clientRequestId === "string") { + args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); + } + return yield* peers.call({ + environmentId, + tool: toolName, + arguments: args, + limits, + success: success as Schema.Decoder, + }); + }); + + const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => + forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); + + const route: PeerForwarding["Service"]["route"] = (scope, tool, params, here) => { + const target = remoteTarget(scope, params.environmentId); + return target === undefined ? here : call(scope, tool, target, params); + }; + + const waitForThread: PeerForwarding["Service"]["waitForThread"] = (scope, environmentId, input) => + Effect.gen(function* () { + let remaining = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + let runId = input.runId; + while (true) { + const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); + const result = yield* forward( + scope, + "t3_thread_wait", + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, + environmentId, + { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk }, + ); + remaining -= chunk; + if (!result.timedOut || remaining <= 0) return result; + // Later pieces wait for the run the first one picked, not a newer one. + runId = result.runId ?? undefined; + } + }); + + const listLinks: PeerForwarding["Service"]["links"] = (scope) => + Effect.gen(function* () { + yield* callerLimits(scope); + const listed = yield* links.list.pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), + ), + ); + return { + environmentId: scope.environmentId, + links: listed.map((link) => ({ + environmentId: link.environmentId, + label: link.label, + status: link.status, + access: link.access, + expiresAt: DateTime.formatIso(link.expiresAt), + lastError: link.lastError, + })), + }; + }); + + return PeerForwarding.of({ call, route, waitForThread, links: listLinks }); +}); + +export const layer = Layer.effect(PeerForwarding, make); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index c1d3b9e460fec..00452fec1f4ce 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,11 +1,5 @@ import { NodeHttpServer } from "@effect/platform-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - EnvironmentHttpApi, - EnvironmentId, - type ExecutionEnvironmentDescriptor, - OrchestratorMcpFailure, -} from "@t3tools/contracts"; +import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -19,46 +13,23 @@ import { Tool, Toolkit } from "effect/ai"; import { FetchHttpClient, HttpClient, - HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse, } from "effect/http"; -import * as HttpApi from "effect/http-api/HttpApi"; -import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import * as AuthHttp from "../auth/http.ts"; -import * as McpOAuth from "../auth/McpOAuth.ts"; -import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as ServerHttp from "../http.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; -import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; -import * as Sqlite from "../persistence/Sqlite.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import { + descriptorOf, + layerLinkingEnvironment, + servePeer, + type ServedPeer, +} from "./PeerLinks.testkit.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; -class PeerTestApi extends HttpApi.make("environment") - .add(EnvironmentHttpApi.groups.metadata) - .add(EnvironmentHttpApi.groups.mcpOAuth) {} - -const descriptorOf = ( - environmentId: string, - label: string, - capabilities: Partial = {}, -): ExecutionEnvironmentDescriptor => ({ - environmentId: EnvironmentId.make(environmentId), - label, - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, -}); - /** What B's probe saw: the calling client and the modes it may start work with. */ const Probe = Schema.Struct({ caller: Schema.String, modes: Schema.String }); @@ -78,126 +49,37 @@ const ProbeToolkit = Toolkit.make( }), ); -/** - * Environment B as it runs: its descriptor, MCP OAuth, and `/mcp` behind the - * real client authenticator, on a real socket. The descriptor can be swapped, - * as when B's address comes to answer as another environment. - */ -const serveB = (initial: ExecutionEnvironmentDescriptor) => - Effect.gen(function* () { - const descriptor = yield* Ref.make(initial); - const bearers = yield* Ref.make>([]); - const probes = yield* Ref.make(0); - const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), - getDescriptor: Ref.get(descriptor), - }); - const authContext = yield* EnvironmentAuth.layer.pipe( - Layer.provide(Sqlite.layerMemory), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge(ServerEnvironment.layerIdentity), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-b-" })), - Layer.provideMerge(NodeServices.layer), - Layer.fresh, - Layer.build, - ); - // Every bearer B receives, on any route, so a test can show one was never sent. - const layerRecordBearers = HttpRouter.middleware( - (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.url.startsWith("/.well-known/t3/environment")) { - yield* Ref.update(probes, (count) => count + 1); - } - const authorization = request.headers.authorization; - if (authorization !== undefined) { - yield* Ref.update(bearers, (seen) => [...seen, authorization]); - } - return yield* httpEffect; - }), - { global: true }, - ); - const layerRoutes = Layer.mergeAll( - HttpApiBuilder.layer(PeerTestApi).pipe( - Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), - Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), - Layer.provide(AuthHttp.layerAuthenticatedAuth), - ), - McpHttpServer.toolkitRegistration( - ProbeToolkit, - McpToolAccess.toLayer(ProbeToolkit, { - probe: McpToolAccess.reads(() => - McpInvocationContext.McpInvocationContext.pipe( - Effect.map((scope) => { - const modes = McpInvocationContext.clientModeCeiling(scope.client); - return { - caller: scope.client?.label ?? "none", - modes: `${modes.runtimeMode}/${modes.interactionMode}`, - }; - }), - ), - ), - refuse: McpToolAccess.reads(() => - Effect.fail( - new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), - ), - ), - }), - ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), - Layer.provide( - Layer.mock(McpSessionRegistry.McpSessionRegistry)({ - resolve: () => Effect.succeed(undefined), - }), - ), - Layer.provide(McpOAuth.layerMcpClientAuthenticator), - ), - ).pipe( - Layer.provide(layerRecordBearers), - Layer.provide(layerEnvironment), - Layer.provide(Layer.succeedContext(authContext)), - ); - yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( - Layer.build, - ); - const address = (yield* HttpServer.HttpServer).address; - if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); - return { - url: `http://127.0.0.1:${address.port}`, - auth: Context.get(authContext, EnvironmentAuth.EnvironmentAuth), - descriptor, - bearers, - /** How often its descriptor was asked for, as a probe of whether it answers. */ - probes, - }; - }); +const probeHandlers = McpToolAccess.toLayer(ProbeToolkit, { + probe: McpToolAccess.reads(() => + McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => { + const modes = McpInvocationContext.clientModeCeiling(scope.client); + return { + caller: scope.client?.label ?? "none", + modes: `${modes.runtimeMode}/${modes.interactionMode}`, + }; + }), + ), + ), + refuse: McpToolAccess.reads(() => + Effect.fail( + new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), + ), + ), +}); -/** Environment A: its own database and secret store, reaching B over plain HTTP. */ +const serveB = (descriptor: ExecutionEnvironmentDescriptor) => + servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers)); + +/** Environment A, which links to B. */ const makeA = (descriptor: ExecutionEnvironmentDescriptor) => - Effect.gen(function* () { - const context = yield* PeerMcpClient.layer.pipe( - Layer.provideMerge(PeerLinks.layer), - Layer.provide( - Layer.mergeAll( - Sqlite.layerMemory, - ServerSecretStore.layer, - FetchHttpClient.layer, - Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Effect.succeed(descriptor.environmentId), - getDescriptor: Effect.succeed(descriptor), - }), - ), - ), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-a-" })), - Layer.provide(NodeServices.layer), - Layer.fresh, - Layer.build, - ); - return { + layerLinkingEnvironment(descriptor).pipe( + Layer.build, + Effect.map((context) => ({ links: Context.get(context, PeerLinks.PeerLinks), peer: Context.get(context, PeerMcpClient.PeerMcpClient), - }; - }); + })), + ); const laptop = descriptorOf("environment-laptop", "Laptop"); const box = descriptorOf("environment-box", "Box"); @@ -214,14 +96,7 @@ const probe = ( success: Probe, }); -const linkedSessions = (b: Effect.Success>) => - b.auth - .listSessions() - .pipe( - Effect.map((sessions) => - sessions.filter((session) => session.client.label?.startsWith("T3 Code ยท ")), - ), - ); +const linkedSessions = (b: ServedPeer) => b.linkedSessions; it.effect("links with a pairing code, and the peer holds its agents to the limits they carry", () => Effect.scoped( diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts new file mode 100644 index 0000000000000..7425e3587eae1 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -0,0 +1,176 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentHttpApi, + EnvironmentId, + type AuthMcpClientAccess, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../auth/http.ts"; +import * as McpOAuth from "../auth/McpOAuth.ts"; +import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerHttp from "../http.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +class PeerTestApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.metadata) + .add(EnvironmentHttpApi.groups.mcpOAuth) {} + +export const descriptorOf = ( + environmentId: string, + label: string, + capabilities: Partial = {}, +): ExecutionEnvironmentDescriptor => ({ + environmentId: EnvironmentId.make(environmentId), + label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, +}); + +/** + * The environment a link points at, as it runs: its descriptor, MCP OAuth, + * and `/mcp` with `tools` behind the real client authenticator, on a real + * socket. The descriptor can be swapped, as when its address comes to answer + * as another environment, and every bearer it receives is recorded. + */ +export const servePeer = ( + initial: ExecutionEnvironmentDescriptor, + tools: Layer.Layer, +) => + Effect.gen(function* () { + const descriptor = yield* Ref.make(initial); + const bearers = yield* Ref.make>([]); + const probes = yield* Ref.make(0); + const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), + getDescriptor: Ref.get(descriptor), + }); + const authContext = yield* EnvironmentAuth.layer.pipe( + Layer.provide(Sqlite.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerEnvironment.layerIdentity), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-peer-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + Layer.build, + ); + const layerRecordBearers = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.url.startsWith("/.well-known/t3/environment")) { + yield* Ref.update(probes, (count) => count + 1); + } + const authorization = request.headers.authorization; + if (authorization !== undefined) { + yield* Ref.update(bearers, (seen) => [...seen, authorization]); + } + return yield* httpEffect; + }), + { global: true }, + ); + const layerRoutes = Layer.mergeAll( + HttpApiBuilder.layer(PeerTestApi).pipe( + Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), + Layer.provide(AuthHttp.layerAuthenticatedAuth), + ), + tools.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide(McpOAuth.layerMcpClientAuthenticator), + ), + ).pipe( + Layer.provide(layerRecordBearers), + Layer.provide(layerEnvironment), + Layer.provide(Layer.succeedContext(authContext)), + ); + // Fresh, so the peer never shares a toolkit or MCP server with the + // environment that links to it in the same test. + yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.fresh, + Layer.build, + ); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + const url = `http://127.0.0.1:${address.port}`; + const auth = Context.get(authContext, EnvironmentAuth.EnvironmentAuth); + return { + url, + auth, + descriptor, + bearers, + /** How often its descriptor was asked for, as a probe of whether it answers. */ + probes, + /** The sessions linking created there, which its Connections lists. */ + linkedSessions: auth + .listSessions() + .pipe( + Effect.map((sessions) => + sessions.filter((session) => session.client.label?.startsWith("T3 Code ยท ")), + ), + ), + }; + }); + +export type ServedPeer = Effect.Success>; + +/** + * The environment that links: its own database and secret store, reaching + * peers over plain HTTP. Provide it to anything built on its links. + */ +export const layerLinkingEnvironment = (descriptor: ExecutionEnvironmentDescriptor) => + PeerMcpClient.layer.pipe( + Layer.provideMerge(PeerLinks.layer), + Layer.provide( + Layer.mergeAll( + Sqlite.layerMemory, + ServerSecretStore.layer, + FetchHttpClient.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-here-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + ); + +/** Links `links` to `peer` with a fresh pairing code from it. */ +export const linkTo = ( + links: PeerLinks.PeerLinks["Service"], + peer: ServedPeer, + access: AuthMcpClientAccess = "auto", +) => + peer.auth + .issuePairingCredential() + .pipe( + Effect.flatMap((pairing) => + links.link({ url: peer.url, pairingCode: pairing.credential, access }), + ), + ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e3809450589d8..373a96a887172 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,7 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as PeerForwarding from "./peer/PeerForwarding.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -725,7 +726,12 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), // Links to other environments, shared by Settings (WebSocket) and agents (/mcp). - Layer.provide(PeerMcpClient.layer.pipe(Layer.provideMerge(PeerLinks.layer))), + Layer.provide( + PeerForwarding.layer.pipe( + Layer.provideMerge(PeerMcpClient.layer), + Layer.provideMerge(PeerLinks.layer), + ), + ), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), Layer.provide(layerCommandReadiness), diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 5fcec03bbaf89..e70dda632d88e 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -11,7 +11,8 @@ not introduce another execution model. See An environment keeps its ID across server restarts and endpoint changes. Saved connections are local to a client profile; the server's identity and state are not. A repository identity can correlate clones across environments, but never -routes work between them. A project and its threads belong to one environment. +routes work between them. A project and its threads belong to one environment; +an agent reaches another environment's threads only through a peer link. The canonical key follows the `upstream` remote when one exists, so pull request features target the repository a fork tracks. A fork also reports its own `origin`, and clients group and label by that, so a fork never collapses into a @@ -57,6 +58,34 @@ learned route still has to answer as this environment before it is used. GitHub routing trust covers the whole route list. Adding or changing a route revokes it; reordering does not, because the same addresses remain trusted. +## Peer links + +A [peer link](../../apps/server/src/peer/PeerLinks.ts) lets one environment's +agents work in another. The linking environment signs in to the other's `/mcp` +as an outside MCP client, with a pairing code from it, and keeps that session's +token in its own secret store. The other environment needs nothing new: it +sees one more OAuth client, lists it in Connections, and revokes it there. +Unlinking on the linking side only forgets the token. + +- **Every forwarded call carries the calling agent's modes** in + `T3-Mode-Limit`, which the receiving `/mcp` only ever narrows the client's + approved access with. A plan-mode agent therefore cannot start full-access + work through a full-access link. The tool's own access declaration also + runs on the linking side first, so a read-only or ended caller is refused + before anything is sent. +- **The token goes only to an address that proves it is the linked + environment**: its descriptor must report the linked environment id. A LAN + address that now belongs to another machine never receives it. +- **All callers on one side share the link's single session on the other**, + so idempotency keys are hashed with the caller's namespace before they + leave. Without that, two agents reusing a key would collide on the far side. +- **A forwarded wait is split into calls of at most 50 s**, because the + receiving `/mcp` sends nothing while a wait is open and T3 Connect's edge + drops idle requests after about 100 s. + +The link is routing, not isolation: an agent the link starts runs as the +receiving environment's user, inside the limits above. + ## Hosted web is a client The hosted web app stores its connection catalog in the browser and connects diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index df655c1d6c218..966a823b5f27a 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -333,6 +333,9 @@ export function summarizeT3ToolCalls( case "environment-update": label = phrase("Updated", "update", `environment preferences ${times}`); break; + case "environment-links": + label = phrase("Listed", "list", `linked environments ${times}`); + break; case "attachment-prepare": label = phrase( "Prepared", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d37b88fde9333..c3a3103a18bb1 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -2,8 +2,10 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AuthMcpClientAccess } from "./auth.ts"; import { ContextTransferId, + EnvironmentId, IsoDateTime, MessageId, NodeId, @@ -305,7 +307,20 @@ const OrchestratorMcpProjectTarget = Schema.optional( }), ); +/** + * A linked environment to act in instead of this one. The tools that take it + * forward the call there, where the link's access and the caller's own modes + * both apply. + */ +export const OrchestratorMcpEnvironmentTarget = Schema.optional( + EnvironmentId.annotate({ + description: + "A linked environment to do this in; t3_environment_links lists them. Omit for this environment. Ids (projects, threads, runs) then belong to that environment.", + }), +); + export const OrchestratorMcpThreadListInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), @@ -378,6 +393,7 @@ const clampedPositiveInt = (maximum: number) => ); export const OrchestratorMcpThreadReadInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, itemId: Schema.optional(TurnItemId), textOffset: Schema.optional(NonNegativeInt), @@ -472,6 +488,7 @@ export const OrchestratorMcpThreadReadResult = Schema.Struct({ export type OrchestratorMcpThreadReadResult = typeof OrchestratorMcpThreadReadResult.Type; export const OrchestratorMcpThreadSendInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, message: OrchestratorMcpPrompt, mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), @@ -490,6 +507,7 @@ export const OrchestratorMcpThreadSendResult = Schema.Struct({ export type OrchestratorMcpThreadSendResult = typeof OrchestratorMcpThreadSendResult.Type; export const OrchestratorMcpThreadWaitInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), @@ -505,9 +523,16 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; export const OrchestratorMcpThreadInterruptInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, runId: Schema.optional(RunId), reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), + /** + * Stop the thread as its Stop button does instead: interrupt its running + * turn whichever run that is, hold its queued messages, end its pull request + * watches and stop every task it delegated. `runId` is then not used. + */ + stop: Schema.optional(Schema.Boolean), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpThreadInterruptInput = typeof OrchestratorMcpThreadInterruptInput.Type; @@ -524,6 +549,7 @@ export const OrchestratorMcpThreadInterruptResult = Schema.Struct({ export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadInterruptResult.Type; export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, search: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({ description: @@ -561,6 +587,25 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ }); export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; +export const OrchestratorMcpEnvironmentLink = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + /** Whether it answered just now. An expired link must be linked again there. */ + status: Schema.Literals(["reachable", "unreachable", "expired"]), + /** The most the linked environment lets this one's agents do there. */ + access: AuthMcpClientAccess, + expiresAt: IsoDateTime, + lastError: Schema.NullOr(Schema.String), +}); +export type OrchestratorMcpEnvironmentLink = typeof OrchestratorMcpEnvironmentLink.Type; + +export const OrchestratorMcpEnvironmentLinksResult = Schema.Struct({ + environmentId: EnvironmentId, + links: Schema.Array(OrchestratorMcpEnvironmentLink), +}); +export type OrchestratorMcpEnvironmentLinksResult = + typeof OrchestratorMcpEnvironmentLinksResult.Type; + export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ parentThreadId: Schema.NullOr(ThreadId), inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 2bb2b2b0ec6e5..d19583064a566 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,8 +14,9 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. +- \`t3_environment_links\` lists the user's other T3 Code environments that this one is linked to, such as another machine. To work there, pass that \`environmentId\` to \`t3_project_list\`, \`orchestrator_capabilities\`, \`t3_thread_launch\`, \`t3_thread_list\`, \`t3_thread_read\`, \`t3_thread_send\`, \`t3_thread_wait\`, or \`t3_thread_interrupt\`; project, thread, run, and model ids then belong to that environment. Work there runs within your own modes and the link's access. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. -- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`, +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. Name a thread in a linked environment by title instead: its link would open here.`, `### Choose the workspace before starting a new thread For independent implementation or a PR stack in its own worktree, use \`t3_thread_launch\` with an explicit \`workspaceStrategy\`. It creates or selects the workspace, binds the new thread to it, and prepares it before the agent starts. Put the task in \`message\`, not \`prompt\`: diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 21f8be3941f95..975361f36b1c0 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -50,6 +50,7 @@ export type T3McpToolSummaryAction = | "project-delete" | "project-clone" | "environment-read" + | "environment-links" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -309,6 +310,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Read", "Reading", "Read", "environment preferences"], "environment-read", ), + t3_environment_links: tool( + ["List", "Listing", "Listed", "linked environments"], + "environment-links", + ), t3_environment_preferences_update: tool( ["Update", "Updating", "Updated", "environment preferences"], "environment-update", From eafc349b23444f674e974d46b27d2c74ab2dc9e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:45:34 -0700 Subject: [PATCH 21/50] feat(server): agents create and clone projects in a linked environment t3_project_create and t3_project_clone take environmentId, so an agent can bring a repository to a machine before launching or delegating work there. Both still need full access here, and the linked environment holds the call to the link's access. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.ts | 21 +++- apps/server/src/mcp/toolkits/project/tools.ts | 17 ++- apps/server/src/peer/PeerForwarding.test.ts | 100 +++++++++++++++++- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 536fb19b5b312..da800e19d9943 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -281,8 +281,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { return result.value; }), ), - t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => + t3_project_create: McpToolAccess.writesEnvironment((params) => Effect.gen(function* () { + const { scope, target } = yield* remoteTarget(params); + if (target !== undefined) { + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.call( + scope, + ProjectToolkit.tools.t3_project_create, + target, + params, + ); + } + const { environmentId: _here, workspaceRoot, ...input } = params; const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { // Project creation records no model default (only an update does), so @@ -341,8 +352,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { .pipe(Effect.mapError(projectFailure)); }), ), - t3_project_clone: McpToolAccess.writesEnvironment((input) => + t3_project_clone: McpToolAccess.writesEnvironment((params) => Effect.gen(function* () { + const { scope, target } = yield* remoteTarget(params); + if (target !== undefined) { + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_clone, target, params); + } + const { environmentId: _here, ...input } = params; const repositories = yield* Repositories.SourceControlRepositoryService; return yield* repositories.cloneRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 679808a916ea0..d42dd00ce124e 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -73,13 +73,18 @@ const ProjectReadTool = Tool.make("t3_project_read", { const ProjectCreateTool = Tool.make("t3_project_create", { ...shared, description: - "Register a project directory through the existing project service. Set createWorkspaceRootIfMissing to create a directory. Omit workspaceRoot to start a new project from just its title: the app makes a Git repository for it in its own projects folder, with a README, an icon, and a first commit (commitError says why a commit failed; the project exists either way). Each call creates a new request; an existing registered workspace is rejected. Clone separately with t3_project_clone when needed.", + "Register a project directory through the existing project service. Set createWorkspaceRootIfMissing to create a directory. Omit workspaceRoot to start a new project from just its title: the app makes a Git repository for it in its own projects folder, with a README, an icon, and a first commit (commitError says why a commit failed; the project exists either way). Each call creates a new request; an existing registered workspace is rejected. Clone separately with t3_project_clone when needed. Pass environmentId to create it in a linked environment; workspaceRoot and defaultModelSelection then name that environment's paths and providers.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, ...ProjectCreatePayload.fields, workspaceRoot: Schema.optional(ProjectCreatePayload.fields.workspaceRoot), }), success: Schema.Struct({ ...Project.fields, commitError: Schema.optional(Schema.String) }), - dependencies: [...shared.dependencies, ManagedProjectFolders.ManagedProjectFolders], + dependencies: [ + ...shared.dependencies, + ManagedProjectFolders.ManagedProjectFolders, + PeerForwarding.PeerForwarding, + ], }).annotate(Tool.Destructive, true); const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, @@ -96,12 +101,16 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, description: - "Clone a repository using the app's source-control service. This only clones; register the returned cwd with t3_project_create. An existing destination is not adopted or removed on failure.", - parameters: SourceControlCloneRepositoryInput, + "Clone a repository using the app's source-control service. This only clones; register the returned cwd with t3_project_create. An existing destination is not adopted or removed on failure. Pass environmentId to clone in a linked environment, then register it there with t3_project_create and the same environmentId.", + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...SourceControlCloneRepositoryInput.fields, + }), success: SourceControlCloneRepositoryResult, dependencies: [ ...shared.dependencies, SourceControlRepositoryService.SourceControlRepositoryService, + PeerForwarding.PeerForwarding, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 6fa6c6aa192ba..8d545e5408c95 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, type OrchestrationV2Run, + type SourceControlCloneRepositoryInput, ProjectId, ProviderInstanceId, RunId, @@ -12,6 +13,7 @@ import { import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import { McpSchema, McpServer } from "effect/ai"; @@ -46,10 +48,19 @@ interface Seen { readonly sends: ReadonlyArray; readonly waits: ReadonlyArray; readonly launches: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly clones: ReadonlyArray; /** Commands dispatched and delegated-task stops, as ` `. */ readonly stops: ReadonlyArray; } -const nothingSeen: Seen = { sends: [], waits: [], launches: [], stops: [] }; +const nothingSeen: Seen = { + sends: [], + waits: [], + launches: [], + projects: [], + clones: [], + stops: [], +}; /** * B's threads: one `auto` thread in `boxProject`, whose run finishes on the @@ -126,6 +137,40 @@ const boxLaunches = (seen: Ref.Ref) => ), }); +/** B clones and registers whatever it is asked to, and records it. */ +const boxProjects = (seen: Ref.Ref) => + Layer.merge( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + create: (input) => + Ref.update(seen, (current) => ({ + ...current, + projects: [...current.projects, input], + })).pipe( + Effect.as({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-18T00:00:00.000Z", + updatedAt: "2026-10-18T00:00:00.000Z", + deletedAt: null, + }), + ), + }), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: (input) => + Ref.update(seen, (current) => ({ ...current, clones: [...current.clones, input] })).pipe( + Effect.as({ + cwd: input.destinationPath, + remoteUrl: "https://github.com/pingdotgg/t3code.git", + repository: null, + }), + ), + }), + ); + /** The orchestrator and project toolkits on B's real `/mcp`, behind its real OAuth. */ const serveBox = (seen: Ref.Ref) => servePeer( @@ -141,7 +186,7 @@ const serveBox = (seen: Ref.Ref) => Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(boxProjects(seen)), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-box-" }).pipe( Layer.provide(NodeServices.layer), @@ -151,7 +196,6 @@ const serveBox = (seen: Ref.Ref) => Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), @@ -427,6 +471,56 @@ it.effect("launches in a linked environment with the caller's modes, retry-safe" ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("brings a repository to a linked environment, only through full access", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + const clone = { repository: "pingdotgg/t3code", destinationPath: "/home/box/t3code" }; + const register = { title: "t3code", workspaceRoot: "/home/box/t3code" }; + + yield* linkTo(a.links, b, "full-access"); + const cloned = yield* a.call("thread:laptop-full", "t3_project_clone", { + environmentId: box.environmentId, + ...clone, + }); + expect(cloned.structuredContent).toMatchObject({ cwd: clone.destinationPath }); + const created = yield* a.call("thread:laptop-full", "t3_project_create", { + environmentId: box.environmentId, + ...register, + }); + expect(created.structuredContent).toMatchObject(register); + expect((yield* Ref.get(seen)).clones).toEqual([clone]); + expect((yield* Ref.get(seen)).projects).toMatchObject([register]); + + // A plan-mode agent here may not, and nothing leaves. + yield* Ref.set(b.bearers, []); + const planned = yield* a.call("thread:laptop-plan", "t3_project_create", { + environmentId: box.environmentId, + ...register, + }); + expect(failureOf(planned)).toMatchObject({ code: "capability_denied" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + // Nor may a full-access agent through a link with less: the box holds it. + yield* linkTo(a.links, b, "auto"); + for (const [name, args] of [ + ["t3_project_clone", clone], + ["t3_project_create", register], + ] as const) { + const refused = yield* a.call("thread:laptop-full", name, { + environmentId: box.environmentId, + ...args, + }); + expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" }); + } + expect((yield* Ref.get(seen)).clones).toHaveLength(1); + expect((yield* Ref.get(seen)).projects).toHaveLength(1); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("refuses what a linked environment cannot take before anything leaves", () => Effect.scoped( Effect.gen(function* () { From cb07020a07fbfeeaaa10ba163f91f736678d631f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:42:03 -0700 Subject: [PATCH 22/50] feat(server): every tool that names a thread, project or queue reaches a linked environment Forwarding was wired tool by tool, so only the first eleven tools took environmentId. It now lives in McpToolAccess.toLayer: a tool whose parameters take OrchestratorMcpEnvironmentTarget runs in the linked environment it names once its declaration has checked the caller here. The local handler never sees environmentId. Tools that act as the calling thread itself cannot take one, and the layer refuses to build if they do. Queues, pending requests, thread settings and organizing, forks and merge-backs, scheduled tasks, pull requests, worktree refs, project read/update/delete and environment preferences now reach a link too. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpToolAccess.test.ts | 113 +++++++++ apps/server/src/mcp/McpToolAccess.ts | 215 +++++++++++++----- .../src/mcp/toolkits/environment/tools.ts | 3 + .../src/mcp/toolkits/orchestrator/handlers.ts | 110 +++------ .../mcp/toolkits/orchestrator/tools.test.ts | 13 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 33 ++- .../src/mcp/toolkits/project/handlers.ts | 50 +--- apps/server/src/mcp/toolkits/project/tools.ts | 27 ++- .../src/mcp/toolkits/pullRequests/tools.ts | 9 +- apps/server/src/mcp/toolkits/thread/tools.ts | 39 +++- .../server/src/mcp/toolkits/worktree/tools.ts | 2 + apps/server/src/peer/PeerForwarding.test.ts | 50 +++- apps/server/src/peer/PeerForwarding.ts | 129 +++++------ docs/internals/remote.md | 11 +- packages/contracts/src/orchestratorMcp.ts | 4 + .../src/server/orchestrationInstructions.ts | 2 +- 16 files changed, 519 insertions(+), 291 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 7bd0d66fa0406..f840b36c31df5 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -1,18 +1,22 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ProviderInstanceId, ProviderInteractionMode, RuntimeMode, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; +import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -340,3 +344,112 @@ it.effect("refuses a change when the calling thread cannot be read", () => ), ), ); + +const box = EnvironmentId.make("environment-box"); +const here = EnvironmentId.make("environment"); +const linkedProbe = { + ...probe, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), +}; +const LinkedProbeToolkit = Toolkit.make( + Tool.make("reads", linkedProbe), + Tool.make("writes", linkedProbe), + Tool.make("writes_with_capability", linkedProbe), + Tool.make("writes_threads", linkedProbe), + Tool.make("starts_threads", linkedProbe), + Tool.make("writes_environment", linkedProbe), +); +/** Runs here, and tells whether `environmentId` reached the handler. */ +const ranHere =