mcp-upload 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # Environments. Two venvs are normal here: the mcp and fastmcp extras cannot coexist.
10
+ .venv*/
11
+ venv/
12
+ .env
13
+
14
+ # Tooling caches
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .coverage
19
+ coverage.xml
20
+ htmlcov/
21
+
22
+ # Editors and OS
23
+ .DS_Store
24
+ .idea/
25
+ .vscode/
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-08-31)
4
+
5
+ First release. Targets MCP protocol revision 2026-07-28.
6
+
7
+ - Upload tickets: 256-bit secrets stored only as their SHA-256, naming a record that
8
+ moves from issued to redeemed to completed or failed and survives redemption. Two
9
+ clocks per record: a redemption deadline and a retention deadline.
10
+ - Stores: an in-memory store with a record cap, and a SQLite store. Both redeem
11
+ atomically, checked with fifty concurrent redemptions and one winner. A six-method
12
+ `Store` protocol for anything else.
13
+ - Destinations come from a registry declared by the server author. Tools pick one by
14
+ name. No API accepts a URL from a tool argument.
15
+ - The upload endpoint. Header-only checks run before the ticket is touched, then the
16
+ atomic flip, then the multipart body streams through a bounded queue to the
17
+ destination with nothing on disk. Size limits are enforced on the bytes seen, not
18
+ on `Content-Length`. Exactly one file part is accepted. The end of the upstream
19
+ body is held until the whole request has parsed. A `GET` on the ticket URL renders
20
+ a browser upload form.
21
+ - Wire shapes in the vocabulary of the MCP file transfer proposal (SEP-2631):
22
+ `AwaitingUpload`, `FileTransferDescriptor`, `FileValue`, `UploadStatus`.
23
+ - Adapters for the official SDK (`mcp` 2.x) and FastMCP (`fastmcp` 3.x), one
24
+ registration call each. `ask_for_upload` for URL-mode elicitation through the
25
+ multi-round-trip flow on the official SDK.
26
+ - Examples: a stub backend, an MCP server with `request_upload`,
27
+ `request_upload_interactive` and `check_upload`, and a Python client.
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,422 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-upload
3
+ Version: 0.1.0
4
+ Summary: File uploads for MCP servers using short-lived upload tickets.
5
+ Project-URL: Repository, https://github.com/imaadkhan-tz/mcp-upload
6
+ Project-URL: Issues, https://github.com/imaadkhan-tz/mcp-upload/issues
7
+ Author-email: Imaad Zaffar Khan <kimaad71@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: asgi,file-upload,mcp,model-context-protocol,starlette,upload
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: httpx>=0.28
22
+ Requires-Dist: starlette>=1.0
23
+ Requires-Dist: streaming-form-data>=2.0
24
+ Requires-Dist: typing-extensions>=4.5; python_version < '3.12'
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.11; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8.3; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Requires-Dist: uvicorn>=0.30; extra == 'dev'
31
+ Provides-Extra: fastmcp
32
+ Requires-Dist: fastmcp<4,>=3.4; extra == 'fastmcp'
33
+ Provides-Extra: mcp
34
+ Requires-Dist: mcp<3,>=2.1; extra == 'mcp'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # mcp-upload
38
+
39
+ File uploads for MCP servers, using short-lived upload tickets.
40
+
41
+ An MCP tool that needs a file hands back a single-use URL. Whoever holds the file
42
+ posts it there over plain HTTPS. The server streams the bytes straight through to
43
+ the backend you already have, and keeps a small record of what happened. Nothing
44
+ about the file ever travels through MCP except a reference to it. No change to the
45
+ protocol is needed, and no host has to know the library exists.
46
+
47
+ Targets protocol revision 2026-07-28. Works with the official Python SDK (`mcp` 2.x)
48
+ and with FastMCP (`fastmcp` 3.x). Python 3.11 or later. Apache 2.0.
49
+
50
+ ## The problem
51
+
52
+ MCP can carry binary data from a server back to a model, as image, audio or embedded
53
+ resource content. It cannot carry a file the other way. A tool call is JSON, its
54
+ inputs are described by JSON Schema, and there is no file type in that vocabulary
55
+ and no way to tell a host "this argument is a file, show a picker".
56
+
57
+ The usual workaround is to base64 the file into a string argument. The objection
58
+ people reach for is context-window size. The real problem is who types the bytes.
59
+ Tool arguments are model output, generated token by token, so the model has to
60
+ produce the whole encoded file, perfectly. Base64 tokenizes at roughly 1.4 to 1.5
61
+ characters per token on OpenAI's encoders (English runs about five). A 126 KB image
62
+ costs about 120,000 output tokens. That is 94% of the 128,000-token per-response
63
+ ceiling of the largest models available today and over the ceiling of most others.
64
+ A 500 KiB photo costs about 490,000 tokens, which is past every model from every
65
+ vendor. Anthropic's tokenizer counts higher than OpenAI's, not lower. The request
66
+ does not finish. Bigger context windows do not help.
67
+
68
+ There is a second wall that has nothing to do with the model. The official Python
69
+ SDK's Streamable HTTP server rejects any request body over 4 MiB with HTTP 413
70
+ before it parses the JSON. Inline transfer of anything but a small file fails in the
71
+ transport regardless of who produced the bytes.
72
+
73
+ Hosts have no standard way around this. Claude.ai, Claude Desktop, Cursor and VS
74
+ Code have no path for a user-attached file to reach an MCP tool. ChatGPT has one, and
75
+ it is proprietary. So a server either accepts base64 and works for toy files, takes a
76
+ local path and only works on one machine, or fetches a URL the model supplies and
77
+ becomes a request forgery. The full argument, with the measurements, is at
78
+ https://imaadkhan.me/writing/the-file-shaped-hole-in-mcp.html.
79
+
80
+ The MCP changelog for 2026-07-28, in the note on removing sessions, describes the
81
+ replacement for cross-call state: "explicit, server-minted handles passed as ordinary
82
+ tool arguments". An upload ticket is that handle.
83
+
84
+ ## How it works
85
+
86
+ ```
87
+ model / client your MCP server your backend
88
+ -------------- --------------- ------------
89
+ tools/call request_upload --> issue a ticket
90
+ <-- { status: awaiting_upload,
91
+ upload: { url: ".../upload/<ticket>" } }
92
+
93
+ whoever has the file
94
+ POST <url> multipart/form-data --> header checks, then redeem the
95
+ ticket atomically, then stream --> PUT /files/x
96
+ the bytes as they arrive <-- 201
97
+ <-- { status: completed, file: { size, digest } }
98
+
99
+ tools/call check_upload --> read the record
100
+ <-- { status: completed, file: {...} }
101
+ ```
102
+
103
+ 1. A tool is called and finds it needs a file. It asks the gateway for a ticket for a
104
+ named destination and returns the upload URL.
105
+ 2. The bytes are posted to that URL by whoever holds them: a person in a browser (a
106
+ `GET` on the URL renders a form), an agent with a shell (`curl -F file=@path <url>`),
107
+ or a program.
108
+ 3. The gateway checks the request headers, redeems the ticket in one atomic step so
109
+ only one request can ever use it, then parses the multipart body incrementally and
110
+ forwards the bytes to the destination through a bounded queue. Nothing is buffered
111
+ in memory beyond a few chunks and nothing is written to disk.
112
+ 4. The record survives redemption and holds the outcome: the filename, media type,
113
+ size, SHA-256, or a failure code. A tool reads it back on request.
114
+
115
+ ## Who can complete an upload
116
+
117
+ The upload URL is the whole interface, so anything that can make an HTTP request
118
+ with the bytes can finish the job. What differs is who does it.
119
+
120
+ | Where the tool is called | Who sends the bytes |
121
+ |---|---|
122
+ | Claude Code, or any agent with a shell | The agent itself, with `curl -F file=@path <url>`. |
123
+ | Claude.ai, Claude Desktop, ChatGPT, Cursor, VS Code | The person, by opening the URL. The page is a file picker and a button. |
124
+ | A client that supports URL-mode elicitation | The client shows the link and asks for consent, through the two-round flow below. |
125
+ | Your own program | It posts the file, then asks the server what happened. |
126
+
127
+ No host today acts on an upload request by itself, and none renders a native file
128
+ picker for MCP. The browser page exists so the pattern works everywhere anyway.
129
+
130
+ ## Install
131
+
132
+ ```
133
+ pip install "mcp-upload[mcp]" # official SDK, mcp 2.x
134
+ pip install "mcp-upload[fastmcp]" # FastMCP 3.x
135
+ ```
136
+
137
+ The two extras cannot be installed together. FastMCP 3.x pins the official SDK below
138
+ 2.0, and the `mcp` extra targets 2.x. Pick the one your server uses. The core has no
139
+ dependency on either.
140
+
141
+ ## Use it
142
+
143
+ Declare where uploads may go, build a gateway, attach it to your server, and return
144
+ what the gateway describes.
145
+
146
+ ```python
147
+ from mcp.server.mcpserver import MCPServer
148
+ from mcp_upload import Destination, MemoryStore, Registry, UploadGateway
149
+ from mcp_upload.adapters.mcp import attach
150
+ from mcp_upload.types import AwaitingUpload, UploadStatus
151
+
152
+ registry = Registry(
153
+ Destination(
154
+ name="reports",
155
+ url="https://api.internal/reports/{filename}",
156
+ method="PUT",
157
+ max_size=50 * 1024 * 1024,
158
+ accept=("application/pdf", "text/*"),
159
+ )
160
+ )
161
+
162
+ gateway = UploadGateway(
163
+ base_url="https://mcp.example.com", # where clients reach the upload endpoint
164
+ registry=registry,
165
+ store=MemoryStore(),
166
+ server_name="example",
167
+ )
168
+
169
+ mcp = MCPServer("example")
170
+ attach(mcp, gateway) # serves GET and POST /upload/{ticket}
171
+
172
+
173
+ @mcp.tool()
174
+ async def request_upload() -> AwaitingUpload:
175
+ """Ask for a report. Returns a single-use upload URL valid for fifteen minutes."""
176
+ issued = await gateway.issue("reports", caller="request_upload")
177
+ return gateway.describe(issued)
178
+
179
+
180
+ @mcp.tool()
181
+ async def check_upload(id: str) -> UploadStatus:
182
+ return await gateway.status(id)
183
+
184
+
185
+ app = mcp.streamable_http_app()
186
+ ```
187
+
188
+ With FastMCP the only differences are `from fastmcp import FastMCP`,
189
+ `from mcp_upload.adapters.fastmcp import attach`, and `app = mcp.http_app()`.
190
+
191
+ `request_upload` returns this, shaped like the transfer descriptor in the MCP file
192
+ transfer proposal (SEP-2631), so a server built on this library reads the same on the
193
+ wire as the proposal:
194
+
195
+ ```json
196
+ {
197
+ "status": "awaiting_upload",
198
+ "id": "up_896t-nivLU7Q",
199
+ "file": { "uri": "mcp-file://example/up_896t-nivLU7Q" },
200
+ "upload": {
201
+ "transport": "https",
202
+ "method": "POST",
203
+ "url": "https://mcp.example.com/upload/_87OdaFTiH0cgPtDfBiZ0L30x--2AnpIUcp92iez_F0",
204
+ "multipart": { "fileField": "file" },
205
+ "expiresAt": "2026-08-31T02:09:11Z"
206
+ }
207
+ }
208
+ ```
209
+
210
+ Then send the file:
211
+
212
+ ```
213
+ curl -F file=@report.pdf https://mcp.example.com/upload/_87OdaFT...
214
+ ```
215
+
216
+ or open that URL in a browser. The response, and later `check_upload`, report:
217
+
218
+ ```json
219
+ {
220
+ "id": "up_896t-nivLU7Q",
221
+ "status": "completed",
222
+ "file": {
223
+ "uri": "mcp-file://example/up_896t-nivLU7Q",
224
+ "name": "report.pdf",
225
+ "mimeType": "application/pdf",
226
+ "size": 5000000,
227
+ "digest": { "algorithm": "sha-256", "value": "5d3cb542..." }
228
+ }
229
+ }
230
+ ```
231
+
232
+ ### Asking through the client
233
+
234
+ Since protocol 2026-07-28 a server cannot open a request to the client in the middle
235
+ of a tool call. What it can do is return `input_required`, naming what it needs. The
236
+ client collects it and retries the call with the answer attached. `ask_for_upload`
237
+ drives that in one line, using URL-mode elicitation so the client shows the upload
238
+ link and asks the user for consent:
239
+
240
+ ```python
241
+ from mcp.server.mcpserver import Context
242
+ from mcp_types import InputRequiredResult
243
+ from mcp_upload.adapters.mcp import ask_for_upload
244
+
245
+
246
+ @mcp.tool()
247
+ async def request_upload_interactive(ctx: Context) -> UploadStatus | InputRequiredResult:
248
+ return await ask_for_upload(ctx, gateway, "reports", message="Upload the report here.")
249
+ ```
250
+
251
+ The first round mints the ticket and returns the elicitation. The retry reports the
252
+ record's status, or `declined` or `cancelled` if the user refused. One ticket is
253
+ minted across both rounds. This needs a client that supports URL-mode elicitation.
254
+ The official SDK's `Client` does, most chat hosts do not yet, and the plain
255
+ `request_upload` tool above works regardless.
256
+
257
+ ## Where the bytes go
258
+
259
+ A `Destination` is an HTTP endpoint you declare at startup. `url` may contain `{id}`
260
+ and `{filename}`, filled in at upload time and percent-encoded. `encoding="raw"`
261
+ (the default) sends the bytes as the request body with the file's media type.
262
+ `encoding="multipart"` wraps them in a single-part form body under `field_name` for
263
+ backends that expect a form upload. `max_size` and `accept` are defaults for tickets
264
+ issued against the destination. A ticket can be issued with tighter limits, never
265
+ looser.
266
+
267
+ Tools pick a destination by name. There is no way to pass a URL, a host or a path
268
+ from a tool argument, and that is deliberate. Letting a model hand the server a URL to
269
+ fetch is a request forgery. Letting it hand the server a URL to stream a file into is
270
+ the same hole with a body attached. The unsafe shape is not discouraged, it is
271
+ unrepresentable.
272
+
273
+ What the backend has to do: accept a streamed body. It will not get a `Content-Length`
274
+ (the gateway forwards as the bytes arrive), and its response body is never passed
275
+ through to the uploader. A failure becomes one of a closed set of codes. The gateway
276
+ also holds back the end of the upstream body until the whole incoming request has
277
+ been parsed, so if something objectionable follows the file part, the backend sees an
278
+ incomplete request rather than a committed upload. A backend should treat an
279
+ incomplete body as a failed upload. `examples/backend.py` shows the shape: write to a
280
+ temporary name, rename on success, delete on an incomplete body.
281
+
282
+ ## The ticket
283
+
284
+ The upload endpoint takes no session, header or OAuth token. The ticket is the
285
+ authorization. That is safe only because the ticket is 256 bits from the OS CSPRNG,
286
+ stored only as its SHA-256 so a copy of the store yields nothing usable, valid for
287
+ one redemption enforced atomically in the store, expiring in minutes, bound to a
288
+ destination the server author chose, and useless for reading anything back (a `GET`
289
+ on the URL renders an upload form, never a file). Remove any one of those and it is
290
+ a hole. Reusing the caller's session token here would be worse: the model often
291
+ cannot supply one, and sending a broad long-lived credential to a second origin
292
+ widens the blast radius when it leaks.
293
+
294
+ The ticket is in the URL path, because a browser form needs it there. URLs land in
295
+ access logs and browser history. The page sends `Referrer-Policy: no-referrer`. Scrub
296
+ the path from your access logs, or accept that a leaked log yields tickets that
297
+ expire in fifteen minutes and work once.
298
+
299
+ Redemption flips a status field instead of deleting the record. Deleting is just as
300
+ atomic, and it is what the obvious Redis `GETDEL` gives you, but it destroys the only
301
+ place the outcome could live. A record here moves `issued` to `redeemed` to
302
+ `completed` or `failed`, and stays until its retention deadline (24 hours by default)
303
+ so "did that upload finish?" has an answer. Redemption stops being allowed at
304
+ `expires_at` (15 minutes by default). The two clocks are independent.
305
+
306
+ Two stores ship. `MemoryStore` is for one process and for tests. It is correct under
307
+ concurrency only because its redeem does the read, check and write with no `await`
308
+ in between, and it has a record cap so ticket issuance cannot grow memory without
309
+ bound. `SqliteStore` is for one host with several workers: redemption is one
310
+ conditional `UPDATE` whose `WHERE` clause carries the status and expiry checks, so
311
+ the database serializes competing writers and exactly one sees a row change. Both
312
+ were checked with fifty concurrent redemptions of one ticket and one winner. The
313
+ `Store` protocol is six methods. Bring your own for anything else.
314
+
315
+ ## What the endpoint refuses, and when
316
+
317
+ The order matters. Nothing that can be judged from the headers alone may cost the
318
+ ticket, so a stray or hostile non-multipart POST cannot burn someone's pending upload.
319
+
320
+ 1. Content type must be exactly `multipart/form-data` (case-insensitive, since a
321
+ naive exact comparison rejects valid uppercase) with a boundary. Otherwise 415 or
322
+ 400, ticket untouched. Framework form helpers that also accept URL-encoded bodies
323
+ are how a text field ends up read as a file.
324
+ 2. A declared `Content-Length` far over the limit is refused with 413, ticket untouched.
325
+ 3. The ticket is looked up by hash. Unknown is 404, already used or expired is 410.
326
+ 4. The atomic flip. From here the ticket is spent.
327
+ 5. The body streams. The size limit is enforced on the bytes actually seen, because a
328
+ chunked upload has no `Content-Length` and a lying one is trivial to send. Exactly
329
+ one part, named `file`, with a filename, of an accepted type. A second file part,
330
+ a part without a filename, or any other part is refused. Frameworks that silently
331
+ keep one of two same-named parts are how a request passes validation on one and
332
+ delivers the other. Filenames are reduced to a base name before forwarding.
333
+ 6. The terminal state is recorded.
334
+
335
+ | Code | HTTP | Meaning |
336
+ |---|---|---|
337
+ | `not_multipart`, `missing_boundary` | 415, 400 | Header-only, ticket untouched |
338
+ | `unknown_ticket` | 404 | |
339
+ | `ticket_used`, `ticket_expired` | 410 | |
340
+ | `too_large` | 413 | On declared length or on the running count |
341
+ | `missing_file`, `duplicate_file`, `unexpected_part`, `bad_multipart`, `truncated` | 400 | |
342
+ | `unsupported_media_type` | 415 | Declared type not in the accept list |
343
+ | `client_disconnected` | 400 | Recorded on the record. No response reaches the client. |
344
+ | `upstream_unreachable`, `upstream_rejected`, `upstream_closed_early` | 502 | Backend failure, mapped, never echoed |
345
+
346
+ ## Streaming
347
+
348
+ The obvious implementation, `await request.form()`, does not blow up memory. It
349
+ writes the whole upload to a temporary file on disk before your handler runs, and
350
+ puts no limit on file parts. That is not streaming and it is not "stores nothing".
351
+
352
+ Here the multipart body is parsed incrementally as it arrives, each chunk is hashed
353
+ and handed to a bounded queue, and an outgoing request to the backend drains that
354
+ queue. When the backend is slow the queue fills, the parser stops, the request body
355
+ stops being read, and the client's upload stalls. The test suite streams 32 MiB
356
+ through a deliberately slow backend and the process grows by about 5 MiB.
357
+
358
+ The parser is `streaming-form-data`, a compiled extension. Wheels exist for CPython
359
+ 3.11 to 3.13 as of its 2.1.0 release, which is why 3.14 is not yet in the test matrix.
360
+
361
+ ## Compared with the alternatives
362
+
363
+ **Base64 in a tool argument.** Works for toy files. Fails on anything real, and
364
+ fails in the transport at 4 MiB before the model's output ceiling is even a factor.
365
+
366
+ **FastMCP's `FileUpload` provider.** A drag-and-drop widget in an MCP Apps host
367
+ calls an app-only tool, so the model never types the bytes. It needs no
368
+ infrastructure and no public endpoint, and for a 2 MB PDF in a host that renders MCP
369
+ Apps it is the simpler choice. It still sends the bytes through JSON-RPC as base64,
370
+ caps at 10 MB against the transport's 4 MiB message limit, needs an Apps host, and
371
+ keys its default storage on a session id the stateless transport no longer provides.
372
+ This library wins above a few megabytes, on the stateless transport, across several
373
+ replicas, and in any host that is not an Apps host.
374
+
375
+ **The server fetches a URL the model supplies.** A request forgery from inside your
376
+ network, steered by a model-controlled string. Safe only with allowlists, redirect
377
+ handling and private-range blocking, and then only mostly.
378
+
379
+ **The MCP file transfer proposal (SEP-2631).** Same architecture, same vocabulary
380
+ (`FileValue`, `FileTransferDescriptor`, `transferModes`). In the proposal the client
381
+ asks for an upload authorization, uploads, and then calls the tool with a file URI.
382
+ The tool-first ordering used here is the proposal's stated fallback for when the
383
+ client cannot bind the file before the call, which today is every host. The record
384
+ carries a stable `mcp-file://` URI from the start so that a `files/authorizeUpload`
385
+ adapter is a thin addition when the proposal lands.
386
+
387
+ ## Limits and non-goals
388
+
389
+ Upload only. Server-to-client delivery is already covered by MCP resources. One file
390
+ per ticket. No resumable or chunked uploads. No content sniffing: the accept list is
391
+ checked against the declared type, and that is a policy check, not a security
392
+ guarantee. No storage of its own: bytes go to your backend and nowhere else. A Redis
393
+ store and the proposal-shaped `files/authorizeUpload` adapter are planned. The
394
+ `Store` protocol and the stable record URI are the seams for them.
395
+
396
+ ## Running the example
397
+
398
+ ```
399
+ pip install "mcp-upload[mcp]" uvicorn
400
+ python examples/backend.py # a stub backend on :8001 that writes files to a temp dir
401
+ python examples/server.py # the MCP server on :8000, upload endpoint under /upload
402
+ python examples/client.py path/to/any/file
403
+ ```
404
+
405
+ The client asks `request_upload`, posts the file, and calls `check_upload`. For the
406
+ other two paths, call `request_upload` from any MCP client and either open the URL
407
+ in a browser or run `curl -F file=@path <url>`.
408
+
409
+ ## Development
410
+
411
+ ```
412
+ uv venv .venv && uv pip install --python .venv/bin/python -e ".[dev,mcp]"
413
+ uv venv .venv-fastmcp && uv pip install --python .venv-fastmcp/bin/python -e ".[dev,fastmcp]"
414
+ .venv/bin/ruff check . && .venv/bin/mypy && .venv/bin/pytest
415
+ ```
416
+
417
+ Two environments because the two frameworks cannot share one. The suite includes
418
+ socket-level tests that run the gateway and a backend under uvicorn on loopback.
419
+
420
+ ## License
421
+
422
+ Apache 2.0. See LICENSE.