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..55961dcc48ef --- /dev/null +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -0,0 +1,86 @@ +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"; +import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; +import { + newCommandId, + readMutationCaller, + readWritableThread, + unavailable, +} 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* () { + 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, 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), + ); + const commandId = yield* newCommandId(); + const messageId = MessageId.make(commandId); + 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) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) + : 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..c5b8a6374ac1 --- /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. 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); +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..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, @@ -20,6 +21,8 @@ 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 * as AttachmentHandlers from "./attachment/handlers.ts"; import { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; @@ -30,6 +33,7 @@ it("publishes unique tool names with object-root inputs", () => { PreviewToolkit, WorktreeToolkit, ThreadToolkit, + AttachmentToolkit, ProjectToolkit, EnvironmentToolkit, PreviewControlsToolkit, @@ -136,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"); + }), +); 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" },