From dc42153c903db6cec75f1e7ef1960439c74b6730 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 12:15:47 -0700 Subject: [PATCH 1/6] feat(mcp): expose existing attachment upload and send services --- apps/server/src/mcp/McpHttpServer.ts | 7 ++ .../src/mcp/toolkits/attachment/handlers.ts | 72 ++++++++++++++++++ .../src/mcp/toolkits/attachment/tools.ts | 73 +++++++++++++++++++ apps/server/src/mcp/toolkits/core.test.ts | 2 + packages/shared/src/t3McpToolPresentation.ts | 3 + 5 files changed, 157 insertions(+) create mode 100644 apps/server/src/mcp/toolkits/attachment/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/attachment/tools.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0e031dea4e91..bd5b22f40b7f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -18,6 +18,8 @@ import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; import { EnvironmentHandlersLive } from "./toolkits/environment/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import { ProjectHandlersLive } from "./toolkits/project/handlers.ts"; +import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; +import { AttachmentHandlersLive } from "./toolkits/attachment/handlers.ts"; import { ThreadToolkit } from "./toolkits/thread/tools.ts"; import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts"; import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts"; @@ -257,6 +259,10 @@ export const ProjectRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe( Layer.provide(ProjectHandlersLive), ); +export const AttachmentRegistrationLive = McpServer.toolkit(AttachmentToolkit).pipe( + Layer.provide(AttachmentHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -268,6 +274,7 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, ThreadToolkitRegistrationLive, + AttachmentRegistrationLive, ProjectRegistrationLive, EnvironmentRegistrationLive, PreviewControlsRegistrationLive, diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts new file mode 100644 index 000000000000..f76c509be608 --- /dev/null +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -0,0 +1,72 @@ +import { MessageId, OrchestratorMcpFailure } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Upload from "../../../assets/AttachmentUpload.ts"; +import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; +import { + newCommandId, + readMutationCaller, + readWritableThread, + unavailable, +} from "../../threadAccess.ts"; +import { AttachmentToolkit } from "./tools.ts"; + +export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ + t3_attachment_prepare_upload: (input) => + Effect.gen(function* () { + yield* readMutationCaller(); + return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( + Effect.mapError(unavailable), + ); + }), + t3_attachment_discard: (input) => + Effect.gen(function* () { + yield* readMutationCaller(); + yield* Upload.deletePendingAttachment(input.attachmentId); + return {}; + }), + t3_thread_send_attachments: (input) => + Effect.gen(function* () { + const { caller, threads, projection } = yield* readWritableThread(input.threadId); + const owned = new Set( + projection.messages.flatMap((message) => + message.attachments.map((attachment) => attachment.id), + ), + ); + if ( + input.attachments.some( + (attachment) => + !Claims.attachmentIsPendingUpload(attachment) && !owned.has(attachment.id), + ) + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Attachments must be pending uploads or already belong to the target thread.", + }); + const commandId = yield* newCommandId(); + const messageId = MessageId.make(commandId); + const claimed = yield* Claims.claimPendingAttachments({ + threadId: projection.thread.id, + attachments: input.attachments, + }).pipe(Effect.mapError(unavailable)); + // Preserve claimed copies if dispatch may have committed before failing to return. + const result = yield* threads + .sendToThread({ + projectId: caller.projectId, + threadId: projection.thread.id, + commandId, + messageId, + text: input.message ?? "", + attachments: claimed.attachments, + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(unavailable)); + return { + threadId: projection.thread.id, + messageId, + runId: result.run.id, + status: result.run.status, + }; + }), +}); diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts new file mode 100644 index 000000000000..2d57f90c4e22 --- /dev/null +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -0,0 +1,73 @@ +import { + AttachmentCreateUploadUrlInput, + AttachmentCreateUploadUrlResult, + AttachmentDeleteInput, + ChatImageAttachment, + ChatFileAttachment, + MessageId, + RunId, + ThreadId, + OrchestrationV2RunStatus, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import { ServerSecretStore } from "../../../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../../../config.ts"; +import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext, + ThreadManagementService, + ServerConfig, + ServerSecretStore, + FileSystem.FileSystem, + Crypto.Crypto, + ], +}; +export const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", { + ...shared, + description: + "Create the app's signed upload URL. PUT the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.", + parameters: Schema.Struct({ upload: AttachmentCreateUploadUrlInput }), + success: AttachmentCreateUploadUrlResult, +}).annotate(Tool.Destructive, true); +export const AttachmentDiscardTool = Tool.make("t3_attachment_discard", { + ...shared, + description: + "Discard a pending upload. Already-delivered thread attachments are never deleted by this operation.", + parameters: AttachmentDeleteInput, + success: Schema.Struct({}), +}).annotate(Tool.Destructive, true); +export const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { + ...shared, + description: + "Send uploaded attachments to this thread or another thread in the calling project. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", + parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), + message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), + attachments: Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).check( + Schema.isMinLength(1), + Schema.isMaxLength(8), + ), + }), + success: Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + runId: RunId, + status: OrchestrationV2RunStatus, + }), +}) + .annotate(Tool.Destructive, true) + .annotate(Tool.OpenWorld, true); +export const AttachmentToolkit = Toolkit.make( + AttachmentUploadTool, + AttachmentDiscardTool, + AttachmentSendTool, +); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a81f5ea0d53f..e179e1581c9b 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -20,6 +20,7 @@ import { PreviewControlsToolkit } from "./previewControls/tools.ts"; import { EnvironmentToolkit } from "./environment/tools.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import { ProjectToolkit } from "./project/tools.ts"; +import { AttachmentToolkit } from "./attachment/tools.ts"; import { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; @@ -30,6 +31,7 @@ it("publishes unique tool names with object-root inputs", () => { PreviewToolkit, WorktreeToolkit, ThreadToolkit, + AttachmentToolkit, ProjectToolkit, EnvironmentToolkit, PreviewControlsToolkit, diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index e3f718843c00..54d7cefd168a 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -81,6 +81,9 @@ const T3_MCP_TOOLS: Record< t3_project_update: { displayName: "Update a project" }, t3_project_delete: { displayName: "Delete a project" }, t3_project_clone: { displayName: "Clone a repository" }, + t3_attachment_prepare_upload: { displayName: "Prepare attachment upload" }, + t3_attachment_discard: { displayName: "Discard pending attachment" }, + t3_thread_send_attachments: { displayName: "Send attachments" }, preview_status: { displayName: "Get preview browser status" }, preview_open: { displayName: "Open a page in the preview browser" }, preview_navigate: { displayName: "Navigate the preview browser" }, From 3fe65d57e655e8873308c13045cf906476ac86d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 12:34:17 -0700 Subject: [PATCH 2/6] fix(mcp): describe the existing attachment upload method --- apps/server/src/mcp/toolkits/attachment/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 2d57f90c4e22..c5b8a6374ac1 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -34,7 +34,7 @@ const shared = { export const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", { ...shared, description: - "Create the app's signed upload URL. PUT the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.", + "Create the app's signed upload URL. POST the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.", parameters: Schema.Struct({ upload: AttachmentCreateUploadUrlInput }), success: AttachmentCreateUploadUrlResult, }).annotate(Tool.Destructive, true); From 7891d7ab57fa437aa3a570674f01e5a9eeca961b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 12:39:31 -0700 Subject: [PATCH 3/6] fix(mcp): retain actionable attachment claim messages --- apps/server/src/mcp/toolkits/attachment/handlers.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index f76c509be608..fe6a6e26b8fd 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -47,7 +47,15 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ const claimed = yield* Claims.claimPendingAttachments({ threadId: projection.thread.id, attachments: input.attachments, - }).pipe(Effect.mapError(unavailable)); + }).pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.message, + }), + ), + ); // Preserve claimed copies if dispatch may have committed before failing to return. const result = yield* threads .sendToThread({ From 3701a416a19c0710bb8d329fe1b0527687004cdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 12:50:45 -0700 Subject: [PATCH 4/6] fix(mcp): reuse stored attachment metadata --- .../src/mcp/toolkits/attachment/handlers.ts | 41 +++++++++++-------- apps/server/src/mcp/toolkits/core.test.ts | 22 ++++++++++ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index fe6a6e26b8fd..8424aeaed171 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -1,4 +1,4 @@ -import { MessageId, OrchestratorMcpFailure } from "@t3tools/contracts"; +import { type ChatAttachment, MessageId, OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; @@ -10,6 +10,26 @@ import { } from "../../threadAccess.ts"; import { AttachmentToolkit } from "./tools.ts"; +export function resolveAttachmentReferences( + requested: ReadonlyArray, + stored: ReadonlyArray, +) { + const owned = new Map(stored.map((attachment) => [attachment.id, attachment])); + return Effect.forEach(requested, (attachment) => { + const canonical = Claims.attachmentIsPendingUpload(attachment) + ? attachment + : owned.get(attachment.id); + return canonical === undefined + ? Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Attachments must be pending uploads or already belong to the target thread.", + }), + ) + : Effect.succeed(canonical); + }); +} + export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ t3_attachment_prepare_upload: (input) => Effect.gen(function* () { @@ -27,26 +47,15 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ t3_thread_send_attachments: (input) => Effect.gen(function* () { const { caller, threads, projection } = yield* readWritableThread(input.threadId); - const owned = new Set( - projection.messages.flatMap((message) => - message.attachments.map((attachment) => attachment.id), - ), + const attachments = yield* resolveAttachmentReferences( + input.attachments, + projection.messages.flatMap((message) => message.attachments), ); - if ( - input.attachments.some( - (attachment) => - !Claims.attachmentIsPendingUpload(attachment) && !owned.has(attachment.id), - ) - ) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "Attachments must be pending uploads or already belong to the target thread.", - }); const commandId = yield* newCommandId(); const messageId = MessageId.make(commandId); const claimed = yield* Claims.claimPendingAttachments({ threadId: projection.thread.id, - attachments: input.attachments, + attachments, }).pipe( Effect.mapError( (error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index e179e1581c9b..67109f72816a 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, + ChatImageAttachment, EnvironmentId, ProviderInstanceId, ThreadId, @@ -21,6 +22,7 @@ import { EnvironmentToolkit } from "./environment/tools.ts"; 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 { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; @@ -138,3 +140,23 @@ it("keeps MCP preference output allowlisted and Unicode-bounded", () => { truncated: true, }); }); + +it.effect("resolves reused attachment references from stored metadata", () => + Effect.gen(function* () { + const stored = ChatImageAttachment.make({ + type: "image", + id: "owned-image", + name: "original.png", + mimeType: "image/png", + sizeBytes: 12, + }); + const forged = { ...stored, name: "changed.jpg", mimeType: "image/jpeg", sizeBytes: 99 }; + const result = yield* AttachmentHandlers.resolveAttachmentReferences([forged], [stored]); + expect(result).toEqual([stored]); + const failure = yield* AttachmentHandlers.resolveAttachmentReferences( + [{ ...forged, id: "other-image" }], + [stored], + ).pipe(Effect.flip); + expect(failure.code).toBe("invalid_request"); + }), +); From 62a515beacbb13a2d47f7a1186bdcfed5816f490 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 12:54:18 -0700 Subject: [PATCH 5/6] fix(mcp): reject archived attachment targets before claiming --- apps/server/src/mcp/toolkits/attachment/handlers.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 8424aeaed171..5431e601174c 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -47,6 +47,11 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ t3_thread_send_attachments: (input) => Effect.gen(function* () { const { caller, threads, projection } = yield* readWritableThread(input.threadId); + if (projection.thread.archivedAt !== null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Unarchive the target thread before sending attachments.", + }); const attachments = yield* resolveAttachmentReferences( input.attachments, projection.messages.flatMap((message) => message.attachments), From 71fa7c96ab7422920fedd57083b38e9992d29b42 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 13:36:35 -0700 Subject: [PATCH 6/6] refactor(mcp): use shared attachment send intake --- .../src/mcp/toolkits/attachment/handlers.ts | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 5431e601174c..55961dcc48ef 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -2,6 +2,7 @@ import { type ChatAttachment, MessageId, OrchestratorMcpFailure } from "@t3tools import * as Effect from "effect/Effect"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; +import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import { newCommandId, readMutationCaller, @@ -46,7 +47,7 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ }), t3_thread_send_attachments: (input) => Effect.gen(function* () { - const { caller, threads, projection } = yield* readWritableThread(input.threadId); + const { caller, projection } = yield* readWritableThread(input.threadId); if (projection.thread.archivedAt !== null) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -58,32 +59,23 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({ ); const commandId = yield* newCommandId(); const messageId = MessageId.make(commandId); - const claimed = yield* Claims.claimPendingAttachments({ + const result = yield* ThreadMessageIntake.sendToThread({ + projectId: caller.projectId, threadId: projection.thread.id, + commandId, + messageId, + text: input.message ?? "", attachments, + mode: "auto", + createdBy: "agent", + creationSource: "mcp", }).pipe( - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ - code: "orchestration_error", - message: error.message, - }), + Effect.mapError((error) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) + : unavailable(), ), ); - // Preserve claimed copies if dispatch may have committed before failing to return. - const result = yield* threads - .sendToThread({ - projectId: caller.projectId, - threadId: projection.thread.id, - commandId, - messageId, - text: input.message ?? "", - attachments: claimed.attachments, - mode: "auto", - createdBy: "agent", - creationSource: "mcp", - }) - .pipe(Effect.mapError(unavailable)); return { threadId: projection.thread.id, messageId,