From b3c5eb2e9c9a8e8607c6916ef5a4c7c352902566 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 7 Nov 2026 17:23:27 +0000 Subject: [PATCH 1/2] fix(server): only the agent that staged an upload can send it t3_attachment_discard already refused a pending upload to any caller but the one that prepared it, but t3_thread_send_attachments and t3_thread_launch did not check. Another thread or client session that learned the attachment id, which shows in the preparing thread's tool calls, could send that file into its own thread. The preparing caller is now recorded in a small PendingUploadOwners service that the attachment and project toolkits share, and both claim paths refuse a pending upload the caller did not prepare, as discard does. Attachments already in the target thread are unchanged. Ownership stays in memory, so after a restart a pending upload fails closed until the 24-hour sweep removes it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/attachmentStore.ts | 2 +- apps/server/src/mcp/McpHttpServer.ts | 9 +- apps/server/src/mcp/PendingUploadOwners.ts | 60 ++++++ .../src/mcp/toolkits/attachment/handlers.ts | 49 ++--- apps/server/src/mcp/toolkits/core.test.ts | 191 +++++++++++++----- .../src/mcp/toolkits/project/handlers.test.ts | 2 + .../src/mcp/toolkits/project/handlers.ts | 6 + apps/server/src/mcp/toolkits/project/tools.ts | 2 + 8 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 apps/server/src/mcp/PendingUploadOwners.ts diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index a2b531e3fa5b0..8d36b97763103 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -27,7 +27,7 @@ const ATTACHMENT_ID_PATTERN = new RegExp( ); export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; -const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; +export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; export function toSafeThreadAttachmentSegment(threadId: string): string | null { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 66fdd446fc991..f02c12bca8fef 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -44,6 +44,7 @@ import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpToolAccess from "./McpToolAccess.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; +import * as PendingUploadOwners from "./PendingUploadOwners.ts"; import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; @@ -1172,12 +1173,16 @@ const layerEnvironmentToolkit = toolkitRegistration( EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); -export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); +// Both toolkits get the same memoized owners layer, so a launch sees the uploads prepare recorded. +export const layerProjectRegistration = toolkitRegistration( + ProjectToolkit, + ProjectHandlers.layer, +).pipe(Layer.provide(PendingUploadOwners.layer)); export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, AttachmentHandlers.layer, -); +).pipe(Layer.provide(PendingUploadOwners.layer)); export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsToolkit, diff --git a/apps/server/src/mcp/PendingUploadOwners.ts b/apps/server/src/mcp/PendingUploadOwners.ts new file mode 100644 index 0000000000000..f7b335835045c --- /dev/null +++ b/apps/server/src/mcp/PendingUploadOwners.ts @@ -0,0 +1,60 @@ +import { ATTACHMENT_UPLOAD_URL_TTL_MS, OrchestratorMcpFailure } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { PENDING_ATTACHMENT_MAX_AGE_MS } from "../attachmentStore.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +/** The sweep's clock starts at upload completion, possibly one upload-URL lifetime after issue. */ +const OWNER_TTL_MS = PENDING_ATTACHMENT_MAX_AGE_MS + ATTACHMENT_UPLOAD_URL_TTL_MS; + +const ownerKey = (scope: McpInvocationScope) => scope.thread?.threadId ?? scope.requestNamespace; + +/** The MCP caller that prepared each pending upload. Kept in memory, so a restart orphans them. */ +export class PendingUploadOwners extends Context.Service< + PendingUploadOwners, + { + readonly record: (scope: McpInvocationScope, attachmentId: string) => Effect.Effect; + readonly requireOwner: ( + scope: McpInvocationScope, + attachmentIds: ReadonlyArray, + action: "send" | "discard", + ) => Effect.Effect; + readonly forget: (attachmentId: string) => Effect.Effect; + } +>()("t3/mcp/PendingUploadOwners") {} + +export const layer = Layer.sync(PendingUploadOwners, () => { + const owners = new Map(); + return PendingUploadOwners.of({ + record: Effect.fn("PendingUploadOwners.record")(function* ( + scope: McpInvocationScope, + attachmentId: string, + ) { + const now = yield* Clock.currentTimeMillis; + for (const [id, entry] of owners) { + if (now - entry.issuedAt > OWNER_TTL_MS) owners.delete(id); + } + owners.set(attachmentId, { owner: ownerKey(scope), issuedAt: now }); + }), + requireOwner: Effect.fn("PendingUploadOwners.requireOwner")(function* ( + scope: McpInvocationScope, + attachmentIds: ReadonlyArray, + action: "send" | "discard", + ) { + const owner = ownerKey(scope); + if (attachmentIds.every((id) => owners.get(id)?.owner === owner)) return; + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `Only the caller that prepared a pending upload can ${action} it.`, + }); + }), + forget: Effect.fn("PendingUploadOwners.forget")((attachmentId: string) => + Effect.sync(() => { + owners.delete(attachmentId); + }), + ), + }); +}); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 8d72943f337ae..7e4ddcecd4dfb 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -1,17 +1,12 @@ -import { - ATTACHMENT_UPLOAD_URL_TTL_MS, - type ChatAttachment, - MessageId, - OrchestratorMcpFailure, -} from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; +import { type ChatAttachment, MessageId, OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as AssetSigner from "../../../assets/AssetSigner.ts"; -import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as PendingUploadOwners from "../../PendingUploadOwners.ts"; import { newCommandId, readThread, unavailable } from "../../threadAccess.ts"; import { AttachmentToolkit } from "./tools.ts"; @@ -35,18 +30,11 @@ export function resolveAttachmentReferences( }); } -/** The 24-hour sweep starts at upload completion, possibly one upload-URL lifetime after issue. */ -const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS; - -const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( - Effect.map((scope) => scope.thread?.threadId ?? scope.requestNamespace), -); - export const layer = McpToolAccess.toLayer( AttachmentToolkit, Effect.gen(function* () { const assetSigner = yield* AssetSigner.AssetSigner; - const uploadOwners = new Map(); + const owners = yield* PendingUploadOwners.PendingUploadOwners; return { // A pending upload belongs to its caller until a send claims it, so // linked callers may stage the uploads their own sends need. @@ -57,11 +45,10 @@ export const layer = McpToolAccess.toLayer( Effect.provideService(AssetSigner.AssetSigner, assetSigner), Effect.mapError(unavailable), ); - const now = yield* Clock.currentTimeMillis; - for (const [id, entry] of uploadOwners) { - if (now - entry.issuedAt > UPLOAD_OWNER_TTL_MS) uploadOwners.delete(id); - } - uploadOwners.set(result.attachmentId, { owner: yield* uploadOwner, issuedAt: now }); + yield* owners.record( + yield* McpInvocationContext.McpInvocationContext, + result.attachmentId, + ); return result; }), { linkedCallers: "allowed" }, @@ -69,14 +56,13 @@ export const layer = McpToolAccess.toLayer( t3_attachment_discard: McpToolAccess.writes( (input) => Effect.gen(function* () { - if (uploadOwners.get(input.attachmentId)?.owner !== (yield* uploadOwner)) { - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "Only the caller that prepared a pending upload can discard it.", - }); - } + yield* owners.requireOwner( + yield* McpInvocationContext.McpInvocationContext, + [input.attachmentId], + "discard", + ); yield* Upload.deletePendingAttachment(input.attachmentId); - uploadOwners.delete(input.attachmentId); + yield* owners.forget(input.attachmentId); return {}; }), { linkedCallers: "allowed" }, @@ -91,6 +77,13 @@ export const layer = McpToolAccess.toLayer( code: "invalid_request", message: "Unarchive the target thread before sending attachments.", }); + yield* owners.requireOwner( + yield* McpInvocationContext.McpInvocationContext, + input.attachments + .filter(Claims.attachmentIsPendingUpload) + .map((attachment) => attachment.id), + "send", + ); const attachments = yield* resolveAttachmentReferences( input.attachments, projection.messages.flatMap((message) => message.attachments), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 602031290763d..614e87e1c0f87 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,4 +1,5 @@ import * as AssetSigner from "../../assets/AssetSigner.ts"; +import * as Upload from "../../assets/AttachmentUpload.ts"; import * as DocumentText from "../../provider/DocumentText.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -46,6 +47,9 @@ import * as ThreadManagement from "../../orchestration-v2/ThreadManagementServic import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../project/ProjectCloneTracker.ts"; +import * as ManagedProjectFolders from "../../project/ManagedProjectFolders.ts"; +import * as Repositories from "../../sourceControl/SourceControlRepositoryService.ts"; import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import * as SecretRequests from "../../secrets/SecretRequests.ts"; import * as RemoteDelegation from "../../peer/RemoteDelegation.ts"; @@ -968,64 +972,147 @@ it.effect("discovers workflow stop targets and requires orchestration capability ); }); -it.effect("only the caller that prepared a pending upload can discard it", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const call = ( - name: string, - args: Record, - invocation: McpInvocationContext.McpInvocationScope, - ) => - server - .callTool({ name, arguments: args }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - const prepared = yield* call( - "t3_attachment_prepare_upload", - { upload: { name: "shot.png", mimeType: "image/png", sizeBytes: 4 } }, - scope, - ); - const { attachmentId } = prepared.structuredContent as { readonly attachmentId: string }; - const otherThread = { - ...scope, - requestNamespace: "other-session", - thread: { ...scope.thread!, threadId: ThreadId.make("other-thread") }, - }; +it.effect( + "only the caller that prepared a pending upload can send, launch with or discard it", + () => { + const sent: Array = []; + const launchedBy: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = ( + name: string, + args: Record, + invocation: McpInvocationContext.McpInvocationScope, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const upload = { type: "file", name: "notes.txt", mimeType: "text/plain", sizeBytes: 4 }; + const prepared = yield* call("t3_attachment_prepare_upload", { upload }, scope); + const { attachmentId, relativeUrl } = prepared.structuredContent as { + readonly attachmentId: string; + readonly relativeUrl: string; + }; + const claims = yield* Upload.validateAttachmentUploadToken( + relativeUrl.slice(`${Upload.ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length), + ); + expect(yield* Upload.storeAttachmentUpload(claims!, new Uint8Array(4))).toEqual({ ok: true }); + const otherThread = { + ...scope, + requestNamespace: "other-session", + thread: { ...scope.thread!, threadId: ThreadId.make("other-thread") }, + }; + const attachments = [{ ...upload, id: attachmentId }]; - const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread); - expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" }); + const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread); + expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" }); + const stolen = yield* call("t3_thread_send_attachments", { attachments }, otherThread); + expect(declaredFailure(stolen)).toMatchObject({ code: "invalid_request" }); + const launchedWithStolen = yield* call( + "t3_thread_launch", + { title: "Stolen", attachments }, + otherThread, + ); + expect(declaredFailure(launchedWithStolen)).toMatchObject({ code: "invalid_request" }); + expect(sent).toEqual([]); + expect(launchedBy).toEqual([]); + const own = yield* call("t3_thread_send_attachments", { attachments }, scope); + expect(own.isError).toBe(false); + expect(sent).toEqual([threadId]); + const ownLaunch = yield* call("t3_thread_launch", { title: "Own", attachments }, scope); + expect(ownLaunch.isError).toBe(false); + expect(launchedBy).toEqual([threadId]); - yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000); - yield* call( - "t3_attachment_prepare_upload", - { upload: { name: "later.png", mimeType: "image/png", sizeBytes: 4 } }, - scope, - ); + yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000); + yield* call( + "t3_attachment_prepare_upload", + { upload: { name: "later.png", mimeType: "image/png", sizeBytes: 4 } }, + scope, + ); - const discarded = yield* call( - "t3_attachment_discard", - { attachmentId }, - { ...scope, requestNamespace: "mcp-core-session-2" }, - ); - expect(discarded.isError).toBe(false); - }).pipe( - Effect.provide( - McpHttpServer.layerAttachmentToolkit.pipe( - Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), - Layer.provide(McpToolAccessTestkit.liveThreadsLayer), - Layer.provide(AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer))), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })), - Layer.provide( - DocumentText.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + const discarded = yield* call( + "t3_attachment_discard", + { attachmentId }, + { ...scope, requestNamespace: "mcp-core-session-2" }, + ); + expect(discarded.isError).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll( + McpHttpServer.layerAttachmentToolkit, + McpHttpServer.layerProjectRegistration, + ).pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: (input) => + Effect.sync(() => { + launchedBy.push(input.initialMessage?.senderThreadId); + return { + threadId: input.threadId, + projection: { + thread: { + id: input.threadId, + projectId: input.projectId, + modelSelection: input.modelSelection, + }, + runs: [], + messages: [{ attachments: input.initialMessage?.attachments ?? [] }], + }, + resumed: false, + } as unknown as ThreadLaunchService.ThreadLaunchResult; + }), + }), + ), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + }), + ), + Layer.provide(Layer.mock(Repositories.SourceControlRepositoryService)({})), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: (input) => + Effect.succeed( + McpToolAccessTestkit.idleThreadProjection( + McpToolAccessTestkit.liveThreadShell(input.threadId), + ), + ), + sendToThread: (input) => + Effect.sync(() => { + sent.push(input.threadId); + return { + run: { id: RunId.make("run:sent"), status: "running" }, + message: { attachments: input.attachments }, + } as unknown as ThreadManagement.ThreadManagementSendResult; + }), + }), + ), + Layer.provideMerge(AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer))), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" }), ), + Layer.provideMerge( + DocumentText.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + Layer.provide(NodeCrypto.layer), ), - Layer.provide(NodeCrypto.layer), ), - ), - ), + ); + }, ); it.effect( diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 2ecbde990d895..55970be8ddb29 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -37,6 +37,7 @@ import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as PendingUploadOwners from "../../PendingUploadOwners.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -159,6 +160,7 @@ const clientLaunchHarness = (input: { ), }), Layer.mock(PeerForwarding.PeerForwarding)({}), + PendingUploadOwners.layer, Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)), ).pipe( diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 667b878e68d33..b0df7ed0913b1 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -29,6 +29,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as PendingUploadOwners from "../../PendingUploadOwners.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -106,6 +107,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { code: "invalid_request", message: "A new thread accepts only pending attachment uploads.", }); + yield* (yield* PendingUploadOwners.PendingUploadOwners).requireOwner( + scope, + attachments.map((attachment) => attachment.id), + "send", + ); if ( input.scratch === true && (input.projectId !== undefined || input.workspaceStrategy !== undefined) diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 56799959f6cd8..5717e10ccecb9 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -37,6 +37,7 @@ import * as ThreadManagementService from "../../../orchestration-v2/ThreadManage import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PendingUploadOwners from "../../PendingUploadOwners.ts"; const shared = { success: Project, @@ -173,6 +174,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, Digest.Digest, + PendingUploadOwners.PendingUploadOwners, ], }) .annotate(Tool.Destructive, true) From b70365076d6f88e06196417e36582a88ab788ff7 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 07:41:04 +0000 Subject: [PATCH 2/2] test(server): ownership tests provide what launches now read on main --- apps/server/src/mcp/McpHttpServer.test.ts | 2 ++ apps/server/src/mcp/toolkits/core.test.ts | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 8f0966f46834b..ee8e2ccf49ef4 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -53,6 +53,7 @@ import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; +import * as PendingUploadOwners from "./PendingUploadOwners.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; import { PreviewToolkit } from "./toolkits/preview/tools.ts"; @@ -309,6 +310,7 @@ const layerRecordingTools = (calls: Array) => { Layer.mock(ServerSettings.ServerSettingsService)({}), Layer.mock(DocumentText.DocumentText)({}), Layer.mock(ProviderRegistry.ProviderRegistry)({}), + PendingUploadOwners.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" }), NodeCrypto.layer, ), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a02fd5c5fb1ea..b1c9cd1f6a2e1 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1108,7 +1108,21 @@ it.effect( ), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: (projectId) => + Effect.succeedSome({ + id: projectId, + title: "MCP test", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-18T00:00:00.000Z", + updatedAt: "2026-10-18T00:00:00.000Z", + deletedAt: null, + }), + }), + ), Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({