Jeremiah Lowin commited on
Commit
f3513ae
·
2 Parent(s): 548235b0e450c5

Merge branch 'main' into docs

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -115,6 +115,7 @@ class OpenAPITool(Tool):
115
  parameters: dict[str, Any],
116
  fn_metadata: Any,
117
  is_async: bool = True,
 
118
  ):
119
  super().__init__(
120
  name=name,
@@ -124,6 +125,7 @@ class OpenAPITool(Tool):
124
  fn_metadata=fn_metadata,
125
  is_async=is_async,
126
  context_kwarg="context", # Default context keyword argument
 
127
  )
128
  self._client = client
129
  self._route = route
@@ -242,12 +244,14 @@ class OpenAPIResource(Resource):
242
  name: str,
243
  description: str,
244
  mime_type: str = "application/json",
 
245
  ):
246
  super().__init__(
247
  uri=AnyUrl(uri), # Convert string to AnyUrl
248
  name=name,
249
  description=description,
250
  mime_type=mime_type,
 
251
  )
252
  self._client = client
253
  self._route = route
@@ -332,6 +336,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
332
  name: str,
333
  description: str,
334
  parameters: dict[str, Any],
 
335
  ):
336
  super().__init__(
337
  uri_template=uri_template,
@@ -339,6 +344,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
339
  description=description,
340
  fn=self._create_resource_fn,
341
  parameters=parameters,
 
342
  )
343
  self._client = client
344
  self._route = route
@@ -405,6 +411,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
405
  description=self.description
406
  or f"Resource for {self._route.path}", # Provide default if None
407
  mime_type="application/json", # Default, will be updated when read
 
408
  )
409
 
410
 
@@ -525,10 +532,13 @@ class FastMCPOpenAPI(FastMCP):
525
  parameters=combined_schema,
526
  fn_metadata=func_metadata(_openapi_passthrough),
527
  is_async=True,
 
528
  )
529
  # Register the tool by directly assigning to the tools dictionary
530
  self._tool_manager._tools[tool_name] = tool
531
- logger.debug(f"Registered TOOL: {tool_name} ({route.method} {route.path})")
 
 
532
 
533
  def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
534
  """Creates and registers an OpenAPIResource with enhanced description."""
@@ -550,11 +560,12 @@ class FastMCPOpenAPI(FastMCP):
550
  uri=resource_uri,
551
  name=resource_name,
552
  description=enhanced_description,
 
553
  )
554
  # Register the resource by directly assigning to the resources dictionary
555
  self._resource_manager._resources[str(resource.uri)] = resource
556
  logger.debug(
557
- f"Registered RESOURCE: {resource_uri} ({route.method} {route.path})"
558
  )
559
 
560
  def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
@@ -594,11 +605,12 @@ class FastMCPOpenAPI(FastMCP):
594
  name=template_name,
595
  description=enhanced_description,
596
  parameters=template_params_schema,
 
597
  )
598
  # Register the template by directly assigning to the templates dictionary
599
  self._resource_manager._templates[uri_template_str] = template
600
  logger.debug(
601
- f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path})"
602
  )
603
 
604
  async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
 
115
  parameters: dict[str, Any],
116
  fn_metadata: Any,
117
  is_async: bool = True,
118
+ tags: set[str] = set(),
119
  ):
120
  super().__init__(
121
  name=name,
 
125
  fn_metadata=fn_metadata,
126
  is_async=is_async,
127
  context_kwarg="context", # Default context keyword argument
128
+ tags=tags,
129
  )
130
  self._client = client
131
  self._route = route
 
244
  name: str,
245
  description: str,
246
  mime_type: str = "application/json",
247
+ tags: set[str] = set(),
248
  ):
249
  super().__init__(
250
  uri=AnyUrl(uri), # Convert string to AnyUrl
251
  name=name,
252
  description=description,
253
  mime_type=mime_type,
254
+ tags=tags,
255
  )
256
  self._client = client
257
  self._route = route
 
336
  name: str,
337
  description: str,
338
  parameters: dict[str, Any],
339
+ tags: set[str] = set(),
340
  ):
341
  super().__init__(
342
  uri_template=uri_template,
 
344
  description=description,
345
  fn=self._create_resource_fn,
346
  parameters=parameters,
347
+ tags=tags,
348
  )
349
  self._client = client
350
  self._route = route
 
411
  description=self.description
412
  or f"Resource for {self._route.path}", # Provide default if None
413
  mime_type="application/json", # Default, will be updated when read
414
+ tags=set(self._route.tags or []),
415
  )
416
 
417
 
 
532
  parameters=combined_schema,
533
  fn_metadata=func_metadata(_openapi_passthrough),
534
  is_async=True,
535
+ tags=set(route.tags or []),
536
  )
537
  # Register the tool by directly assigning to the tools dictionary
538
  self._tool_manager._tools[tool_name] = tool
539
+ logger.debug(
540
+ f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
541
+ )
542
 
543
  def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
544
  """Creates and registers an OpenAPIResource with enhanced description."""
 
560
  uri=resource_uri,
561
  name=resource_name,
562
  description=enhanced_description,
563
+ tags=set(route.tags or []),
564
  )
565
  # Register the resource by directly assigning to the resources dictionary
566
  self._resource_manager._resources[str(resource.uri)] = resource
567
  logger.debug(
568
+ f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
569
  )
570
 
571
  def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
 
605
  name=template_name,
606
  description=enhanced_description,
607
  parameters=template_params_schema,
608
+ tags=set(route.tags or []),
609
  )
610
  # Register the template by directly assigning to the templates dictionary
611
  self._resource_manager._templates[uri_template_str] = template
612
  logger.debug(
613
+ f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
614
  )
615
 
616
  async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
src/fastmcp/server/server.py CHANGED
@@ -3,7 +3,7 @@
3
  import inspect
4
  import json
5
  import re
6
- from collections.abc import AsyncIterator, Callable, Sequence
7
  from contextlib import (
8
  AbstractAsyncContextManager,
9
  asynccontextmanager,
@@ -78,8 +78,10 @@ class FastMCP(Generic[LifespanResultT]):
78
  lifespan: (
79
  Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None
80
  ) = None,
 
81
  **settings: Any,
82
  ):
 
83
  self.settings = fastmcp.settings.ServerSettings(**settings)
84
 
85
  self._mcp_server = MCPServer[LifespanResultT](
@@ -178,7 +180,7 @@ class FastMCP(Generic[LifespanResultT]):
178
 
179
  async def call_tool(
180
  self, name: str, arguments: dict[str, Any]
181
- ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
182
  """Call a tool by name with arguments."""
183
  context = self.get_context()
184
  result = await self._tool_manager.call_tool(name, arguments, context=context)
 
3
  import inspect
4
  import json
5
  import re
6
+ from collections.abc import AsyncIterator, Callable
7
  from contextlib import (
8
  AbstractAsyncContextManager,
9
  asynccontextmanager,
 
78
  lifespan: (
79
  Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None
80
  ) = None,
81
+ tags: set[str] | None = None,
82
  **settings: Any,
83
  ):
84
+ self.tags: set[str] = tags or set()
85
  self.settings = fastmcp.settings.ServerSettings(**settings)
86
 
87
  self._mcp_server = MCPServer[LifespanResultT](
 
180
 
181
  async def call_tool(
182
  self, name: str, arguments: dict[str, Any]
183
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
184
  """Call a tool by name with arguments."""
185
  context = self.get_context()
186
  result = await self._tool_manager.call_tool(name, arguments, context=context)
tests/server/test_openapi.py CHANGED
@@ -36,17 +36,17 @@ def users_db() -> dict[int, User]:
36
  def fastapi_app(users_db: dict[int, User]) -> FastAPI:
37
  app = FastAPI(title="FastAPI App")
38
 
39
- @app.get("/users")
40
  async def get_users() -> list[User]:
41
  """Get all users."""
42
  return sorted(users_db.values(), key=lambda x: x.id)
43
 
44
- @app.get("/users/{user_id}")
45
  async def get_user(user_id: int) -> User | None:
46
  """Get a user by ID."""
47
  return users_db.get(user_id)
48
 
49
- @app.post("/users")
50
  async def create_user(user: UserCreate) -> User:
51
  """Create a new user."""
52
  user_id = max(users_db.keys()) + 1
@@ -54,7 +54,7 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI:
54
  users_db[user_id] = new_user
55
  return new_user
56
 
57
- @app.patch("/users/{user_id}/name")
58
  async def update_user_name(user_id: int, name: str) -> User:
59
  """Update a user's name."""
60
  user = users_db.get(user_id)
@@ -258,3 +258,98 @@ class TestPrompts:
258
  """
259
  prompts = await fastmcp_server.list_prompts()
260
  assert len(prompts) == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def fastapi_app(users_db: dict[int, User]) -> FastAPI:
37
  app = FastAPI(title="FastAPI App")
38
 
39
+ @app.get("/users", tags=["users", "list"])
40
  async def get_users() -> list[User]:
41
  """Get all users."""
42
  return sorted(users_db.values(), key=lambda x: x.id)
43
 
44
+ @app.get("/users/{user_id}", tags=["users", "detail"])
45
  async def get_user(user_id: int) -> User | None:
46
  """Get a user by ID."""
47
  return users_db.get(user_id)
48
 
49
+ @app.post("/users", tags=["users", "create"])
50
  async def create_user(user: UserCreate) -> User:
51
  """Create a new user."""
52
  user_id = max(users_db.keys()) + 1
 
54
  users_db[user_id] = new_user
55
  return new_user
56
 
57
+ @app.patch("/users/{user_id}/name", tags=["users", "update"])
58
  async def update_user_name(user_id: int, name: str) -> User:
59
  """Update a user's name."""
60
  user = users_db.get(user_id)
 
258
  """
259
  prompts = await fastmcp_server.list_prompts()
260
  assert len(prompts) == 0
261
+
262
+
263
+ class TestTagTransfer:
264
+ """Tests for transferring tags from OpenAPI to MCP objects."""
265
+
266
+ async def test_tags_transferred_to_tools(self, fastmcp_server: FastMCPOpenAPI):
267
+ """Test that tags from OpenAPI routes are correctly transferred to Tools."""
268
+ # Get internal tools directly (not the public API which returns MCP.Content)
269
+ tools = fastmcp_server._tool_manager.list_tools()
270
+
271
+ # Find the create_user and update_user_name tools
272
+ create_user_tool = next(
273
+ (t for t in tools if t.name == "create_user_users_post"), None
274
+ )
275
+ update_user_tool = next(
276
+ (
277
+ t
278
+ for t in tools
279
+ if t.name == "update_user_name_users__user_id__name_patch"
280
+ ),
281
+ None,
282
+ )
283
+
284
+ assert create_user_tool is not None
285
+ assert update_user_tool is not None
286
+
287
+ # Check that tags from OpenAPI routes were transferred to the Tool objects
288
+ assert "users" in create_user_tool.tags
289
+ assert "create" in create_user_tool.tags
290
+ assert len(create_user_tool.tags) == 2
291
+
292
+ assert "users" in update_user_tool.tags
293
+ assert "update" in update_user_tool.tags
294
+ assert len(update_user_tool.tags) == 2
295
+
296
+ async def test_tags_transferred_to_resources(self, fastmcp_server: FastMCPOpenAPI):
297
+ """Test that tags from OpenAPI routes are correctly transferred to Resources."""
298
+ # Get internal resources directly
299
+ resources = fastmcp_server._resource_manager.list_resources()
300
+
301
+ # Find the get_users resource
302
+ get_users_resource = next(
303
+ (r for r in resources if r.name == "get_users_users_get"), None
304
+ )
305
+
306
+ assert get_users_resource is not None
307
+
308
+ # Check that tags from OpenAPI routes were transferred to the Resource object
309
+ assert "users" in get_users_resource.tags
310
+ assert "list" in get_users_resource.tags
311
+ assert len(get_users_resource.tags) == 2
312
+
313
+ async def test_tags_transferred_to_resource_templates(
314
+ self, fastmcp_server: FastMCPOpenAPI
315
+ ):
316
+ """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
317
+ # Get internal resource templates directly
318
+ templates = fastmcp_server._resource_manager.list_templates()
319
+
320
+ # Find the get_user template
321
+ get_user_template = next(
322
+ (t for t in templates if t.name == "get_user_users__user_id__get"), None
323
+ )
324
+
325
+ assert get_user_template is not None
326
+
327
+ # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object
328
+ assert "users" in get_user_template.tags
329
+ assert "detail" in get_user_template.tags
330
+ assert len(get_user_template.tags) == 2
331
+
332
+ async def test_tags_preserved_in_resources_created_from_templates(
333
+ self, fastmcp_server: FastMCPOpenAPI
334
+ ):
335
+ """Test that tags are preserved when creating resources from templates."""
336
+ # Get internal resource templates directly
337
+ templates = fastmcp_server._resource_manager.list_templates()
338
+
339
+ # Find the get_user template
340
+ get_user_template = next(
341
+ (t for t in templates if t.name == "get_user_users__user_id__get"), None
342
+ )
343
+
344
+ assert get_user_template is not None
345
+
346
+ # Manually create a resource from template
347
+ params = {"user_id": 1}
348
+ resource = await get_user_template.create_resource(
349
+ "resource://openapi/get_user_users__user_id__get/1", params
350
+ )
351
+
352
+ # Verify tags are preserved from template to resource
353
+ assert "users" in resource.tags
354
+ assert "detail" in resource.tags
355
+ assert len(resource.tags) == 2
tests/server/test_server.py CHANGED
@@ -5,9 +5,6 @@ from typing import TYPE_CHECKING
5
 
6
  import pytest
7
  from mcp.shared.exceptions import McpError
8
- from mcp.shared.memory import (
9
- create_connected_server_and_client_session as client_session,
10
- )
11
  from mcp.types import (
12
  BlobResourceContents,
13
  ImageContent,
@@ -16,7 +13,8 @@ from mcp.types import (
16
  )
17
  from pydantic import AnyUrl, Field
18
 
19
- from fastmcp import Context, FastMCP
 
20
  from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
21
  from fastmcp.resources import FileResource, FunctionResource
22
  from fastmcp.utilities.types import Image
@@ -25,7 +23,7 @@ if TYPE_CHECKING:
25
  from fastmcp import Context
26
 
27
 
28
- class TestServer:
29
  async def test_create_server(self):
30
  mcp = FastMCP(instructions="Server instructions")
31
  assert mcp.name == "FastMCP"
@@ -43,18 +41,18 @@ class TestServer:
43
  def hello_world(name: str = "世界") -> str:
44
  return f"¡Hola, {name}! 👋"
45
 
46
- async with client_session(mcp._mcp_server) as client:
47
  tools = await client.list_tools()
48
- assert len(tools.tools) == 1
49
- tool = tools.tools[0]
50
  assert tool.description is not None
51
  assert "🌟" in tool.description
52
  assert "漢字" in tool.description
53
  assert "🎉" in tool.description
54
 
55
  result = await client.call_tool("hello_world", {})
56
- assert len(result.content) == 1
57
- content = result.content[0]
58
  assert isinstance(content, TextContent)
59
  assert "¡Hola, 世界! 👋" == content.text
60
 
@@ -97,171 +95,135 @@ class TestServer:
97
  return f"Data: {x}"
98
 
99
 
100
- def tool_fn(x: int, y: int) -> int:
101
- return x + y
102
-
103
 
104
- def tool_fn_list() -> list[str | int]:
105
- return ["x", 2]
 
106
 
 
 
 
107
 
108
- def error_tool_fn() -> None:
109
- raise ValueError("Test error")
 
110
 
 
 
 
111
 
112
- def image_tool_fn(path: str) -> Image:
113
- return Image(path)
 
 
 
 
114
 
 
 
 
 
 
 
 
 
115
 
116
- def mixed_content_tool_fn() -> list[TextContent | ImageContent]:
117
- return [
118
- TextContent(type="text", text="Hello"),
119
- ImageContent(type="image", data="abc", mimeType="image/png"),
120
- ]
121
 
122
 
123
  class TestServerTools:
124
- async def test_add_tool(self):
125
- mcp = FastMCP()
126
- mcp.add_tool(tool_fn)
127
- mcp.add_tool(tool_fn)
128
- assert len(mcp._tool_manager.list_tools()) == 1
129
-
130
- async def test_list_tools(self):
131
- mcp = FastMCP()
132
- mcp.add_tool(tool_fn)
133
- async with client_session(mcp._mcp_server) as client:
134
- tools = await client.list_tools()
135
- assert len(tools.tools) == 1
136
-
137
- async def test_call_tool(self):
138
- mcp = FastMCP()
139
- mcp.add_tool(tool_fn)
140
- async with client_session(mcp._mcp_server) as client:
141
- result = await client.call_tool("my_tool", {"arg1": "value"})
142
- assert not hasattr(result, "error")
143
- assert len(result.content) > 0
144
-
145
- async def test_tool_exception_handling(self):
146
- mcp = FastMCP()
147
- mcp.add_tool(error_tool_fn)
148
- async with client_session(mcp._mcp_server) as client:
149
- result = await client.call_tool("error_tool_fn", {})
150
- assert len(result.content) == 1
151
- content = result.content[0]
152
- assert isinstance(content, TextContent)
153
- assert "Test error" in content.text
154
- assert result.isError is True
155
-
156
- async def test_tool_error_handling(self):
157
- mcp = FastMCP()
158
- mcp.add_tool(error_tool_fn)
159
- async with client_session(mcp._mcp_server) as client:
160
- result = await client.call_tool("error_tool_fn", {})
161
- assert len(result.content) == 1
162
- content = result.content[0]
163
- assert isinstance(content, TextContent)
164
- assert "Test error" in content.text
165
- assert result.isError is True
166
-
167
- async def test_tool_error_details(self):
168
- """Test that exception details are properly formatted in the response"""
169
- mcp = FastMCP()
170
- mcp.add_tool(error_tool_fn)
171
- async with client_session(mcp._mcp_server) as client:
172
- result = await client.call_tool("error_tool_fn", {})
173
- content = result.content[0]
174
- assert isinstance(content, TextContent)
175
- assert isinstance(content.text, str)
176
- assert "Test error" in content.text
177
- assert result.isError is True
178
-
179
- async def test_tool_return_value_conversion(self):
180
- mcp = FastMCP()
181
- mcp.add_tool(tool_fn)
182
- async with client_session(mcp._mcp_server) as client:
183
- result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
184
- assert len(result.content) == 1
185
- content = result.content[0]
186
- assert isinstance(content, TextContent)
187
- assert content.text == "3"
188
-
189
- async def test_tool_returns_list(self):
190
- mcp = FastMCP()
191
- mcp.add_tool(tool_fn_list)
192
- async with client_session(mcp._mcp_server) as client:
193
- result = await client.call_tool("tool_fn_list", {})
194
- assert len(result.content) == 1
195
- content = result.content[0]
196
- assert isinstance(content, TextContent)
197
- assert json.loads(content.text) == ["x", 2]
198
-
199
- async def test_tool_image_helper(self, tmp_path: Path):
200
  # Create a test image
201
  image_path = tmp_path / "test.png"
202
  image_path.write_bytes(b"fake png data")
203
 
204
- mcp = FastMCP()
205
- mcp.add_tool(image_tool_fn)
206
- async with client_session(mcp._mcp_server) as client:
207
- result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
208
- assert len(result.content) == 1
209
- content = result.content[0]
210
- assert isinstance(content, ImageContent)
211
- assert content.type == "image"
212
- assert content.mimeType == "image/png"
213
- # Verify base64 encoding
214
- decoded = base64.b64decode(content.data)
215
- assert decoded == b"fake png data"
216
-
217
- async def test_tool_mixed_content(self):
218
- mcp = FastMCP()
219
- mcp.add_tool(mixed_content_tool_fn)
220
- async with client_session(mcp._mcp_server) as client:
221
- result = await client.call_tool("mixed_content_tool_fn", {})
222
-
223
- assert len(result.content) == 2
224
- content1 = result.content[0]
225
- content2 = result.content[1]
226
- assert isinstance(content1, TextContent)
227
- assert content1.text == "Hello"
228
- assert isinstance(content2, ImageContent)
229
- assert content2.mimeType == "image/png"
230
- assert content2.data == "abc"
231
-
232
- async def test_tool_mixed_list_with_image(self, tmp_path: Path):
233
  """Test that lists containing Image objects and other types are handled
234
  correctly. Note that the non-MCP content will be grouped together."""
235
  # Create a test image
236
  image_path = tmp_path / "test.png"
237
  image_path.write_bytes(b"test image data")
238
 
239
- def mixed_list_fn() -> list:
240
- return [
241
- "text message",
242
- Image(image_path),
243
- {"key": "value"},
244
- TextContent(type="text", text="direct content"),
245
- ]
246
-
247
- mcp = FastMCP()
248
- mcp.add_tool(mixed_list_fn)
249
- async with client_session(mcp._mcp_server) as client:
250
- result = await client.call_tool("mixed_list_fn", {})
251
- assert len(result.content) == 3
252
- # Check text conversion
253
- content1 = result.content[0]
254
- assert isinstance(content1, TextContent)
255
- assert json.loads(content1.text) == ["text message", {"key": "value"}]
256
- # Check image conversion
257
- content2 = result.content[1]
258
- assert isinstance(content2, ImageContent)
259
- assert content2.mimeType == "image/png"
260
- assert base64.b64decode(content2.data) == b"test image data"
261
- # Check direct TextContent
262
- content3 = result.content[2]
263
- assert isinstance(content3, TextContent)
264
- assert content3.text == "direct content"
265
 
266
  async def test_parameter_descriptions(self):
267
  mcp = FastMCP("Test Server")
@@ -298,10 +260,10 @@ class TestServerResources:
298
  )
299
  mcp.add_resource(resource)
300
 
301
- async with client_session(mcp._mcp_server) as client:
302
  result = await client.read_resource(AnyUrl("resource://test"))
303
- assert isinstance(result.contents[0], TextResourceContents)
304
- assert result.contents[0].text == "Hello, world!"
305
 
306
  async def test_binary_resource(self):
307
  mcp = FastMCP()
@@ -317,10 +279,10 @@ class TestServerResources:
317
  )
318
  mcp.add_resource(resource)
319
 
320
- async with client_session(mcp._mcp_server) as client:
321
  result = await client.read_resource(AnyUrl("resource://binary"))
322
- assert isinstance(result.contents[0], BlobResourceContents)
323
- assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
324
 
325
  async def test_file_resource_text(self, tmp_path: Path):
326
  mcp = FastMCP()
@@ -334,10 +296,10 @@ class TestServerResources:
334
  )
335
  mcp.add_resource(resource)
336
 
337
- async with client_session(mcp._mcp_server) as client:
338
  result = await client.read_resource(AnyUrl("file://test.txt"))
339
- assert isinstance(result.contents[0], TextResourceContents)
340
- assert result.contents[0].text == "Hello from file!"
341
 
342
  async def test_file_resource_binary(self, tmp_path: Path):
343
  mcp = FastMCP()
@@ -354,13 +316,10 @@ class TestServerResources:
354
  )
355
  mcp.add_resource(resource)
356
 
357
- async with client_session(mcp._mcp_server) as client:
358
  result = await client.read_resource(AnyUrl("file://test.bin"))
359
- assert isinstance(result.contents[0], BlobResourceContents)
360
- assert (
361
- result.contents[0].blob
362
- == base64.b64encode(b"Binary file data").decode()
363
- )
364
 
365
 
366
  class TestServerResourceTemplates:
@@ -401,10 +360,10 @@ class TestServerResourceTemplates:
401
  def get_data(name: str) -> str:
402
  return f"Data for {name}"
403
 
404
- async with client_session(mcp._mcp_server) as client:
405
  result = await client.read_resource(AnyUrl("resource://test/data"))
406
- assert isinstance(result.contents[0], TextResourceContents)
407
- assert result.contents[0].text == "Data for test"
408
 
409
  async def test_resource_mismatched_params(self):
410
  """Test that mismatched parameters raise an error"""
@@ -424,12 +383,12 @@ class TestServerResourceTemplates:
424
  def get_data(org: str, repo: str) -> str:
425
  return f"Data for {org}/{repo}"
426
 
427
- async with client_session(mcp._mcp_server) as client:
428
  result = await client.read_resource(
429
  AnyUrl("resource://cursor/fastmcp/data")
430
  )
431
- assert isinstance(result.contents[0], TextResourceContents)
432
- assert result.contents[0].text == "Data for cursor/fastmcp"
433
 
434
  async def test_resource_multiple_mismatched_params(self):
435
  """Test that mismatched parameters raise an error"""
@@ -448,10 +407,10 @@ class TestServerResourceTemplates:
448
  def get_static_data() -> str:
449
  return "Static data"
450
 
451
- async with client_session(mcp._mcp_server) as client:
452
  result = await client.read_resource(AnyUrl("resource://static"))
453
- assert isinstance(result.contents[0], TextResourceContents)
454
- assert result.contents[0].text == "Static data"
455
 
456
  async def test_template_to_resource_conversion(self):
457
  """Test that templates are properly converted to resources when accessed"""
@@ -494,10 +453,10 @@ class TestContextInjection:
494
  return f"Request {ctx.request_id}: {x}"
495
 
496
  mcp.add_tool(tool_with_context)
497
- async with client_session(mcp._mcp_server) as client:
498
  result = await client.call_tool("tool_with_context", {"x": 42})
499
- assert len(result.content) == 1
500
- content = result.content[0]
501
  assert isinstance(content, TextContent)
502
  assert "Request" in content.text
503
  assert "42" in content.text
@@ -511,10 +470,10 @@ class TestContextInjection:
511
  return f"Async request {ctx.request_id}: {x}"
512
 
513
  mcp.add_tool(async_tool)
514
- async with client_session(mcp._mcp_server) as client:
515
  result = await client.call_tool("async_tool", {"x": 42})
516
- assert len(result.content) == 1
517
- content = result.content[0]
518
  assert isinstance(content, TextContent)
519
  assert "Async request" in content.text
520
  assert "42" in content.text
@@ -537,10 +496,10 @@ class TestContextInjection:
537
  mcp.add_tool(logging_tool)
538
 
539
  with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
540
- async with client_session(mcp._mcp_server) as client:
541
  result = await client.call_tool("logging_tool", {"msg": "test"})
542
- assert len(result.content) == 1
543
- content = result.content[0]
544
  assert isinstance(content, TextContent)
545
  assert "Logged messages for test" in content.text
546
 
@@ -564,10 +523,10 @@ class TestContextInjection:
564
  return x * 2
565
 
566
  mcp.add_tool(no_context)
567
- async with client_session(mcp._mcp_server) as client:
568
  result = await client.call_tool("no_context", {"x": 21})
569
- assert len(result.content) == 1
570
- content = result.content[0]
571
  assert isinstance(content, TextContent)
572
  assert content.text == "42"
573
 
@@ -587,10 +546,10 @@ class TestContextInjection:
587
  r = r_list[0]
588
  return f"Read resource: {r.content} with mime type {r.mime_type}"
589
 
590
- async with client_session(mcp._mcp_server) as client:
591
  result = await client.call_tool("tool_with_resource", {})
592
- assert len(result.content) == 1
593
- content = result.content[0]
594
  assert isinstance(content, TextContent)
595
  assert "Read resource: resource data" in content.text
596
 
@@ -661,11 +620,11 @@ class TestServerPrompts:
661
  def fn(name: str, optional: str = "default") -> str:
662
  return f"Hello, {name}!"
663
 
664
- async with client_session(mcp._mcp_server) as client:
665
  result = await client.list_prompts()
666
- assert result.prompts is not None
667
- assert len(result.prompts) == 1
668
- prompt = result.prompts[0]
669
  assert prompt.name == "fn"
670
  assert prompt.arguments is not None
671
  assert len(prompt.arguments) == 2
@@ -682,7 +641,7 @@ class TestServerPrompts:
682
  def fn(name: str) -> str:
683
  return f"Hello, {name}!"
684
 
685
- async with client_session(mcp._mcp_server) as client:
686
  result = await client.get_prompt("fn", {"name": "World"})
687
  assert len(result.messages) == 1
688
  message = result.messages[0]
@@ -708,12 +667,10 @@ class TestServerPrompts:
708
  )
709
  )
710
 
711
- async with client_session(mcp._mcp_server) as client:
712
  result = await client.get_prompt("fn")
713
- assert len(result.messages) == 1
714
- message = result.messages[0]
715
- assert message.role == "user"
716
- content = message.content
717
  assert isinstance(content, EmbeddedResource)
718
  resource = content.resource
719
  assert isinstance(resource, TextResourceContents)
@@ -723,7 +680,7 @@ class TestServerPrompts:
723
  async def test_get_unknown_prompt(self):
724
  """Test error when getting unknown prompt."""
725
  mcp = FastMCP()
726
- async with client_session(mcp._mcp_server) as client:
727
  with pytest.raises(McpError, match="Unknown prompt"):
728
  await client.get_prompt("unknown")
729
 
@@ -735,7 +692,7 @@ class TestServerPrompts:
735
  def prompt_fn(name: str) -> str:
736
  return f"Hello, {name}!"
737
 
738
- async with client_session(mcp._mcp_server) as client:
739
  with pytest.raises(McpError, match="Missing required arguments"):
740
  await client.get_prompt("prompt_fn")
741
 
 
5
 
6
  import pytest
7
  from mcp.shared.exceptions import McpError
 
 
 
8
  from mcp.types import (
9
  BlobResourceContents,
10
  ImageContent,
 
13
  )
14
  from pydantic import AnyUrl, Field
15
 
16
+ from fastmcp import Client, Context, FastMCP
17
+ from fastmcp.exceptions import ToolError
18
  from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
19
  from fastmcp.resources import FileResource, FunctionResource
20
  from fastmcp.utilities.types import Image
 
23
  from fastmcp import Context
24
 
25
 
26
+ class TestCreateServer:
27
  async def test_create_server(self):
28
  mcp = FastMCP(instructions="Server instructions")
29
  assert mcp.name == "FastMCP"
 
41
  def hello_world(name: str = "世界") -> str:
42
  return f"¡Hola, {name}! 👋"
43
 
44
+ async with Client(mcp) as client:
45
  tools = await client.list_tools()
46
+ assert len(tools) == 1
47
+ tool = tools[0]
48
  assert tool.description is not None
49
  assert "🌟" in tool.description
50
  assert "漢字" in tool.description
51
  assert "🎉" in tool.description
52
 
53
  result = await client.call_tool("hello_world", {})
54
+ assert len(result) == 1
55
+ content = result[0]
56
  assert isinstance(content, TextContent)
57
  assert "¡Hola, 世界! 👋" == content.text
58
 
 
95
  return f"Data: {x}"
96
 
97
 
98
+ @pytest.fixture
99
+ def tool_server():
100
+ mcp = FastMCP()
101
 
102
+ @mcp.tool()
103
+ def add(x: int, y: int) -> int:
104
+ return x + y
105
 
106
+ @mcp.tool()
107
+ def list_tool() -> list[str | int]:
108
+ return ["x", 2]
109
 
110
+ @mcp.tool()
111
+ def error_tool() -> None:
112
+ raise ValueError("Test error")
113
 
114
+ @mcp.tool()
115
+ def image_tool(path: str) -> Image:
116
+ return Image(path)
117
 
118
+ @mcp.tool()
119
+ def mixed_content_tool() -> list[TextContent | ImageContent]:
120
+ return [
121
+ TextContent(type="text", text="Hello"),
122
+ ImageContent(type="image", data="abc", mimeType="image/png"),
123
+ ]
124
 
125
+ @mcp.tool()
126
+ def mixed_list_fn(image_path: str) -> list:
127
+ return [
128
+ "text message",
129
+ Image(image_path),
130
+ {"key": "value"},
131
+ TextContent(type="text", text="direct content"),
132
+ ]
133
 
134
+ return mcp
 
 
 
 
135
 
136
 
137
  class TestServerTools:
138
+ async def test_add_tool_exists(self, tool_server: FastMCP):
139
+ assert "add" in [t.name for t in await tool_server.list_tools()]
140
+
141
+ async def test_list_tools(self, tool_server: FastMCP):
142
+ assert len(await tool_server.list_tools()) == 6
143
+
144
+ async def test_call_tool(self, tool_server: FastMCP):
145
+ result = await tool_server.call_tool("add", {"x": 1, "y": 2})
146
+ assert isinstance(result[0], TextContent)
147
+ assert result[0].text == "3"
148
+
149
+ async def test_call_tool_as_client(self, tool_server: FastMCP):
150
+ async with Client(tool_server) as client:
151
+ result = await client.call_tool("add", {"x": 1, "y": 2})
152
+ assert isinstance(result[0], TextContent)
153
+ assert result[0].text == "3"
154
+
155
+ async def test_call_tool_error(self, tool_server: FastMCP):
156
+ with pytest.raises(ToolError):
157
+ await tool_server.call_tool("error_tool", {})
158
+
159
+ async def test_call_tool_error_as_client(self, tool_server: FastMCP):
160
+ async with Client(tool_server) as client:
161
+ with pytest.raises(Exception):
162
+ await client.call_tool("error_tool", {})
163
+
164
+ async def test_call_tool_error_as_client_raw(self, tool_server: FastMCP):
165
+ async with Client(tool_server) as client:
166
+ result = await client.call_tool("error_tool", {}, _return_raw_result=True)
167
+ assert result.isError
168
+ assert isinstance(result.content[0], TextContent)
169
+ assert "Test error" in result.content[0].text
170
+
171
+ async def test_tool_returns_list(self, tool_server: FastMCP):
172
+ result = await tool_server.call_tool("list_tool", {})
173
+ assert isinstance(result[0], TextContent)
174
+ assert result[0].text == '["x", 2]'
175
+
176
+ async def test_tool_image_helper(self, tool_server: FastMCP, tmp_path: Path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  # Create a test image
178
  image_path = tmp_path / "test.png"
179
  image_path.write_bytes(b"fake png data")
180
 
181
+ result = await tool_server.call_tool("image_tool", {"path": str(image_path)})
182
+ content = result[0]
183
+ assert isinstance(content, ImageContent)
184
+ assert content.type == "image"
185
+ assert content.mimeType == "image/png"
186
+ # Verify base64 encoding
187
+ decoded = base64.b64decode(content.data)
188
+ assert decoded == b"fake png data"
189
+
190
+ async def test_tool_mixed_content(self, tool_server: FastMCP):
191
+ result = await tool_server.call_tool("mixed_content_tool", {})
192
+ assert len(result) == 2
193
+ content1 = result[0]
194
+ content2 = result[1]
195
+ assert isinstance(content1, TextContent)
196
+ assert content1.text == "Hello"
197
+ assert isinstance(content2, ImageContent)
198
+ assert content2.mimeType == "image/png"
199
+ assert content2.data == "abc"
200
+
201
+ async def test_tool_mixed_list_with_image(
202
+ self, tool_server: FastMCP, tmp_path: Path
203
+ ):
 
 
 
 
 
 
204
  """Test that lists containing Image objects and other types are handled
205
  correctly. Note that the non-MCP content will be grouped together."""
206
  # Create a test image
207
  image_path = tmp_path / "test.png"
208
  image_path.write_bytes(b"test image data")
209
 
210
+ result = await tool_server.call_tool(
211
+ "mixed_list_fn", {"image_path": str(image_path)}
212
+ )
213
+ assert len(result) == 3
214
+ # Check text conversion
215
+ content1 = result[0]
216
+ assert isinstance(content1, TextContent)
217
+ assert json.loads(content1.text) == ["text message", {"key": "value"}]
218
+ # Check image conversion
219
+ content2 = result[1]
220
+ assert isinstance(content2, ImageContent)
221
+ assert content2.mimeType == "image/png"
222
+ assert base64.b64decode(content2.data) == b"test image data"
223
+ # Check direct TextContent
224
+ content3 = result[2]
225
+ assert isinstance(content3, TextContent)
226
+ assert content3.text == "direct content"
 
 
 
 
 
 
 
 
 
227
 
228
  async def test_parameter_descriptions(self):
229
  mcp = FastMCP("Test Server")
 
260
  )
261
  mcp.add_resource(resource)
262
 
263
+ async with Client(mcp) as client:
264
  result = await client.read_resource(AnyUrl("resource://test"))
265
+ assert isinstance(result[0], TextResourceContents)
266
+ assert result[0].text == "Hello, world!"
267
 
268
  async def test_binary_resource(self):
269
  mcp = FastMCP()
 
279
  )
280
  mcp.add_resource(resource)
281
 
282
+ async with Client(mcp) as client:
283
  result = await client.read_resource(AnyUrl("resource://binary"))
284
+ assert isinstance(result[0], BlobResourceContents)
285
+ assert result[0].blob == base64.b64encode(b"Binary data").decode()
286
 
287
  async def test_file_resource_text(self, tmp_path: Path):
288
  mcp = FastMCP()
 
296
  )
297
  mcp.add_resource(resource)
298
 
299
+ async with Client(mcp) as client:
300
  result = await client.read_resource(AnyUrl("file://test.txt"))
301
+ assert isinstance(result[0], TextResourceContents)
302
+ assert result[0].text == "Hello from file!"
303
 
304
  async def test_file_resource_binary(self, tmp_path: Path):
305
  mcp = FastMCP()
 
316
  )
317
  mcp.add_resource(resource)
318
 
319
+ async with Client(mcp) as client:
320
  result = await client.read_resource(AnyUrl("file://test.bin"))
321
+ assert isinstance(result[0], BlobResourceContents)
322
+ assert result[0].blob == base64.b64encode(b"Binary file data").decode()
 
 
 
323
 
324
 
325
  class TestServerResourceTemplates:
 
360
  def get_data(name: str) -> str:
361
  return f"Data for {name}"
362
 
363
+ async with Client(mcp) as client:
364
  result = await client.read_resource(AnyUrl("resource://test/data"))
365
+ assert isinstance(result[0], TextResourceContents)
366
+ assert result[0].text == "Data for test"
367
 
368
  async def test_resource_mismatched_params(self):
369
  """Test that mismatched parameters raise an error"""
 
383
  def get_data(org: str, repo: str) -> str:
384
  return f"Data for {org}/{repo}"
385
 
386
+ async with Client(mcp) as client:
387
  result = await client.read_resource(
388
  AnyUrl("resource://cursor/fastmcp/data")
389
  )
390
+ assert isinstance(result[0], TextResourceContents)
391
+ assert result[0].text == "Data for cursor/fastmcp"
392
 
393
  async def test_resource_multiple_mismatched_params(self):
394
  """Test that mismatched parameters raise an error"""
 
407
  def get_static_data() -> str:
408
  return "Static data"
409
 
410
+ async with Client(mcp) as client:
411
  result = await client.read_resource(AnyUrl("resource://static"))
412
+ assert isinstance(result[0], TextResourceContents)
413
+ assert result[0].text == "Static data"
414
 
415
  async def test_template_to_resource_conversion(self):
416
  """Test that templates are properly converted to resources when accessed"""
 
453
  return f"Request {ctx.request_id}: {x}"
454
 
455
  mcp.add_tool(tool_with_context)
456
+ async with Client(mcp) as client:
457
  result = await client.call_tool("tool_with_context", {"x": 42})
458
+ assert len(result) == 1
459
+ content = result[0]
460
  assert isinstance(content, TextContent)
461
  assert "Request" in content.text
462
  assert "42" in content.text
 
470
  return f"Async request {ctx.request_id}: {x}"
471
 
472
  mcp.add_tool(async_tool)
473
+ async with Client(mcp) as client:
474
  result = await client.call_tool("async_tool", {"x": 42})
475
+ assert len(result) == 1
476
+ content = result[0]
477
  assert isinstance(content, TextContent)
478
  assert "Async request" in content.text
479
  assert "42" in content.text
 
496
  mcp.add_tool(logging_tool)
497
 
498
  with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
499
+ async with Client(mcp) as client:
500
  result = await client.call_tool("logging_tool", {"msg": "test"})
501
+ assert len(result) == 1
502
+ content = result[0]
503
  assert isinstance(content, TextContent)
504
  assert "Logged messages for test" in content.text
505
 
 
523
  return x * 2
524
 
525
  mcp.add_tool(no_context)
526
+ async with Client(mcp) as client:
527
  result = await client.call_tool("no_context", {"x": 21})
528
+ assert len(result) == 1
529
+ content = result[0]
530
  assert isinstance(content, TextContent)
531
  assert content.text == "42"
532
 
 
546
  r = r_list[0]
547
  return f"Read resource: {r.content} with mime type {r.mime_type}"
548
 
549
+ async with Client(mcp) as client:
550
  result = await client.call_tool("tool_with_resource", {})
551
+ assert len(result) == 1
552
+ content = result[0]
553
  assert isinstance(content, TextContent)
554
  assert "Read resource: resource data" in content.text
555
 
 
620
  def fn(name: str, optional: str = "default") -> str:
621
  return f"Hello, {name}!"
622
 
623
+ async with Client(mcp) as client:
624
  result = await client.list_prompts()
625
+ assert result is not None
626
+ assert len(result) == 1
627
+ prompt = result[0]
628
  assert prompt.name == "fn"
629
  assert prompt.arguments is not None
630
  assert len(prompt.arguments) == 2
 
641
  def fn(name: str) -> str:
642
  return f"Hello, {name}!"
643
 
644
+ async with Client(mcp) as client:
645
  result = await client.get_prompt("fn", {"name": "World"})
646
  assert len(result.messages) == 1
647
  message = result.messages[0]
 
667
  )
668
  )
669
 
670
+ async with Client(mcp) as client:
671
  result = await client.get_prompt("fn")
672
+ assert result.messages[0].role == "user"
673
+ content = result.messages[0].content
 
 
674
  assert isinstance(content, EmbeddedResource)
675
  resource = content.resource
676
  assert isinstance(resource, TextResourceContents)
 
680
  async def test_get_unknown_prompt(self):
681
  """Test error when getting unknown prompt."""
682
  mcp = FastMCP()
683
+ async with Client(mcp) as client:
684
  with pytest.raises(McpError, match="Unknown prompt"):
685
  await client.get_prompt("unknown")
686
 
 
692
  def prompt_fn(name: str) -> str:
693
  return f"Hello, {name}!"
694
 
695
+ async with Client(mcp) as client:
696
  with pytest.raises(McpError, match="Missing required arguments"):
697
  await client.get_prompt("prompt_fn")
698
 
tests/utilities/openapi/test_openapi.py CHANGED
@@ -460,6 +460,63 @@ def test_petstore_required_fields_resolution(parsed_petstore_routes):
460
  assert json_schema.get("required") == ["id", "name"]
461
 
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  # --- Tests for BookStore schema --- #
464
 
465
 
 
460
  assert json_schema.get("required") == ["id", "name"]
461
 
462
 
463
+ def test_tags_parsing_in_petstore_routes(parsed_petstore_routes):
464
+ """Test that tags are correctly parsed from the OpenAPI schema."""
465
+ # All petstore routes should have the "pets" tag
466
+ for route in parsed_petstore_routes:
467
+ assert "pets" in route.tags, (
468
+ f"Route {route.method} {route.path} is missing 'pets' tag"
469
+ )
470
+
471
+
472
+ def test_tag_list_structure(parsed_petstore_routes):
473
+ """Test that tags are stored as a list of strings."""
474
+ for route in parsed_petstore_routes:
475
+ assert isinstance(route.tags, list), "Tags should be stored as a list"
476
+ for tag in route.tags:
477
+ assert isinstance(tag, str), "Each tag should be a string"
478
+
479
+
480
+ def test_empty_tags_handling(bookstore_schema):
481
+ """Test that routes with no tags are handled correctly with empty lists."""
482
+ # Modify a route to remove tags
483
+ if "tags" in bookstore_schema["paths"]["/books"]["get"]:
484
+ del bookstore_schema["paths"]["/books"]["get"]["tags"]
485
+
486
+ # Parse the modified schema
487
+ routes = parse_openapi_to_http_routes(bookstore_schema)
488
+
489
+ # Find the GET /books route
490
+ get_books = next(
491
+ (r for r in routes if r.method == "GET" and r.path == "/books"), None
492
+ )
493
+ assert get_books is not None
494
+
495
+ # Should have an empty list, not None
496
+ assert get_books.tags == [], "Routes without tags should have empty tag lists"
497
+
498
+
499
+ def test_multiple_tags_preserved(bookstore_schema):
500
+ """Test that multiple tags are preserved during parsing."""
501
+ # Add multiple tags to a route
502
+ bookstore_schema["paths"]["/books"]["get"]["tags"] = ["books", "catalog", "api"]
503
+
504
+ # Parse the modified schema
505
+ routes = parse_openapi_to_http_routes(bookstore_schema)
506
+
507
+ # Find the GET /books route
508
+ get_books = next(
509
+ (r for r in routes if r.method == "GET" and r.path == "/books"), None
510
+ )
511
+ assert get_books is not None
512
+
513
+ # Should have all tags
514
+ assert "books" in get_books.tags
515
+ assert "catalog" in get_books.tags
516
+ assert "api" in get_books.tags
517
+ assert len(get_books.tags) == 3
518
+
519
+
520
  # --- Tests for BookStore schema --- #
521
 
522
 
tests/utilities/openapi/test_openapi_fastapi.py CHANGED
@@ -432,3 +432,91 @@ def test_token_dependency_handling(route_map):
432
  token_headers = [p for p in header_params if p.name == "x-token"]
433
  assert len(token_headers) == 1, f"Expected x-token header in {op_id}"
434
  assert token_headers[0].required is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  token_headers = [p for p in header_params if p.name == "x-token"]
433
  assert len(token_headers) == 1, f"Expected x-token header in {op_id}"
434
  assert token_headers[0].required is True
435
+
436
+
437
+ # --- Additional Tag-related Tests --- #
438
+
439
+
440
+ def test_all_routes_have_tags(parsed_routes):
441
+ """Test that all routes have a non-empty tags list."""
442
+ for route in parsed_routes:
443
+ assert hasattr(route, "tags"), f"Route {route.path} should have tags attribute"
444
+ assert route.tags is not None, f"Route {route.path} tags should not be None"
445
+ # FastAPI adds tags to all routes in our test fixture
446
+ assert len(route.tags) > 0, f"Route {route.path} should have at least one tag"
447
+
448
+
449
+ def test_tag_consistency_across_related_endpoints(route_map):
450
+ """Test that related endpoints have consistent tags."""
451
+ # All item endpoints should have the "items" tag
452
+ item_endpoints = [
453
+ "list_items",
454
+ "create_item",
455
+ "get_item",
456
+ "update_item",
457
+ "delete_item",
458
+ ]
459
+ for endpoint in item_endpoints:
460
+ assert "items" in route_map[endpoint].tags, (
461
+ f"Endpoint {endpoint} should have 'items' tag"
462
+ )
463
+
464
+ # Tag-related endpoints should have both "items" and "tags" tags
465
+ tag_endpoints = ["update_item_tags", "get_item_tag"]
466
+ for endpoint in tag_endpoints:
467
+ assert "items" in route_map[endpoint].tags, (
468
+ f"Endpoint {endpoint} should have 'items' tag"
469
+ )
470
+ assert "tags" in route_map[endpoint].tags, (
471
+ f"Endpoint {endpoint} should have 'tags' tag"
472
+ )
473
+
474
+
475
+ def test_tag_order_preservation(fastapi_server):
476
+ """Test that tag order is preserved in the parsed routes."""
477
+
478
+ # Add a new endpoint with specifically ordered tags
479
+ @fastapi_server.get(
480
+ "/test-tag-order",
481
+ tags=["first", "second", "third"],
482
+ operation_id="test_tag_order",
483
+ )
484
+ async def test_tag_order():
485
+ return {"result": "testing tag order"}
486
+
487
+ # Get the updated schema and parse routes
488
+ routes = parse_openapi_to_http_routes(fastapi_server.openapi())
489
+
490
+ # Find our test route
491
+ test_route = next((r for r in routes if r.path == "/test-tag-order"), None)
492
+ assert test_route is not None
493
+
494
+ # Check tag order is preserved
495
+ assert test_route.tags == ["first", "second", "third"], (
496
+ "Tag order should be preserved"
497
+ )
498
+
499
+
500
+ def test_duplicate_tags_handling(fastapi_server):
501
+ """Test handling of duplicate tags in the OpenAPI schema."""
502
+
503
+ # Add an endpoint with duplicate tags
504
+ @fastapi_server.get(
505
+ "/test-duplicate-tags",
506
+ tags=["duplicate", "items", "duplicate"],
507
+ operation_id="test_duplicate_tags",
508
+ )
509
+ async def test_duplicate_tags():
510
+ return {"result": "testing duplicate tags"}
511
+
512
+ # Get the updated schema and parse routes
513
+ routes = parse_openapi_to_http_routes(fastapi_server.openapi())
514
+
515
+ # Find our test route
516
+ test_route = next((r for r in routes if r.path == "/test-duplicate-tags"), None)
517
+ assert test_route is not None
518
+
519
+ # Check that duplicate tags are preserved (FastAPI might deduplicate)
520
+ # We'll test both possibilities to be safe
521
+ assert "duplicate" in test_route.tags, "Tag 'duplicate' should be present"
522
+ assert "items" in test_route.tags, "Tag 'items' should be present"