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.
- mcp_upload-0.1.0/.gitignore +25 -0
- mcp_upload-0.1.0/CHANGELOG.md +27 -0
- mcp_upload-0.1.0/LICENSE +202 -0
- mcp_upload-0.1.0/PKG-INFO +422 -0
- mcp_upload-0.1.0/README.md +386 -0
- mcp_upload-0.1.0/pyproject.toml +105 -0
- mcp_upload-0.1.0/src/mcp_upload/__init__.py +48 -0
- mcp_upload-0.1.0/src/mcp_upload/adapters/__init__.py +23 -0
- mcp_upload-0.1.0/src/mcp_upload/adapters/fastmcp.py +17 -0
- mcp_upload-0.1.0/src/mcp_upload/adapters/mcp.py +95 -0
- mcp_upload-0.1.0/src/mcp_upload/destinations.py +75 -0
- mcp_upload-0.1.0/src/mcp_upload/gateway.py +625 -0
- mcp_upload-0.1.0/src/mcp_upload/multipart.py +68 -0
- mcp_upload-0.1.0/src/mcp_upload/page.py +75 -0
- mcp_upload-0.1.0/src/mcp_upload/py.typed +0 -0
- mcp_upload-0.1.0/src/mcp_upload/store.py +291 -0
- mcp_upload-0.1.0/src/mcp_upload/tickets.py +126 -0
- mcp_upload-0.1.0/src/mcp_upload/types.py +72 -0
- mcp_upload-0.1.0/tests/__init__.py +0 -0
- mcp_upload-0.1.0/tests/conftest.py +109 -0
- mcp_upload-0.1.0/tests/test_adapters.py +204 -0
- mcp_upload-0.1.0/tests/test_gateway.py +448 -0
- mcp_upload-0.1.0/tests/test_live.py +253 -0
- mcp_upload-0.1.0/tests/test_package.py +8 -0
- mcp_upload-0.1.0/tests/test_store.py +117 -0
- mcp_upload-0.1.0/typings/streaming_form_data/__init__.pyi +2 -0
- mcp_upload-0.1.0/typings/streaming_form_data/parser.pyi +21 -0
- mcp_upload-0.1.0/typings/streaming_form_data/targets.pyi +20 -0
|
@@ -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.
|
mcp_upload-0.1.0/LICENSE
ADDED
|
@@ -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.
|