Skip to content

Commit d12cf17

Browse files
claudeochafik
authored andcommitted
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 3d7b311 commit d12cf17

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

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)