Skip to content

Reference implementation of the MCP file-uploads extension - #25

Draft
markmnl wants to merge 1 commit into
mainfrom
files-extension
Draft

markmnl wants to merge 1 commit into
mainfrom
files-extension

Conversation

@markmnl

@markmnl markmnl commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Based on main (presigned links landed in #27). On hold until the upstream draft (modelcontextprotocol/experimental-ext-file-uploads#1) merges.

Uploads to targets go through #27's streamIntoDraft (src/upload.ts).

A reference implementation of the experimental MCP file-uploads extension (io.modelcontextprotocol/files). It is built against specification/draft/file-uploads.md on localden/initial-draft at 6101d34eb52b21d4b87028543ecd1fd59ed3d5df, the head of the draft branch after PR #2 merged as 348c794.

What it does

The extension is on over Streamable HTTP with an https public MCP URL. FMSG_MCP_FILES_EXTENSION=off turns it off.

  • Negotiation. server/discover advertises {"io.modelcontextprotocol/files": {"maxInlineBytes": N}}. Upload requests go only to requests that declared the extension in their per-request clientCapabilities.
  • File arguments. The attachments items of send_message, reply and create_draft are anyOf: [classic attachment, FileInput]. The FileInput branch is marked x-mcp-file, with maxSize when the operator sets a per-file limit. A call can carry several files.
  • Inline files. Decoded, checked against their declared size and digest, and attached. The total must fit within maxInlineBytes.
  • Out-of-band files. The tool's own checks run first (recipients, the parent's reply rules), then every file check that needs no bytes. The call then creates its fmsg draft as the staging location, attaches inline files, and answers input_required with one target per file under inputRequests.uploads.
  • Targets. PUT {public URL}/uploads/{token} with credentials: "include" (same origin, under the canonical path). The token is an HMAC bound to the principal, draft, file, declared size and SHA-256, media type, call and expiry (10 minutes by default). A target takes one successful upload and refuses a wrong size (413/400) or digest (422). Bytes stream into the draft, so none are stored on this server.
  • Retry. The SDK's codec verifies requestState (MAC, expiry, principal binding) and answers -32602 on failure. The flow then checks the tool, a canonical hash of the arguments, and the per-file outcomes, and confirms each file in the draft by name, size and SHA-256. Only then is the draft sent, or returned for create_draft.
  • Specific errors for: failed outcomes (the call ends and the draft is deleted), early retries (the call stays open), repeated or concurrent retries (never run twice), expired targets (410 on PUT; a retry ends the call and deletes the draft), changed arguments, the wrong tool, and malformed, duplicate or unknown outcomes. A missing outcome re-issues the same targets.
  • No change for other clients. data_base64, data_text and stdio path work as before, and stdio does not advertise the extension.

SDK workaround

SDK v2.0.0 accepts only elicitation, sampling and roots as inputRequests entries; this is the draft's Open Question 1. So a tool returns input_required with requestState only. FilesRuntime.serve then adds inputRequests.uploads to that one response, in JSON or SSE, via a per-request AsyncLocalStorage scope. The inbound inputResponses.uploads passes through the SDK unchanged.

Code

  • src/files/extension.ts: identifiers, the FileInput schema, metadata checks, digest conversion, argument hashing.
  • src/files/runtime.ts: HMAC key, signed targets, call state, the abandoned-draft reaper, response injection.
  • src/files/flow.ts: initial call and retry.
  • src/files/upload-route.ts: the PUT handler.
  • src/files/tools.ts: what the send-type tools share.
  • send.ts and draft.ts: the tools' result building is pulled into functions so the upload path returns the same results.
  • New settings: FMSG_MCP_FILES_EXTENSION, FMSG_MCP_FILES_MAX_INLINE_BYTES, FMSG_MCP_FILES_MAX_FILE_BYTES, FMSG_MCP_FILES_TARGET_TTL_SECONDS, FMSG_MCP_STATE_SECRET (needed when several instances serve one endpoint).

Feedback for the working group

docs/files-extension-notes.md lists 24 places where the draft is ambiguous or silent, the choice made and a suggested spec fix. Examples: the MRTR extension point, the undefined upload-answer interface, x-mcp-file inside anyOf, how retries are rejected, what "single-use" means with three attempts, which statuses are permanent, staging when the backend has no storage-side expiry, and inline-only servers.

Tests

  • test/files-extension.test.ts (27 tests). An end-to-end run of a minimal client harness (test/files-client.ts) over HTTPS through a TLS proxy, with a throwaway openssl certificate made at test time so no key is committed. The harness does the client side: declare, check targets (https, address allowlist, headers, include only for the canonical URI), PUT with at most three attempts, retry. Tests cover send/reply/create_draft with out-of-band, inline and classic files in one call, plus every rule above.
  • test/files-runtime.test.ts: JSON and SSE injection, the reaper (single key versus shared), target signing and expiry.
  • test/oauth.test.ts: targets need fmsg:write and stream with the exchanged Web API token.
  • npm run typecheck && npm run build && npm test: 308 passed. The suite needs openssl on PATH for the HTTPS tests; they are skipped without it.

Not done

  • Downloads (the draft's download section, which the working group has not reviewed yet).
  • No checks against host limits or quota before bytes arrive: the Web API exposes neither. The host's refusal is passed through at the PUT or the send.

🤖 Generated with Claude Code

Base automatically changed from fix/added-recipient-and-version to main October 22, 2026 05:45
@markmnl
markmnl changed the base branch from main to presigned-links October 22, 2026 06:18
Base automatically changed from presigned-links to main October 22, 2026 06:26
A reference implementation of the experimental file-uploads draft at
modelcontextprotocol/experimental-ext-file-uploads, branch
localden/initial-draft at 6101d34eb52b21d4b87028543ecd1fd59ed3d5df (the head
after PR #2's merge).

Over HTTP with an https public MCP URL, server/discover advertises the
extension with maxInlineBytes, and the attachments of send_message, reply and
create_draft also take FileInputs ({id, name, mimeType, size, digest, data?},
marked x-mcp-file), several per call.

- Inline files are decoded and checked against their size and digest.
- Files without data are checked on their metadata (ids, names, media type,
  digest form, the operator's per-file limit, the inline budget) after the
  tool's own checks. The call then creates its fmsg draft as the staging
  location and answers input_required with one upload target per file.
- Targets are PUT /mcp/uploads/{token} with credentials "include": an HMAC
  token bound to the principal, draft, file, declared size and SHA-256, call
  and expiry. Each takes one successful upload and streams it into the draft
  through the same receiver as presigned upload links (streamIntoDraft), so
  no file bytes are kept on this server.
- The retry is verified by the SDK codec (MAC, expiry, principal; -32602 on
  failure), then against the tool, the canonical argument hash and the
  per-file outcomes. Each file is confirmed in the draft by name, size and
  digest before the draft is sent (or returned by create_draft). Failed
  outcomes, early retries, repeated or concurrent retries, expired targets
  and malformed outcomes get specific errors.

The SDK accepts only the base protocol's input request kinds, so a tool
returns input_required with requestState alone, and the HTTP layer adds
inputRequests.uploads to that response (JSON or SSE).

Classic data_base64, data_text and stdio path attachments are unchanged.
Tests cover the rules, plus an end-to-end run of a minimal client harness
over HTTPS through a TLS proxy and the OAuth path.
docs/files-extension-notes.md lists every ambiguity or gap in the draft, the
choice made, and a suggested fix, for the working group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant