File inputs (SEP-2356)

x-mcp-file schema marker and RFC 2397 data-URI encoding for file arguments. Experimental.

SEP-2356 File Inputs — Data URI File Arguments

Experimental — tracks SEP-2356 (File Inputs), a draft SEP. Wire format may change.

Demonstrates the x-mcp-file JSON Schema extension: a server marks string
properties of format: "uri" as file pickers, the client encodes the
selected file as an RFC 2397 base64 data URI
(data:<mediatype>;name=<pct-encoded-filename>;base64,<payload>), and the
server decodes the URI on the receiving end.

Spec: modelcontextprotocol/specification PR 2356.

Three demo tools

Tool Schema marker Demonstrates
upload_image image: FileInputProperty(image/*, max 5 MB) single-file picker with MIME wildcard + size cap
analyze_documents documents: FileInputArrayProperty(application/pdf, .pdf) array of files (items.x-mcp-file)
process_any_file file: FileInputProperty({}) empty descriptor — any file, any size

Quick Start

# Terminal 1 — start the MCP server
just serve

# Terminal 2 — scripted walkthrough
just demo
# or for the interactive TUI:
go run . --tui

The walkthrough reads four embedded fixtures from
testdata/pixel.png (a 24×24 gradient), contract.pdf,
appendix.pdf, README.txt — encodes each via core.EncodeDataURI, and
invokes every tool. Before each upload, the demo shows a small preview of
what’s being sent: the iTerm2 inline-image
protocol
for images
(rendered inline in iTerm2 / WezTerm / Mintty / Kitty; silently consumed
elsewhere), the first lines of text, and a hex preview for everything
else. See WALKTHROUGH.md for the full sequence diagram
and step-by-step description.

Run just serve in a second terminal to watch the server side too — it
streams colored [mcp] traces (MCP tools/call ok [276µs], [http] → POST /mcp) for every request the walkthrough fires, matching the
elicitation / mrtr examples.

The server also writes every received file to a fresh
/tmp/file-inputs-demo-XXXX/ directory (announced at startup) so you can
ls the directory and open pixel.png to verify byte-for-byte that the
right payload arrived. The directory is not auto-cleaned — rm -rf /tmp/file-inputs-demo-* when you’re done. The full path is also surfaced
in each tool’s response so the demo client prints it inline (saved as: /tmp/file-inputs-demo-…/pixel.png).

To upload a file from disk instead, pass --file <path> to the demo:

go run . --file /path/to/photo.png

That step is skipped silently when the flag is absent so the default run
stays hermetic.

Connecting an external MCP host

Any MCP host can drive the running server.

VS Code (MCP extension)

Add to your VS Code MCP settings (Cmd+Shift+PMCP: Edit User
Configuration
):

{
  "mcpServers": {
    "file-inputs-demo": {
      "type": "streamable-http",
      "url": "http://localhost:8080/mcp"
    }
  }
}

Reload the MCP view; the three tools will appear under
file-inputs-demo. Until VS Code learns the SEP-2356 file-picker hint, it
shows the file argument as a plain string — paste a data URI manually
(data:image/png;base64,iVBORw0K…) or use a small helper to build one.

Claude Code

claude mcp add file-inputs --transport streamable-http http://localhost:8080/mcp

MCPJam

  1. Server URL: http://localhost:8080/mcp
  2. Transport: Streamable HTTP

Wire format cheatsheet

data:image/png;name=pixel.png;base64,iVBORw0KGgoAAAANS...
└─┬─┘└───┬──┘ └────┬────┘└─┬─┘└──────────┬──────────┘
  │     │         │       │              │
  scheme media-   filename encoding      payload
        type     (pct-enc) marker        (base64)
  • core.EncodeDataURI(data, mediaType, filename) builds the string.
  • core.DecodeDataURI(uri) returns (data, mediaType, filename, error)
    and rejects non-base64 forms with ErrNonBase64DataURI.
  • core.IsDataURI(s) is a cheap prefix check.

Where to look in the code

What Where
Schema helpers core.FileInputProperty, FileInputArrayProperty, ExtractFileInputDescriptor
Wire encoding core.EncodeDataURI, DecodeDataURI, IsDataURI
Capability marker ClientCapabilities.FileInputs (core/protocol.go), core.HasFileInputs(ctx)
SEP plan docs/SEP_2356_FILE_INPUTS_PLAN.md
Spec modelcontextprotocol/specification PR 2356

What’s still pending

This example exercises Phase 1.1 (types) + 1.2 (data URIs) + a starter
1.7 walkthrough. Subsequent phases will extend it:

  • 1.4server.ValidateFileInput for size/MIME enforcement (the
    handlers will swap their inline checks for the shared validator).
  • 1.5 — strip x-mcp-file from tools/list when the client did not
    declare capabilities.fileInputs.
  • 1.6client.PrepareFileArg(path, descriptor) so the host side of
    the walkthrough can drop the manual os.ReadFile + EncodeDataURI
    boilerplate.
  • 2.xmcp.selectFile() in the MCP Apps bridge so an in-iframe app
    can prompt the user for a file and forward the data URI to a tool.

Next steps


Walkthrough

MCP File Inputs (SEP-2356) — Data URI File Arguments

Walks through SEP-2356, which lets servers declare file-input properties on tool inputSchemas via the x-mcp-file JSON Schema extension. Clients render a file picker for those fields and pass selected files as RFC 2397 base64 data URIs (data:<mediatype>;name=<filename>;base64,<...>). The server decodes the URI, validates size/MIME, and processes the bytes.

What you’ll learn

  • Connect to the file-inputs serverclient.NewClient(...) + client.WithFileInputs() + Connect(). The WithFileInputs option advertises capabilities.fileInputs={} on the wire — without it, the server would strip x-mcp-file from every tool’s inputSchema (per SEP-2356 cap-gating). The next step inspects the raw response to confirm the keyword survives.
  • tools/list — extract file-input descriptorsclient.FileInputsFromTool(tool) extracts the per-property descriptors a server advertised. Single-file properties land under their name ("image"); array-of-files shapes land under name[] ("documents[]") so callers can disambiguate without re-walking the schema.
  • upload_image — encode + call with a real PNG — Read testdata/pixel.png (a 24×24 RGB gradient, embedded at build time), encode it via core.EncodeDataURI, and pass the resulting string as the image argument. The handler runs core.DecodeDataURI to recover bytes, media type, and the original filename. Size and MIME validation will be enforced by server.ValidateFileInput once Phase 1.4 lands; today the handler trusts the input.
  • analyze_documents — array-of-files input — Demonstrates core.FileInputArrayProperty — the schema marks the documents array’s items with x-mcp-file, so a host renders one picker per row. The walkthrough loads two embedded PDFs (testdata/contract.pdf, testdata/appendix.pdf) and sends both in one call.
  • process_any_file — no accept/maxSize filter — Empty FileInputDescriptor{} means “any file, any size.” Useful for ad-hoc inspection. The handler still decodes via core.DecodeDataURI, which rejects malformed or non-base64 URIs. The walkthrough reads testdata/README.txt so the payload is a real on-disk file.
  • Optional: send a file from disk via client.PrepareFileArg — Pass --file <path> on the demo command line to read an image from disk and upload it. Skipped silently when the flag isn’t set so the walkthrough stays hermetic. The encode path is now client.PrepareFileArg(path, descriptor) — single call that reads the file, detects MIME, validates against the descriptor (size + accept patterns, same rules as the server-side validator), and returns the data URI. Failures surface as typed errors (*core.FileTooLargeError, *core.FileTypeNotAcceptedError) so callers can branch with errors.As.
  • upload_image rejects wrong MIME (text/plain into image/ slot)* — The descriptor declares accept: ["image/*"]. Sending a text/plain data URI hits the dispatcher’s accept-pattern matcher (core.FileMatchesAccept), which fails before the handler runs. The error data carries mediaType (what we sent) and accept (what the server requires) so a client can render a useful message.
  • upload_image rejects oversized payload (6 MiB into 5 MiB cap) — Same descriptor declares maxSize: 5_242_880 (5 MiB). We synthesize a 6 MiB null-byte buffer, encode as image/png, and send it. The validator decodes the data URI, sees the size cap is exceeded, and short-circuits with structured size info.
  • analyze_documents rejects per-element with field path tracking — Send a 2-element array where element 0 is a valid PDF and element 1 is a text/plain payload. The dispatcher’s array-items walker validates each element against items.x-mcp-file and surfaces the path of the offender — data.field == "documents[1]". Useful so a client rendering rich error UX can highlight the specific input that failed instead of asking the user to re-pick everything.

Flow

sequenceDiagram
    participant Host as MCP Host (this client)
    participant Server as MCP Server (just serve)

    Note over Host,Server: Step 1: Connect to the file-inputs server
    Host->>Server: POST /mcp — initialize (capabilities.fileInputs={})
    Server-->>Host: serverInfo + capabilities

    Note over Host,Server: Step 2: tools/list — extract file-input descriptors
    Host->>Server: tools/list
    Server-->>Host: tools[] with x-mcp-file marked properties

    Note over Host,Server: Step 3: upload_image — encode + call with a real PNG
    Host->>Server: tools/call upload_image { image: data:image/png;name=…;base64,… }
    Server-->>Host: text result with size + media type

    Note over Host,Server: Step 4: analyze_documents — array-of-files input
    Host->>Server: tools/call analyze_documents { documents: [data:application/pdf;…, data:application/pdf;…] }
    Server-->>Host: summary line per document

    Note over Host,Server: Step 5: process_any_file — no accept/maxSize filter
    Host->>Server: tools/call process_any_file { file: data:text/plain;name=README.txt;base64,… }
    Server-->>Host: decoded media type + size

    Note over Host,Server: Step 6: Optional: send a file from disk via client.PrepareFileArg
    Host->>Server: tools/call upload_image (from --file <path>)
    Server-->>Host: decoded metadata of the on-disk file

    Note over Host,Server: Step 7: upload_image rejects wrong MIME (text/plain into image/* slot)
    Host->>Server: tools/call upload_image { image: data:text/plain;… }
    Server-->>Host: -32602 + data: {reason: file_type_not_accepted, mediaType, accept, field}

    Note over Host,Server: Step 8: upload_image rejects oversized payload (6 MiB into 5 MiB cap)
    Host->>Server: tools/call upload_image { image: data:image/png;… 6 MiB }
    Server-->>Host: -32602 + data: {reason: file_too_large, actualSize, maxSize, field}

    Note over Host,Server: Step 9: analyze_documents rejects per-element with field path tracking
    Host->>Server: tools/call analyze_documents { documents: [valid pdf, text/plain] }
    Server-->>Host: -32602 + data.field = "documents[1]"

Steps

Setup

Start the MCP server in a separate terminal first:

Terminal 1:  just serve         # file-inputs server on :8080
Terminal 2:  just demo          # this walkthrough (--tui for the interactive TUI)

Any MCP host can connect to the running server (Claude Desktop, VS Code, MCPJam). The walkthrough below acts as a scripted host that reads files from disk, encodes them as data URIs via core.EncodeDataURI, and calls the tools. See the README for VS Code config.

Wire format

SEP-2356 reuses two well-understood pieces:

  • Schema marker — a string property of format: "uri" carries an extra x-mcp-file keyword whose value is a FileInputDescriptor (accept MIME patterns / extensions, optional maxSize in decoded bytes). Server-side helpers: core.FileInputProperty(desc) and core.FileInputArrayProperty(desc).
  • Wire encoding — files travel as RFC 2397 base64 data URIs with an optional percent-encoded name= parameter: data:image/png;name=photo.png;base64,iVBORw0…. Helpers: core.EncodeDataURI(data, mediaType, filename) and core.DecodeDataURI(uri).

Capability negotiation: the client advertises "fileInputs": {} inside ClientCapabilities during initialize. Servers MUST NOT include x-mcp-file in tool schemas if the client did not declare the capability — core.HasFileInputs(ctx) is the server-side check (gating ships in Phase 1.5 of the plan).

Step 1: Connect to the file-inputs server

client.NewClient(...) + client.WithFileInputs() + Connect(). The WithFileInputs option advertises capabilities.fileInputs={} on the wire — without it, the server would strip x-mcp-file from every tool’s inputSchema (per SEP-2356 cap-gating). The next step inspects the raw response to confirm the keyword survives.

Step 2: tools/list — extract file-input descriptors

client.FileInputsFromTool(tool) extracts the per-property descriptors a server advertised. Single-file properties land under their name ("image"); array-of-files shapes land under name[] ("documents[]") so callers can disambiguate without re-walking the schema.

Step 3: upload_image — encode + call with a real PNG

Read testdata/pixel.png (a 24×24 RGB gradient, embedded at build time), encode it via core.EncodeDataURI, and pass the resulting string as the image argument. The handler runs core.DecodeDataURI to recover bytes, media type, and the original filename. Size and MIME validation will be enforced by server.ValidateFileInput once Phase 1.4 lands; today the handler trusts the input.

Step 4: analyze_documents — array-of-files input

Demonstrates core.FileInputArrayProperty — the schema marks the documents array’s items with x-mcp-file, so a host renders one picker per row. The walkthrough loads two embedded PDFs (testdata/contract.pdf, testdata/appendix.pdf) and sends both in one call.

Step 5: process_any_file — no accept/maxSize filter

Empty FileInputDescriptor{} means “any file, any size.” Useful for ad-hoc inspection. The handler still decodes via core.DecodeDataURI, which rejects malformed or non-base64 URIs. The walkthrough reads testdata/README.txt so the payload is a real on-disk file.

Step 6: Optional: send a file from disk via client.PrepareFileArg

Pass --file <path> on the demo command line to read an image from disk and upload it. Skipped silently when the flag isn’t set so the walkthrough stays hermetic. The encode path is now client.PrepareFileArg(path, descriptor) — single call that reads the file, detects MIME, validates against the descriptor (size + accept patterns, same rules as the server-side validator), and returns the data URI. Failures surface as typed errors (*core.FileTooLargeError, *core.FileTypeNotAcceptedError) so callers can branch with errors.As.

Validation — server rejects non-conforming uploads (Phase 1.4)

The server is started with server.WithFileInputValidation() (see examples/file-inputs/main.go), so the dispatcher walks each tool’s inputSchema for x-mcp-file properties and runs core.ValidateFileInput on every matching arg BEFORE the handler runs. Failures surface as JSON-RPC -32602 with a structured data payload — that exact shape is the contract pinned by the SEP-2356 conformance scenarios on the panyam/mcpconformance pending branch (src/scenarios/server/file-inputs/).

The next three steps exercise all three failure modes the validator covers. They drive the server through the Go MCP client (*client.Client); the client.RPCError returned on rejection carries the same structured data field the wire emits. Each step prints error.code, error.message, and error.data so the rejection contract is visible in the demo output.

Each step also attaches a copy-pasteable verbatim block with two variants (rendered via demokit VerbatimVariants): a curl form (the default — for validating from a non-Go SDK or sanity-checking the JSON shape directly) and a go form showing the equivalent *client.Client call. Pass --variant=go to render only the Go form.

Step 7: upload_image rejects wrong MIME (text/plain into image/* slot)

The descriptor declares accept: ["image/*"]. Sending a text/plain data URI hits the dispatcher’s accept-pattern matcher (core.FileMatchesAccept), which fails before the handler runs. The error data carries mediaType (what we sent) and accept (what the server requires) so a client can render a useful message.

Reproduce on the wire

# Mint a session
SID=$(curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{"fileInputs":{}}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json" -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null

# Send a text/plain payload into upload_image's image/* slot
URI='data:text/plain;name=x.txt;base64,aGVsbG8='
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"upload_image\",\"arguments\":{\"image\":\"$URI\"}}}"

Step 8: upload_image rejects oversized payload (6 MiB into 5 MiB cap)

Same descriptor declares maxSize: 5_242_880 (5 MiB). We synthesize a 6 MiB null-byte buffer, encode as image/png, and send it. The validator decodes the data URI, sees the size cap is exceeded, and short-circuits with structured size info.

Reproduce on the wire

# Mint a session
SID=$(curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{"fileInputs":{}}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json" -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null

# Generate a 6 MiB image/png payload (Python keeps the curl short)
BIG=$(python3 -c 'import base64; print("data:image/png;name=big.png;base64," + base64.b64encode(b"\x00" * (6*1024*1024)).decode())')
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"upload_image\",\"arguments\":{\"image\":\"$BIG\"}}}"

Step 9: analyze_documents rejects per-element with field path tracking

Send a 2-element array where element 0 is a valid PDF and element 1 is a text/plain payload. The dispatcher’s array-items walker validates each element against items.x-mcp-file and surfaces the path of the offender — data.field == "documents[1]". Useful so a client rendering rich error UX can highlight the specific input that failed instead of asking the user to re-pick everything.

Reproduce on the wire

# Mint a session
SID=$(curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{"fileInputs":{}}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json" -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null

# Send 2 documents — index 0 valid PDF, index 1 wrong MIME
GOOD='data:application/pdf;name=ok.pdf;base64,JVBERi0xLjQKJSVFT0YK'
BAD='data:text/plain;name=bad.txt;base64,aGVsbG8='
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"analyze_documents\",\"arguments\":{\"documents\":[\"$GOOD\",\"$BAD\"]}}}"

MCP Apps mode (Phase 2.1)

This same server also registers two MCP App tools that drive the same handlers via in-iframe file pickers — the human-in-the-loop case file-uploads-wg flagged as a gap:

  • apps_upload_imageui://file-inputs/upload-image — single image picker (mcp.selectFile)
  • apps_analyze_documentsui://file-inputs/analyze-documents — multi PDF picker (mcp.selectFiles)

To exercise these, point a host that supports the MCP Apps extension (e.g. MCPJam) at this server and invoke either tool — the host renders the embedded HTML + bridge, the user clicks the picker, and the bridge encodes the chosen file(s) as data URI(s) before calling the regular tool. The walkthrough above doesn’t drive these because demokit can’t synthesize iframe user-gestures.

Where to look in the code

  • Schema helpers: core.FileInputProperty / core.FileInputArrayProperty / core.ExtractFileInputDescriptor — core/file_input.go
  • Wire encoding: core.EncodeDataURI / core.DecodeDataURI / core.IsDataURI — core/datauri.go
  • Capability marker: ClientCapabilities.FileInputs + core.HasFileInputs(ctx) — core/protocol.go, core/file_input.go
  • Server validation (Phase 1.4): server.ValidateFileInput — pending
  • Capability gating (Phase 1.5): strip x-mcp-file from tools/list when client lacks the cap — pending
  • Client helpers (Phase 1.6): client.FileInputsFromTool / client.PrepareFileArg — pending
  • Bridge selectFile / selectFiles (Phase 2.1): ext/ui/assets/file-picker.ts — shipped
  • Apps fixtures: examples/file-inputs/apps/upload-image.html, analyze-documents.html
  • SEP-2356 spec: modelcontextprotocol/specification PR 2356

Run it

go run ./examples/file-inputs/

Pass --non-interactive to skip pauses:

go run ./examples/file-inputs/ --non-interactive