feat(mcp): expose attachment upload and send#10564

merged6
agents/mcp-thin/projects
agents/mcp-thin/attachments
openedSep 7, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago

Part 14/16 of the shared-core and MCP stack. Based on #10563. Next: #10565.

Expose signed upload preparation, pending-upload discard and an attachment-aware send tool using the existing upload, claim and thread-send services.

t3_thread_send remains unchanged. The new t3_thread_send_attachments checks target ownership and permission ceilings. Each call is a new message; claimed files are retained when dispatch outcome is uncertain. Provider support stays in adapters, and no keyed replay protocol is added.

Attachment claiming and send execution now go through ThreadMessageIntake, shared with RPC. MCP retains only credential/target checks, canonical owned attachment references, and response mapping.

MCP-only rebuild of #8700, preserving attribution to Julius Marminge's original work. Original branches remain available for separate service follow-ups.

Validation: The composed stack passes 94 tests across 12 focused files, including shared core MCP and real MCP/V2 integration, attachment intake, project RPC/service contracts, and client model command selection. Server/contracts/shared/client-runtime typechecks and targeted format/lint/diff checks pass. New behavior coverage lives with the shared operation; no per-tool mock suite was added. Current-head CI is shown below.

Layer size: 5 files, +193/-0. No domain-service production implementation or documentation files change in this MCP layer.

Prepared with Codex in the OpenAI agent runtime.

Note

Expose attachment upload, discard, and send as MCP tools

  • Adds the AttachmentToolkit with three destructive tools: t3_attachment_prepare_upload, t3_attachment_discard, and t3_thread_send_attachments
  • t3_thread_send_attachments resolves attachment references against the target thread's stored attachments, rejects archived threads, and sends a new MCP-originated agent message in automatic mode, returning message and run identifiers
  • resolveAttachmentReferences keeps pending-upload references as-is, replaces known IDs with stored metadata, and fails unknown IDs with an invalid-request MCP failure
  • Upload-service errors map to unavailable failures; dispatch errors map to orchestration or unavailable failures depending on type
  • Behavioral Change: t3_thread_send_attachments accepts 1–8 attachments and an optional message capped at 120,000 characters; references that are neither pending uploads nor in the target thread's stored set are rejected

Macroscope summarized 71fa7c9.

apps/server/src/mcp/toolkits/attachment/tools.tsline 37Outdated · 0024ed0Open thread
……@@ -0,0 +1,73 @@
34+export const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", {
35+ ...shared,
36+ description:
37+ "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.",
MmacroscopeappBoton line 37Sep 7, 2026

🟠 High attachment/tools.ts:37

Following the PUT instruction in this description reaches no handler because attachmentUploadRouteLayer only registers POST for the returned upload URL. The staged file is therefore never uploaded, so the subsequent t3_thread_send_attachments call cannot send it; register a PUT handler or change the contract to match the route.

Also found in 1 other location(s)

apps/server/src/mcp/McpHttpServer.ts:277

Registering AttachmentRegistrationLive exposes t3_attachment_prepare_upload, whose tool description directs clients to PUT the upload bytes, but attachmentUploadRouteLayer is registered only for POST at that URL. A client following the newly exposed tool contract therefore cannot upload the staged file and cannot subsequently send the attachment.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/toolkits/attachment/tools.ts around line 37:
2
3Following the `PUT` instruction in this description reaches no handler because `attachmentUploadRouteLayer` only registers `POST` for the returned upload URL. The staged file is therefore never uploaded, so the subsequent `t3_thread_send_attachments` call cannot send it; register a `PUT` handler or change the contract to match the route.
4
5Also found in 1 other location(s):
6- apps/server/src/mcp/McpHttpServer.ts:277 -- Registering `AttachmentRegistrationLive` exposes `t3_attachment_prepare_upload`, whose tool description directs clients to `PUT` the upload bytes, but `attachmentUploadRouteLayer` is registered only for `POST` at that URL. A client following the newly exposed tool contract therefore cannot upload the staged file and cannot subsequently send the attachment.

Fixed in bf8195852e913b0cefd21d9e2510d942de8066c7. The tool now says POST, matching attachmentUploadRouteLayer in apps/server/src/http.ts:402-404. The existing route consumes the raw request stream and returns 204 on successful storage; no new route or service behavior was needed. Targeted formatting/diff check, the shared core MCP tests (3/3), and canonical server typecheck pass.

Synced from GitHub

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 71fa7c9.

This comment will update automatically after the next completed run.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR exposes three new destructive MCP operations that persist uploads and create attachment-bearing thread runs, materially expanding the production tool surface. An unresolved High-severity finding also reports that pending-upload metadata can be client-forged during attachment claiming, requiring review before merge.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

No code changes detected at 71fa7c9. Prior analysis still applies.

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

apps/server/src/mcp/toolkits/attachment/handlers.tslines 47–50Outdated · bf81958Open thread
……@@ -0,0 +1,72 @@
47+ const claimed = yield* Claims.claimPendingAttachments({
48+ threadId: projection.thread.id,
49+ attachments: input.attachments,
50+ }).pipe(Effect.mapError(unavailable));
MmacroscopeappBoton lines 47–50Sep 7, 2026

claimPendingAttachments fails only with AttachmentClaimError, whose messages are caller-actionable validation results ("attachment not found", "stored size does not match", "attachment type does not match the upload"). Mapping them through unavailable collapses all of them into orchestration_error / "The operation could not be completed.", so the calling agent cannot tell a bad upload reference from a server fault. Consider mapping where the context is known, as readThread already does for thread-not-found:

suggestion
1 const claimed = yield* Claims.claimPendingAttachments({
2 threadId: projection.thread.id,
3 attachments: input.attachments,
4 }).pipe(
5 Effect.catchTags({
6 AttachmentClaimError: (error) =>
7 Effect.fail(
8 new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }),
9 ),
10 }),
11 );

Posted via Macroscope — Effect Service Conventions

Fixed in dd7eca05adffd26e53763cd3ea083320b38f49c2 by retaining AttachmentClaimError.message but never its cause. The production claim service constructs these messages from bounded caller filenames and fixed explanations. I kept orchestration_error rather than mapping every error to invalid_request: copyFile failures and operational stat failures share this same typed channel, so that blanket classification would be inaccurate. This now gives the agent the actionable message without inventing a new service taxonomy. Shared tests/typecheck/lint pass.

apps/server/src/mcp/toolkits/attachment/handlers.tsline 38Outdated · dd7eca0Open thread
……@@ -0,0 +1,80 @@
35+ if (
36+ input.attachments.some(
37+ (attachment) =>
38+ !Claims.attachmentIsPendingUpload(attachment) && !owned.has(attachment.id),
MmacroscopeappBoton line 38Sep 7, 2026

🟠 High attachment/handlers.ts:38

Already-owned attachments can be dispatched with forged metadata, causing the provider to resolve an existing <id>.png as <id>.jpg (or deliver incorrect MIME metadata) and fail to read it. The check at line 38 retains only IDs and forwards the caller-supplied attachment unchanged; look up the matching attachment in projection and use its canonical fields, or require an exact metadata match.

No longer relevant as of 8a1460937c86baa3c41b0c036e1b2a92d107218d

Fixed in 8a1460937c. Already-owned IDs now resolve to the exact attachment object stored on the target projection before claim/send; caller-supplied MIME/name/size no longer replace the stored fields. Pending uploads keep the existing claim-service validation. A shared MCP boundary regression proves forged JPEG metadata returns the stored PNG attachment and an unknown ID rejects. Shared core 5/5, final core + real MCP integration 7/7, server typecheck and targeted lint/fmt/diff pass. No service implementation changed.

apps/server/src/mcp/toolkits/attachment/handlers.tslines 35–44Outdated · dd7eca0Open thread
……@@ -0,0 +1,80 @@
41+ return yield* new OrchestratorMcpFailure({
42+ code: "invalid_request",
43+ message: "Attachments must be pending uploads or already belong to the target thread.",
44+ });
MmacroscopeappBoton lines 35–44Sep 7, 2026

This ownership guard is new backend behavior (it is what stops an agent from re-sending another thread's attachment id) and nothing exercises it: no test invokes t3_thread_send_attachments, and the only test touched by this PR asserts tool-name uniqueness. Consider a focused handler test that drives the toolkit through the production registration — the same shape as toolkits/core.test.ts — with test layers for FileSystem/ThreadManagementService only, covering the pending-upload path, the already-owned path, and the rejected foreign-id path.

Posted via Macroscope — Effect Service Conventions

The ownership boundary now has a focused shared MCP regression in 8a1460937c: it resolves stored canonical metadata and rejects an unknown/foreign attachment ID. Pending-upload validation remains in AttachmentClaims, whose behavior is unchanged. The final shared core + real MCP integration run passed 7/7. Per the maintainer's explicit direction, I am not adding a separate mocked filesystem/send suite for every forwarding tool; the new mapping itself is covered and existing service tests retain responsibility for upload/claim execution.

apps/server/src/mcp/toolkits/attachment/handlers.tsline 56Outdated · 8a14609Open thread
……@@ -0,0 +1,89 @@
53+ );
54+ const commandId = yield* newCommandId();
55+ const messageId = MessageId.make(commandId);
56+ const claimed = yield* Claims.claimPendingAttachments({
MmacroscopeappBoton line 56Sep 7, 2026

🟠 High attachment/handlers.ts:56

Sending attachments to an archived thread claims the pending uploads but leaves the claimed copies behind when sendToThread rejects the target. Repeating the request creates another UUID-named non-pending file each time, bypassing the pending sweep and eventually exhausting attachment storage; release the claims on this error path or reject archived threads before claimPendingAttachments.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/toolkits/attachment/handlers.ts around line 56:
2
3Sending attachments to an archived thread claims the pending uploads but leaves the claimed copies behind when `sendToThread` rejects the target. Repeating the request creates another UUID-named non-pending file each time, bypassing the pending sweep and eventually exhausting attachment storage; release the claims on this error path or reject archived threads before `claimPendingAttachments`.

Fixed in 8c58757ba6: the wrapper rejects an already-archived target immediately after the scoped target read, before resolving or claiming any attachments. This closes the reported repeated archived-target allocation path. Shared MCP core 5/5, server typecheck, targeted lint/fmt/diff pass. A later concurrent archive can still make dispatch fail; blanket cleanup is intentionally not used because send may have durably committed before a later observation fails. Precise accepted-versus-unaccepted cleanup belongs in the shared service, separately from this wrapper PR.

apps/server/src/mcp/toolkits/attachment/handlers.tsline 20Outdated · 1125342Open thread
……@@ -0,0 +1,86 @@
17+) {
18+ const owned = new Map(stored.map((attachment) => [attachment.id, attachment]));
19+ return Effect.forEach(requested, (attachment) => {
20+ const canonical = Claims.attachmentIsPendingUpload(attachment)
MmacroscopeappBoton line 20Sep 7, 2026

🟠 High attachment/handlers.ts:20

Pending attachment IDs are accepted with client-supplied type, contentType, and size, so a file upload can be dispatched as an image even when its upload authorization was for a different type. Because the pending branch returns attachment unchanged, resolve the ID against the server-side upload metadata and use that canonical metadata when constructing the claim.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/toolkits/attachment/handlers.ts around line 20:
2
3Pending attachment IDs are accepted with client-supplied `type`, `contentType`, and `size`, so a `file` upload can be dispatched as an `image` even when its upload authorization was for a different type. Because the pending branch returns `attachment` unchanged, resolve the ID against the server-side upload metadata and use that canonical metadata when constructing the claim.

This branch keeps pending uploads on the existing RPC claim path; it does not introduce a separate upload-type policy. The upload HTTP endpoint verifies the signed token and size, while AttachmentClaims validates the stored size and requires the path derived from the supplied type/name/MIME to match the actual staged path before copying. A different extension fails that check. There is no persisted upload-metadata record to resolve here: the metadata lives in the signed upload token. Image/file classification is not a separate MCP authorization capability, and RPC accepts the same references. Adding a persistent metadata record or a stricter MIME/content policy would be a shared upload-service change, not an MCP-only check. Keeping this extraction aligned with RPC; no new bypass was introduced by the shared intake change.

Added 6 commits

  • fix(mcp): reuse stored attachment metadata3701a41
  • fix(mcp): describe the existing attachment upload method3fe65d5
  • fix(mcp): reject archived attachment targets before claiming62a515b
  • refactor(mcp): use shared attachment send intake71fa7c9
  • fix(mcp): retain actionable attachment claim messages7891d7a
  • feat(mcp): expose existing attachment upload and send servicesdc42153
Synced from GitHub

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.