Jeremiah Lowin commited on
Commit
c151bc3
·
1 Parent(s): f2446fb

Split giant test file into smaller files

Browse files
tests/server/openapi/conftest.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import pytest
3
+ from fastapi import FastAPI, HTTPException, Response
4
+ from fastapi.responses import PlainTextResponse
5
+ from httpx import ASGITransport, AsyncClient
6
+ from pydantic import BaseModel
7
+
8
+ from fastmcp.server.openapi import (
9
+ FastMCPOpenAPI,
10
+ MCPType,
11
+ RouteMap,
12
+ )
13
+
14
+
15
+ class User(BaseModel):
16
+ id: int
17
+ name: str
18
+ active: bool
19
+
20
+
21
+ class UserCreate(BaseModel):
22
+ name: str
23
+ active: bool
24
+
25
+
26
+ @pytest.fixture
27
+ def users_db() -> dict[int, User]:
28
+ return {
29
+ 1: User(id=1, name="Alice", active=True),
30
+ 2: User(id=2, name="Bob", active=True),
31
+ 3: User(id=3, name="Charlie", active=False),
32
+ }
33
+
34
+
35
+ # route maps for GET requests
36
+ # use these to create components of all types instead of just tools
37
+ GET_ROUTE_MAPS = [
38
+ # GET requests with path parameters go to ResourceTemplate
39
+ RouteMap(
40
+ methods=["GET"],
41
+ pattern=r".*\{.*\}.*",
42
+ mcp_type=MCPType.RESOURCE_TEMPLATE,
43
+ ),
44
+ # GET requests without path parameters go to Resource
45
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
46
+ ]
47
+
48
+
49
+ @pytest.fixture
50
+ def fastapi_app(users_db: dict[int, User]) -> FastAPI:
51
+ app = FastAPI(title="FastAPI App")
52
+
53
+ @app.get("/users", tags=["users", "list"])
54
+ async def get_users() -> list[User]:
55
+ """Get all users."""
56
+ return sorted(users_db.values(), key=lambda x: x.id)
57
+
58
+ @app.get("/search", tags=["search"])
59
+ async def search_users(
60
+ name: str | None = None, active: bool | None = None, min_id: int | None = None
61
+ ) -> list[User]:
62
+ """Search users with optional filters."""
63
+ results = list(users_db.values())
64
+
65
+ if name is not None:
66
+ results = [u for u in results if name.lower() in u.name.lower()]
67
+ if active is not None:
68
+ results = [u for u in results if u.active == active]
69
+ if min_id is not None:
70
+ results = [u for u in results if u.id >= min_id]
71
+
72
+ return sorted(results, key=lambda x: x.id)
73
+
74
+ @app.get("/users/{user_id}", tags=["users", "detail"])
75
+ async def get_user(user_id: int) -> User | None:
76
+ """Get a user by ID."""
77
+ return users_db.get(user_id)
78
+
79
+ @app.get("/users/{user_id}/{is_active}", tags=["users", "detail"])
80
+ async def get_user_active_state(user_id: int, is_active: bool) -> User | None:
81
+ """Get a user by ID and filter by active state."""
82
+ user = users_db.get(user_id)
83
+ if user is not None and user.active == is_active:
84
+ return user
85
+ return None
86
+
87
+ @app.post("/users", tags=["users", "create"])
88
+ async def create_user(user: UserCreate) -> User:
89
+ """Create a new user."""
90
+ user_id = max(users_db.keys()) + 1
91
+ new_user = User(id=user_id, **user.model_dump())
92
+ users_db[user_id] = new_user
93
+ return new_user
94
+
95
+ @app.patch("/users/{user_id}/name", tags=["users", "update"])
96
+ async def update_user_name(user_id: int, name: str) -> User:
97
+ """Update a user's name."""
98
+ user = users_db.get(user_id)
99
+ if user is None:
100
+ raise HTTPException(status_code=404, detail="User not found")
101
+ user.name = name
102
+ return user
103
+
104
+ @app.get("/ping", response_class=PlainTextResponse)
105
+ async def ping() -> str:
106
+ """Ping the server."""
107
+ return "pong"
108
+
109
+ @app.get("/ping-bytes")
110
+ async def ping_bytes() -> Response:
111
+ """Ping the server and get a bytes response."""
112
+
113
+ return Response(content=b"pong")
114
+
115
+ return app
116
+
117
+
118
+ @pytest.fixture
119
+ def api_client(fastapi_app: FastAPI) -> AsyncClient:
120
+ """Create a pre-configured httpx client for testing."""
121
+ return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
122
+
123
+
124
+ @pytest.fixture
125
+ async def fastmcp_openapi_server(
126
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
127
+ ) -> FastMCPOpenAPI:
128
+ openapi_spec = fastapi_app.openapi()
129
+
130
+ return FastMCPOpenAPI(
131
+ openapi_spec=openapi_spec,
132
+ client=api_client,
133
+ name="Test App",
134
+ route_maps=GET_ROUTE_MAPS,
135
+ )
tests/server/openapi/test_advanced_behavior.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from urllib.parse import parse_qs, urlparse
3
+
4
+ import httpx
5
+ import pytest
6
+ from fastapi import FastAPI
7
+ from httpx import ASGITransport, AsyncClient
8
+
9
+ from fastmcp.client import Client
10
+ from fastmcp.exceptions import ToolError
11
+ from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
12
+
13
+
14
+ async def test_empty_query_parameters_not_sent(
15
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
16
+ ):
17
+ """Test that empty and None query parameters are not sent in the request."""
18
+
19
+ # Create a TransportAdapter to track requests
20
+ class RequestCapture(httpx.AsyncBaseTransport):
21
+ def __init__(self, wrapped):
22
+ self.wrapped = wrapped
23
+ self.requests = []
24
+
25
+ async def handle_async_request(self, request):
26
+ self.requests.append(request)
27
+ return await self.wrapped.handle_async_request(request)
28
+
29
+ # Use our transport adapter to wrap the original one
30
+ capture = RequestCapture(api_client._transport)
31
+ api_client._transport = capture
32
+
33
+ # Create the OpenAPI server with new route map to make search endpoint a tool
34
+ openapi_spec = fastapi_app.openapi()
35
+ mcp_server = FastMCPOpenAPI(
36
+ openapi_spec=openapi_spec,
37
+ client=api_client,
38
+ route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
39
+ )
40
+
41
+ # Call the search tool with mixed parameter values
42
+ async with Client(mcp_server) as client:
43
+ await client.call_tool(
44
+ "search_users_search_get",
45
+ {
46
+ "name": "", # Empty string should be excluded
47
+ "active": None, # None should be excluded
48
+ "min_id": 2, # Has value, should be included
49
+ },
50
+ )
51
+
52
+ # Verify that the request URL only has min_id parameter
53
+ assert len(capture.requests) > 0
54
+ request = capture.requests[-1] # Get the last request
55
+
56
+ # URL should only contain min_id=2, not name= or active=
57
+ url = str(request.url)
58
+ assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}"
59
+ assert "name=" not in url, f"URL should not contain name=, got: {url}"
60
+ assert "active=" not in url, f"URL should not contain active=, got: {url}"
61
+
62
+ # More direct check - parse the URL to examine query params
63
+ parsed_url = urlparse(url)
64
+ query_params = parse_qs(parsed_url.query)
65
+
66
+ assert "min_id" in query_params
67
+ assert "name" not in query_params
68
+ assert "active" not in query_params
69
+
70
+
71
+ async def test_none_path_parameters_rejected(
72
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
73
+ ):
74
+ """Test that None values for path parameters are properly rejected."""
75
+ # Create the OpenAPI server
76
+ openapi_spec = fastapi_app.openapi()
77
+ mcp_server = FastMCPOpenAPI(
78
+ openapi_spec=openapi_spec,
79
+ client=api_client,
80
+ )
81
+
82
+ # Create a client and try to call a tool with a None path parameter
83
+ async with Client(mcp_server) as client:
84
+ # get_user has a required path parameter user_id
85
+ with pytest.raises(
86
+ ToolError, match="Input validation error|Missing required path parameters"
87
+ ):
88
+ await client.call_tool(
89
+ "update_user_name_users",
90
+ {
91
+ "user_id": None, # This should cause an error
92
+ "name": "New Name",
93
+ },
94
+ )
95
+
96
+
97
+ class TestTagTransfer:
98
+ """Tests for transferring tags from OpenAPI routes to MCP objects."""
99
+
100
+ async def test_tags_transferred_to_tools(
101
+ self, fastmcp_openapi_server: FastMCPOpenAPI
102
+ ):
103
+ """Test that tags from OpenAPI routes are correctly transferred to Tools."""
104
+ # Get internal tools directly (not the public API which returns MCP.Content)
105
+ tools = await fastmcp_openapi_server._tool_manager.list_tools()
106
+
107
+ # Find the create_user and update_user_name tools
108
+ create_user_tool = next(
109
+ (t for t in tools if t.name == "create_user_users_post"), None
110
+ )
111
+ update_user_tool = next(
112
+ (t for t in tools if t.name == "update_user_name_users"),
113
+ None,
114
+ )
115
+
116
+ assert create_user_tool is not None
117
+ assert update_user_tool is not None
118
+
119
+ # Check that tags from OpenAPI routes were transferred to the Tool objects
120
+ assert "users" in create_user_tool.tags
121
+ assert "create" in create_user_tool.tags
122
+ assert len(create_user_tool.tags) == 2
123
+
124
+ assert "users" in update_user_tool.tags
125
+ assert "update" in update_user_tool.tags
126
+ assert len(update_user_tool.tags) == 2
127
+
128
+ async def test_tags_transferred_to_resources(
129
+ self, fastmcp_openapi_server: FastMCPOpenAPI
130
+ ):
131
+ """Test that tags from OpenAPI routes are correctly transferred to Resources."""
132
+ # Get internal resources directly
133
+ resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
134
+ resources = list(resources_dict.values())
135
+
136
+ # Find the get_users resource
137
+ get_users_resource = next(
138
+ (r for r in resources if r.name == "get_users_users_get"), None
139
+ )
140
+
141
+ assert get_users_resource is not None
142
+
143
+ # Check that tags from OpenAPI routes were transferred to the Resource object
144
+ assert "users" in get_users_resource.tags
145
+ assert "list" in get_users_resource.tags
146
+ assert len(get_users_resource.tags) == 2
147
+
148
+ async def test_tags_transferred_to_resource_templates(
149
+ self, fastmcp_openapi_server: FastMCPOpenAPI
150
+ ):
151
+ """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
152
+ # Get internal resource templates directly
153
+ templates_dict = (
154
+ await fastmcp_openapi_server._resource_manager.get_resource_templates()
155
+ )
156
+ templates = list(templates_dict.values())
157
+
158
+ # Find the get_user template
159
+ get_user_template = next(
160
+ (t for t in templates if t.name == "get_user_users"), None
161
+ )
162
+
163
+ assert get_user_template is not None
164
+
165
+ # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object
166
+ assert "users" in get_user_template.tags
167
+ assert "detail" in get_user_template.tags
168
+ assert len(get_user_template.tags) == 2
169
+
170
+ async def test_tags_preserved_in_resources_created_from_templates(
171
+ self, fastmcp_openapi_server: FastMCPOpenAPI
172
+ ):
173
+ """Test that tags are preserved when creating resources from templates."""
174
+ # Get internal resource templates directly
175
+ templates_dict = (
176
+ await fastmcp_openapi_server._resource_manager.get_resource_templates()
177
+ )
178
+ templates = list(templates_dict.values())
179
+
180
+ # Find the get_user template
181
+ get_user_template = next(
182
+ (t for t in templates if t.name == "get_user_users"), None
183
+ )
184
+
185
+ assert get_user_template is not None
186
+
187
+ # Manually create a resource from template
188
+ params = {"user_id": 1}
189
+ resource = await get_user_template.create_resource(
190
+ "resource://get_user_users/1", params
191
+ )
192
+
193
+ # Verify tags are preserved from template to resource
194
+ assert "users" in resource.tags
195
+ assert "detail" in resource.tags
196
+ assert len(resource.tags) == 2
197
+
198
+
199
+ class TestReprMethods:
200
+ """Tests for the custom __repr__ methods of OpenAPI objects."""
201
+
202
+ async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
203
+ """Test that OpenAPITool's __repr__ method works without recursion errors."""
204
+ tools = await fastmcp_openapi_server._tool_manager.list_tools()
205
+ tool = next(iter(tools))
206
+
207
+ # Verify repr doesn't cause recursion and contains expected elements
208
+ tool_repr = repr(tool)
209
+ assert "OpenAPITool" in tool_repr
210
+ assert f"name={tool.name!r}" in tool_repr
211
+ assert "method=" in tool_repr
212
+ assert "path=" in tool_repr
213
+
214
+ async def test_openapi_resource_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
215
+ """Test that OpenAPIResource's __repr__ method works without recursion errors."""
216
+ resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
217
+ resources = list(resources_dict.values())
218
+ resource = next(iter(resources))
219
+
220
+ # Verify repr doesn't cause recursion and contains expected elements
221
+ resource_repr = repr(resource)
222
+ assert "OpenAPIResource" in resource_repr
223
+ assert f"name={resource.name!r}" in resource_repr
224
+ assert "uri=" in resource_repr
225
+ assert "path=" in resource_repr
226
+
227
+ async def test_openapi_resource_template_repr(
228
+ self, fastmcp_openapi_server: FastMCPOpenAPI
229
+ ):
230
+ """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
231
+ templates_dict = (
232
+ await fastmcp_openapi_server._resource_manager.get_resource_templates()
233
+ )
234
+ templates = list(templates_dict.values())
235
+ template = next(iter(templates))
236
+
237
+ # Verify repr doesn't cause recursion and contains expected elements
238
+ template_repr = repr(template)
239
+ assert "OpenAPIResourceTemplate" in template_repr
240
+ assert f"name={template.name!r}" in template_repr
241
+ assert "uri_template=" in template_repr
242
+ assert "path=" in template_repr
243
+
244
+
245
+ class TestEnumHandling:
246
+ """Tests for handling enum parameters in OpenAPI schemas."""
247
+
248
+ async def test_enum_parameter_schema(self):
249
+ """Test that enum parameters are properly handled in tool parameter schemas."""
250
+
251
+ # Define an enum just like in example.py
252
+ class QueryEnum(str, Enum):
253
+ foo = "foo"
254
+ bar = "bar"
255
+ baz = "baz"
256
+
257
+ # Create a minimal FastAPI app with an endpoint using the enum
258
+ app = FastAPI()
259
+
260
+ @app.post("/items/{item_id}")
261
+ def read_item(
262
+ item_id: int,
263
+ query: QueryEnum | None = None,
264
+ ):
265
+ return {"item_id": item_id, "query": query}
266
+
267
+ # Create a client for the app
268
+ client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
269
+
270
+ # Create the FastMCPOpenAPI server from the app
271
+ openapi_spec = app.openapi()
272
+ server = FastMCPOpenAPI(
273
+ openapi_spec=openapi_spec,
274
+ client=client,
275
+ name="Enum Test",
276
+ )
277
+
278
+ # Get the tools from the server
279
+ tools = await server._tool_manager.list_tools()
280
+
281
+ # Find the read_item tool
282
+ read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
283
+
284
+ # Verify the tool exists
285
+ assert read_item_tool is not None, "read_item tool wasn't created"
286
+
287
+ # Check that the parameters include the enum reference
288
+ assert "properties" in read_item_tool.parameters
289
+ assert "query" in read_item_tool.parameters["properties"]
290
+
291
+ # Check for the anyOf with $ref to the enum definition
292
+ query_param = read_item_tool.parameters["properties"]["query"]
293
+ assert "anyOf" in query_param
294
+
295
+ # Find the ref in the anyOf list
296
+ ref_found = False
297
+ for option in query_param["anyOf"]:
298
+ if "$ref" in option and option["$ref"].startswith("#/$defs/QueryEnum"):
299
+ ref_found = True
300
+ break
301
+
302
+ assert ref_found, "Reference to enum definition not found in query parameter"
303
+
304
+ # Check that the $defs section exists and contains the enum definition
305
+ assert "$defs" in read_item_tool.parameters
306
+ assert "QueryEnum" in read_item_tool.parameters["$defs"]
307
+
308
+ # Verify the enum definition
309
+ enum_def = read_item_tool.parameters["$defs"]["QueryEnum"]
310
+ assert "enum" in enum_def
311
+ assert enum_def["enum"] == ["foo", "bar", "baz"]
312
+ assert enum_def["type"] == "string"
tests/server/openapi/test_basic_functionality.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import re
4
+
5
+ import httpx
6
+ from dirty_equals import IsStr
7
+ from fastapi import FastAPI
8
+ from mcp.types import BlobResourceContents
9
+ from pydantic import TypeAdapter
10
+ from pydantic.networks import AnyUrl
11
+
12
+ from fastmcp import FastMCP
13
+ from fastmcp.client import Client
14
+ from fastmcp.server.openapi import (
15
+ FastMCPOpenAPI,
16
+ MCPType,
17
+ OpenAPIResource,
18
+ OpenAPIResourceTemplate,
19
+ OpenAPITool,
20
+ RouteMap,
21
+ )
22
+
23
+ from .conftest import GET_ROUTE_MAPS, User
24
+
25
+
26
+ async def test_create_openapi_server(
27
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
28
+ ):
29
+ openapi_spec = fastapi_app.openapi()
30
+
31
+ server = FastMCPOpenAPI(
32
+ openapi_spec=openapi_spec, client=api_client, name="Test App"
33
+ )
34
+
35
+ assert isinstance(server, FastMCP)
36
+ assert server.name == "Test App"
37
+
38
+
39
+ async def test_create_openapi_server_classmethod(
40
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
41
+ ):
42
+ server = FastMCP.from_openapi(openapi_spec=fastapi_app.openapi(), client=api_client)
43
+ assert isinstance(server, FastMCPOpenAPI)
44
+ assert server.name == "OpenAPI FastMCP"
45
+
46
+
47
+ async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
48
+ server = FastMCP.from_fastapi(fastapi_app)
49
+ assert isinstance(server, FastMCPOpenAPI)
50
+ assert server.name == "FastAPI App"
51
+
52
+
53
+ async def test_create_openapi_server_with_timeout(
54
+ fastapi_app: FastAPI, api_client: httpx.AsyncClient
55
+ ):
56
+ server = FastMCPOpenAPI(
57
+ openapi_spec=fastapi_app.openapi(),
58
+ client=api_client,
59
+ name="Test App",
60
+ timeout=1.0,
61
+ route_maps=GET_ROUTE_MAPS,
62
+ )
63
+ assert server._timeout == 1.0
64
+
65
+ for tool in (await server.get_tools()).values():
66
+ assert isinstance(tool, OpenAPITool)
67
+ assert tool._timeout == 1.0
68
+
69
+ for resource in (await server.get_resources()).values():
70
+ assert isinstance(resource, OpenAPIResource)
71
+ assert resource._timeout == 1.0
72
+
73
+ for template in (await server.get_resource_templates()).values():
74
+ assert isinstance(template, OpenAPIResourceTemplate)
75
+ assert template._timeout == 1.0
76
+
77
+
78
+ class TestTools:
79
+ async def test_default_behavior_converts_everything_to_tools(
80
+ self, fastapi_app: FastAPI
81
+ ):
82
+ """
83
+ By default, tools exclude GET methods
84
+ """
85
+ server = FastMCPOpenAPI.from_fastapi(fastapi_app)
86
+ assert len(await server.get_tools()) == 8
87
+ assert len(await server.get_resources()) == 0
88
+ assert len(await server.get_resource_templates()) == 0
89
+
90
+ async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
91
+ """
92
+ By default, tools exclude GET methods
93
+ """
94
+ async with Client(fastmcp_openapi_server) as client:
95
+ tools = await client.list_tools()
96
+ assert len(tools) == 2
97
+
98
+ assert tools[0].model_dump() == dict(
99
+ name="create_user_users_post",
100
+ meta=None,
101
+ title=None,
102
+ annotations=None,
103
+ description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL),
104
+ inputSchema={
105
+ "type": "object",
106
+ "properties": {
107
+ "name": {"type": "string", "title": "Name"},
108
+ "active": {"type": "boolean", "title": "Active"},
109
+ },
110
+ "required": ["name", "active"],
111
+ },
112
+ outputSchema=None,
113
+ )
114
+ assert tools[1].model_dump() == dict(
115
+ name="update_user_name_users",
116
+ meta=None,
117
+ title=None,
118
+ annotations=None,
119
+ description=IsStr(
120
+ regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
121
+ ),
122
+ inputSchema={
123
+ "type": "object",
124
+ "properties": {
125
+ "user_id": {"type": "integer", "title": "User Id"},
126
+ "name": {"type": "string", "title": "Name"},
127
+ },
128
+ "required": ["user_id", "name"],
129
+ },
130
+ outputSchema=None,
131
+ )
132
+
133
+ async def test_call_create_user_tool(
134
+ self,
135
+ fastmcp_openapi_server: FastMCPOpenAPI,
136
+ api_client,
137
+ ):
138
+ """
139
+ The tool created by the OpenAPI server should be the same as the original
140
+ """
141
+ async with Client(fastmcp_openapi_server) as client:
142
+ tool_response = await client.call_tool(
143
+ "create_user_users_post", {"name": "David", "active": False}
144
+ )
145
+
146
+ expected_user = User(id=4, name="David", active=False).model_dump()
147
+ assert tool_response.data == expected_user
148
+
149
+ # Check that the user was created via API
150
+ response = await api_client.get("/users")
151
+ assert len(response.json()) == 4
152
+
153
+ # Check that the user was created via MCP
154
+ async with Client(fastmcp_openapi_server) as client:
155
+ user_response = await client.read_resource("resource://get_user_users/4")
156
+ response_text = user_response[0].text # type: ignore[attr-defined]
157
+ user = json.loads(response_text)
158
+ assert user == expected_user
159
+
160
+ async def test_call_update_user_name_tool(
161
+ self,
162
+ fastmcp_openapi_server: FastMCPOpenAPI,
163
+ api_client,
164
+ ):
165
+ """
166
+ The tool created by the OpenAPI server should be the same as the original
167
+ """
168
+ async with Client(fastmcp_openapi_server) as client:
169
+ tool_response = await client.call_tool(
170
+ "update_user_name_users",
171
+ {"user_id": 1, "name": "XYZ"},
172
+ )
173
+
174
+ expected_data = dict(id=1, name="XYZ", active=True)
175
+ assert tool_response.data == expected_data
176
+
177
+ # Check that the user was updated via API
178
+ response = await api_client.get("/users")
179
+ assert expected_data in response.json()
180
+
181
+ # Check that the user was updated via MCP
182
+ async with Client(fastmcp_openapi_server) as client:
183
+ user_response = await client.read_resource("resource://get_user_users/1")
184
+ response_text = user_response[0].text # type: ignore[attr-defined]
185
+ user = json.loads(response_text)
186
+ assert user == expected_data
187
+
188
+ async def test_call_tool_return_list(
189
+ self,
190
+ fastapi_app: FastAPI,
191
+ api_client: httpx.AsyncClient,
192
+ users_db: dict[int, User],
193
+ ):
194
+ """
195
+ The tool created by the OpenAPI server should return a list of content.
196
+ """
197
+ openapi_spec = fastapi_app.openapi()
198
+ mcp_server = FastMCPOpenAPI(
199
+ openapi_spec=openapi_spec,
200
+ client=api_client,
201
+ route_maps=[
202
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
203
+ ],
204
+ )
205
+ async with Client(mcp_server) as client:
206
+ tool_response = await client.call_tool("get_users_users_get", {})
207
+ assert tool_response.data == {
208
+ "result": [
209
+ user.model_dump()
210
+ for user in sorted(users_db.values(), key=lambda x: x.id)
211
+ ]
212
+ }
213
+
214
+
215
+ class TestResources:
216
+ async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
217
+ """
218
+ By default, resources exclude GET methods without parameters
219
+ """
220
+ async with Client(fastmcp_openapi_server) as client:
221
+ resources = await client.list_resources()
222
+ assert len(resources) == 4
223
+ assert resources[0].uri == AnyUrl("resource://get_users_users_get")
224
+ assert resources[0].name == "get_users_users_get"
225
+
226
+ async def test_get_resource(
227
+ self,
228
+ fastmcp_openapi_server: FastMCPOpenAPI,
229
+ api_client,
230
+ users_db: dict[int, User],
231
+ ):
232
+ """
233
+ The resource created by the OpenAPI server should be the same as the original
234
+ """
235
+
236
+ json_users = TypeAdapter(list[User]).dump_python(
237
+ sorted(users_db.values(), key=lambda x: x.id)
238
+ )
239
+ async with Client(fastmcp_openapi_server) as client:
240
+ resource_response = await client.read_resource(
241
+ "resource://get_users_users_get"
242
+ )
243
+ response_text = resource_response[0].text # type: ignore[attr-defined]
244
+ resource = json.loads(response_text)
245
+ assert resource == json_users
246
+ response = await api_client.get("/users")
247
+ assert response.json() == json_users
248
+
249
+ async def test_get_bytes_resource(
250
+ self,
251
+ fastmcp_openapi_server: FastMCPOpenAPI,
252
+ api_client,
253
+ ):
254
+ """Test reading a resource that returns bytes."""
255
+ async with Client(fastmcp_openapi_server) as client:
256
+ resource_response = await client.read_resource(
257
+ "resource://ping_bytes_ping_bytes_get"
258
+ )
259
+ assert isinstance(resource_response[0], BlobResourceContents)
260
+ assert base64.b64decode(resource_response[0].blob) == b"pong"
261
+
262
+ async def test_get_str_resource(
263
+ self,
264
+ fastmcp_openapi_server: FastMCPOpenAPI,
265
+ api_client,
266
+ ):
267
+ """Test reading a resource that returns a string."""
268
+ async with Client(fastmcp_openapi_server) as client:
269
+ resource_response = await client.read_resource("resource://ping_ping_get")
270
+ assert resource_response[0].text == "pong" # type: ignore[attr-defined]
271
+
272
+
273
+ class TestResourceTemplates:
274
+ async def test_list_resource_templates(
275
+ self, fastmcp_openapi_server: FastMCPOpenAPI
276
+ ):
277
+ """
278
+ By default, resource templates exclude GET methods without parameters
279
+ """
280
+ async with Client(fastmcp_openapi_server) as client:
281
+ resource_templates = await client.list_resource_templates()
282
+ assert len(resource_templates) == 2
283
+ assert resource_templates[0].name == "get_user_users"
284
+ assert (
285
+ resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}"
286
+ )
287
+ assert resource_templates[1].name == "get_user_active_state_users"
288
+ assert (
289
+ resource_templates[1].uriTemplate
290
+ == r"resource://get_user_active_state_users/{is_active}/{user_id}"
291
+ )
292
+
293
+ async def test_get_resource_template(
294
+ self,
295
+ fastmcp_openapi_server: FastMCPOpenAPI,
296
+ api_client,
297
+ users_db: dict[int, User],
298
+ ):
299
+ """
300
+ The resource template created by the OpenAPI server should be the same as the original
301
+ """
302
+ user_id = 2
303
+ async with Client(fastmcp_openapi_server) as client:
304
+ resource_response = await client.read_resource(
305
+ f"resource://get_user_users/{user_id}"
306
+ )
307
+ response_text = resource_response[0].text # type: ignore[attr-defined]
308
+ resource = json.loads(response_text)
309
+
310
+ assert resource == users_db[user_id].model_dump()
311
+ response = await api_client.get(f"/users/{user_id}")
312
+ assert resource == response.json()
313
+
314
+ async def test_get_resource_template_multi_param(
315
+ self,
316
+ fastmcp_openapi_server: FastMCPOpenAPI,
317
+ api_client,
318
+ users_db: dict[int, User],
319
+ ):
320
+ """
321
+ The resource template created by the OpenAPI server should be the same as the original
322
+ """
323
+ user_id = 2
324
+ is_active = True
325
+ async with Client(fastmcp_openapi_server) as client:
326
+ resource_response = await client.read_resource(
327
+ f"resource://get_user_active_state_users/{is_active}/{user_id}"
328
+ )
329
+ response_text = resource_response[0].text # type: ignore[attr-defined]
330
+ resource = json.loads(response_text)
331
+
332
+ assert resource == users_db[user_id].model_dump()
333
+ response = await api_client.get(f"/users/{user_id}/{is_active}")
334
+ assert resource == response.json()
335
+
336
+
337
+ class TestPrompts:
338
+ async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
339
+ """
340
+ By default, there are no prompts.
341
+ """
342
+ async with Client(fastmcp_openapi_server) as client:
343
+ prompts = await client.list_prompts()
344
+ assert len(prompts) == 0
tests/server/openapi/{test_openapi.py → test_configuration.py} RENAMED
@@ -1,1921 +1,11 @@
1
- import base64
2
- import json
3
- import re
4
- from enum import Enum
5
-
6
  import httpx
7
  import pytest
8
- from dirty_equals import IsStr
9
- from fastapi import FastAPI, HTTPException, Response
10
- from fastapi.responses import PlainTextResponse
11
- from httpx import ASGITransport, AsyncClient
12
- from mcp.types import BlobResourceContents
13
- from pydantic import BaseModel, TypeAdapter
14
- from pydantic.networks import AnyUrl
15
 
16
  from fastmcp import FastMCP
17
- from fastmcp.client import Client
18
- from fastmcp.exceptions import ToolError
19
- from fastmcp.server.openapi import (
20
- FastMCPOpenAPI,
21
- MCPType,
22
- OpenAPIResource,
23
- OpenAPIResourceTemplate,
24
- OpenAPITool,
25
- RouteMap,
26
- )
27
-
28
-
29
- class User(BaseModel):
30
- id: int
31
- name: str
32
- active: bool
33
-
34
-
35
- class UserCreate(BaseModel):
36
- name: str
37
- active: bool
38
-
39
-
40
- @pytest.fixture
41
- def users_db() -> dict[int, User]:
42
- return {
43
- 1: User(id=1, name="Alice", active=True),
44
- 2: User(id=2, name="Bob", active=True),
45
- 3: User(id=3, name="Charlie", active=False),
46
- }
47
-
48
-
49
- # route maps for GET requests
50
- # use these to create components of all types instead of just tools
51
- GET_ROUTE_MAPS = [
52
- # GET requests with path parameters go to ResourceTemplate
53
- RouteMap(
54
- methods=["GET"],
55
- pattern=r".*\{.*\}.*",
56
- mcp_type=MCPType.RESOURCE_TEMPLATE,
57
- ),
58
- # GET requests without path parameters go to Resource
59
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
60
- ]
61
-
62
-
63
- @pytest.fixture
64
- def fastapi_app(users_db: dict[int, User]) -> FastAPI:
65
- app = FastAPI(title="FastAPI App")
66
-
67
- @app.get("/users", tags=["users", "list"])
68
- async def get_users() -> list[User]:
69
- """Get all users."""
70
- return sorted(users_db.values(), key=lambda x: x.id)
71
-
72
- @app.get("/search", tags=["search"])
73
- async def search_users(
74
- name: str | None = None, active: bool | None = None, min_id: int | None = None
75
- ) -> list[User]:
76
- """Search users with optional filters."""
77
- results = list(users_db.values())
78
-
79
- if name is not None:
80
- results = [u for u in results if name.lower() in u.name.lower()]
81
- if active is not None:
82
- results = [u for u in results if u.active == active]
83
- if min_id is not None:
84
- results = [u for u in results if u.id >= min_id]
85
-
86
- return sorted(results, key=lambda x: x.id)
87
-
88
- @app.get("/users/{user_id}", tags=["users", "detail"])
89
- async def get_user(user_id: int) -> User | None:
90
- """Get a user by ID."""
91
- return users_db.get(user_id)
92
-
93
- @app.get("/users/{user_id}/{is_active}", tags=["users", "detail"])
94
- async def get_user_active_state(user_id: int, is_active: bool) -> User | None:
95
- """Get a user by ID and filter by active state."""
96
- user = users_db.get(user_id)
97
- if user is not None and user.active == is_active:
98
- return user
99
- return None
100
-
101
- @app.post("/users", tags=["users", "create"])
102
- async def create_user(user: UserCreate) -> User:
103
- """Create a new user."""
104
- user_id = max(users_db.keys()) + 1
105
- new_user = User(id=user_id, **user.model_dump())
106
- users_db[user_id] = new_user
107
- return new_user
108
-
109
- @app.patch("/users/{user_id}/name", tags=["users", "update"])
110
- async def update_user_name(user_id: int, name: str) -> User:
111
- """Update a user's name."""
112
- user = users_db.get(user_id)
113
- if user is None:
114
- raise HTTPException(status_code=404, detail="User not found")
115
- user.name = name
116
- return user
117
-
118
- @app.get("/ping", response_class=PlainTextResponse)
119
- async def ping() -> str:
120
- """Ping the server."""
121
- return "pong"
122
-
123
- @app.get("/ping-bytes")
124
- async def ping_bytes() -> Response:
125
- """Ping the server and get a bytes response."""
126
-
127
- return Response(content=b"pong")
128
-
129
- return app
130
-
131
-
132
- @pytest.fixture
133
- def api_client(fastapi_app: FastAPI) -> AsyncClient:
134
- """Create a pre-configured httpx client for testing."""
135
- return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
136
-
137
-
138
- @pytest.fixture
139
- async def fastmcp_openapi_server(
140
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
141
- ) -> FastMCPOpenAPI:
142
- openapi_spec = fastapi_app.openapi()
143
-
144
- return FastMCPOpenAPI(
145
- openapi_spec=openapi_spec,
146
- client=api_client,
147
- name="Test App",
148
- route_maps=GET_ROUTE_MAPS,
149
- )
150
-
151
-
152
- async def test_create_openapi_server(
153
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
154
- ):
155
- openapi_spec = fastapi_app.openapi()
156
-
157
- server = FastMCPOpenAPI(
158
- openapi_spec=openapi_spec, client=api_client, name="Test App"
159
- )
160
-
161
- assert isinstance(server, FastMCP)
162
- assert server.name == "Test App"
163
-
164
-
165
- async def test_create_openapi_server_classmethod(
166
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
167
- ):
168
- server = FastMCP.from_openapi(openapi_spec=fastapi_app.openapi(), client=api_client)
169
- assert isinstance(server, FastMCPOpenAPI)
170
- assert server.name == "OpenAPI FastMCP"
171
-
172
-
173
- async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
174
- server = FastMCP.from_fastapi(fastapi_app)
175
- assert isinstance(server, FastMCPOpenAPI)
176
- assert server.name == "FastAPI App"
177
-
178
-
179
- async def test_create_openapi_server_with_timeout(
180
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
181
- ):
182
- server = FastMCPOpenAPI(
183
- openapi_spec=fastapi_app.openapi(),
184
- client=api_client,
185
- name="Test App",
186
- timeout=1.0,
187
- route_maps=GET_ROUTE_MAPS,
188
- )
189
- assert server._timeout == 1.0
190
-
191
- for tool in (await server.get_tools()).values():
192
- assert isinstance(tool, OpenAPITool)
193
- assert tool._timeout == 1.0
194
-
195
- for resource in (await server.get_resources()).values():
196
- assert isinstance(resource, OpenAPIResource)
197
- assert resource._timeout == 1.0
198
-
199
- for template in (await server.get_resource_templates()).values():
200
- assert isinstance(template, OpenAPIResourceTemplate)
201
- assert template._timeout == 1.0
202
-
203
-
204
- class TestTools:
205
- async def test_default_behavior_converts_everything_to_tools(
206
- self, fastapi_app: FastAPI
207
- ):
208
- """
209
- By default, tools exclude GET methods
210
- """
211
- server = FastMCPOpenAPI.from_fastapi(fastapi_app)
212
- assert len(await server.get_tools()) == 8
213
- assert len(await server.get_resources()) == 0
214
- assert len(await server.get_resource_templates()) == 0
215
-
216
- async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
217
- """
218
- By default, tools exclude GET methods
219
- """
220
- async with Client(fastmcp_openapi_server) as client:
221
- tools = await client.list_tools()
222
- assert len(tools) == 2
223
-
224
- assert tools[0].model_dump() == dict(
225
- name="create_user_users_post",
226
- meta=None,
227
- title=None,
228
- annotations=None,
229
- description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL),
230
- inputSchema={
231
- "type": "object",
232
- "properties": {
233
- "name": {"type": "string", "title": "Name"},
234
- "active": {"type": "boolean", "title": "Active"},
235
- },
236
- "required": ["name", "active"],
237
- },
238
- outputSchema=None,
239
- )
240
- assert tools[1].model_dump() == dict(
241
- name="update_user_name_users",
242
- meta=None,
243
- title=None,
244
- annotations=None,
245
- description=IsStr(
246
- regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
247
- ),
248
- inputSchema={
249
- "type": "object",
250
- "properties": {
251
- "user_id": {"type": "integer", "title": "User Id"},
252
- "name": {"type": "string", "title": "Name"},
253
- },
254
- "required": ["user_id", "name"],
255
- },
256
- outputSchema=None,
257
- )
258
-
259
- async def test_call_create_user_tool(
260
- self,
261
- fastmcp_openapi_server: FastMCPOpenAPI,
262
- api_client,
263
- ):
264
- """
265
- The tool created by the OpenAPI server should be the same as the original
266
- """
267
- async with Client(fastmcp_openapi_server) as client:
268
- tool_response = await client.call_tool(
269
- "create_user_users_post", {"name": "David", "active": False}
270
- )
271
-
272
- expected_user = User(id=4, name="David", active=False).model_dump()
273
- assert tool_response.data == expected_user
274
-
275
- # Check that the user was created via API
276
- response = await api_client.get("/users")
277
- assert len(response.json()) == 4
278
-
279
- # Check that the user was created via MCP
280
- async with Client(fastmcp_openapi_server) as client:
281
- user_response = await client.read_resource("resource://get_user_users/4")
282
- response_text = user_response[0].text # type: ignore[attr-defined]
283
- user = json.loads(response_text)
284
- assert user == expected_user
285
-
286
- async def test_call_update_user_name_tool(
287
- self,
288
- fastmcp_openapi_server: FastMCPOpenAPI,
289
- api_client,
290
- ):
291
- """
292
- The tool created by the OpenAPI server should be the same as the original
293
- """
294
- async with Client(fastmcp_openapi_server) as client:
295
- tool_response = await client.call_tool(
296
- "update_user_name_users",
297
- {"user_id": 1, "name": "XYZ"},
298
- )
299
-
300
- expected_data = dict(id=1, name="XYZ", active=True)
301
- assert tool_response.data == expected_data
302
-
303
- # Check that the user was updated via API
304
- response = await api_client.get("/users")
305
- assert expected_data in response.json()
306
-
307
- # Check that the user was updated via MCP
308
- async with Client(fastmcp_openapi_server) as client:
309
- user_response = await client.read_resource("resource://get_user_users/1")
310
- response_text = user_response[0].text # type: ignore[attr-defined]
311
- user = json.loads(response_text)
312
- assert user == expected_data
313
-
314
- async def test_call_tool_return_list(
315
- self,
316
- fastapi_app: FastAPI,
317
- api_client: httpx.AsyncClient,
318
- users_db: dict[int, User],
319
- ):
320
- """
321
- The tool created by the OpenAPI server should return a list of content.
322
- """
323
- openapi_spec = fastapi_app.openapi()
324
- mcp_server = FastMCPOpenAPI(
325
- openapi_spec=openapi_spec,
326
- client=api_client,
327
- route_maps=[
328
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
329
- ],
330
- )
331
- async with Client(mcp_server) as client:
332
- tool_response = await client.call_tool("get_users_users_get", {})
333
- assert tool_response.data == {
334
- "result": [
335
- user.model_dump()
336
- for user in sorted(users_db.values(), key=lambda x: x.id)
337
- ]
338
- }
339
-
340
-
341
- class TestResources:
342
- async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
343
- """
344
- By default, resources exclude GET methods without parameters
345
- """
346
- async with Client(fastmcp_openapi_server) as client:
347
- resources = await client.list_resources()
348
- assert len(resources) == 4
349
- assert resources[0].uri == AnyUrl("resource://get_users_users_get")
350
- assert resources[0].name == "get_users_users_get"
351
-
352
- async def test_get_resource(
353
- self,
354
- fastmcp_openapi_server: FastMCPOpenAPI,
355
- api_client,
356
- users_db: dict[int, User],
357
- ):
358
- """
359
- The resource created by the OpenAPI server should be the same as the original
360
- """
361
-
362
- json_users = TypeAdapter(list[User]).dump_python(
363
- sorted(users_db.values(), key=lambda x: x.id)
364
- )
365
- async with Client(fastmcp_openapi_server) as client:
366
- resource_response = await client.read_resource(
367
- "resource://get_users_users_get"
368
- )
369
- response_text = resource_response[0].text # type: ignore[attr-defined]
370
- resource = json.loads(response_text)
371
- assert resource == json_users
372
- response = await api_client.get("/users")
373
- assert response.json() == json_users
374
-
375
- async def test_get_bytes_resource(
376
- self,
377
- fastmcp_openapi_server: FastMCPOpenAPI,
378
- api_client,
379
- ):
380
- """Test reading a resource that returns bytes."""
381
- async with Client(fastmcp_openapi_server) as client:
382
- resource_response = await client.read_resource(
383
- "resource://ping_bytes_ping_bytes_get"
384
- )
385
- assert isinstance(resource_response[0], BlobResourceContents)
386
- assert base64.b64decode(resource_response[0].blob) == b"pong"
387
-
388
- async def test_get_str_resource(
389
- self,
390
- fastmcp_openapi_server: FastMCPOpenAPI,
391
- api_client,
392
- ):
393
- """Test reading a resource that returns a string."""
394
- async with Client(fastmcp_openapi_server) as client:
395
- resource_response = await client.read_resource("resource://ping_ping_get")
396
- assert resource_response[0].text == "pong" # type: ignore[attr-defined]
397
-
398
-
399
- class TestResourceTemplates:
400
- async def test_list_resource_templates(
401
- self, fastmcp_openapi_server: FastMCPOpenAPI
402
- ):
403
- """
404
- By default, resource templates exclude GET methods without parameters
405
- """
406
- async with Client(fastmcp_openapi_server) as client:
407
- resource_templates = await client.list_resource_templates()
408
- assert len(resource_templates) == 2
409
- assert resource_templates[0].name == "get_user_users"
410
- assert (
411
- resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}"
412
- )
413
- assert resource_templates[1].name == "get_user_active_state_users"
414
- assert (
415
- resource_templates[1].uriTemplate
416
- == r"resource://get_user_active_state_users/{is_active}/{user_id}"
417
- )
418
-
419
- async def test_get_resource_template(
420
- self,
421
- fastmcp_openapi_server: FastMCPOpenAPI,
422
- api_client,
423
- users_db: dict[int, User],
424
- ):
425
- """
426
- The resource template created by the OpenAPI server should be the same as the original
427
- """
428
- user_id = 2
429
- async with Client(fastmcp_openapi_server) as client:
430
- resource_response = await client.read_resource(
431
- f"resource://get_user_users/{user_id}"
432
- )
433
- response_text = resource_response[0].text # type: ignore[attr-defined]
434
- resource = json.loads(response_text)
435
-
436
- assert resource == users_db[user_id].model_dump()
437
- response = await api_client.get(f"/users/{user_id}")
438
- assert resource == response.json()
439
-
440
- async def test_get_resource_template_multi_param(
441
- self,
442
- fastmcp_openapi_server: FastMCPOpenAPI,
443
- api_client,
444
- users_db: dict[int, User],
445
- ):
446
- """
447
- The resource template created by the OpenAPI server should be the same as the original
448
- """
449
- user_id = 2
450
- is_active = True
451
- async with Client(fastmcp_openapi_server) as client:
452
- resource_response = await client.read_resource(
453
- f"resource://get_user_active_state_users/{is_active}/{user_id}"
454
- )
455
- response_text = resource_response[0].text # type: ignore[attr-defined]
456
- resource = json.loads(response_text)
457
-
458
- assert resource == users_db[user_id].model_dump()
459
- response = await api_client.get(f"/users/{user_id}/{is_active}")
460
- assert resource == response.json()
461
-
462
-
463
- class TestPrompts:
464
- async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
465
- """
466
- By default, there are no prompts.
467
- """
468
- async with Client(fastmcp_openapi_server) as client:
469
- prompts = await client.list_prompts()
470
- assert len(prompts) == 0
471
-
472
-
473
- class TestTagTransfer:
474
- """Tests for transferring tags from OpenAPI routes to MCP objects."""
475
-
476
- async def test_tags_transferred_to_tools(
477
- self, fastmcp_openapi_server: FastMCPOpenAPI
478
- ):
479
- """Test that tags from OpenAPI routes are correctly transferred to Tools."""
480
- # Get internal tools directly (not the public API which returns MCP.Content)
481
- tools = await fastmcp_openapi_server._tool_manager.list_tools()
482
-
483
- # Find the create_user and update_user_name tools
484
- create_user_tool = next(
485
- (t for t in tools if t.name == "create_user_users_post"), None
486
- )
487
- update_user_tool = next(
488
- (t for t in tools if t.name == "update_user_name_users"),
489
- None,
490
- )
491
-
492
- assert create_user_tool is not None
493
- assert update_user_tool is not None
494
-
495
- # Check that tags from OpenAPI routes were transferred to the Tool objects
496
- assert "users" in create_user_tool.tags
497
- assert "create" in create_user_tool.tags
498
- assert len(create_user_tool.tags) == 2
499
-
500
- assert "users" in update_user_tool.tags
501
- assert "update" in update_user_tool.tags
502
- assert len(update_user_tool.tags) == 2
503
-
504
- async def test_tags_transferred_to_resources(
505
- self, fastmcp_openapi_server: FastMCPOpenAPI
506
- ):
507
- """Test that tags from OpenAPI routes are correctly transferred to Resources."""
508
- # Get internal resources directly
509
- resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
510
- resources = list(resources_dict.values())
511
-
512
- # Find the get_users resource
513
- get_users_resource = next(
514
- (r for r in resources if r.name == "get_users_users_get"), None
515
- )
516
-
517
- assert get_users_resource is not None
518
-
519
- # Check that tags from OpenAPI routes were transferred to the Resource object
520
- assert "users" in get_users_resource.tags
521
- assert "list" in get_users_resource.tags
522
- assert len(get_users_resource.tags) == 2
523
-
524
- async def test_tags_transferred_to_resource_templates(
525
- self, fastmcp_openapi_server: FastMCPOpenAPI
526
- ):
527
- """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
528
- # Get internal resource templates directly
529
- templates_dict = (
530
- await fastmcp_openapi_server._resource_manager.get_resource_templates()
531
- )
532
- templates = list(templates_dict.values())
533
-
534
- # Find the get_user template
535
- get_user_template = next(
536
- (t for t in templates if t.name == "get_user_users"), None
537
- )
538
-
539
- assert get_user_template is not None
540
-
541
- # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object
542
- assert "users" in get_user_template.tags
543
- assert "detail" in get_user_template.tags
544
- assert len(get_user_template.tags) == 2
545
-
546
- async def test_tags_preserved_in_resources_created_from_templates(
547
- self, fastmcp_openapi_server: FastMCPOpenAPI
548
- ):
549
- """Test that tags are preserved when creating resources from templates."""
550
- # Get internal resource templates directly
551
- templates_dict = (
552
- await fastmcp_openapi_server._resource_manager.get_resource_templates()
553
- )
554
- templates = list(templates_dict.values())
555
-
556
- # Find the get_user template
557
- get_user_template = next(
558
- (t for t in templates if t.name == "get_user_users"), None
559
- )
560
-
561
- assert get_user_template is not None
562
-
563
- # Manually create a resource from template
564
- params = {"user_id": 1}
565
- resource = await get_user_template.create_resource(
566
- "resource://get_user_users/1", params
567
- )
568
-
569
- # Verify tags are preserved from template to resource
570
- assert "users" in resource.tags
571
- assert "detail" in resource.tags
572
- assert len(resource.tags) == 2
573
-
574
-
575
- class TestOpenAPI30Compatibility:
576
- """Tests for compatibility with OpenAPI 3.0 specifications."""
577
-
578
- @pytest.fixture
579
- def openapi_30_spec(self) -> dict:
580
- """Fixture that returns a simple OpenAPI 3.0 specification."""
581
- return {
582
- "openapi": "3.0.0",
583
- "info": {"title": "Product API (3.0)", "version": "1.0.0"},
584
- "paths": {
585
- "/products": {
586
- "get": {
587
- "operationId": "listProducts",
588
- "summary": "List all products",
589
- "responses": {"200": {"description": "A list of products"}},
590
- },
591
- "post": {
592
- "operationId": "createProduct",
593
- "summary": "Create a new product",
594
- "requestBody": {
595
- "required": True,
596
- "content": {
597
- "application/json": {
598
- "schema": {
599
- "type": "object",
600
- "properties": {
601
- "name": {"type": "string"},
602
- "price": {"type": "number"},
603
- },
604
- "required": ["name", "price"],
605
- }
606
- }
607
- },
608
- },
609
- "responses": {"201": {"description": "Product created"}},
610
- },
611
- },
612
- "/products/{product_id}": {
613
- "get": {
614
- "operationId": "getProduct",
615
- "summary": "Get product by ID",
616
- "parameters": [
617
- {
618
- "name": "product_id",
619
- "in": "path",
620
- "required": True,
621
- "schema": {"type": "string"},
622
- }
623
- ],
624
- "responses": {"200": {"description": "A product"}},
625
- }
626
- },
627
- },
628
- }
629
-
630
- @pytest.fixture
631
- async def mock_30_client(self) -> httpx.AsyncClient:
632
- """Mock client that returns predefined responses for the 3.0 API."""
633
-
634
- async def _responder(request):
635
- if request.url.path == "/products" and request.method == "GET":
636
- return httpx.Response(
637
- 200,
638
- json=[
639
- {"id": "p1", "name": "Product 1", "price": 19.99},
640
- {"id": "p2", "name": "Product 2", "price": 29.99},
641
- ],
642
- )
643
- elif request.url.path == "/products" and request.method == "POST":
644
- import json
645
-
646
- data = json.loads(request.content)
647
- return httpx.Response(
648
- 201, json={"id": "p3", "name": data["name"], "price": data["price"]}
649
- )
650
- elif request.url.path.startswith("/products/") and request.method == "GET":
651
- product_id = request.url.path.split("/")[-1]
652
- products = {
653
- "p1": {"id": "p1", "name": "Product 1", "price": 19.99},
654
- "p2": {"id": "p2", "name": "Product 2", "price": 29.99},
655
- }
656
- if product_id in products:
657
- return httpx.Response(200, json=products[product_id])
658
- return httpx.Response(404, json={"error": "Product not found"})
659
- return httpx.Response(404)
660
-
661
- transport = httpx.MockTransport(_responder)
662
- return httpx.AsyncClient(transport=transport, base_url="http://test")
663
-
664
- @pytest.fixture
665
- async def openapi_30_server_with_all_types(
666
- self, openapi_30_spec, mock_30_client
667
- ) -> FastMCPOpenAPI:
668
- """Create a FastMCPOpenAPI server from the OpenAPI 3.0 spec."""
669
- return FastMCPOpenAPI(
670
- openapi_spec=openapi_30_spec,
671
- client=mock_30_client,
672
- name="Product API 3.0",
673
- route_maps=GET_ROUTE_MAPS,
674
- )
675
-
676
- async def test_server_creation(self, openapi_30_server_with_all_types):
677
- """Test that a server can be created from an OpenAPI 3.0 spec."""
678
- assert isinstance(openapi_30_server_with_all_types, FastMCP)
679
- assert openapi_30_server_with_all_types.name == "Product API 3.0"
680
-
681
- async def test_resource_discovery(self, openapi_30_server_with_all_types):
682
- """Test that resources are correctly discovered from an OpenAPI 3.0 spec."""
683
- async with Client(openapi_30_server_with_all_types) as client:
684
- resources = await client.list_resources()
685
- assert len(resources) == 1
686
- assert resources[0].uri == AnyUrl("resource://listProducts")
687
-
688
- async def test_resource_template_discovery(self, openapi_30_server_with_all_types):
689
- """Test that resource templates are correctly discovered from an OpenAPI 3.0 spec."""
690
- async with Client(openapi_30_server_with_all_types) as client:
691
- templates = await client.list_resource_templates()
692
- assert len(templates) == 1
693
- assert templates[0].name == "getProduct"
694
- assert templates[0].uriTemplate == r"resource://getProduct/{product_id}"
695
-
696
- async def test_tool_discovery(self, openapi_30_server_with_all_types):
697
- """Test that tools are correctly discovered from an OpenAPI 3.0 spec."""
698
- async with Client(openapi_30_server_with_all_types) as client:
699
- tools = await client.list_tools()
700
- assert len(tools) == 1
701
- assert tools[0].name == "createProduct"
702
- assert "name" in tools[0].inputSchema["properties"]
703
- assert "price" in tools[0].inputSchema["properties"]
704
-
705
- async def test_resource_access(self, openapi_30_server_with_all_types):
706
- """Test reading a resource from an OpenAPI 3.0 server."""
707
- async with Client(openapi_30_server_with_all_types) as client:
708
- resource_response = await client.read_resource("resource://listProducts")
709
- response_text = resource_response[0].text # type: ignore[attr-defined]
710
- content = json.loads(response_text)
711
- assert len(content) == 2
712
- assert content[0]["name"] == "Product 1"
713
- assert content[1]["name"] == "Product 2"
714
-
715
- async def test_resource_template_access(self, openapi_30_server_with_all_types):
716
- """Test reading a resource from template from an OpenAPI 3.0 server."""
717
- async with Client(openapi_30_server_with_all_types) as client:
718
- resource_response = await client.read_resource("resource://getProduct/p1")
719
- response_text = resource_response[0].text # type: ignore[attr-defined]
720
- content = json.loads(response_text)
721
- assert content["id"] == "p1"
722
- assert content["name"] == "Product 1"
723
- assert content["price"] == 19.99
724
-
725
- async def test_tool_execution(self, openapi_30_server_with_all_types):
726
- """Test executing a tool from an OpenAPI 3.0 server."""
727
- async with Client(openapi_30_server_with_all_types) as client:
728
- result = await client.call_tool(
729
- "createProduct", {"name": "New Product", "price": 39.99}
730
- )
731
- # Result should be a text content
732
- assert len(result.content) == 1
733
- product = json.loads(result.content[0].text) # type: ignore[attr-defined]
734
- assert product["id"] == "p3"
735
- assert product["name"] == "New Product"
736
- assert product["price"] == 39.99
737
-
738
- assert result.structured_content is not None
739
- assert result.structured_content["id"] == "p3"
740
- assert result.structured_content["name"] == "New Product"
741
- assert result.structured_content["price"] == 39.99
742
-
743
- assert result.data is not None
744
- assert result.data["id"] == "p3"
745
- assert result.data["name"] == "New Product"
746
- assert result.data["price"] == 39.99
747
-
748
-
749
- class TestOpenAPI31Compatibility:
750
- """Tests for compatibility with OpenAPI 3.1 specifications."""
751
-
752
- @pytest.fixture
753
- def openapi_31_spec(self) -> dict:
754
- """Fixture that returns a simple OpenAPI 3.1 specification."""
755
- return {
756
- "openapi": "3.1.0",
757
- "info": {"title": "Order API (3.1)", "version": "1.0.0"},
758
- "paths": {
759
- "/orders": {
760
- "get": {
761
- "operationId": "listOrders",
762
- "summary": "List all orders",
763
- "responses": {"200": {"description": "A list of orders"}},
764
- },
765
- "post": {
766
- "operationId": "createOrder",
767
- "summary": "Place a new order",
768
- "requestBody": {
769
- "required": True,
770
- "content": {
771
- "application/json": {
772
- "schema": {
773
- "type": "object",
774
- "properties": {
775
- "customer": {"type": "string"},
776
- "items": {
777
- "type": "array",
778
- "items": {"type": "string"},
779
- },
780
- },
781
- "required": ["customer", "items"],
782
- }
783
- }
784
- },
785
- },
786
- "responses": {"201": {"description": "Order created"}},
787
- },
788
- },
789
- "/orders/{order_id}": {
790
- "get": {
791
- "operationId": "getOrder",
792
- "summary": "Get order by ID",
793
- "parameters": [
794
- {
795
- "name": "order_id",
796
- "in": "path",
797
- "required": True,
798
- "schema": {"type": "string"},
799
- }
800
- ],
801
- "responses": {"200": {"description": "An order"}},
802
- }
803
- },
804
- },
805
- }
806
-
807
- @pytest.fixture
808
- async def mock_31_client(self) -> httpx.AsyncClient:
809
- """Mock client that returns predefined responses for the 3.1 API."""
810
-
811
- async def _responder(request):
812
- if request.url.path == "/orders" and request.method == "GET":
813
- return httpx.Response(
814
- 200,
815
- json=[
816
- {"id": "o1", "customer": "Alice", "items": ["item1", "item2"]},
817
- {"id": "o2", "customer": "Bob", "items": ["item3"]},
818
- ],
819
- )
820
- elif request.url.path == "/orders" and request.method == "POST":
821
- import json
822
-
823
- data = json.loads(request.content)
824
- return httpx.Response(
825
- 201,
826
- json={
827
- "id": "o3",
828
- "customer": data["customer"],
829
- "items": data["items"],
830
- },
831
- )
832
- elif request.url.path.startswith("/orders/") and request.method == "GET":
833
- order_id = request.url.path.split("/")[-1]
834
- orders = {
835
- "o1": {
836
- "id": "o1",
837
- "customer": "Alice",
838
- "items": ["item1", "item2"],
839
- },
840
- "o2": {"id": "o2", "customer": "Bob", "items": ["item3"]},
841
- }
842
- if order_id in orders:
843
- return httpx.Response(200, json=orders[order_id])
844
- return httpx.Response(404, json={"error": "Order not found"})
845
- return httpx.Response(404)
846
-
847
- transport = httpx.MockTransport(_responder)
848
- return httpx.AsyncClient(transport=transport, base_url="http://test")
849
-
850
- @pytest.fixture
851
- async def openapi_31_server_with_all_types(
852
- self, openapi_31_spec, mock_31_client
853
- ) -> FastMCPOpenAPI:
854
- """Create a FastMCPOpenAPI server from the OpenAPI 3.1 spec."""
855
- return FastMCPOpenAPI(
856
- openapi_spec=openapi_31_spec,
857
- client=mock_31_client,
858
- name="Order API 3.1",
859
- route_maps=GET_ROUTE_MAPS,
860
- )
861
-
862
- async def test_server_creation(self, openapi_31_server_with_all_types):
863
- """Test that a server can be created from an OpenAPI 3.1 spec."""
864
- assert isinstance(openapi_31_server_with_all_types, FastMCP)
865
- assert openapi_31_server_with_all_types.name == "Order API 3.1"
866
-
867
- async def test_resource_discovery(self, openapi_31_server_with_all_types):
868
- """Test that resources are correctly discovered from an OpenAPI 3.1 spec."""
869
- async with Client(openapi_31_server_with_all_types) as client:
870
- resources = await client.list_resources()
871
- assert len(resources) == 1
872
- assert resources[0].uri == AnyUrl("resource://listOrders")
873
-
874
- async def test_resource_template_discovery(self, openapi_31_server_with_all_types):
875
- """Test that resource templates are correctly discovered from an OpenAPI 3.1 spec."""
876
- async with Client(openapi_31_server_with_all_types) as client:
877
- templates = await client.list_resource_templates()
878
- assert len(templates) == 1
879
- assert templates[0].name == "getOrder"
880
- assert templates[0].uriTemplate == r"resource://getOrder/{order_id}"
881
-
882
- async def test_tool_discovery(self, openapi_31_server_with_all_types):
883
- """Test that tools are correctly discovered from an OpenAPI 3.1 spec."""
884
- async with Client(openapi_31_server_with_all_types) as client:
885
- tools = await client.list_tools()
886
- assert len(tools) == 1
887
- assert tools[0].name == "createOrder"
888
- assert "customer" in tools[0].inputSchema["properties"]
889
- assert "items" in tools[0].inputSchema["properties"]
890
-
891
- async def test_resource_access(self, openapi_31_server_with_all_types):
892
- """Test reading a resource from an OpenAPI 3.1 server."""
893
- async with Client(openapi_31_server_with_all_types) as client:
894
- resource_response = await client.read_resource("resource://listOrders")
895
- response_text = resource_response[0].text # type: ignore[attr-defined]
896
- content = json.loads(response_text)
897
- assert len(content) == 2
898
- assert content[0]["customer"] == "Alice"
899
- assert content[1]["customer"] == "Bob"
900
-
901
- async def test_resource_template_access(self, openapi_31_server_with_all_types):
902
- """Test reading a resource from template from an OpenAPI 3.1 server."""
903
- async with Client(openapi_31_server_with_all_types) as client:
904
- resource_response = await client.read_resource("resource://getOrder/o1")
905
- response_text = resource_response[0].text # type: ignore[attr-defined]
906
- content = json.loads(response_text)
907
- assert content["id"] == "o1"
908
- assert content["customer"] == "Alice"
909
- assert content["items"] == ["item1", "item2"]
910
-
911
- async def test_tool_execution(self, openapi_31_server_with_all_types):
912
- """Test executing a tool from an OpenAPI 3.1 server."""
913
- async with Client(openapi_31_server_with_all_types) as client:
914
- result = await client.call_tool(
915
- "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
916
- )
917
- # Result should be a text content
918
- assert len(result.content) == 1
919
- order = json.loads(result.content[0].text) # type: ignore[attr-dict]
920
- assert order["id"] == "o3"
921
- assert order["customer"] == "Charlie"
922
- assert order["items"] == ["item4", "item5"]
923
-
924
- assert result.structured_content is not None
925
- assert result.structured_content["id"] == "o3"
926
- assert result.structured_content["customer"] == "Charlie"
927
- assert result.structured_content["items"] == ["item4", "item5"]
928
-
929
- assert result.data is not None
930
- assert result.data["id"] == "o3"
931
- assert result.data["customer"] == "Charlie"
932
- assert result.data["items"] == ["item4", "item5"]
933
-
934
-
935
- async def test_empty_query_parameters_not_sent(
936
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
937
- ):
938
- """Test that empty and None query parameters are not sent in the request."""
939
-
940
- # Create a TransportAdapter to track requests
941
- class RequestCapture(httpx.AsyncBaseTransport):
942
- def __init__(self, wrapped):
943
- self.wrapped = wrapped
944
- self.requests = []
945
-
946
- async def handle_async_request(self, request):
947
- self.requests.append(request)
948
- return await self.wrapped.handle_async_request(request)
949
-
950
- # Use our transport adapter to wrap the original one
951
- capture = RequestCapture(api_client._transport)
952
- api_client._transport = capture
953
-
954
- # Create the OpenAPI server with new route map to make search endpoint a tool
955
- openapi_spec = fastapi_app.openapi()
956
- mcp_server = FastMCPOpenAPI(
957
- openapi_spec=openapi_spec,
958
- client=api_client,
959
- route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
960
- )
961
-
962
- # Call the search tool with mixed parameter values
963
- async with Client(mcp_server) as client:
964
- await client.call_tool(
965
- "search_users_search_get",
966
- {
967
- "name": "", # Empty string should be excluded
968
- "active": None, # None should be excluded
969
- "min_id": 2, # Has value, should be included
970
- },
971
- )
972
-
973
- # Verify that the request URL only has min_id parameter
974
- assert len(capture.requests) > 0
975
- request = capture.requests[-1] # Get the last request
976
-
977
- # URL should only contain min_id=2, not name= or active=
978
- url = str(request.url)
979
- assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}"
980
- assert "name=" not in url, f"URL should not contain name=, got: {url}"
981
- assert "active=" not in url, f"URL should not contain active=, got: {url}"
982
-
983
- # More direct check - parse the URL to examine query params
984
- from urllib.parse import parse_qs, urlparse
985
-
986
- parsed_url = urlparse(url)
987
- query_params = parse_qs(parsed_url.query)
988
-
989
- assert "min_id" in query_params
990
- assert "name" not in query_params
991
- assert "active" not in query_params
992
-
993
-
994
- async def test_none_path_parameters_rejected(
995
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
996
- ):
997
- """Test that None values for path parameters are properly rejected."""
998
- # Create the OpenAPI server
999
- openapi_spec = fastapi_app.openapi()
1000
- mcp_server = FastMCPOpenAPI(
1001
- openapi_spec=openapi_spec,
1002
- client=api_client,
1003
- )
1004
-
1005
- # Create a client and try to call a tool with a None path parameter
1006
- async with Client(mcp_server) as client:
1007
- # get_user has a required path parameter user_id
1008
- with pytest.raises(
1009
- ToolError, match="Input validation error|Missing required path parameters"
1010
- ):
1011
- await client.call_tool(
1012
- "update_user_name_users",
1013
- {
1014
- "user_id": None, # This should cause an error
1015
- "name": "New Name",
1016
- },
1017
- )
1018
-
1019
-
1020
- class TestDescriptionPropagation:
1021
- """Tests for OpenAPI description propagation to FastMCP components.
1022
-
1023
- Each test focuses on a single, specific behavior to make it immediately clear
1024
- what's broken when a test fails.
1025
- """
1026
-
1027
- @pytest.fixture
1028
- def simple_openapi_spec(self) -> dict:
1029
- """Create a minimal OpenAPI spec with obvious test descriptions."""
1030
- return {
1031
- "openapi": "3.1.0",
1032
- "info": {"title": "Test API", "version": "1.0.0"},
1033
- "paths": {
1034
- "/items": {
1035
- "get": {
1036
- "operationId": "listItems",
1037
- "summary": "List items summary",
1038
- "description": "LIST_DESCRIPTION\n\nFUNCTION_LIST_DESCRIPTION",
1039
- "responses": {
1040
- "200": {
1041
- "description": "LIST_RESPONSE_DESCRIPTION",
1042
- "content": {
1043
- "application/json": {
1044
- "schema": {
1045
- "type": "array",
1046
- "items": {
1047
- "type": "object",
1048
- "properties": {
1049
- "id": {
1050
- "type": "string",
1051
- "description": "ITEM_RESPONSE_ID_DESCRIPTION",
1052
- },
1053
- "name": {
1054
- "type": "string",
1055
- "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
1056
- },
1057
- "price": {
1058
- "type": "number",
1059
- "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
1060
- },
1061
- },
1062
- },
1063
- },
1064
- }
1065
- },
1066
- }
1067
- },
1068
- }
1069
- },
1070
- "/items/{item_id}": {
1071
- "get": {
1072
- "operationId": "getItem",
1073
- "summary": "Get item summary",
1074
- "description": "GET_DESCRIPTION\n\nFUNCTION_GET_DESCRIPTION",
1075
- "parameters": [
1076
- {
1077
- "name": "item_id",
1078
- "in": "path",
1079
- "required": True,
1080
- "description": "PATH_PARAM_DESCRIPTION",
1081
- "schema": {"type": "string"},
1082
- },
1083
- {
1084
- "name": "fields",
1085
- "in": "query",
1086
- "required": False,
1087
- "description": "QUERY_PARAM_DESCRIPTION",
1088
- "schema": {"type": "string"},
1089
- },
1090
- ],
1091
- "responses": {
1092
- "200": {
1093
- "description": "GET_RESPONSE_DESCRIPTION",
1094
- "content": {
1095
- "application/json": {
1096
- "schema": {
1097
- "type": "object",
1098
- "properties": {
1099
- "id": {
1100
- "type": "string",
1101
- "description": "ITEM_RESPONSE_ID_DESCRIPTION",
1102
- },
1103
- "name": {
1104
- "type": "string",
1105
- "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
1106
- },
1107
- "price": {
1108
- "type": "number",
1109
- "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
1110
- },
1111
- },
1112
- },
1113
- }
1114
- },
1115
- }
1116
- },
1117
- }
1118
- },
1119
- "/items/create": {
1120
- "post": {
1121
- "operationId": "createItem",
1122
- "summary": "Create item summary",
1123
- "description": "CREATE_DESCRIPTION\n\nFUNCTION_CREATE_DESCRIPTION",
1124
- "requestBody": {
1125
- "required": True,
1126
- "description": "BODY_DESCRIPTION",
1127
- "content": {
1128
- "application/json": {
1129
- "schema": {
1130
- "type": "object",
1131
- "properties": {
1132
- "name": {
1133
- "type": "string",
1134
- "description": "PROP_DESCRIPTION",
1135
- }
1136
- },
1137
- "required": ["name"],
1138
- }
1139
- }
1140
- },
1141
- },
1142
- "responses": {
1143
- "201": {
1144
- "description": "CREATE_RESPONSE_DESCRIPTION",
1145
- "content": {
1146
- "application/json": {
1147
- "schema": {
1148
- "type": "object",
1149
- "properties": {
1150
- "id": {
1151
- "type": "string",
1152
- "description": "ITEM_RESPONSE_ID_DESCRIPTION",
1153
- },
1154
- "name": {
1155
- "type": "string",
1156
- "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
1157
- },
1158
- },
1159
- },
1160
- }
1161
- },
1162
- }
1163
- },
1164
- }
1165
- },
1166
- },
1167
- }
1168
-
1169
- @pytest.fixture
1170
- async def mock_client(self) -> httpx.AsyncClient:
1171
- """Create a mock client that returns simple responses."""
1172
-
1173
- async def _responder(request):
1174
- if request.url.path == "/items" and request.method == "GET":
1175
- return httpx.Response(200, json=[{"id": "1", "name": "Item 1"}])
1176
- elif request.url.path.startswith("/items/") and request.method == "GET":
1177
- item_id = request.url.path.split("/")[-1]
1178
- return httpx.Response(
1179
- 200, json={"id": item_id, "name": f"Item {item_id}"}
1180
- )
1181
- elif request.url.path == "/items/create" and request.method == "POST":
1182
- import json
1183
-
1184
- data = json.loads(request.content)
1185
- return httpx.Response(201, json={"id": "new", "name": data.get("name")})
1186
-
1187
- return httpx.Response(404)
1188
-
1189
- transport = httpx.MockTransport(_responder)
1190
- return httpx.AsyncClient(transport=transport, base_url="http://test")
1191
-
1192
- @pytest.fixture
1193
- async def simple_mcp_server(self, simple_openapi_spec, mock_client):
1194
- """Create a FastMCPOpenAPI server with the simple test spec."""
1195
- return FastMCPOpenAPI(
1196
- openapi_spec=simple_openapi_spec,
1197
- client=mock_client,
1198
- name="Test API",
1199
- route_maps=GET_ROUTE_MAPS,
1200
- )
1201
-
1202
- # --- RESOURCE TESTS ---
1203
-
1204
- async def test_resource_includes_route_description(
1205
- self, simple_mcp_server: FastMCP
1206
- ):
1207
- """Test that a Resource includes the route description."""
1208
- resources = list(
1209
- (await simple_mcp_server._resource_manager.get_resources()).values()
1210
- )
1211
- list_resource = next((r for r in resources if r.name == "listItems"), None)
1212
-
1213
- assert list_resource is not None, "listItems resource wasn't created"
1214
- assert "LIST_DESCRIPTION" in (list_resource.description or ""), (
1215
- "Route description missing from Resource"
1216
- )
1217
-
1218
- async def test_resource_includes_response_description(
1219
- self, simple_mcp_server: FastMCP
1220
- ):
1221
- """Test that a Resource includes the response description."""
1222
- resources = list(
1223
- (await simple_mcp_server._resource_manager.get_resources()).values()
1224
- )
1225
- list_resource = next((r for r in resources if r.name == "listItems"), None)
1226
-
1227
- assert list_resource is not None, "listItems resource wasn't created"
1228
- assert "LIST_RESPONSE_DESCRIPTION" in (list_resource.description or ""), (
1229
- "Response description missing from Resource"
1230
- )
1231
-
1232
- async def test_resource_includes_response_model_fields(
1233
- self, simple_mcp_server: FastMCP
1234
- ):
1235
- """Test that a Resource description includes response model field descriptions."""
1236
- resources = list(
1237
- (await simple_mcp_server._resource_manager.get_resources()).values()
1238
- )
1239
- list_resource = next((r for r in resources if r.name == "listItems"), None)
1240
-
1241
- assert list_resource is not None, "listItems resource wasn't created"
1242
- description = list_resource.description or ""
1243
- assert "ITEM_RESPONSE_ID_DESCRIPTION" in description, (
1244
- "Response model field descriptions missing from Resource description"
1245
- )
1246
- assert "ITEM_RESPONSE_NAME_DESCRIPTION" in description, (
1247
- "Response model field descriptions missing from Resource description"
1248
- )
1249
- assert "ITEM_RESPONSE_PRICE_DESCRIPTION" in description, (
1250
- "Response model field descriptions missing from Resource description"
1251
- )
1252
-
1253
- # --- RESOURCE TEMPLATE TESTS ---
1254
-
1255
- async def test_template_includes_route_description(
1256
- self, simple_mcp_server: FastMCP
1257
- ):
1258
- """Test that a ResourceTemplate includes the route description."""
1259
- templates_dict = (
1260
- await simple_mcp_server._resource_manager.get_resource_templates()
1261
- )
1262
- templates = list(templates_dict.values())
1263
- get_template = next((t for t in templates if t.name == "getItem"), None)
1264
-
1265
- assert get_template is not None, "getItem template wasn't created"
1266
- assert "GET_DESCRIPTION" in (get_template.description or ""), (
1267
- "Route description missing from ResourceTemplate"
1268
- )
1269
-
1270
- async def test_template_includes_function_docstring(
1271
- self, simple_mcp_server: FastMCP
1272
- ):
1273
- """Test that a ResourceTemplate includes the function docstring."""
1274
- templates_dict = (
1275
- await simple_mcp_server._resource_manager.get_resource_templates()
1276
- )
1277
- templates = list(templates_dict.values())
1278
- get_template = next((t for t in templates if t.name == "getItem"), None)
1279
-
1280
- assert get_template is not None, "getItem template wasn't created"
1281
- assert "FUNCTION_GET_DESCRIPTION" in (get_template.description or ""), (
1282
- "Function docstring missing from ResourceTemplate"
1283
- )
1284
-
1285
- async def test_template_includes_path_parameter_description(
1286
- self, simple_mcp_server: FastMCP
1287
- ):
1288
- """Test that a ResourceTemplate includes path parameter descriptions."""
1289
- templates_dict = (
1290
- await simple_mcp_server._resource_manager.get_resource_templates()
1291
- )
1292
- templates = list(templates_dict.values())
1293
- get_template = next((t for t in templates if t.name == "getItem"), None)
1294
-
1295
- assert get_template is not None, "getItem template wasn't created"
1296
- assert "PATH_PARAM_DESCRIPTION" in (get_template.description or ""), (
1297
- "Path parameter description missing from ResourceTemplate description"
1298
- )
1299
-
1300
- async def test_template_includes_query_parameter_description(
1301
- self, simple_mcp_server: FastMCP
1302
- ):
1303
- """Test that a ResourceTemplate includes query parameter descriptions."""
1304
- templates_dict = (
1305
- await simple_mcp_server._resource_manager.get_resource_templates()
1306
- )
1307
- templates = list(templates_dict.values())
1308
- get_template = next((t for t in templates if t.name == "getItem"), None)
1309
-
1310
- assert get_template is not None, "getItem template wasn't created"
1311
- assert "QUERY_PARAM_DESCRIPTION" in (get_template.description or ""), (
1312
- "Query parameter description missing from ResourceTemplate description"
1313
- )
1314
-
1315
- async def test_template_parameter_schema_includes_description(
1316
- self, simple_mcp_server: FastMCP
1317
- ):
1318
- """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
1319
- templates_dict = (
1320
- await simple_mcp_server._resource_manager.get_resource_templates()
1321
- )
1322
- templates = list(templates_dict.values())
1323
- get_template = next((t for t in templates if t.name == "getItem"), None)
1324
-
1325
- assert get_template is not None, "getItem template wasn't created"
1326
- assert "properties" in get_template.parameters, (
1327
- "Schema properties missing from ResourceTemplate"
1328
- )
1329
- assert "item_id" in get_template.parameters["properties"], (
1330
- "item_id missing from ResourceTemplate schema"
1331
- )
1332
- assert "description" in get_template.parameters["properties"]["item_id"], (
1333
- "Description missing from item_id parameter schema"
1334
- )
1335
- assert (
1336
- "PATH_PARAM_DESCRIPTION"
1337
- in get_template.parameters["properties"]["item_id"]["description"]
1338
- ), "Path parameter description incorrect in schema"
1339
-
1340
- # --- TOOL TESTS ---
1341
-
1342
- async def test_tool_includes_route_description(self, simple_mcp_server: FastMCP):
1343
- """Test that a Tool includes the route description."""
1344
- tools_dict = await simple_mcp_server._tool_manager.get_tools()
1345
- tools = list(tools_dict.values())
1346
- create_tool = next((t for t in tools if t.name == "createItem"), None)
1347
-
1348
- assert create_tool is not None, "createItem tool wasn't created"
1349
- assert "CREATE_DESCRIPTION" in (create_tool.description or ""), (
1350
- "Route description missing from Tool"
1351
- )
1352
-
1353
- async def test_tool_includes_function_docstring(self, simple_mcp_server: FastMCP):
1354
- """Test that a Tool includes the function docstring."""
1355
- tools_dict = await simple_mcp_server._tool_manager.get_tools()
1356
- tools = list(tools_dict.values())
1357
- create_tool = next((t for t in tools if t.name == "createItem"), None)
1358
-
1359
- assert create_tool is not None, "createItem tool wasn't created"
1360
- description = create_tool.description or ""
1361
- assert "FUNCTION_CREATE_DESCRIPTION" in description, (
1362
- "Function docstring missing from Tool"
1363
- )
1364
-
1365
- async def test_tool_parameter_schema_includes_property_description(
1366
- self, simple_mcp_server: FastMCP
1367
- ):
1368
- """Test that a Tool's parameter schema includes property descriptions from request model."""
1369
- tools_dict = await simple_mcp_server._tool_manager.get_tools()
1370
- tools = list(tools_dict.values())
1371
- create_tool = next((t for t in tools if t.name == "createItem"), None)
1372
-
1373
- assert create_tool is not None, "createItem tool wasn't created"
1374
- assert "properties" in create_tool.parameters, (
1375
- "Schema properties missing from Tool"
1376
- )
1377
- assert "name" in create_tool.parameters["properties"], (
1378
- "name parameter missing from Tool schema"
1379
- )
1380
- assert "description" in create_tool.parameters["properties"]["name"], (
1381
- "Description missing from name parameter schema"
1382
- )
1383
- assert (
1384
- "PROP_DESCRIPTION"
1385
- in create_tool.parameters["properties"]["name"]["description"]
1386
- ), "Property description incorrect in schema"
1387
-
1388
- # --- CLIENT API TESTS ---
1389
-
1390
- async def test_client_api_resource_description(self, simple_mcp_server: FastMCP):
1391
- """Test that Resource descriptions are accessible via the client API."""
1392
- async with Client(simple_mcp_server) as client:
1393
- resources = await client.list_resources()
1394
- list_resource = next((r for r in resources if r.name == "listItems"), None)
1395
-
1396
- assert list_resource is not None, (
1397
- "listItems resource not accessible via client API"
1398
- )
1399
- resource_description = list_resource.description or ""
1400
- assert "LIST_DESCRIPTION" in resource_description, (
1401
- "Route description missing in Resource from client API"
1402
- )
1403
-
1404
- async def test_client_api_template_description(self, simple_mcp_server: FastMCP):
1405
- """Test that ResourceTemplate descriptions are accessible via the client API."""
1406
- async with Client(simple_mcp_server) as client:
1407
- templates = await client.list_resource_templates()
1408
- get_template = next((t for t in templates if t.name == "getItem"), None)
1409
-
1410
- assert get_template is not None, (
1411
- "getItem template not accessible via client API"
1412
- )
1413
- template_description = get_template.description or ""
1414
- assert "GET_DESCRIPTION" in template_description, (
1415
- "Route description missing in ResourceTemplate from client API"
1416
- )
1417
-
1418
- async def test_client_api_tool_description(self, simple_mcp_server: FastMCP):
1419
- """Test that Tool descriptions are accessible via the client API."""
1420
- async with Client(simple_mcp_server) as client:
1421
- tools = await client.list_tools()
1422
- create_tool = next((t for t in tools if t.name == "createItem"), None)
1423
-
1424
- assert create_tool is not None, (
1425
- "createItem tool not accessible via client API"
1426
- )
1427
- tool_description = create_tool.description or ""
1428
- assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
1429
- "Function docstring missing in Tool from client API"
1430
- )
1431
-
1432
- async def test_client_api_tool_parameter_schema(self, simple_mcp_server: FastMCP):
1433
- """Test that Tool parameter schemas are accessible via the client API."""
1434
- async with Client(simple_mcp_server) as client:
1435
- tools = await client.list_tools()
1436
- create_tool = next((t for t in tools if t.name == "createItem"), None)
1437
-
1438
- assert create_tool is not None, (
1439
- "createItem tool not accessible via client API"
1440
- )
1441
- assert "properties" in create_tool.inputSchema, (
1442
- "Schema properties missing from Tool inputSchema in client API"
1443
- )
1444
- assert "name" in create_tool.inputSchema["properties"], (
1445
- "name parameter missing from Tool schema in client API"
1446
- )
1447
- assert "description" in create_tool.inputSchema["properties"]["name"], (
1448
- "Description missing from name parameter in client API"
1449
- )
1450
- assert (
1451
- "PROP_DESCRIPTION"
1452
- in create_tool.inputSchema["properties"]["name"]["description"]
1453
- ), "Property description incorrect in schema from client API"
1454
-
1455
-
1456
- class TestFastAPIDescriptionPropagation:
1457
- """Tests for FastAPI docstring and annotation propagation to FastMCP components.
1458
-
1459
- Each test focuses on a single, specific behavior to make it immediately clear
1460
- what's broken when a test fails.
1461
- """
1462
-
1463
- @pytest.fixture
1464
- def fastapi_app_with_descriptions(self) -> FastAPI:
1465
- """Create a simple FastAPI app with docstrings and annotations."""
1466
- from typing import Annotated
1467
-
1468
- from pydantic import BaseModel, Field
1469
-
1470
- app = FastAPI(title="Test FastAPI App")
1471
-
1472
- class Item(BaseModel):
1473
- name: str = Field(..., description="ITEM_NAME_DESCRIPTION")
1474
- price: float = Field(..., description="ITEM_PRICE_DESCRIPTION")
1475
-
1476
- class ItemResponse(BaseModel):
1477
- id: str = Field(..., description="ITEM_RESPONSE_ID_DESCRIPTION")
1478
- name: str = Field(..., description="ITEM_RESPONSE_NAME_DESCRIPTION")
1479
- price: float = Field(..., description="ITEM_RESPONSE_PRICE_DESCRIPTION")
1480
-
1481
- @app.get("/items", tags=["items"])
1482
- async def list_items() -> list[ItemResponse]:
1483
- """FUNCTION_LIST_DESCRIPTION
1484
-
1485
- Returns a list of items.
1486
- """
1487
- return [
1488
- ItemResponse(id="1", name="Item 1", price=10.0),
1489
- ItemResponse(id="2", name="Item 2", price=20.0),
1490
- ]
1491
-
1492
- @app.get("/items/{item_id}", tags=["items", "detail"])
1493
- async def get_item(
1494
- item_id: Annotated[str, Field(description="PATH_PARAM_DESCRIPTION")],
1495
- fields: Annotated[
1496
- str | None, Field(description="QUERY_PARAM_DESCRIPTION")
1497
- ] = None,
1498
- ) -> ItemResponse:
1499
- """FUNCTION_GET_DESCRIPTION
1500
-
1501
- Gets a specific item by ID.
1502
-
1503
- Args:
1504
- item_id: The ID of the item to retrieve
1505
- fields: Optional fields to include
1506
- """
1507
- return ItemResponse(
1508
- id=item_id, name=f"Item {item_id}", price=float(item_id) * 10.0
1509
- )
1510
-
1511
- @app.post("/items", tags=["items", "create"])
1512
- async def create_item(item: Item) -> ItemResponse:
1513
- """FUNCTION_CREATE_DESCRIPTION
1514
-
1515
- Creates a new item.
1516
-
1517
- Body:
1518
- Item object with name and price
1519
- """
1520
- return ItemResponse(id="new", name=item.name, price=item.price)
1521
-
1522
- return app
1523
-
1524
- @pytest.fixture
1525
- async def fastapi_server(self, fastapi_app_with_descriptions):
1526
- """Create a FastMCP server from the FastAPI app with custom route mappings."""
1527
- # First create from FastAPI app to get the OpenAPI spec
1528
- openapi_spec = fastapi_app_with_descriptions.openapi()
1529
-
1530
- # Debug: check the operationIds in the OpenAPI spec
1531
- print("\nDEBUG - OpenAPI Paths:")
1532
- for path, methods in openapi_spec["paths"].items():
1533
- for method, details in methods.items():
1534
- if method != "parameters": # Skip non-HTTP method keys
1535
- operation_id = details.get("operationId", "no_operation_id")
1536
- print(
1537
- f" Path: {path}, Method: {method}, OperationId: {operation_id}"
1538
- )
1539
-
1540
- # Create custom route mappings
1541
- route_maps = [
1542
- # Map GET /items to Resource
1543
- RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
1544
- # Map GET /items/{item_id} to ResourceTemplate
1545
- RouteMap(
1546
- methods=["GET"],
1547
- pattern=r"^/items/\{.*\}$",
1548
- mcp_type=MCPType.RESOURCE_TEMPLATE,
1549
- ),
1550
- # Map POST /items to Tool
1551
- RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
1552
- ]
1553
-
1554
- # Create FastMCP server with the OpenAPI spec and custom route mappings
1555
- server = FastMCPOpenAPI(
1556
- openapi_spec=openapi_spec,
1557
- client=AsyncClient(
1558
- transport=ASGITransport(app=fastapi_app_with_descriptions),
1559
- base_url="http://test",
1560
- ),
1561
- name="Test FastAPI App",
1562
- route_maps=route_maps,
1563
- )
1564
-
1565
- # Debug: print all components created
1566
- print("\nDEBUG - Resources created:")
1567
- resources_dict = await server._resource_manager.get_resources()
1568
- for name, resource in resources_dict.items():
1569
- print(f" Resource: {name}, Name attribute: {resource.name}")
1570
-
1571
- print("\nDEBUG - Templates created:")
1572
- templates_dict = await server._resource_manager.get_resource_templates()
1573
- for name, template in templates_dict.items():
1574
- print(f" Template: {name}, Name attribute: {template.name}")
1575
-
1576
- print("\nDEBUG - Tools created:")
1577
- tools = await server._tool_manager.list_tools()
1578
- for tool in tools:
1579
- print(f" Tool: {tool.name}")
1580
-
1581
- return server
1582
-
1583
- async def test_resource_includes_function_docstring(self, fastapi_server: FastMCP):
1584
- """Test that a Resource includes the function docstring."""
1585
- resources_dict = await fastapi_server._resource_manager.get_resources()
1586
- resources = list(resources_dict.values())
1587
-
1588
- # Now checking for the get_items operation ID rather than list_items
1589
- list_resource = next((r for r in resources if "items_get" in r.name), None)
1590
-
1591
- assert list_resource is not None, "GET /items resource wasn't created"
1592
- description = list_resource.description or ""
1593
- assert "FUNCTION_LIST_DESCRIPTION" in description, (
1594
- "Function docstring missing from Resource"
1595
- )
1596
-
1597
- async def test_resource_includes_response_model_fields(
1598
- self, fastapi_server: FastMCP
1599
- ):
1600
- """Test that a Resource description includes basic response information.
1601
-
1602
- Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema,
1603
- so we can only check for basic response information being present.
1604
- """
1605
- resources_dict = await fastapi_server._resource_manager.get_resources()
1606
- resources = list(resources_dict.values())
1607
- list_resource = next((r for r in resources if "items_get" in r.name), None)
1608
-
1609
- assert list_resource is not None, "GET /items resource wasn't created"
1610
- description = list_resource.description or ""
1611
-
1612
- # Check that at least the response information is included
1613
- assert "Successful Response" in description, (
1614
- "Response information missing from Resource description"
1615
- )
1616
-
1617
- # We've already verified in TestDescriptionPropagation that when descriptions
1618
- # are present in the OpenAPI schema, they are properly included in the component description
1619
-
1620
- async def test_template_includes_function_docstring(self, fastapi_server: FastMCP):
1621
- """Test that a ResourceTemplate includes the function docstring."""
1622
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
1623
- templates = list(templates_dict.values())
1624
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
1625
-
1626
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
1627
- description = get_template.description or ""
1628
- assert "FUNCTION_GET_DESCRIPTION" in description, (
1629
- "Function docstring missing from ResourceTemplate"
1630
- )
1631
-
1632
- async def test_template_includes_path_parameter_description(
1633
- self, fastapi_server: FastMCP
1634
- ):
1635
- """Test that a ResourceTemplate includes path parameter descriptions.
1636
-
1637
- Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
1638
- are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
1639
- """
1640
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
1641
- templates = list(templates_dict.values())
1642
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
1643
-
1644
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
1645
- description = get_template.description or ""
1646
-
1647
- # Just test that parameters are included at all
1648
- assert "Path Parameters" in description, (
1649
- "Path parameters section missing from ResourceTemplate description"
1650
- )
1651
- assert "item_id" in description, (
1652
- "item_id parameter missing from ResourceTemplate description"
1653
- )
1654
-
1655
- async def test_template_includes_query_parameter_description(
1656
- self, fastapi_server: FastMCP
1657
- ):
1658
- """Test that a ResourceTemplate includes query parameter descriptions.
1659
-
1660
- Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
1661
- are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
1662
- """
1663
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
1664
- templates = list(templates_dict.values())
1665
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
1666
-
1667
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
1668
- description = get_template.description or ""
1669
-
1670
- # Just test that parameters are included at all
1671
- assert "Query Parameters" in description, (
1672
- "Query parameters section missing from ResourceTemplate description"
1673
- )
1674
- assert "fields" in description, (
1675
- "fields parameter missing from ResourceTemplate description"
1676
- )
1677
-
1678
- async def test_template_parameter_schema_includes_description(
1679
- self, fastapi_server: FastMCP
1680
- ):
1681
- """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
1682
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
1683
- templates = list(templates_dict.values())
1684
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
1685
-
1686
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
1687
- assert "properties" in get_template.parameters, (
1688
- "Schema properties missing from ResourceTemplate"
1689
- )
1690
- assert "item_id" in get_template.parameters["properties"], (
1691
- "item_id missing from ResourceTemplate schema"
1692
- )
1693
- assert "description" in get_template.parameters["properties"]["item_id"], (
1694
- "Description missing from item_id parameter schema"
1695
- )
1696
- assert (
1697
- "PATH_PARAM_DESCRIPTION"
1698
- in get_template.parameters["properties"]["item_id"]["description"]
1699
- ), "Path parameter description incorrect in schema"
1700
-
1701
- async def test_tool_includes_function_docstring(self, fastapi_server: FastMCP):
1702
- """Test that a Tool includes the function docstring."""
1703
- tools_dict = await fastapi_server._tool_manager.get_tools()
1704
- tools = list(tools_dict.values())
1705
- create_tool = next(
1706
- (t for t in tools if "create_item_items_post" == t.name), None
1707
- )
1708
-
1709
- assert create_tool is not None, "POST /items tool wasn't created"
1710
- description = create_tool.description or ""
1711
- assert "FUNCTION_CREATE_DESCRIPTION" in description, (
1712
- "Function docstring missing from Tool"
1713
- )
1714
-
1715
- async def test_tool_parameter_schema_includes_property_description(
1716
- self, fastapi_server: FastMCP
1717
- ):
1718
- """Test that a Tool's parameter schema includes property descriptions from request model.
1719
-
1720
- Note: Currently, model field descriptions defined in Pydantic models using Field(description=...)
1721
- may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's
1722
- parameter schema.
1723
- """
1724
- tools_dict = await fastapi_server._tool_manager.get_tools()
1725
- tools = list(tools_dict.values())
1726
- create_tool = next(
1727
- (t for t in tools if "create_item_items_post" == t.name), None
1728
- )
1729
-
1730
- assert create_tool is not None, "POST /items tool wasn't created"
1731
- assert "properties" in create_tool.parameters, (
1732
- "Schema properties missing from Tool"
1733
- )
1734
- assert "name" in create_tool.parameters["properties"], (
1735
- "name parameter missing from Tool schema"
1736
- )
1737
- # We don't test for the description field content as it may not be consistently propagated
1738
-
1739
- async def test_client_api_resource_description(self, fastapi_server: FastMCP):
1740
- """Test that Resource descriptions are accessible via the client API."""
1741
- async with Client(fastapi_server) as client:
1742
- resources = await client.list_resources()
1743
- list_resource = next((r for r in resources if "items_get" in r.name), None)
1744
-
1745
- assert list_resource is not None, (
1746
- "GET /items resource not accessible via client API"
1747
- )
1748
- resource_description = list_resource.description or ""
1749
- assert "FUNCTION_LIST_DESCRIPTION" in resource_description, (
1750
- "Function docstring missing in Resource from client API"
1751
- )
1752
-
1753
- async def test_client_api_template_description(self, fastapi_server: FastMCP):
1754
- """Test that ResourceTemplate descriptions are accessible via the client API."""
1755
- async with Client(fastapi_server) as client:
1756
- templates = await client.list_resource_templates()
1757
- get_template = next(
1758
- (t for t in templates if "get_item_items" in t.name), None
1759
- )
1760
-
1761
- assert get_template is not None, (
1762
- "GET /items/{item_id} template not accessible via client API"
1763
- )
1764
- template_description = get_template.description or ""
1765
- assert "FUNCTION_GET_DESCRIPTION" in template_description, (
1766
- "Function docstring missing in ResourceTemplate from client API"
1767
- )
1768
-
1769
- async def test_client_api_tool_description(self, fastapi_server: FastMCP):
1770
- """Test that Tool descriptions are accessible via the client API."""
1771
- async with Client(fastapi_server) as client:
1772
- tools = await client.list_tools()
1773
- create_tool = next(
1774
- (t for t in tools if "create_item_items_post" == t.name), None
1775
- )
1776
-
1777
- assert create_tool is not None, (
1778
- "POST /items tool not accessible via client API"
1779
- )
1780
- tool_description = create_tool.description or ""
1781
- assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
1782
- "Function docstring missing in Tool from client API"
1783
- )
1784
-
1785
- async def test_client_api_tool_parameter_schema(self, fastapi_server: FastMCP):
1786
- """Test that Tool parameter schemas are accessible via the client API."""
1787
- async with Client(fastapi_server) as client:
1788
- tools = await client.list_tools()
1789
- create_tool = next(
1790
- (t for t in tools if "create_item_items_post" == t.name), None
1791
- )
1792
-
1793
- assert create_tool is not None, (
1794
- "POST /items tool not accessible via client API"
1795
- )
1796
- assert "properties" in create_tool.inputSchema, (
1797
- "Schema properties missing from Tool inputSchema in client API"
1798
- )
1799
- assert "name" in create_tool.inputSchema["properties"], (
1800
- "name parameter missing from Tool schema in client API"
1801
- )
1802
- # We don't test for the description field content as it may not be consistently propagated
1803
-
1804
-
1805
- class TestReprMethods:
1806
- """Tests for the custom __repr__ methods of OpenAPI objects."""
1807
-
1808
- async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
1809
- """Test that OpenAPITool's __repr__ method works without recursion errors."""
1810
- tools = await fastmcp_openapi_server._tool_manager.list_tools()
1811
- tool = next(iter(tools))
1812
-
1813
- # Verify repr doesn't cause recursion and contains expected elements
1814
- tool_repr = repr(tool)
1815
- assert "OpenAPITool" in tool_repr
1816
- assert f"name={tool.name!r}" in tool_repr
1817
- assert "method=" in tool_repr
1818
- assert "path=" in tool_repr
1819
-
1820
- async def test_openapi_resource_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
1821
- """Test that OpenAPIResource's __repr__ method works without recursion errors."""
1822
- resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
1823
- resources = list(resources_dict.values())
1824
- resource = next(iter(resources))
1825
-
1826
- # Verify repr doesn't cause recursion and contains expected elements
1827
- resource_repr = repr(resource)
1828
- assert "OpenAPIResource" in resource_repr
1829
- assert f"name={resource.name!r}" in resource_repr
1830
- assert "uri=" in resource_repr
1831
- assert "path=" in resource_repr
1832
-
1833
- async def test_openapi_resource_template_repr(
1834
- self, fastmcp_openapi_server: FastMCPOpenAPI
1835
- ):
1836
- """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
1837
- templates_dict = (
1838
- await fastmcp_openapi_server._resource_manager.get_resource_templates()
1839
- )
1840
- templates = list(templates_dict.values())
1841
- template = next(iter(templates))
1842
-
1843
- # Verify repr doesn't cause recursion and contains expected elements
1844
- template_repr = repr(template)
1845
- assert "OpenAPIResourceTemplate" in template_repr
1846
- assert f"name={template.name!r}" in template_repr
1847
- assert "uri_template=" in template_repr
1848
- assert "path=" in template_repr
1849
-
1850
-
1851
- class TestEnumHandling:
1852
- """Tests for handling enum parameters in OpenAPI schemas."""
1853
-
1854
- async def test_enum_parameter_schema(self):
1855
- """Test that enum parameters are properly handled in tool parameter schemas."""
1856
-
1857
- # Define an enum just like in example.py
1858
- class QueryEnum(str, Enum):
1859
- foo = "foo"
1860
- bar = "bar"
1861
- baz = "baz"
1862
-
1863
- # Create a minimal FastAPI app with an endpoint using the enum
1864
- app = FastAPI()
1865
-
1866
- @app.post("/items/{item_id}")
1867
- def read_item(
1868
- item_id: int,
1869
- query: QueryEnum | None = None,
1870
- ):
1871
- return {"item_id": item_id, "query": query}
1872
-
1873
- # Create a client for the app
1874
- client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
1875
-
1876
- # Create the FastMCPOpenAPI server from the app
1877
- openapi_spec = app.openapi()
1878
- server = FastMCPOpenAPI(
1879
- openapi_spec=openapi_spec,
1880
- client=client,
1881
- name="Enum Test",
1882
- )
1883
-
1884
- # Get the tools from the server
1885
- tools = await server._tool_manager.list_tools()
1886
-
1887
- # Find the read_item tool
1888
- read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
1889
-
1890
- # Verify the tool exists
1891
- assert read_item_tool is not None, "read_item tool wasn't created"
1892
-
1893
- # Check that the parameters include the enum reference
1894
- assert "properties" in read_item_tool.parameters
1895
- assert "query" in read_item_tool.parameters["properties"]
1896
-
1897
- # Check for the anyOf with $ref to the enum definition
1898
- query_param = read_item_tool.parameters["properties"]["query"]
1899
- assert "anyOf" in query_param
1900
-
1901
- # Find the ref in the anyOf list
1902
- ref_found = False
1903
- for option in query_param["anyOf"]:
1904
- if "$ref" in option and option["$ref"].startswith("#/$defs/QueryEnum"):
1905
- ref_found = True
1906
- break
1907
-
1908
- assert ref_found, "Reference to enum definition not found in query parameter"
1909
-
1910
- # Check that the $defs section exists and contains the enum definition
1911
- assert "$defs" in read_item_tool.parameters
1912
- assert "QueryEnum" in read_item_tool.parameters["$defs"]
1913
 
1914
- # Verify the enum definition
1915
- enum_def = read_item_tool.parameters["$defs"]["QueryEnum"]
1916
- assert "enum" in enum_def
1917
- assert enum_def["enum"] == ["foo", "bar", "baz"]
1918
- assert enum_def["type"] == "string"
1919
 
1920
 
1921
  class TestRouteMapWildcard:
 
 
 
 
 
 
1
  import httpx
2
  import pytest
3
+ from fastapi import FastAPI
 
 
 
 
 
 
4
 
5
  from fastmcp import FastMCP
6
+ from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ from .conftest import GET_ROUTE_MAPS
 
 
 
 
9
 
10
 
11
  class TestRouteMapWildcard:
tests/server/openapi/test_description_propagation.py ADDED
@@ -0,0 +1,795 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import pytest
3
+ from fastapi import FastAPI
4
+ from httpx import ASGITransport, AsyncClient
5
+
6
+ from fastmcp import FastMCP
7
+ from fastmcp.client import Client
8
+ from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
9
+
10
+ from .conftest import GET_ROUTE_MAPS
11
+
12
+
13
+ class TestDescriptionPropagation:
14
+ """Tests for OpenAPI description propagation to FastMCP components.
15
+
16
+ Each test focuses on a single, specific behavior to make it immediately clear
17
+ what's broken when a test fails.
18
+ """
19
+
20
+ @pytest.fixture
21
+ def simple_openapi_spec(self) -> dict:
22
+ """Create a minimal OpenAPI spec with obvious test descriptions."""
23
+ return {
24
+ "openapi": "3.1.0",
25
+ "info": {"title": "Test API", "version": "1.0.0"},
26
+ "paths": {
27
+ "/items": {
28
+ "get": {
29
+ "operationId": "listItems",
30
+ "summary": "List items summary",
31
+ "description": "LIST_DESCRIPTION\n\nFUNCTION_LIST_DESCRIPTION",
32
+ "responses": {
33
+ "200": {
34
+ "description": "LIST_RESPONSE_DESCRIPTION",
35
+ "content": {
36
+ "application/json": {
37
+ "schema": {
38
+ "type": "array",
39
+ "items": {
40
+ "type": "object",
41
+ "properties": {
42
+ "id": {
43
+ "type": "string",
44
+ "description": "ITEM_RESPONSE_ID_DESCRIPTION",
45
+ },
46
+ "name": {
47
+ "type": "string",
48
+ "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
49
+ },
50
+ "price": {
51
+ "type": "number",
52
+ "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
53
+ },
54
+ },
55
+ },
56
+ },
57
+ }
58
+ },
59
+ }
60
+ },
61
+ }
62
+ },
63
+ "/items/{item_id}": {
64
+ "get": {
65
+ "operationId": "getItem",
66
+ "summary": "Get item summary",
67
+ "description": "GET_DESCRIPTION\n\nFUNCTION_GET_DESCRIPTION",
68
+ "parameters": [
69
+ {
70
+ "name": "item_id",
71
+ "in": "path",
72
+ "required": True,
73
+ "description": "PATH_PARAM_DESCRIPTION",
74
+ "schema": {"type": "string"},
75
+ },
76
+ {
77
+ "name": "fields",
78
+ "in": "query",
79
+ "required": False,
80
+ "description": "QUERY_PARAM_DESCRIPTION",
81
+ "schema": {"type": "string"},
82
+ },
83
+ ],
84
+ "responses": {
85
+ "200": {
86
+ "description": "GET_RESPONSE_DESCRIPTION",
87
+ "content": {
88
+ "application/json": {
89
+ "schema": {
90
+ "type": "object",
91
+ "properties": {
92
+ "id": {
93
+ "type": "string",
94
+ "description": "ITEM_RESPONSE_ID_DESCRIPTION",
95
+ },
96
+ "name": {
97
+ "type": "string",
98
+ "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
99
+ },
100
+ "price": {
101
+ "type": "number",
102
+ "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
103
+ },
104
+ },
105
+ },
106
+ }
107
+ },
108
+ }
109
+ },
110
+ }
111
+ },
112
+ "/items/create": {
113
+ "post": {
114
+ "operationId": "createItem",
115
+ "summary": "Create item summary",
116
+ "description": "CREATE_DESCRIPTION\n\nFUNCTION_CREATE_DESCRIPTION",
117
+ "requestBody": {
118
+ "required": True,
119
+ "description": "BODY_DESCRIPTION",
120
+ "content": {
121
+ "application/json": {
122
+ "schema": {
123
+ "type": "object",
124
+ "properties": {
125
+ "name": {
126
+ "type": "string",
127
+ "description": "PROP_DESCRIPTION",
128
+ }
129
+ },
130
+ "required": ["name"],
131
+ }
132
+ }
133
+ },
134
+ },
135
+ "responses": {
136
+ "201": {
137
+ "description": "CREATE_RESPONSE_DESCRIPTION",
138
+ "content": {
139
+ "application/json": {
140
+ "schema": {
141
+ "type": "object",
142
+ "properties": {
143
+ "id": {
144
+ "type": "string",
145
+ "description": "ITEM_RESPONSE_ID_DESCRIPTION",
146
+ },
147
+ "name": {
148
+ "type": "string",
149
+ "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
150
+ },
151
+ },
152
+ },
153
+ }
154
+ },
155
+ }
156
+ },
157
+ }
158
+ },
159
+ },
160
+ }
161
+
162
+ @pytest.fixture
163
+ async def mock_client(self) -> httpx.AsyncClient:
164
+ """Create a mock client that returns simple responses."""
165
+
166
+ async def _responder(request):
167
+ if request.url.path == "/items" and request.method == "GET":
168
+ return httpx.Response(200, json=[{"id": "1", "name": "Item 1"}])
169
+ elif request.url.path.startswith("/items/") and request.method == "GET":
170
+ item_id = request.url.path.split("/")[-1]
171
+ return httpx.Response(
172
+ 200, json={"id": item_id, "name": f"Item {item_id}"}
173
+ )
174
+ elif request.url.path == "/items/create" and request.method == "POST":
175
+ import json
176
+
177
+ data = json.loads(request.content)
178
+ return httpx.Response(201, json={"id": "new", "name": data.get("name")})
179
+
180
+ return httpx.Response(404)
181
+
182
+ transport = httpx.MockTransport(_responder)
183
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
184
+
185
+ @pytest.fixture
186
+ async def simple_mcp_server(self, simple_openapi_spec, mock_client):
187
+ """Create a FastMCPOpenAPI server with the simple test spec."""
188
+ return FastMCPOpenAPI(
189
+ openapi_spec=simple_openapi_spec,
190
+ client=mock_client,
191
+ name="Test API",
192
+ route_maps=GET_ROUTE_MAPS,
193
+ )
194
+
195
+ # --- RESOURCE TESTS ---
196
+
197
+ async def test_resource_includes_route_description(
198
+ self, simple_mcp_server: FastMCP
199
+ ):
200
+ """Test that a Resource includes the route description."""
201
+ resources = list(
202
+ (await simple_mcp_server._resource_manager.get_resources()).values()
203
+ )
204
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
205
+
206
+ assert list_resource is not None, "listItems resource wasn't created"
207
+ assert "LIST_DESCRIPTION" in (list_resource.description or ""), (
208
+ "Route description missing from Resource"
209
+ )
210
+
211
+ async def test_resource_includes_response_description(
212
+ self, simple_mcp_server: FastMCP
213
+ ):
214
+ """Test that a Resource includes the response description."""
215
+ resources = list(
216
+ (await simple_mcp_server._resource_manager.get_resources()).values()
217
+ )
218
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
219
+
220
+ assert list_resource is not None, "listItems resource wasn't created"
221
+ assert "LIST_RESPONSE_DESCRIPTION" in (list_resource.description or ""), (
222
+ "Response description missing from Resource"
223
+ )
224
+
225
+ async def test_resource_includes_response_model_fields(
226
+ self, simple_mcp_server: FastMCP
227
+ ):
228
+ """Test that a Resource description includes response model field descriptions."""
229
+ resources = list(
230
+ (await simple_mcp_server._resource_manager.get_resources()).values()
231
+ )
232
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
233
+
234
+ assert list_resource is not None, "listItems resource wasn't created"
235
+ description = list_resource.description or ""
236
+ assert "ITEM_RESPONSE_ID_DESCRIPTION" in description, (
237
+ "Response model field descriptions missing from Resource description"
238
+ )
239
+ assert "ITEM_RESPONSE_NAME_DESCRIPTION" in description, (
240
+ "Response model field descriptions missing from Resource description"
241
+ )
242
+ assert "ITEM_RESPONSE_PRICE_DESCRIPTION" in description, (
243
+ "Response model field descriptions missing from Resource description"
244
+ )
245
+
246
+ # --- RESOURCE TEMPLATE TESTS ---
247
+
248
+ async def test_template_includes_route_description(
249
+ self, simple_mcp_server: FastMCP
250
+ ):
251
+ """Test that a ResourceTemplate includes the route description."""
252
+ templates_dict = (
253
+ await simple_mcp_server._resource_manager.get_resource_templates()
254
+ )
255
+ templates = list(templates_dict.values())
256
+ get_template = next((t for t in templates if t.name == "getItem"), None)
257
+
258
+ assert get_template is not None, "getItem template wasn't created"
259
+ assert "GET_DESCRIPTION" in (get_template.description or ""), (
260
+ "Route description missing from ResourceTemplate"
261
+ )
262
+
263
+ async def test_template_includes_function_docstring(
264
+ self, simple_mcp_server: FastMCP
265
+ ):
266
+ """Test that a ResourceTemplate includes the function docstring."""
267
+ templates_dict = (
268
+ await simple_mcp_server._resource_manager.get_resource_templates()
269
+ )
270
+ templates = list(templates_dict.values())
271
+ get_template = next((t for t in templates if t.name == "getItem"), None)
272
+
273
+ assert get_template is not None, "getItem template wasn't created"
274
+ assert "FUNCTION_GET_DESCRIPTION" in (get_template.description or ""), (
275
+ "Function docstring missing from ResourceTemplate"
276
+ )
277
+
278
+ async def test_template_includes_path_parameter_description(
279
+ self, simple_mcp_server: FastMCP
280
+ ):
281
+ """Test that a ResourceTemplate includes path parameter descriptions."""
282
+ templates_dict = (
283
+ await simple_mcp_server._resource_manager.get_resource_templates()
284
+ )
285
+ templates = list(templates_dict.values())
286
+ get_template = next((t for t in templates if t.name == "getItem"), None)
287
+
288
+ assert get_template is not None, "getItem template wasn't created"
289
+ assert "PATH_PARAM_DESCRIPTION" in (get_template.description or ""), (
290
+ "Path parameter description missing from ResourceTemplate description"
291
+ )
292
+
293
+ async def test_template_includes_query_parameter_description(
294
+ self, simple_mcp_server: FastMCP
295
+ ):
296
+ """Test that a ResourceTemplate includes query parameter descriptions."""
297
+ templates_dict = (
298
+ await simple_mcp_server._resource_manager.get_resource_templates()
299
+ )
300
+ templates = list(templates_dict.values())
301
+ get_template = next((t for t in templates if t.name == "getItem"), None)
302
+
303
+ assert get_template is not None, "getItem template wasn't created"
304
+ assert "QUERY_PARAM_DESCRIPTION" in (get_template.description or ""), (
305
+ "Query parameter description missing from ResourceTemplate description"
306
+ )
307
+
308
+ async def test_template_parameter_schema_includes_description(
309
+ self, simple_mcp_server: FastMCP
310
+ ):
311
+ """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
312
+ templates_dict = (
313
+ await simple_mcp_server._resource_manager.get_resource_templates()
314
+ )
315
+ templates = list(templates_dict.values())
316
+ get_template = next((t for t in templates if t.name == "getItem"), None)
317
+
318
+ assert get_template is not None, "getItem template wasn't created"
319
+ assert "properties" in get_template.parameters, (
320
+ "Schema properties missing from ResourceTemplate"
321
+ )
322
+ assert "item_id" in get_template.parameters["properties"], (
323
+ "item_id missing from ResourceTemplate schema"
324
+ )
325
+ assert "description" in get_template.parameters["properties"]["item_id"], (
326
+ "Description missing from item_id parameter schema"
327
+ )
328
+ assert (
329
+ "PATH_PARAM_DESCRIPTION"
330
+ in get_template.parameters["properties"]["item_id"]["description"]
331
+ ), "Path parameter description incorrect in schema"
332
+
333
+ # --- TOOL TESTS ---
334
+
335
+ async def test_tool_includes_route_description(self, simple_mcp_server: FastMCP):
336
+ """Test that a Tool includes the route description."""
337
+ tools_dict = await simple_mcp_server._tool_manager.get_tools()
338
+ tools = list(tools_dict.values())
339
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
340
+
341
+ assert create_tool is not None, "createItem tool wasn't created"
342
+ assert "CREATE_DESCRIPTION" in (create_tool.description or ""), (
343
+ "Route description missing from Tool"
344
+ )
345
+
346
+ async def test_tool_includes_function_docstring(self, simple_mcp_server: FastMCP):
347
+ """Test that a Tool includes the function docstring."""
348
+ tools_dict = await simple_mcp_server._tool_manager.get_tools()
349
+ tools = list(tools_dict.values())
350
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
351
+
352
+ assert create_tool is not None, "createItem tool wasn't created"
353
+ description = create_tool.description or ""
354
+ assert "FUNCTION_CREATE_DESCRIPTION" in description, (
355
+ "Function docstring missing from Tool"
356
+ )
357
+
358
+ async def test_tool_parameter_schema_includes_property_description(
359
+ self, simple_mcp_server: FastMCP
360
+ ):
361
+ """Test that a Tool's parameter schema includes property descriptions from request model."""
362
+ tools_dict = await simple_mcp_server._tool_manager.get_tools()
363
+ tools = list(tools_dict.values())
364
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
365
+
366
+ assert create_tool is not None, "createItem tool wasn't created"
367
+ assert "properties" in create_tool.parameters, (
368
+ "Schema properties missing from Tool"
369
+ )
370
+ assert "name" in create_tool.parameters["properties"], (
371
+ "name parameter missing from Tool schema"
372
+ )
373
+ assert "description" in create_tool.parameters["properties"]["name"], (
374
+ "Description missing from name parameter schema"
375
+ )
376
+ assert (
377
+ "PROP_DESCRIPTION"
378
+ in create_tool.parameters["properties"]["name"]["description"]
379
+ ), "Property description incorrect in schema"
380
+
381
+ # --- CLIENT API TESTS ---
382
+
383
+ async def test_client_api_resource_description(self, simple_mcp_server: FastMCP):
384
+ """Test that Resource descriptions are accessible via the client API."""
385
+ async with Client(simple_mcp_server) as client:
386
+ resources = await client.list_resources()
387
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
388
+
389
+ assert list_resource is not None, (
390
+ "listItems resource not accessible via client API"
391
+ )
392
+ resource_description = list_resource.description or ""
393
+ assert "LIST_DESCRIPTION" in resource_description, (
394
+ "Route description missing in Resource from client API"
395
+ )
396
+
397
+ async def test_client_api_template_description(self, simple_mcp_server: FastMCP):
398
+ """Test that ResourceTemplate descriptions are accessible via the client API."""
399
+ async with Client(simple_mcp_server) as client:
400
+ templates = await client.list_resource_templates()
401
+ get_template = next((t for t in templates if t.name == "getItem"), None)
402
+
403
+ assert get_template is not None, (
404
+ "getItem template not accessible via client API"
405
+ )
406
+ template_description = get_template.description or ""
407
+ assert "GET_DESCRIPTION" in template_description, (
408
+ "Route description missing in ResourceTemplate from client API"
409
+ )
410
+
411
+ async def test_client_api_tool_description(self, simple_mcp_server: FastMCP):
412
+ """Test that Tool descriptions are accessible via the client API."""
413
+ async with Client(simple_mcp_server) as client:
414
+ tools = await client.list_tools()
415
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
416
+
417
+ assert create_tool is not None, (
418
+ "createItem tool not accessible via client API"
419
+ )
420
+ tool_description = create_tool.description or ""
421
+ assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
422
+ "Function docstring missing in Tool from client API"
423
+ )
424
+
425
+ async def test_client_api_tool_parameter_schema(self, simple_mcp_server: FastMCP):
426
+ """Test that Tool parameter schemas are accessible via the client API."""
427
+ async with Client(simple_mcp_server) as client:
428
+ tools = await client.list_tools()
429
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
430
+
431
+ assert create_tool is not None, (
432
+ "createItem tool not accessible via client API"
433
+ )
434
+ assert "properties" in create_tool.inputSchema, (
435
+ "Schema properties missing from Tool inputSchema in client API"
436
+ )
437
+ assert "name" in create_tool.inputSchema["properties"], (
438
+ "name parameter missing from Tool schema in client API"
439
+ )
440
+ assert "description" in create_tool.inputSchema["properties"]["name"], (
441
+ "Description missing from name parameter in client API"
442
+ )
443
+ assert (
444
+ "PROP_DESCRIPTION"
445
+ in create_tool.inputSchema["properties"]["name"]["description"]
446
+ ), "Property description incorrect in schema from client API"
447
+
448
+
449
+ class TestFastAPIDescriptionPropagation:
450
+ """Tests for FastAPI docstring and annotation propagation to FastMCP components.
451
+
452
+ Each test focuses on a single, specific behavior to make it immediately clear
453
+ what's broken when a test fails.
454
+ """
455
+
456
+ @pytest.fixture
457
+ def fastapi_app_with_descriptions(self) -> FastAPI:
458
+ """Create a simple FastAPI app with docstrings and annotations."""
459
+ from typing import Annotated
460
+
461
+ from pydantic import BaseModel, Field
462
+
463
+ app = FastAPI(title="Test FastAPI App")
464
+
465
+ class Item(BaseModel):
466
+ name: str = Field(..., description="ITEM_NAME_DESCRIPTION")
467
+ price: float = Field(..., description="ITEM_PRICE_DESCRIPTION")
468
+
469
+ class ItemResponse(BaseModel):
470
+ id: str = Field(..., description="ITEM_RESPONSE_ID_DESCRIPTION")
471
+ name: str = Field(..., description="ITEM_RESPONSE_NAME_DESCRIPTION")
472
+ price: float = Field(..., description="ITEM_RESPONSE_PRICE_DESCRIPTION")
473
+
474
+ @app.get("/items", tags=["items"])
475
+ async def list_items() -> list[ItemResponse]:
476
+ """FUNCTION_LIST_DESCRIPTION
477
+
478
+ Returns a list of items.
479
+ """
480
+ return [
481
+ ItemResponse(id="1", name="Item 1", price=10.0),
482
+ ItemResponse(id="2", name="Item 2", price=20.0),
483
+ ]
484
+
485
+ @app.get("/items/{item_id}", tags=["items", "detail"])
486
+ async def get_item(
487
+ item_id: Annotated[str, Field(description="PATH_PARAM_DESCRIPTION")],
488
+ fields: Annotated[
489
+ str | None, Field(description="QUERY_PARAM_DESCRIPTION")
490
+ ] = None,
491
+ ) -> ItemResponse:
492
+ """FUNCTION_GET_DESCRIPTION
493
+
494
+ Gets a specific item by ID.
495
+
496
+ Args:
497
+ item_id: The ID of the item to retrieve
498
+ fields: Optional fields to include
499
+ """
500
+ return ItemResponse(
501
+ id=item_id, name=f"Item {item_id}", price=float(item_id) * 10.0
502
+ )
503
+
504
+ @app.post("/items", tags=["items", "create"])
505
+ async def create_item(item: Item) -> ItemResponse:
506
+ """FUNCTION_CREATE_DESCRIPTION
507
+
508
+ Creates a new item.
509
+
510
+ Body:
511
+ Item object with name and price
512
+ """
513
+ return ItemResponse(id="new", name=item.name, price=item.price)
514
+
515
+ return app
516
+
517
+ @pytest.fixture
518
+ async def fastapi_server(self, fastapi_app_with_descriptions):
519
+ """Create a FastMCP server from the FastAPI app with custom route mappings."""
520
+ # First create from FastAPI app to get the OpenAPI spec
521
+ openapi_spec = fastapi_app_with_descriptions.openapi()
522
+
523
+ # Debug: check the operationIds in the OpenAPI spec
524
+ print("\nDEBUG - OpenAPI Paths:")
525
+ for path, methods in openapi_spec["paths"].items():
526
+ for method, details in methods.items():
527
+ if method != "parameters": # Skip non-HTTP method keys
528
+ operation_id = details.get("operationId", "no_operation_id")
529
+ print(
530
+ f" Path: {path}, Method: {method}, OperationId: {operation_id}"
531
+ )
532
+
533
+ # Create custom route mappings
534
+ route_maps = [
535
+ # Map GET /items to Resource
536
+ RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
537
+ # Map GET /items/{item_id} to ResourceTemplate
538
+ RouteMap(
539
+ methods=["GET"],
540
+ pattern=r"^/items/\{.*\}$",
541
+ mcp_type=MCPType.RESOURCE_TEMPLATE,
542
+ ),
543
+ # Map POST /items to Tool
544
+ RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
545
+ ]
546
+
547
+ # Create FastMCP server with the OpenAPI spec and custom route mappings
548
+ server = FastMCPOpenAPI(
549
+ openapi_spec=openapi_spec,
550
+ client=AsyncClient(
551
+ transport=ASGITransport(app=fastapi_app_with_descriptions),
552
+ base_url="http://test",
553
+ ),
554
+ name="Test FastAPI App",
555
+ route_maps=route_maps,
556
+ )
557
+
558
+ # Debug: print all components created
559
+ print("\nDEBUG - Resources created:")
560
+ resources_dict = await server._resource_manager.get_resources()
561
+ for name, resource in resources_dict.items():
562
+ print(f" Resource: {name}, Name attribute: {resource.name}")
563
+
564
+ print("\nDEBUG - Templates created:")
565
+ templates_dict = await server._resource_manager.get_resource_templates()
566
+ for name, template in templates_dict.items():
567
+ print(f" Template: {name}, Name attribute: {template.name}")
568
+
569
+ print("\nDEBUG - Tools created:")
570
+ tools = await server._tool_manager.list_tools()
571
+ for tool in tools:
572
+ print(f" Tool: {tool.name}")
573
+
574
+ return server
575
+
576
+ async def test_resource_includes_function_docstring(self, fastapi_server: FastMCP):
577
+ """Test that a Resource includes the function docstring."""
578
+ resources_dict = await fastapi_server._resource_manager.get_resources()
579
+ resources = list(resources_dict.values())
580
+
581
+ # Now checking for the get_items operation ID rather than list_items
582
+ list_resource = next((r for r in resources if "items_get" in r.name), None)
583
+
584
+ assert list_resource is not None, "GET /items resource wasn't created"
585
+ description = list_resource.description or ""
586
+ assert "FUNCTION_LIST_DESCRIPTION" in description, (
587
+ "Function docstring missing from Resource"
588
+ )
589
+
590
+ async def test_resource_includes_response_model_fields(
591
+ self, fastapi_server: FastMCP
592
+ ):
593
+ """Test that a Resource description includes basic response information.
594
+
595
+ Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema,
596
+ so we can only check for basic response information being present.
597
+ """
598
+ resources_dict = await fastapi_server._resource_manager.get_resources()
599
+ resources = list(resources_dict.values())
600
+ list_resource = next((r for r in resources if "items_get" in r.name), None)
601
+
602
+ assert list_resource is not None, "GET /items resource wasn't created"
603
+ description = list_resource.description or ""
604
+
605
+ # Check that at least the response information is included
606
+ assert "Successful Response" in description, (
607
+ "Response information missing from Resource description"
608
+ )
609
+
610
+ # We've already verified in TestDescriptionPropagation that when descriptions
611
+ # are present in the OpenAPI schema, they are properly included in the component description
612
+
613
+ async def test_template_includes_function_docstring(self, fastapi_server: FastMCP):
614
+ """Test that a ResourceTemplate includes the function docstring."""
615
+ templates_dict = await fastapi_server._resource_manager.get_resource_templates()
616
+ templates = list(templates_dict.values())
617
+ get_template = next((t for t in templates if "get_item_items" in t.name), None)
618
+
619
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
620
+ description = get_template.description or ""
621
+ assert "FUNCTION_GET_DESCRIPTION" in description, (
622
+ "Function docstring missing from ResourceTemplate"
623
+ )
624
+
625
+ async def test_template_includes_path_parameter_description(
626
+ self, fastapi_server: FastMCP
627
+ ):
628
+ """Test that a ResourceTemplate includes path parameter descriptions.
629
+
630
+ Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
631
+ are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
632
+ """
633
+ templates_dict = await fastapi_server._resource_manager.get_resource_templates()
634
+ templates = list(templates_dict.values())
635
+ get_template = next((t for t in templates if "get_item_items" in t.name), None)
636
+
637
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
638
+ description = get_template.description or ""
639
+
640
+ # Just test that parameters are included at all
641
+ assert "Path Parameters" in description, (
642
+ "Path parameters section missing from ResourceTemplate description"
643
+ )
644
+ assert "item_id" in description, (
645
+ "item_id parameter missing from ResourceTemplate description"
646
+ )
647
+
648
+ async def test_template_includes_query_parameter_description(
649
+ self, fastapi_server: FastMCP
650
+ ):
651
+ """Test that a ResourceTemplate includes query parameter descriptions.
652
+
653
+ Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
654
+ are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
655
+ """
656
+ templates_dict = await fastapi_server._resource_manager.get_resource_templates()
657
+ templates = list(templates_dict.values())
658
+ get_template = next((t for t in templates if "get_item_items" in t.name), None)
659
+
660
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
661
+ description = get_template.description or ""
662
+
663
+ # Just test that parameters are included at all
664
+ assert "Query Parameters" in description, (
665
+ "Query parameters section missing from ResourceTemplate description"
666
+ )
667
+ assert "fields" in description, (
668
+ "fields parameter missing from ResourceTemplate description"
669
+ )
670
+
671
+ async def test_template_parameter_schema_includes_description(
672
+ self, fastapi_server: FastMCP
673
+ ):
674
+ """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
675
+ templates_dict = await fastapi_server._resource_manager.get_resource_templates()
676
+ templates = list(templates_dict.values())
677
+ get_template = next((t for t in templates if "get_item_items" in t.name), None)
678
+
679
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
680
+ assert "properties" in get_template.parameters, (
681
+ "Schema properties missing from ResourceTemplate"
682
+ )
683
+ assert "item_id" in get_template.parameters["properties"], (
684
+ "item_id missing from ResourceTemplate schema"
685
+ )
686
+ assert "description" in get_template.parameters["properties"]["item_id"], (
687
+ "Description missing from item_id parameter schema"
688
+ )
689
+ assert (
690
+ "PATH_PARAM_DESCRIPTION"
691
+ in get_template.parameters["properties"]["item_id"]["description"]
692
+ ), "Path parameter description incorrect in schema"
693
+
694
+ async def test_tool_includes_function_docstring(self, fastapi_server: FastMCP):
695
+ """Test that a Tool includes the function docstring."""
696
+ tools_dict = await fastapi_server._tool_manager.get_tools()
697
+ tools = list(tools_dict.values())
698
+ create_tool = next(
699
+ (t for t in tools if "create_item_items_post" == t.name), None
700
+ )
701
+
702
+ assert create_tool is not None, "POST /items tool wasn't created"
703
+ description = create_tool.description or ""
704
+ assert "FUNCTION_CREATE_DESCRIPTION" in description, (
705
+ "Function docstring missing from Tool"
706
+ )
707
+
708
+ async def test_tool_parameter_schema_includes_property_description(
709
+ self, fastapi_server: FastMCP
710
+ ):
711
+ """Test that a Tool's parameter schema includes property descriptions from request model.
712
+
713
+ Note: Currently, model field descriptions defined in Pydantic models using Field(description=...)
714
+ may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's
715
+ parameter schema.
716
+ """
717
+ tools_dict = await fastapi_server._tool_manager.get_tools()
718
+ tools = list(tools_dict.values())
719
+ create_tool = next(
720
+ (t for t in tools if "create_item_items_post" == t.name), None
721
+ )
722
+
723
+ assert create_tool is not None, "POST /items tool wasn't created"
724
+ assert "properties" in create_tool.parameters, (
725
+ "Schema properties missing from Tool"
726
+ )
727
+ assert "name" in create_tool.parameters["properties"], (
728
+ "name parameter missing from Tool schema"
729
+ )
730
+ # We don't test for the description field content as it may not be consistently propagated
731
+
732
+ async def test_client_api_resource_description(self, fastapi_server: FastMCP):
733
+ """Test that Resource descriptions are accessible via the client API."""
734
+ async with Client(fastapi_server) as client:
735
+ resources = await client.list_resources()
736
+ list_resource = next((r for r in resources if "items_get" in r.name), None)
737
+
738
+ assert list_resource is not None, (
739
+ "GET /items resource not accessible via client API"
740
+ )
741
+ resource_description = list_resource.description or ""
742
+ assert "FUNCTION_LIST_DESCRIPTION" in resource_description, (
743
+ "Function docstring missing in Resource from client API"
744
+ )
745
+
746
+ async def test_client_api_template_description(self, fastapi_server: FastMCP):
747
+ """Test that ResourceTemplate descriptions are accessible via the client API."""
748
+ async with Client(fastapi_server) as client:
749
+ templates = await client.list_resource_templates()
750
+ get_template = next(
751
+ (t for t in templates if "get_item_items" in t.name), None
752
+ )
753
+
754
+ assert get_template is not None, (
755
+ "GET /items/{item_id} template not accessible via client API"
756
+ )
757
+ template_description = get_template.description or ""
758
+ assert "FUNCTION_GET_DESCRIPTION" in template_description, (
759
+ "Function docstring missing in ResourceTemplate from client API"
760
+ )
761
+
762
+ async def test_client_api_tool_description(self, fastapi_server: FastMCP):
763
+ """Test that Tool descriptions are accessible via the client API."""
764
+ async with Client(fastapi_server) as client:
765
+ tools = await client.list_tools()
766
+ create_tool = next(
767
+ (t for t in tools if "create_item_items_post" == t.name), None
768
+ )
769
+
770
+ assert create_tool is not None, (
771
+ "POST /items tool not accessible via client API"
772
+ )
773
+ tool_description = create_tool.description or ""
774
+ assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
775
+ "Function docstring missing in Tool from client API"
776
+ )
777
+
778
+ async def test_client_api_tool_parameter_schema(self, fastapi_server: FastMCP):
779
+ """Test that Tool parameter schemas are accessible via the client API."""
780
+ async with Client(fastapi_server) as client:
781
+ tools = await client.list_tools()
782
+ create_tool = next(
783
+ (t for t in tools if "create_item_items_post" == t.name), None
784
+ )
785
+
786
+ assert create_tool is not None, (
787
+ "POST /items tool not accessible via client API"
788
+ )
789
+ assert "properties" in create_tool.inputSchema, (
790
+ "Schema properties missing from Tool inputSchema in client API"
791
+ )
792
+ assert "name" in create_tool.inputSchema["properties"], (
793
+ "name parameter missing from Tool schema in client API"
794
+ )
795
+ # We don't test for the description field content as it may not be consistently propagated
tests/server/openapi/test_openapi_compatibility.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+ from pydantic.networks import AnyUrl
6
+
7
+ from fastmcp import FastMCP
8
+ from fastmcp.client import Client
9
+ from fastmcp.server.openapi import FastMCPOpenAPI
10
+
11
+ from .conftest import GET_ROUTE_MAPS
12
+
13
+
14
+ class TestOpenAPI30Compatibility:
15
+ """Tests for compatibility with OpenAPI 3.0 specifications."""
16
+
17
+ @pytest.fixture
18
+ def openapi_30_spec(self) -> dict:
19
+ """Fixture that returns a simple OpenAPI 3.0 specification."""
20
+ return {
21
+ "openapi": "3.0.0",
22
+ "info": {"title": "Product API (3.0)", "version": "1.0.0"},
23
+ "paths": {
24
+ "/products": {
25
+ "get": {
26
+ "operationId": "listProducts",
27
+ "summary": "List all products",
28
+ "responses": {"200": {"description": "A list of products"}},
29
+ },
30
+ "post": {
31
+ "operationId": "createProduct",
32
+ "summary": "Create a new product",
33
+ "requestBody": {
34
+ "required": True,
35
+ "content": {
36
+ "application/json": {
37
+ "schema": {
38
+ "type": "object",
39
+ "properties": {
40
+ "name": {"type": "string"},
41
+ "price": {"type": "number"},
42
+ },
43
+ "required": ["name", "price"],
44
+ }
45
+ }
46
+ },
47
+ },
48
+ "responses": {"201": {"description": "Product created"}},
49
+ },
50
+ },
51
+ "/products/{product_id}": {
52
+ "get": {
53
+ "operationId": "getProduct",
54
+ "summary": "Get product by ID",
55
+ "parameters": [
56
+ {
57
+ "name": "product_id",
58
+ "in": "path",
59
+ "required": True,
60
+ "schema": {"type": "string"},
61
+ }
62
+ ],
63
+ "responses": {"200": {"description": "A product"}},
64
+ }
65
+ },
66
+ },
67
+ }
68
+
69
+ @pytest.fixture
70
+ async def mock_30_client(self) -> httpx.AsyncClient:
71
+ """Mock client that returns predefined responses for the 3.0 API."""
72
+
73
+ async def _responder(request):
74
+ if request.url.path == "/products" and request.method == "GET":
75
+ return httpx.Response(
76
+ 200,
77
+ json=[
78
+ {"id": "p1", "name": "Product 1", "price": 19.99},
79
+ {"id": "p2", "name": "Product 2", "price": 29.99},
80
+ ],
81
+ )
82
+ elif request.url.path == "/products" and request.method == "POST":
83
+ import json
84
+
85
+ data = json.loads(request.content)
86
+ return httpx.Response(
87
+ 201, json={"id": "p3", "name": data["name"], "price": data["price"]}
88
+ )
89
+ elif request.url.path.startswith("/products/") and request.method == "GET":
90
+ product_id = request.url.path.split("/")[-1]
91
+ products = {
92
+ "p1": {"id": "p1", "name": "Product 1", "price": 19.99},
93
+ "p2": {"id": "p2", "name": "Product 2", "price": 29.99},
94
+ }
95
+ if product_id in products:
96
+ return httpx.Response(200, json=products[product_id])
97
+ return httpx.Response(404, json={"error": "Product not found"})
98
+ return httpx.Response(404)
99
+
100
+ transport = httpx.MockTransport(_responder)
101
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
102
+
103
+ @pytest.fixture
104
+ async def openapi_30_server_with_all_types(
105
+ self, openapi_30_spec, mock_30_client
106
+ ) -> FastMCPOpenAPI:
107
+ """Create a FastMCPOpenAPI server from the OpenAPI 3.0 spec."""
108
+ return FastMCPOpenAPI(
109
+ openapi_spec=openapi_30_spec,
110
+ client=mock_30_client,
111
+ name="Product API 3.0",
112
+ route_maps=GET_ROUTE_MAPS,
113
+ )
114
+
115
+ async def test_server_creation(self, openapi_30_server_with_all_types):
116
+ """Test that a server can be created from an OpenAPI 3.0 spec."""
117
+ assert isinstance(openapi_30_server_with_all_types, FastMCP)
118
+ assert openapi_30_server_with_all_types.name == "Product API 3.0"
119
+
120
+ async def test_resource_discovery(self, openapi_30_server_with_all_types):
121
+ """Test that resources are correctly discovered from an OpenAPI 3.0 spec."""
122
+ async with Client(openapi_30_server_with_all_types) as client:
123
+ resources = await client.list_resources()
124
+ assert len(resources) == 1
125
+ assert resources[0].uri == AnyUrl("resource://listProducts")
126
+
127
+ async def test_resource_template_discovery(self, openapi_30_server_with_all_types):
128
+ """Test that resource templates are correctly discovered from an OpenAPI 3.0 spec."""
129
+ async with Client(openapi_30_server_with_all_types) as client:
130
+ templates = await client.list_resource_templates()
131
+ assert len(templates) == 1
132
+ assert templates[0].name == "getProduct"
133
+ assert templates[0].uriTemplate == r"resource://getProduct/{product_id}"
134
+
135
+ async def test_tool_discovery(self, openapi_30_server_with_all_types):
136
+ """Test that tools are correctly discovered from an OpenAPI 3.0 spec."""
137
+ async with Client(openapi_30_server_with_all_types) as client:
138
+ tools = await client.list_tools()
139
+ assert len(tools) == 1
140
+ assert tools[0].name == "createProduct"
141
+ assert "name" in tools[0].inputSchema["properties"]
142
+ assert "price" in tools[0].inputSchema["properties"]
143
+
144
+ async def test_resource_access(self, openapi_30_server_with_all_types):
145
+ """Test reading a resource from an OpenAPI 3.0 server."""
146
+ async with Client(openapi_30_server_with_all_types) as client:
147
+ resource_response = await client.read_resource("resource://listProducts")
148
+ response_text = resource_response[0].text # type: ignore[attr-defined]
149
+ content = json.loads(response_text)
150
+ assert len(content) == 2
151
+ assert content[0]["name"] == "Product 1"
152
+ assert content[1]["name"] == "Product 2"
153
+
154
+ async def test_resource_template_access(self, openapi_30_server_with_all_types):
155
+ """Test reading a resource from template from an OpenAPI 3.0 server."""
156
+ async with Client(openapi_30_server_with_all_types) as client:
157
+ resource_response = await client.read_resource("resource://getProduct/p1")
158
+ response_text = resource_response[0].text # type: ignore[attr-defined]
159
+ content = json.loads(response_text)
160
+ assert content["id"] == "p1"
161
+ assert content["name"] == "Product 1"
162
+ assert content["price"] == 19.99
163
+
164
+ async def test_tool_execution(self, openapi_30_server_with_all_types):
165
+ """Test executing a tool from an OpenAPI 3.0 server."""
166
+ async with Client(openapi_30_server_with_all_types) as client:
167
+ result = await client.call_tool(
168
+ "createProduct", {"name": "New Product", "price": 39.99}
169
+ )
170
+ # Result should be a text content
171
+ assert len(result.content) == 1
172
+ product = json.loads(result.content[0].text) # type: ignore[attr-defined]
173
+ assert product["id"] == "p3"
174
+ assert product["name"] == "New Product"
175
+ assert product["price"] == 39.99
176
+
177
+ assert result.structured_content is not None
178
+ assert result.structured_content["id"] == "p3"
179
+ assert result.structured_content["name"] == "New Product"
180
+ assert result.structured_content["price"] == 39.99
181
+
182
+ assert result.data is not None
183
+ assert result.data["id"] == "p3"
184
+ assert result.data["name"] == "New Product"
185
+ assert result.data["price"] == 39.99
186
+
187
+
188
+ class TestOpenAPI31Compatibility:
189
+ """Tests for compatibility with OpenAPI 3.1 specifications."""
190
+
191
+ @pytest.fixture
192
+ def openapi_31_spec(self) -> dict:
193
+ """Fixture that returns a simple OpenAPI 3.1 specification."""
194
+ return {
195
+ "openapi": "3.1.0",
196
+ "info": {"title": "Order API (3.1)", "version": "1.0.0"},
197
+ "paths": {
198
+ "/orders": {
199
+ "get": {
200
+ "operationId": "listOrders",
201
+ "summary": "List all orders",
202
+ "responses": {"200": {"description": "A list of orders"}},
203
+ },
204
+ "post": {
205
+ "operationId": "createOrder",
206
+ "summary": "Place a new order",
207
+ "requestBody": {
208
+ "required": True,
209
+ "content": {
210
+ "application/json": {
211
+ "schema": {
212
+ "type": "object",
213
+ "properties": {
214
+ "customer": {"type": "string"},
215
+ "items": {
216
+ "type": "array",
217
+ "items": {"type": "string"},
218
+ },
219
+ },
220
+ "required": ["customer", "items"],
221
+ }
222
+ }
223
+ },
224
+ },
225
+ "responses": {"201": {"description": "Order created"}},
226
+ },
227
+ },
228
+ "/orders/{order_id}": {
229
+ "get": {
230
+ "operationId": "getOrder",
231
+ "summary": "Get order by ID",
232
+ "parameters": [
233
+ {
234
+ "name": "order_id",
235
+ "in": "path",
236
+ "required": True,
237
+ "schema": {"type": "string"},
238
+ }
239
+ ],
240
+ "responses": {"200": {"description": "An order"}},
241
+ }
242
+ },
243
+ },
244
+ }
245
+
246
+ @pytest.fixture
247
+ async def mock_31_client(self) -> httpx.AsyncClient:
248
+ """Mock client that returns predefined responses for the 3.1 API."""
249
+
250
+ async def _responder(request):
251
+ if request.url.path == "/orders" and request.method == "GET":
252
+ return httpx.Response(
253
+ 200,
254
+ json=[
255
+ {"id": "o1", "customer": "Alice", "items": ["item1", "item2"]},
256
+ {"id": "o2", "customer": "Bob", "items": ["item3"]},
257
+ ],
258
+ )
259
+ elif request.url.path == "/orders" and request.method == "POST":
260
+ import json
261
+
262
+ data = json.loads(request.content)
263
+ return httpx.Response(
264
+ 201,
265
+ json={
266
+ "id": "o3",
267
+ "customer": data["customer"],
268
+ "items": data["items"],
269
+ },
270
+ )
271
+ elif request.url.path.startswith("/orders/") and request.method == "GET":
272
+ order_id = request.url.path.split("/")[-1]
273
+ orders = {
274
+ "o1": {
275
+ "id": "o1",
276
+ "customer": "Alice",
277
+ "items": ["item1", "item2"],
278
+ },
279
+ "o2": {"id": "o2", "customer": "Bob", "items": ["item3"]},
280
+ }
281
+ if order_id in orders:
282
+ return httpx.Response(200, json=orders[order_id])
283
+ return httpx.Response(404, json={"error": "Order not found"})
284
+ return httpx.Response(404)
285
+
286
+ transport = httpx.MockTransport(_responder)
287
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
288
+
289
+ @pytest.fixture
290
+ async def openapi_31_server_with_all_types(
291
+ self, openapi_31_spec, mock_31_client
292
+ ) -> FastMCPOpenAPI:
293
+ """Create a FastMCPOpenAPI server from the OpenAPI 3.1 spec."""
294
+ return FastMCPOpenAPI(
295
+ openapi_spec=openapi_31_spec,
296
+ client=mock_31_client,
297
+ name="Order API 3.1",
298
+ route_maps=GET_ROUTE_MAPS,
299
+ )
300
+
301
+ async def test_server_creation(self, openapi_31_server_with_all_types):
302
+ """Test that a server can be created from an OpenAPI 3.1 spec."""
303
+ assert isinstance(openapi_31_server_with_all_types, FastMCP)
304
+ assert openapi_31_server_with_all_types.name == "Order API 3.1"
305
+
306
+ async def test_resource_discovery(self, openapi_31_server_with_all_types):
307
+ """Test that resources are correctly discovered from an OpenAPI 3.1 spec."""
308
+ async with Client(openapi_31_server_with_all_types) as client:
309
+ resources = await client.list_resources()
310
+ assert len(resources) == 1
311
+ assert resources[0].uri == AnyUrl("resource://listOrders")
312
+
313
+ async def test_resource_template_discovery(self, openapi_31_server_with_all_types):
314
+ """Test that resource templates are correctly discovered from an OpenAPI 3.1 spec."""
315
+ async with Client(openapi_31_server_with_all_types) as client:
316
+ templates = await client.list_resource_templates()
317
+ assert len(templates) == 1
318
+ assert templates[0].name == "getOrder"
319
+ assert templates[0].uriTemplate == r"resource://getOrder/{order_id}"
320
+
321
+ async def test_tool_discovery(self, openapi_31_server_with_all_types):
322
+ """Test that tools are correctly discovered from an OpenAPI 3.1 spec."""
323
+ async with Client(openapi_31_server_with_all_types) as client:
324
+ tools = await client.list_tools()
325
+ assert len(tools) == 1
326
+ assert tools[0].name == "createOrder"
327
+ assert "customer" in tools[0].inputSchema["properties"]
328
+ assert "items" in tools[0].inputSchema["properties"]
329
+
330
+ async def test_resource_access(self, openapi_31_server_with_all_types):
331
+ """Test reading a resource from an OpenAPI 3.1 server."""
332
+ async with Client(openapi_31_server_with_all_types) as client:
333
+ resource_response = await client.read_resource("resource://listOrders")
334
+ response_text = resource_response[0].text # type: ignore[attr-defined]
335
+ content = json.loads(response_text)
336
+ assert len(content) == 2
337
+ assert content[0]["customer"] == "Alice"
338
+ assert content[1]["customer"] == "Bob"
339
+
340
+ async def test_resource_template_access(self, openapi_31_server_with_all_types):
341
+ """Test reading a resource from template from an OpenAPI 3.1 server."""
342
+ async with Client(openapi_31_server_with_all_types) as client:
343
+ resource_response = await client.read_resource("resource://getOrder/o1")
344
+ response_text = resource_response[0].text # type: ignore[attr-defined]
345
+ content = json.loads(response_text)
346
+ assert content["id"] == "o1"
347
+ assert content["customer"] == "Alice"
348
+ assert content["items"] == ["item1", "item2"]
349
+
350
+ async def test_tool_execution(self, openapi_31_server_with_all_types):
351
+ """Test executing a tool from an OpenAPI 3.1 server."""
352
+ async with Client(openapi_31_server_with_all_types) as client:
353
+ result = await client.call_tool(
354
+ "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
355
+ )
356
+ # Result should be a text content
357
+ assert len(result.content) == 1
358
+ order = json.loads(result.content[0].text) # type: ignore[attr-defined]
359
+ assert order["id"] == "o3"
360
+ assert order["customer"] == "Charlie"
361
+ assert order["items"] == ["item4", "item5"]
362
+
363
+ assert result.structured_content is not None
364
+ assert result.structured_content["id"] == "o3"
365
+ assert result.structured_content["customer"] == "Charlie"
366
+ assert result.structured_content["items"] == ["item4", "item5"]
367
+
368
+ assert result.data is not None
369
+ assert result.data["id"] == "o3"
370
+ assert result.data["customer"] == "Charlie"
371
+ assert result.data["items"] == ["item4", "item5"]