From d12cf17648177726c4998d0dcd8dad781e9f8f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 20:46:35 +0000 Subject: [PATCH 1/2] Add FileInputDescriptor and fileInputs capability for file upload SEP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:;name=;base64, https://claude.ai/code/session_01JxhHWiXrXgE4JWC27dznRN --- src/mcp/types/__init__.py | 4 ++ src/mcp/types/_types.py | 62 ++++++++++++++++++++++ tests/test_types.py | 108 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index b44230393..4522bd18f 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -59,6 +59,8 @@ ElicitResult, EmbeddedResource, EmptyResult, + FileInputDescriptor, + FileInputsCapability, FormElicitationCapability, GetPromptRequest, GetPromptRequestParams, @@ -249,6 +251,7 @@ "ClientTasksRequestsCapability", "CompletionsCapability", "ElicitationCapability", + "FileInputsCapability", "FormElicitationCapability", "LoggingCapability", "PromptsCapability", @@ -303,6 +306,7 @@ "Task", "TaskMetadata", "RelatedTaskMetadata", + "FileInputDescriptor", "Tool", "ToolAnnotations", "ToolChoice", diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index 9005d253a..8ccaae30b 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -222,6 +222,16 @@ class SamplingToolsCapability(MCPModel): """ +class FileInputsCapability(MCPModel): + """Capability for declarative file inputs on tools and elicitation forms. + + When a client declares this capability, servers may include ``input_files`` + on Tool definitions and ``requested_files`` on form-mode elicitation + requests. Servers must not send those fields unless this capability is + present. + """ + + class FormElicitationCapability(MCPModel): """Capability for form mode elicitation.""" @@ -323,6 +333,8 @@ class ClientCapabilities(MCPModel): """Present if the client supports listing roots.""" tasks: ClientTasksCapability | None = None """Present if the client supports task-augmented requests.""" + file_inputs: FileInputsCapability | None = None + """Present if the client supports declarative file inputs for tools and elicitation.""" class PromptsCapability(MCPModel): @@ -1150,6 +1162,28 @@ class ToolExecution(MCPModel): """ +class FileInputDescriptor(MCPModel): + """Describes a single file input argument for a tool or elicitation form. + + Provides optional hints for client-side file picker filtering and validation. + All fields are advisory; servers must still validate inputs independently. + """ + + accept: list[str] | None = None + """MIME type patterns the server will accept for this input. + + Supports exact types (``"image/png"``) and wildcard subtypes (``"image/*"``). + If omitted, any file type is accepted. + """ + + max_size: int | None = None + """Maximum file size in bytes (decoded size, per file). + + Servers should reject larger files with JSON-RPC ``-32602`` (Invalid Params) + and the structured reason ``"file_too_large"``. + """ + + class Tool(BaseMetadata): """Definition for a tool the client can call.""" @@ -1174,6 +1208,20 @@ class Tool(BaseMetadata): execution: ToolExecution | None = None + input_files: dict[str, FileInputDescriptor] | None = None + """Declares which arguments in ``input_schema`` are file inputs. + + Keys must match property names in ``input_schema["properties"]`` and the + corresponding schema properties must be ``{"type": "string", "format": "uri"}`` + or an array thereof. Servers must not include this field unless the client + declared the ``file_inputs`` capability during initialization. + + Clients should render a native file picker for these arguments and encode + selected files as RFC 2397 data URIs of the form + ``data:;name=;base64,`` where the ``name=`` + parameter (percent-encoded) carries the original filename. + """ + class ListToolsResult(PaginatedResult): """The server's response to a tools/list request from the client.""" @@ -1649,6 +1697,20 @@ class ElicitRequestFormParams(RequestParams): Only top-level properties are allowed, without nesting. """ + requested_files: dict[str, FileInputDescriptor] | None = None + """Declares which fields in ``requested_schema`` are file inputs. + + Keys must match property names in ``requested_schema["properties"]`` and the + corresponding schema properties must be a string schema with ``format: "uri"`` + or an array of such string schemas. Servers must not include this field unless + the client declared the ``file_inputs`` capability during initialization. + + Clients should render a native file picker for these fields and encode + selected files as RFC 2397 data URIs of the form + ``data:;name=;base64,`` where the ``name=`` + parameter (percent-encoded) carries the original filename. + """ + class ElicitRequestURLParams(RequestParams): """Parameters for URL mode elicitation requests. diff --git a/tests/test_types.py b/tests/test_types.py index f424efdbf..6a1644e7e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -8,6 +8,9 @@ CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, + ElicitRequestFormParams, + FileInputDescriptor, + FileInputsCapability, Implementation, InitializeRequest, InitializeRequestParams, @@ -360,3 +363,108 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" assert "$defs" in tool.input_schema assert tool.input_schema["additionalProperties"] is False + + +def test_file_input_descriptor_roundtrip(): + """FileInputDescriptor serializes maxSize camelCase and accepts MIME patterns.""" + wire: dict[str, Any] = {"accept": ["image/png", "image/*"], "maxSize": 1048576} + desc = FileInputDescriptor.model_validate(wire) + assert desc.accept == ["image/png", "image/*"] + assert desc.max_size == 1048576 + + dumped = desc.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"accept": ["image/png", "image/*"], "maxSize": 1048576} + + # Both fields are optional; empty descriptor is valid + empty = FileInputDescriptor.model_validate({}) + assert empty.accept is None + assert empty.max_size is None + assert empty.model_dump(by_alias=True, exclude_none=True) == {} + + +def test_tool_with_input_files(): + """Tool.inputFiles round-trips via wire-format camelCase alias.""" + wire: dict[str, Any] = { + "name": "upload_attachment", + "description": "Upload a file", + "inputSchema": { + "type": "object", + "properties": { + "file": {"type": "string", "format": "uri"}, + "note": {"type": "string"}, + }, + "required": ["file"], + }, + "inputFiles": { + "file": {"accept": ["application/pdf", "image/*"], "maxSize": 5242880}, + }, + } + tool = Tool.model_validate(wire) + assert tool.input_files is not None + assert set(tool.input_files.keys()) == {"file"} + assert isinstance(tool.input_files["file"], FileInputDescriptor) + assert tool.input_files["file"].accept == ["application/pdf", "image/*"] + assert tool.input_files["file"].max_size == 5242880 + + dumped = tool.model_dump(by_alias=True, exclude_none=True) + assert "inputFiles" in dumped + assert "input_files" not in dumped + assert dumped["inputFiles"]["file"]["maxSize"] == 5242880 + assert dumped["inputFiles"]["file"]["accept"] == ["application/pdf", "image/*"] + + # input_files defaults to None and is omitted when absent + plain = Tool(name="echo", input_schema={"type": "object"}) + assert plain.input_files is None + assert "inputFiles" not in plain.model_dump(by_alias=True, exclude_none=True) + + +def test_client_capabilities_with_file_inputs(): + """ClientCapabilities.fileInputs round-trips as an empty capability object.""" + caps = ClientCapabilities.model_validate({"fileInputs": {}}) + assert caps.file_inputs is not None + assert isinstance(caps.file_inputs, FileInputsCapability) + + dumped = caps.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"fileInputs": {}} + + # Absent by default + bare = ClientCapabilities.model_validate({}) + assert bare.file_inputs is None + assert "fileInputs" not in bare.model_dump(by_alias=True, exclude_none=True) + + +def test_elicit_form_params_with_requested_files(): + """ElicitRequestFormParams.requestedFiles round-trips through the wire format.""" + wire: dict[str, Any] = { + "mode": "form", + "message": "Upload your documents", + "requestedSchema": { + "type": "object", + "properties": { + "resume": {"type": "string", "format": "uri"}, + "samples": { + "type": "array", + "items": {"type": "string", "format": "uri"}, + "maxItems": 3, + }, + }, + "required": ["resume"], + }, + "requestedFiles": { + "resume": {"accept": ["application/pdf"], "maxSize": 2097152}, + "samples": {"accept": ["image/*"]}, + }, + } + params = ElicitRequestFormParams.model_validate(wire) + assert params.requested_files is not None + assert isinstance(params.requested_files["resume"], FileInputDescriptor) + assert params.requested_files["resume"].max_size == 2097152 + assert params.requested_files["samples"].accept == ["image/*"] + assert params.requested_files["samples"].max_size is None + + dumped = params.model_dump(by_alias=True, exclude_none=True) + assert "requestedFiles" in dumped + assert "requested_files" not in dumped + assert dumped["requestedFiles"]["resume"]["maxSize"] == 2097152 + # samples had no maxSize; ensure it's excluded, not serialized as null + assert "maxSize" not in dumped["requestedFiles"]["samples"] From 75374a0fca32efa51ec302bbf50952b65140b326 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 23 Apr 2026 01:34:16 +0100 Subject: [PATCH 2/2] chore(sep-2356): sync file-input types to spec PR @ef7d52f The spec was substantially redesigned since the original SDK draft: the v1 sidecar-map model (Tool.inputFiles, ElicitRequestFormParams.requestedFiles, fileInputs client capability, StringArraySchema) was dropped in favor of an inline `mcpFile` JSON Schema extension keyword on string-uri properties. Since this SDK keeps both Tool.input_schema and ElicitRequestedSchema as dict[str, Any], the mcpFile keyword passes through untyped. The only typed surface is FileInputDescriptor itself, which servers can model_dump() into their schema dicts and clients can model_validate() out of them. Intentionally NOT adding a "file_too_large" error-reason constant: the structuredContent.reason vs outputSchema-conformance thread (pja-ant) on the spec PR is unresolved. - Remove FileInputsCapability + ClientCapabilities.file_inputs - Remove Tool.input_files - Remove ElicitRequestFormParams.requested_files - Update FileInputDescriptor docstrings to match spec (extension entries in accept, MUST-reject wording for maxSize, mcpFile keyword framing) - Tests now mirror the spec examples (mcpFile inline in inputSchema / requestedSchema, parsed via FileInputDescriptor.model_validate) --- src/mcp/types/__init__.py | 2 - src/mcp/types/_types.py | 68 +++++++------------------ tests/test_types.py | 102 +++++++++++++------------------------- 3 files changed, 54 insertions(+), 118 deletions(-) diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index 4522bd18f..47e8f56cc 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -60,7 +60,6 @@ EmbeddedResource, EmptyResult, FileInputDescriptor, - FileInputsCapability, FormElicitationCapability, GetPromptRequest, GetPromptRequestParams, @@ -251,7 +250,6 @@ "ClientTasksRequestsCapability", "CompletionsCapability", "ElicitationCapability", - "FileInputsCapability", "FormElicitationCapability", "LoggingCapability", "PromptsCapability", diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index 8ccaae30b..9cfaf37f2 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -222,16 +222,6 @@ class SamplingToolsCapability(MCPModel): """ -class FileInputsCapability(MCPModel): - """Capability for declarative file inputs on tools and elicitation forms. - - When a client declares this capability, servers may include ``input_files`` - on Tool definitions and ``requested_files`` on form-mode elicitation - requests. Servers must not send those fields unless this capability is - present. - """ - - class FormElicitationCapability(MCPModel): """Capability for form mode elicitation.""" @@ -333,8 +323,6 @@ class ClientCapabilities(MCPModel): """Present if the client supports listing roots.""" tasks: ClientTasksCapability | None = None """Present if the client supports task-augmented requests.""" - file_inputs: FileInputsCapability | None = None - """Present if the client supports declarative file inputs for tools and elicitation.""" class PromptsCapability(MCPModel): @@ -1163,24 +1151,34 @@ class ToolExecution(MCPModel): class FileInputDescriptor(MCPModel): - """Describes a single file input argument for a tool or elicitation form. + """Value of the ``mcpFile`` JSON Schema extension keyword. - Provides optional hints for client-side file picker filtering and validation. - All fields are advisory; servers must still validate inputs independently. + When present on a ``{"type": "string", "format": "uri"}`` property in a + :class:`Tool` ``input_schema`` or an elicitation ``requested_schema``, it + marks that property as a file input that clients SHOULD render as a native + file picker. Selected files are encoded as RFC 2397 data URIs. + + Both fields are advisory; servers MUST still validate inputs independently. """ accept: list[str] | None = None - """MIME type patterns the server will accept for this input. + """Media type patterns and/or file extensions the client SHOULD filter the + picker to. - Supports exact types (``"image/png"``) and wildcard subtypes (``"image/*"``). - If omitted, any file type is accepted. + Supports exact MIME types (``"image/png"``), wildcard subtypes + (``"image/*"``), and dot-prefixed extensions (``".pdf"``) following the same + grammar as the HTML ``accept`` attribute. Extension entries are picker hints + only; server-side validation compares MIME types. If omitted, any file type + is accepted. """ max_size: int | None = None - """Maximum file size in bytes (decoded size, per file). + """Maximum decoded file size in bytes that the server will accept inline as + a data URI. - Servers should reject larger files with JSON-RPC ``-32602`` (Invalid Params) - and the structured reason ``"file_too_large"``. + Servers MUST reject larger payloads with the ``"file_too_large"`` structured + error reason. For files larger than this, servers obtain the file via + URL-mode elicitation instead of this property. """ @@ -1208,20 +1206,6 @@ class Tool(BaseMetadata): execution: ToolExecution | None = None - input_files: dict[str, FileInputDescriptor] | None = None - """Declares which arguments in ``input_schema`` are file inputs. - - Keys must match property names in ``input_schema["properties"]`` and the - corresponding schema properties must be ``{"type": "string", "format": "uri"}`` - or an array thereof. Servers must not include this field unless the client - declared the ``file_inputs`` capability during initialization. - - Clients should render a native file picker for these arguments and encode - selected files as RFC 2397 data URIs of the form - ``data:;name=;base64,`` where the ``name=`` - parameter (percent-encoded) carries the original filename. - """ - class ListToolsResult(PaginatedResult): """The server's response to a tools/list request from the client.""" @@ -1697,20 +1681,6 @@ class ElicitRequestFormParams(RequestParams): Only top-level properties are allowed, without nesting. """ - requested_files: dict[str, FileInputDescriptor] | None = None - """Declares which fields in ``requested_schema`` are file inputs. - - Keys must match property names in ``requested_schema["properties"]`` and the - corresponding schema properties must be a string schema with ``format: "uri"`` - or an array of such string schemas. Servers must not include this field unless - the client declared the ``file_inputs`` capability during initialization. - - Clients should render a native file picker for these fields and encode - selected files as RFC 2397 data URIs of the form - ``data:;name=;base64,`` where the ``name=`` - parameter (percent-encoded) carries the original filename. - """ - class ElicitRequestURLParams(RequestParams): """Parameters for URL mode elicitation requests. diff --git a/tests/test_types.py b/tests/test_types.py index 6a1644e7e..ae4d0c93c 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -10,7 +10,6 @@ CreateMessageResultWithTools, ElicitRequestFormParams, FileInputDescriptor, - FileInputsCapability, Implementation, InitializeRequest, InitializeRequestParams, @@ -366,14 +365,14 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): def test_file_input_descriptor_roundtrip(): - """FileInputDescriptor serializes maxSize camelCase and accepts MIME patterns.""" - wire: dict[str, Any] = {"accept": ["image/png", "image/*"], "maxSize": 1048576} + """FileInputDescriptor serializes maxSize camelCase and accepts MIME + extension patterns.""" + wire: dict[str, Any] = {"accept": ["image/png", "image/*", ".pdf"], "maxSize": 1048576} desc = FileInputDescriptor.model_validate(wire) - assert desc.accept == ["image/png", "image/*"] + assert desc.accept == ["image/png", "image/*", ".pdf"] assert desc.max_size == 1048576 dumped = desc.model_dump(by_alias=True, exclude_none=True) - assert dumped == {"accept": ["image/png", "image/*"], "maxSize": 1048576} + assert dumped == {"accept": ["image/png", "image/*", ".pdf"], "maxSize": 1048576} # Both fields are optional; empty descriptor is valid empty = FileInputDescriptor.model_validate({}) @@ -382,89 +381,58 @@ def test_file_input_descriptor_roundtrip(): assert empty.model_dump(by_alias=True, exclude_none=True) == {} -def test_tool_with_input_files(): - """Tool.inputFiles round-trips via wire-format camelCase alias.""" +def test_tool_input_schema_mcpfile_roundtrip(): + """mcpFile keyword in Tool.input_schema survives roundtrip and parses as FileInputDescriptor.""" wire: dict[str, Any] = { - "name": "upload_attachment", - "description": "Upload a file", + "name": "describe_image", + "description": "Describe the contents of an image.", "inputSchema": { "type": "object", "properties": { - "file": {"type": "string", "format": "uri"}, - "note": {"type": "string"}, + "image": { + "type": "string", + "format": "uri", + "mcpFile": {"accept": ["image/png", "image/jpeg"], "maxSize": 5242880}, + }, }, - "required": ["file"], - }, - "inputFiles": { - "file": {"accept": ["application/pdf", "image/*"], "maxSize": 5242880}, + "required": ["image"], }, } tool = Tool.model_validate(wire) - assert tool.input_files is not None - assert set(tool.input_files.keys()) == {"file"} - assert isinstance(tool.input_files["file"], FileInputDescriptor) - assert tool.input_files["file"].accept == ["application/pdf", "image/*"] - assert tool.input_files["file"].max_size == 5242880 - - dumped = tool.model_dump(by_alias=True, exclude_none=True) - assert "inputFiles" in dumped - assert "input_files" not in dumped - assert dumped["inputFiles"]["file"]["maxSize"] == 5242880 - assert dumped["inputFiles"]["file"]["accept"] == ["application/pdf", "image/*"] - - # input_files defaults to None and is omitted when absent - plain = Tool(name="echo", input_schema={"type": "object"}) - assert plain.input_files is None - assert "inputFiles" not in plain.model_dump(by_alias=True, exclude_none=True) + image_prop = tool.input_schema["properties"]["image"] + assert image_prop["mcpFile"] == {"accept": ["image/png", "image/jpeg"], "maxSize": 5242880} + desc = FileInputDescriptor.model_validate(image_prop["mcpFile"]) + assert desc.accept == ["image/png", "image/jpeg"] + assert desc.max_size == 5242880 -def test_client_capabilities_with_file_inputs(): - """ClientCapabilities.fileInputs round-trips as an empty capability object.""" - caps = ClientCapabilities.model_validate({"fileInputs": {}}) - assert caps.file_inputs is not None - assert isinstance(caps.file_inputs, FileInputsCapability) - - dumped = caps.model_dump(by_alias=True, exclude_none=True) - assert dumped == {"fileInputs": {}} - - # Absent by default - bare = ClientCapabilities.model_validate({}) - assert bare.file_inputs is None - assert "fileInputs" not in bare.model_dump(by_alias=True, exclude_none=True) + dumped = tool.model_dump(by_alias=True, exclude_none=True) + assert dumped["inputSchema"]["properties"]["image"]["mcpFile"]["maxSize"] == 5242880 -def test_elicit_form_params_with_requested_files(): - """ElicitRequestFormParams.requestedFiles round-trips through the wire format.""" +def test_elicit_form_params_mcpfile_roundtrip(): + """mcpFile keyword in requested_schema survives roundtrip and parses as FileInputDescriptor.""" wire: dict[str, Any] = { "mode": "form", - "message": "Upload your documents", + "message": "Please select a profile photo.", "requestedSchema": { "type": "object", "properties": { - "resume": {"type": "string", "format": "uri"}, - "samples": { - "type": "array", - "items": {"type": "string", "format": "uri"}, - "maxItems": 3, + "photo": { + "type": "string", + "format": "uri", + "title": "Profile photo", + "mcpFile": {"accept": ["image/*"], "maxSize": 2097152}, }, }, - "required": ["resume"], - }, - "requestedFiles": { - "resume": {"accept": ["application/pdf"], "maxSize": 2097152}, - "samples": {"accept": ["image/*"]}, + "required": ["photo"], }, } params = ElicitRequestFormParams.model_validate(wire) - assert params.requested_files is not None - assert isinstance(params.requested_files["resume"], FileInputDescriptor) - assert params.requested_files["resume"].max_size == 2097152 - assert params.requested_files["samples"].accept == ["image/*"] - assert params.requested_files["samples"].max_size is None + photo_prop = params.requested_schema["properties"]["photo"] + desc = FileInputDescriptor.model_validate(photo_prop["mcpFile"]) + assert desc.accept == ["image/*"] + assert desc.max_size == 2097152 dumped = params.model_dump(by_alias=True, exclude_none=True) - assert "requestedFiles" in dumped - assert "requested_files" not in dumped - assert dumped["requestedFiles"]["resume"]["maxSize"] == 2097152 - # samples had no maxSize; ensure it's excluded, not serialized as null - assert "maxSize" not in dumped["requestedFiles"]["samples"] + assert dumped["requestedSchema"]["properties"]["photo"]["mcpFile"]["maxSize"] == 2097152