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"]