File inputs (SEP-2356)
x-mcp-file schema marker and RFC 2397 data-URI encoding for file arguments. Experimental.
Source: examples/file-inputs/README.md
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+P → MCP: 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 ¶
- Server URL:
http://localhost:8080/mcp - 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 withErrNonBase64DataURI.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.4 —
server.ValidateFileInputfor size/MIME enforcement (the
handlers will swap their inline checks for the shared validator). - 1.5 — strip
x-mcp-filefromtools/listwhen the client did not
declarecapabilities.fileInputs. - 1.6 —
client.PrepareFileArg(path, descriptor)so the host side of
the walkthrough can drop the manualos.ReadFile+EncodeDataURI
boilerplate. - 2.x —
mcp.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
Generated from the scripted demo — run it with make serve + make demo. Source: examples/file-inputs/WALKTHROUGH.md
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 server —
client.NewClient(...)+client.WithFileInputs()+Connect(). TheWithFileInputsoption advertisescapabilities.fileInputs={}on the wire — without it, the server would stripx-mcp-filefrom 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 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 undername[]("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 viacore.EncodeDataURI, and pass the resulting string as theimageargument. The handler runscore.DecodeDataURIto recover bytes, media type, and the original filename. Size and MIME validation will be enforced byserver.ValidateFileInputonce Phase 1.4 lands; today the handler trusts the input. - analyze_documents — array-of-files input — Demonstrates
core.FileInputArrayProperty— the schema marks thedocumentsarray’s items withx-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 viacore.DecodeDataURI, which rejects malformed or non-base64 URIs. The walkthrough readstestdata/README.txtso 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 nowclient.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 witherrors.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 carriesmediaType(what we sent) andaccept(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 asimage/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-fileand 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 extrax-mcp-filekeyword whose value is aFileInputDescriptor(acceptMIME patterns / extensions, optionalmaxSizein decoded bytes). Server-side helpers:core.FileInputProperty(desc)andcore.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)andcore.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_image—ui://file-inputs/upload-image— single image picker (mcp.selectFile)apps_analyze_documents—ui://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-filefrom 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