Skip to content

Commit 9c68107

Browse files
committed
Add FileInputDescriptor and fileInputs capability for file upload SEP
Implements declarative file input metadata so servers can tell clients which tool arguments / elicitation form fields should receive a native file picker. - FileInputDescriptor: Pydantic model with accept (MIME patterns) and max_size (bytes) - FileInputsCapability: client capability gate - Tool.input_files: maps arg names to FileInputDescriptor - ElicitRequestFormParams.requested_files: symmetric for elicitation - StringArraySchema: new PrimitiveSchemaDefinition member for multi-file fields All models use alias_generator=to_camel for snake_case ↔ camelCase on the wire. Files are transmitted as RFC 2397 data URIs: data:<mediatype>;name=<filename>;base64,<data> https://claude.ai/code/session_01JxhHWiXrXgE4JWC27dznRN
1 parent 528abfa commit 9c68107

File tree

3 files changed

+174
-0
lines changed

3 files changed

+174
-0
lines changed

src/mcp/types/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
ElicitResult,
6060
EmbeddedResource,
6161
EmptyResult,
62+
FileInputDescriptor,
63+
FileInputsCapability,
6264
FormElicitationCapability,
6365
GetPromptRequest,
6466
GetPromptRequestParams,
@@ -249,6 +251,7 @@
249251
"ClientTasksRequestsCapability",
250252
"CompletionsCapability",
251253
"ElicitationCapability",
254+
"FileInputsCapability",
252255
"FormElicitationCapability",
253256
"LoggingCapability",
254257
"PromptsCapability",
@@ -303,6 +306,7 @@
303306
"Task",
304307
"TaskMetadata",
305308
"RelatedTaskMetadata",
309+
"FileInputDescriptor",
306310
"Tool",
307311
"ToolAnnotations",
308312
"ToolChoice",

src/mcp/types/_types.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ class SamplingToolsCapability(MCPModel):
222222
"""
223223

224224

225+
class FileInputsCapability(MCPModel):
226+
"""Capability for declarative file inputs on tools and elicitation forms.
227+
228+
When a client declares this capability, servers may include ``input_files``
229+
on Tool definitions and ``requested_files`` on form-mode elicitation
230+
requests. Servers must not send those fields unless this capability is
231+
present.
232+
"""
233+
234+
225235
class FormElicitationCapability(MCPModel):
226236
"""Capability for form mode elicitation."""
227237

@@ -323,6 +333,8 @@ class ClientCapabilities(MCPModel):
323333
"""Present if the client supports listing roots."""
324334
tasks: ClientTasksCapability | None = None
325335
"""Present if the client supports task-augmented requests."""
336+
file_inputs: FileInputsCapability | None = None
337+
"""Present if the client supports declarative file inputs for tools and elicitation."""
326338

327339

328340
class PromptsCapability(MCPModel):
@@ -1150,6 +1162,28 @@ class ToolExecution(MCPModel):
11501162
"""
11511163

11521164

1165+
class FileInputDescriptor(MCPModel):
1166+
"""Describes a single file input argument for a tool or elicitation form.
1167+
1168+
Provides optional hints for client-side file picker filtering and validation.
1169+
All fields are advisory; servers must still validate inputs independently.
1170+
"""
1171+
1172+
accept: list[str] | None = None
1173+
"""MIME type patterns the server will accept for this input.
1174+
1175+
Supports exact types (``"image/png"``) and wildcard subtypes (``"image/*"``).
1176+
If omitted, any file type is accepted.
1177+
"""
1178+
1179+
max_size: int | None = None
1180+
"""Maximum file size in bytes (decoded size, per file).
1181+
1182+
Servers should reject larger files with JSON-RPC ``-32602`` (Invalid Params)
1183+
and the structured reason ``"file_too_large"``.
1184+
"""
1185+
1186+
11531187
class Tool(BaseMetadata):
11541188
"""Definition for a tool the client can call."""
11551189

@@ -1174,6 +1208,20 @@ class Tool(BaseMetadata):
11741208

11751209
execution: ToolExecution | None = None
11761210

1211+
input_files: dict[str, FileInputDescriptor] | None = None
1212+
"""Declares which arguments in ``input_schema`` are file inputs.
1213+
1214+
Keys must match property names in ``input_schema["properties"]`` and the
1215+
corresponding schema properties must be ``{"type": "string", "format": "uri"}``
1216+
or an array thereof. Servers must not include this field unless the client
1217+
declared the ``file_inputs`` capability during initialization.
1218+
1219+
Clients should render a native file picker for these arguments and encode
1220+
selected files as RFC 2397 data URIs of the form
1221+
``data:<mediatype>;name=<filename>;base64,<data>`` where the ``name=``
1222+
parameter (percent-encoded) carries the original filename.
1223+
"""
1224+
11771225

11781226
class ListToolsResult(PaginatedResult):
11791227
"""The server's response to a tools/list request from the client."""
@@ -1649,6 +1697,20 @@ class ElicitRequestFormParams(RequestParams):
16491697
Only top-level properties are allowed, without nesting.
16501698
"""
16511699

1700+
requested_files: dict[str, FileInputDescriptor] | None = None
1701+
"""Declares which fields in ``requested_schema`` are file inputs.
1702+
1703+
Keys must match property names in ``requested_schema["properties"]`` and the
1704+
corresponding schema properties must be a string schema with ``format: "uri"``
1705+
or an array of such string schemas. Servers must not include this field unless
1706+
the client declared the ``file_inputs`` capability during initialization.
1707+
1708+
Clients should render a native file picker for these fields and encode
1709+
selected files as RFC 2397 data URIs of the form
1710+
``data:<mediatype>;name=<filename>;base64,<data>`` where the ``name=``
1711+
parameter (percent-encoded) carries the original filename.
1712+
"""
1713+
16521714

16531715
class ElicitRequestURLParams(RequestParams):
16541716
"""Parameters for URL mode elicitation requests.

tests/test_types.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
CreateMessageRequestParams,
99
CreateMessageResult,
1010
CreateMessageResultWithTools,
11+
ElicitRequestFormParams,
12+
FileInputDescriptor,
13+
FileInputsCapability,
1114
Implementation,
1215
InitializeRequest,
1316
InitializeRequestParams,
@@ -360,3 +363,108 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields():
360363
assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
361364
assert "$defs" in tool.input_schema
362365
assert tool.input_schema["additionalProperties"] is False
366+
367+
368+
def test_file_input_descriptor_roundtrip():
369+
"""FileInputDescriptor serializes maxSize camelCase and accepts MIME patterns."""
370+
wire: dict[str, Any] = {"accept": ["image/png", "image/*"], "maxSize": 1048576}
371+
desc = FileInputDescriptor.model_validate(wire)
372+
assert desc.accept == ["image/png", "image/*"]
373+
assert desc.max_size == 1048576
374+
375+
dumped = desc.model_dump(by_alias=True, exclude_none=True)
376+
assert dumped == {"accept": ["image/png", "image/*"], "maxSize": 1048576}
377+
378+
# Both fields are optional; empty descriptor is valid
379+
empty = FileInputDescriptor.model_validate({})
380+
assert empty.accept is None
381+
assert empty.max_size is None
382+
assert empty.model_dump(by_alias=True, exclude_none=True) == {}
383+
384+
385+
def test_tool_with_input_files():
386+
"""Tool.inputFiles round-trips via wire-format camelCase alias."""
387+
wire: dict[str, Any] = {
388+
"name": "upload_attachment",
389+
"description": "Upload a file",
390+
"inputSchema": {
391+
"type": "object",
392+
"properties": {
393+
"file": {"type": "string", "format": "uri"},
394+
"note": {"type": "string"},
395+
},
396+
"required": ["file"],
397+
},
398+
"inputFiles": {
399+
"file": {"accept": ["application/pdf", "image/*"], "maxSize": 5242880},
400+
},
401+
}
402+
tool = Tool.model_validate(wire)
403+
assert tool.input_files is not None
404+
assert set(tool.input_files.keys()) == {"file"}
405+
assert isinstance(tool.input_files["file"], FileInputDescriptor)
406+
assert tool.input_files["file"].accept == ["application/pdf", "image/*"]
407+
assert tool.input_files["file"].max_size == 5242880
408+
409+
dumped = tool.model_dump(by_alias=True, exclude_none=True)
410+
assert "inputFiles" in dumped
411+
assert "input_files" not in dumped
412+
assert dumped["inputFiles"]["file"]["maxSize"] == 5242880
413+
assert dumped["inputFiles"]["file"]["accept"] == ["application/pdf", "image/*"]
414+
415+
# input_files defaults to None and is omitted when absent
416+
plain = Tool(name="echo", input_schema={"type": "object"})
417+
assert plain.input_files is None
418+
assert "inputFiles" not in plain.model_dump(by_alias=True, exclude_none=True)
419+
420+
421+
def test_client_capabilities_with_file_inputs():
422+
"""ClientCapabilities.fileInputs round-trips as an empty capability object."""
423+
caps = ClientCapabilities.model_validate({"fileInputs": {}})
424+
assert caps.file_inputs is not None
425+
assert isinstance(caps.file_inputs, FileInputsCapability)
426+
427+
dumped = caps.model_dump(by_alias=True, exclude_none=True)
428+
assert dumped == {"fileInputs": {}}
429+
430+
# Absent by default
431+
bare = ClientCapabilities.model_validate({})
432+
assert bare.file_inputs is None
433+
assert "fileInputs" not in bare.model_dump(by_alias=True, exclude_none=True)
434+
435+
436+
def test_elicit_form_params_with_requested_files():
437+
"""ElicitRequestFormParams.requestedFiles round-trips through the wire format."""
438+
wire: dict[str, Any] = {
439+
"mode": "form",
440+
"message": "Upload your documents",
441+
"requestedSchema": {
442+
"type": "object",
443+
"properties": {
444+
"resume": {"type": "string", "format": "uri"},
445+
"samples": {
446+
"type": "array",
447+
"items": {"type": "string", "format": "uri"},
448+
"maxItems": 3,
449+
},
450+
},
451+
"required": ["resume"],
452+
},
453+
"requestedFiles": {
454+
"resume": {"accept": ["application/pdf"], "maxSize": 2097152},
455+
"samples": {"accept": ["image/*"]},
456+
},
457+
}
458+
params = ElicitRequestFormParams.model_validate(wire)
459+
assert params.requested_files is not None
460+
assert isinstance(params.requested_files["resume"], FileInputDescriptor)
461+
assert params.requested_files["resume"].max_size == 2097152
462+
assert params.requested_files["samples"].accept == ["image/*"]
463+
assert params.requested_files["samples"].max_size is None
464+
465+
dumped = params.model_dump(by_alias=True, exclude_none=True)
466+
assert "requestedFiles" in dumped
467+
assert "requested_files" not in dumped
468+
assert dumped["requestedFiles"]["resume"]["maxSize"] == 2097152
469+
# samples had no maxSize; ensure it's excluded, not serialized as null
470+
assert "maxSize" not in dumped["requestedFiles"]["samples"]

0 commit comments

Comments
 (0)