Jeremiah Lowin commited on
Commit
9822b73
·
1 Parent(s): 48dfb8a

Ensure that openapi tags are transferred to MCP objects

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
@@ -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](
 
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](
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/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"