File size: 11,844 Bytes
c151bc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
from enum import Enum
from urllib.parse import parse_qs, urlparse

import httpx
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient

from fastmcp.client import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap


async def test_empty_query_parameters_not_sent(
    fastapi_app: FastAPI, api_client: httpx.AsyncClient
):
    """Test that empty and None query parameters are not sent in the request."""

    # Create a TransportAdapter to track requests
    class RequestCapture(httpx.AsyncBaseTransport):
        def __init__(self, wrapped):
            self.wrapped = wrapped
            self.requests = []

        async def handle_async_request(self, request):
            self.requests.append(request)
            return await self.wrapped.handle_async_request(request)

    # Use our transport adapter to wrap the original one
    capture = RequestCapture(api_client._transport)
    api_client._transport = capture

    # Create the OpenAPI server with new route map to make search endpoint a tool
    openapi_spec = fastapi_app.openapi()
    mcp_server = FastMCPOpenAPI(
        openapi_spec=openapi_spec,
        client=api_client,
        route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
    )

    # Call the search tool with mixed parameter values
    async with Client(mcp_server) as client:
        await client.call_tool(
            "search_users_search_get",
            {
                "name": "",  # Empty string should be excluded
                "active": None,  # None should be excluded
                "min_id": 2,  # Has value, should be included
            },
        )

    # Verify that the request URL only has min_id parameter
    assert len(capture.requests) > 0
    request = capture.requests[-1]  # Get the last request

    # URL should only contain min_id=2, not name= or active=
    url = str(request.url)
    assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}"
    assert "name=" not in url, f"URL should not contain name=, got: {url}"
    assert "active=" not in url, f"URL should not contain active=, got: {url}"

    # More direct check - parse the URL to examine query params
    parsed_url = urlparse(url)
    query_params = parse_qs(parsed_url.query)

    assert "min_id" in query_params
    assert "name" not in query_params
    assert "active" not in query_params


async def test_none_path_parameters_rejected(
    fastapi_app: FastAPI, api_client: httpx.AsyncClient
):
    """Test that None values for path parameters are properly rejected."""
    # Create the OpenAPI server
    openapi_spec = fastapi_app.openapi()
    mcp_server = FastMCPOpenAPI(
        openapi_spec=openapi_spec,
        client=api_client,
    )

    # Create a client and try to call a tool with a None path parameter
    async with Client(mcp_server) as client:
        # get_user has a required path parameter user_id
        with pytest.raises(
            ToolError, match="Input validation error|Missing required path parameters"
        ):
            await client.call_tool(
                "update_user_name_users",
                {
                    "user_id": None,  # This should cause an error
                    "name": "New Name",
                },
            )


class TestTagTransfer:
    """Tests for transferring tags from OpenAPI routes to MCP objects."""

    async def test_tags_transferred_to_tools(
        self, fastmcp_openapi_server: FastMCPOpenAPI
    ):
        """Test that tags from OpenAPI routes are correctly transferred to Tools."""
        # Get internal tools directly (not the public API which returns MCP.Content)
        tools = await fastmcp_openapi_server._tool_manager.list_tools()

        # Find the create_user and update_user_name tools
        create_user_tool = next(
            (t for t in tools if t.name == "create_user_users_post"), None
        )
        update_user_tool = next(
            (t for t in tools if t.name == "update_user_name_users"),
            None,
        )

        assert create_user_tool is not None
        assert update_user_tool is not None

        # Check that tags from OpenAPI routes were transferred to the Tool objects
        assert "users" in create_user_tool.tags
        assert "create" in create_user_tool.tags
        assert len(create_user_tool.tags) == 2

        assert "users" in update_user_tool.tags
        assert "update" in update_user_tool.tags
        assert len(update_user_tool.tags) == 2

    async def test_tags_transferred_to_resources(
        self, fastmcp_openapi_server: FastMCPOpenAPI
    ):
        """Test that tags from OpenAPI routes are correctly transferred to Resources."""
        # Get internal resources directly
        resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
        resources = list(resources_dict.values())

        # Find the get_users resource
        get_users_resource = next(
            (r for r in resources if r.name == "get_users_users_get"), None
        )

        assert get_users_resource is not None

        # Check that tags from OpenAPI routes were transferred to the Resource object
        assert "users" in get_users_resource.tags
        assert "list" in get_users_resource.tags
        assert len(get_users_resource.tags) == 2

    async def test_tags_transferred_to_resource_templates(
        self, fastmcp_openapi_server: FastMCPOpenAPI
    ):
        """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
        # Get internal resource templates directly
        templates_dict = (
            await fastmcp_openapi_server._resource_manager.get_resource_templates()
        )
        templates = list(templates_dict.values())

        # Find the get_user template
        get_user_template = next(
            (t for t in templates if t.name == "get_user_users"), None
        )

        assert get_user_template is not None

        # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object
        assert "users" in get_user_template.tags
        assert "detail" in get_user_template.tags
        assert len(get_user_template.tags) == 2

    async def test_tags_preserved_in_resources_created_from_templates(
        self, fastmcp_openapi_server: FastMCPOpenAPI
    ):
        """Test that tags are preserved when creating resources from templates."""
        # Get internal resource templates directly
        templates_dict = (
            await fastmcp_openapi_server._resource_manager.get_resource_templates()
        )
        templates = list(templates_dict.values())

        # Find the get_user template
        get_user_template = next(
            (t for t in templates if t.name == "get_user_users"), None
        )

        assert get_user_template is not None

        # Manually create a resource from template
        params = {"user_id": 1}
        resource = await get_user_template.create_resource(
            "resource://get_user_users/1", params
        )

        # Verify tags are preserved from template to resource
        assert "users" in resource.tags
        assert "detail" in resource.tags
        assert len(resource.tags) == 2


class TestReprMethods:
    """Tests for the custom __repr__ methods of OpenAPI objects."""

    async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
        """Test that OpenAPITool's __repr__ method works without recursion errors."""
        tools = await fastmcp_openapi_server._tool_manager.list_tools()
        tool = next(iter(tools))

        # Verify repr doesn't cause recursion and contains expected elements
        tool_repr = repr(tool)
        assert "OpenAPITool" in tool_repr
        assert f"name={tool.name!r}" in tool_repr
        assert "method=" in tool_repr
        assert "path=" in tool_repr

    async def test_openapi_resource_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
        """Test that OpenAPIResource's __repr__ method works without recursion errors."""
        resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
        resources = list(resources_dict.values())
        resource = next(iter(resources))

        # Verify repr doesn't cause recursion and contains expected elements
        resource_repr = repr(resource)
        assert "OpenAPIResource" in resource_repr
        assert f"name={resource.name!r}" in resource_repr
        assert "uri=" in resource_repr
        assert "path=" in resource_repr

    async def test_openapi_resource_template_repr(
        self, fastmcp_openapi_server: FastMCPOpenAPI
    ):
        """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
        templates_dict = (
            await fastmcp_openapi_server._resource_manager.get_resource_templates()
        )
        templates = list(templates_dict.values())
        template = next(iter(templates))

        # Verify repr doesn't cause recursion and contains expected elements
        template_repr = repr(template)
        assert "OpenAPIResourceTemplate" in template_repr
        assert f"name={template.name!r}" in template_repr
        assert "uri_template=" in template_repr
        assert "path=" in template_repr


class TestEnumHandling:
    """Tests for handling enum parameters in OpenAPI schemas."""

    async def test_enum_parameter_schema(self):
        """Test that enum parameters are properly handled in tool parameter schemas."""

        # Define an enum just like in example.py
        class QueryEnum(str, Enum):
            foo = "foo"
            bar = "bar"
            baz = "baz"

        # Create a minimal FastAPI app with an endpoint using the enum
        app = FastAPI()

        @app.post("/items/{item_id}")
        def read_item(
            item_id: int,
            query: QueryEnum | None = None,
        ):
            return {"item_id": item_id, "query": query}

        # Create a client for the app
        client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")

        # Create the FastMCPOpenAPI server from the app
        openapi_spec = app.openapi()
        server = FastMCPOpenAPI(
            openapi_spec=openapi_spec,
            client=client,
            name="Enum Test",
        )

        # Get the tools from the server
        tools = await server._tool_manager.list_tools()

        # Find the read_item tool
        read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)

        # Verify the tool exists
        assert read_item_tool is not None, "read_item tool wasn't created"

        # Check that the parameters include the enum reference
        assert "properties" in read_item_tool.parameters
        assert "query" in read_item_tool.parameters["properties"]

        # Check for the anyOf with $ref to the enum definition
        query_param = read_item_tool.parameters["properties"]["query"]
        assert "anyOf" in query_param

        # Find the ref in the anyOf list
        ref_found = False
        for option in query_param["anyOf"]:
            if "$ref" in option and option["$ref"].startswith("#/$defs/QueryEnum"):
                ref_found = True
                break

        assert ref_found, "Reference to enum definition not found in query parameter"

        # Check that the $defs section exists and contains the enum definition
        assert "$defs" in read_item_tool.parameters
        assert "QueryEnum" in read_item_tool.parameters["$defs"]

        # Verify the enum definition
        enum_def = read_item_tool.parameters["$defs"]["QueryEnum"]
        assert "enum" in enum_def
        assert enum_def["enum"] == ["foo", "bar", "baz"]
        assert enum_def["type"] == "string"