# Files changed · PR \#10564 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10564/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mcp\): expose attachment upload and send](https://git.cafe/pingdotgg/t3code/pull/10564.md)

Version: 13

Head commit: 71fa7c96ab7422920fedd57083b38e9992d29b42

Base commit: bc357ee0f9bf9f944ee62d3541ced375f4437c76

Comparison base: bc357ee0f9bf9f944ee62d3541ced375f4437c76

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/mcp/McpHttpServer.ts

Status: modified; +7; −0

```diff
@@ -18,6 +18,8 @@
 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 @@
   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 @@
   PreviewToolkitRegistrationLive,
   OrchestratorToolkitRegistrationLive,
   ThreadToolkitRegistrationLive,
+  AttachmentRegistrationLive,
   ProjectRegistrationLive,
   EnvironmentRegistrationLive,
   PreviewControlsRegistrationLive,
```

## apps/server/src/mcp/toolkits/attachment/handlers.ts

Status: added; +86; −0

```diff
@@ -1,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<ChatAttachment>,
+  stored: ReadonlyArray<ChatAttachment>,
+) {
+  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,
+      };
+    }),
+});
```

## apps/server/src/mcp/toolkits/attachment/tools.ts

Status: added; +73; −0

```diff
@@ -1,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,
+);
```

## apps/server/src/mcp/toolkits/core.test.ts

Status: modified; +24; −0

```diff
@@ -2,6 +2,7 @@
 import { expect, it } from "@effect/vitest";
 import {
   DEFAULT_SERVER_SETTINGS,
+  ChatImageAttachment,
   EnvironmentId,
   ProviderInstanceId,
   ThreadId,
@@ -20,6 +21,8 @@
 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 @@
     PreviewToolkit,
     WorktreeToolkit,
     ThreadToolkit,
+    AttachmentToolkit,
     ProjectToolkit,
     EnvironmentToolkit,
     PreviewControlsToolkit,
@@ -136,3 +140,23 @@
     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");
+  }),
+);
```

## packages/shared/src/t3McpToolPresentation.ts

Status: modified; +3; −0

```diff
@@ -81,6 +81,9 @@
   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" },
```
