Jeremiah Lowin commited on
Commit
70f2db7
·
1 Parent(s): 9c8a530

Ensure objects are copied properly and test mounting fastapi

Browse files
src/fastmcp/prompts/prompt.py CHANGED
@@ -8,7 +8,6 @@ from typing import Annotated, Any, Literal
8
  import pydantic_core
9
  from mcp.types import EmbeddedResource, ImageContent, TextContent
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
- from typing_extensions import Self
12
 
13
  from fastmcp.utilities.types import _convert_set_defaults
14
 
@@ -163,13 +162,6 @@ class Prompt(BaseModel):
163
  except Exception as e:
164
  raise ValueError(f"Error rendering prompt {self.name}: {e}")
165
 
166
- def copy(self, updates: dict[str, Any] | None = None) -> Self:
167
- """Copy the prompt with optional updates."""
168
- data = self.model_dump()
169
- if updates:
170
- data.update(updates)
171
- return type(self)(**data)
172
-
173
  def __eq__(self, other: object) -> bool:
174
  if not isinstance(other, Prompt):
175
  return False
 
8
  import pydantic_core
9
  from mcp.types import EmbeddedResource, ImageContent, TextContent
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
 
11
 
12
  from fastmcp.utilities.types import _convert_set_defaults
13
 
 
162
  except Exception as e:
163
  raise ValueError(f"Error rendering prompt {self.name}: {e}")
164
 
 
 
 
 
 
 
 
165
  def __eq__(self, other: object) -> bool:
166
  if not isinstance(other, Prompt):
167
  return False
src/fastmcp/prompts/prompt_manager.py CHANGED
@@ -1,5 +1,6 @@
1
  """Prompt management functionality."""
2
 
 
3
  from collections.abc import Awaitable, Callable
4
  from typing import Any
5
 
@@ -84,7 +85,8 @@ class PromptManager:
84
  # Create prefixed name
85
  prefixed_name = f"{prefix}{name}" if prefix else name
86
 
87
- new_prompt = prompt.copy(updates=dict(name=prefixed_name))
 
88
 
89
  # Store the prompt with the prefixed name
90
  self.add_prompt(new_prompt)
 
1
  """Prompt management functionality."""
2
 
3
+ import copy
4
  from collections.abc import Awaitable, Callable
5
  from typing import Any
6
 
 
85
  # Create prefixed name
86
  prefixed_name = f"{prefix}{name}" if prefix else name
87
 
88
+ new_prompt = copy.copy(prompt)
89
+ new_prompt.name = prefixed_name
90
 
91
  # Store the prompt with the prefixed name
92
  self.add_prompt(new_prompt)
src/fastmcp/resources/resource.py CHANGED
@@ -1,7 +1,7 @@
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
- from typing import Annotated, Any
5
 
6
  from pydantic import (
7
  AnyUrl,
@@ -13,7 +13,6 @@ from pydantic import (
13
  ValidationInfo,
14
  field_validator,
15
  )
16
- from typing_extensions import Self
17
 
18
  from fastmcp.utilities.types import _convert_set_defaults
19
 
@@ -54,13 +53,6 @@ class Resource(BaseModel, abc.ABC):
54
  """Read the resource content."""
55
  pass
56
 
57
- def copy(self, updates: dict[str, Any] | None = None) -> Self:
58
- """Copy the resource with optional updates."""
59
- data = self.model_dump()
60
- if updates:
61
- data.update(updates)
62
- return type(self)(**data)
63
-
64
  def __eq__(self, other: object) -> bool:
65
  if not isinstance(other, Resource):
66
  return False
 
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
+ from typing import Annotated
5
 
6
  from pydantic import (
7
  AnyUrl,
 
13
  ValidationInfo,
14
  field_validator,
15
  )
 
16
 
17
  from fastmcp.utilities.types import _convert_set_defaults
18
 
 
53
  """Read the resource content."""
54
  pass
55
 
 
 
 
 
 
 
 
56
  def __eq__(self, other: object) -> bool:
57
  if not isinstance(other, Resource):
58
  return False
src/fastmcp/resources/resource_manager.py CHANGED
@@ -1,5 +1,6 @@
1
  """Resource manager functionality."""
2
 
 
3
  import inspect
4
  import re
5
  from collections.abc import Callable
@@ -238,7 +239,8 @@ class ResourceManager:
238
  # Create prefixed URI and copy the resource with the new URI
239
  prefixed_uri = f"{prefix}{uri}" if prefix else uri
240
 
241
- new_resource = resource.copy(updates=dict(uri=prefixed_uri))
 
242
 
243
  # Store directly in resources dictionary
244
  self.add_resource(new_resource)
@@ -266,9 +268,8 @@ class ResourceManager:
266
  f"{prefix}{uri_template}" if prefix else uri_template
267
  )
268
 
269
- new_template = template.copy(
270
- updates=dict(uri_template=prefixed_uri_template)
271
- )
272
 
273
  # Store directly in templates dictionary
274
  self.add_template(new_template)
 
1
  """Resource manager functionality."""
2
 
3
+ import copy
4
  import inspect
5
  import re
6
  from collections.abc import Callable
 
239
  # Create prefixed URI and copy the resource with the new URI
240
  prefixed_uri = f"{prefix}{uri}" if prefix else uri
241
 
242
+ new_resource = copy.copy(resource)
243
+ new_resource.uri = AnyUrl(prefixed_uri)
244
 
245
  # Store directly in resources dictionary
246
  self.add_resource(new_resource)
 
268
  f"{prefix}{uri_template}" if prefix else uri_template
269
  )
270
 
271
+ new_template = copy.copy(template)
272
+ new_template.uri_template = prefixed_uri_template
 
273
 
274
  # Store directly in templates dictionary
275
  self.add_template(new_template)
src/fastmcp/resources/template.py CHANGED
@@ -8,7 +8,6 @@ from collections.abc import Callable
8
  from typing import Annotated, Any
9
 
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
- from typing_extensions import Self
12
 
13
  from fastmcp.resources.types import FunctionResource, Resource
14
  from fastmcp.utilities.types import _convert_set_defaults
@@ -92,13 +91,6 @@ class ResourceTemplate(BaseModel):
92
  except Exception as e:
93
  raise ValueError(f"Error creating resource from template: {e}")
94
 
95
- def copy(self, updates: dict[str, Any] | None = None) -> Self:
96
- """Copy the resource template with optional updates."""
97
- data = self.model_dump()
98
- if updates:
99
- data.update(updates)
100
- return type(self)(**data)
101
-
102
  def __eq__(self, other: object) -> bool:
103
  if not isinstance(other, ResourceTemplate):
104
  return False
 
8
  from typing import Annotated, Any
9
 
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
 
11
 
12
  from fastmcp.resources.types import FunctionResource, Resource
13
  from fastmcp.utilities.types import _convert_set_defaults
 
91
  except Exception as e:
92
  raise ValueError(f"Error creating resource from template: {e}")
93
 
 
 
 
 
 
 
 
94
  def __eq__(self, other: object) -> bool:
95
  if not isinstance(other, ResourceTemplate):
96
  return False
src/fastmcp/tools/tool.py CHANGED
@@ -5,7 +5,6 @@ from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
  from pydantic import BaseModel, BeforeValidator, Field
8
- from typing_extensions import Self
9
 
10
  from fastmcp.exceptions import ToolError
11
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
@@ -102,13 +101,6 @@ class Tool(BaseModel):
102
  except Exception as e:
103
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
104
 
105
- def copy(self, updates: dict[str, Any] | None = None) -> Self:
106
- """Copy the tool with optional updates."""
107
- data = self.model_dump()
108
- if updates:
109
- data.update(updates)
110
- return type(self)(**data)
111
-
112
  def __eq__(self, other: object) -> bool:
113
  if not isinstance(other, Tool):
114
  return False
 
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
  from pydantic import BaseModel, BeforeValidator, Field
 
8
 
9
  from fastmcp.exceptions import ToolError
10
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
 
101
  except Exception as e:
102
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
103
 
 
 
 
 
 
 
 
104
  def __eq__(self, other: object) -> bool:
105
  if not isinstance(other, Tool):
106
  return False
src/fastmcp/tools/tool_manager.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations as _annotations
2
 
 
3
  from collections.abc import Callable
4
  from typing import TYPE_CHECKING, Any
5
 
@@ -90,7 +91,9 @@ class ToolManager:
90
  for name, tool in tool_manager._tools.items():
91
  prefixed_name = f"{prefix}{name}" if prefix else name
92
 
93
- new_tool = tool.copy(updates=dict(name=prefixed_name))
 
 
94
  # Store the copied tool
95
  self.add_tool(new_tool)
96
  logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')
 
1
  from __future__ import annotations as _annotations
2
 
3
+ import copy
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Any
6
 
 
91
  for name, tool in tool_manager._tools.items():
92
  prefixed_name = f"{prefix}{name}" if prefix else name
93
 
94
+ new_tool = copy.copy(tool)
95
+ new_tool.name = prefixed_name
96
+
97
  # Store the copied tool
98
  self.add_tool(new_tool)
99
  logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')
src/fastmcp/utilities/openapi.py CHANGED
@@ -150,93 +150,6 @@ def _resolve_ref(
150
  return item
151
 
152
 
153
- def _extract_schema_as_dict(
154
- schema_obj: Schema | Reference, openapi: OpenAPI
155
- ) -> JsonSchema:
156
- """Resolves a schema/reference and returns it as a dictionary."""
157
- resolved_schema = _resolve_ref(schema_obj, openapi)
158
- if isinstance(resolved_schema, Schema):
159
- # Using exclude_none=True might be better than exclude_unset sometimes
160
- return resolved_schema.model_dump(mode="json", by_alias=True, exclude_none=True)
161
- elif isinstance(resolved_schema, dict):
162
- logger.warning(
163
- "Resolved schema reference resulted in a dict, not a Schema model."
164
- )
165
- return resolved_schema
166
- else:
167
- ref_str = getattr(schema_obj, "ref", "unknown")
168
- logger.warning(
169
- f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
170
- )
171
- return {}
172
-
173
-
174
- def _convert_to_parameter_location(param_in: str) -> ParameterLocation:
175
- """Convert string parameter location to our ParameterLocation type."""
176
- if param_in == "path":
177
- return "path"
178
- elif param_in == "query":
179
- return "query"
180
- elif param_in == "header":
181
- return "header"
182
- elif param_in == "cookie":
183
- return "cookie"
184
- else:
185
- logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
186
- return "query"
187
-
188
-
189
- def _extract_responses(
190
- operation_responses: dict[str, Response | Reference] | None,
191
- openapi: OpenAPI,
192
- ) -> dict[str, ResponseInfo]:
193
- """Extracts and resolves response information for an operation."""
194
- extracted_responses: dict[str, ResponseInfo] = {}
195
- if not operation_responses:
196
- return extracted_responses
197
-
198
- for status_code, resp_or_ref in operation_responses.items():
199
- try:
200
- response = cast(Response, _resolve_ref(resp_or_ref, openapi))
201
- if not isinstance(response, Response):
202
- ref_str = getattr(resp_or_ref, "ref", "unknown")
203
- logger.warning(
204
- f"Expected Response after resolving ref '{ref_str}' for status code {status_code}, got {type(response)}. Skipping."
205
- )
206
- continue
207
-
208
- content_schemas: dict[str, JsonSchema] = {}
209
- if response.content:
210
- for media_type_str, media_type_obj in response.content.items():
211
- if (
212
- isinstance(media_type_obj, MediaType)
213
- and media_type_obj.media_type_schema
214
- ):
215
- try:
216
- schema_dict = _extract_schema_as_dict(
217
- media_type_obj.media_type_schema, openapi
218
- )
219
- content_schemas[media_type_str] = schema_dict
220
- except ValueError as schema_err:
221
- logger.error(
222
- f"Failed to extract schema for media type '{media_type_str}' in response {status_code}: {schema_err}"
223
- )
224
-
225
- resp_info = ResponseInfo(
226
- description=response.description, content_schema=content_schemas
227
- )
228
- extracted_responses[str(status_code)] = resp_info
229
-
230
- except (ValidationError, ValueError, AttributeError) as e:
231
- ref_name = getattr(resp_or_ref, "ref", "unknown")
232
- logger.error(
233
- f"Failed to extract response for status code {status_code} (ref: '{ref_name}'): {e}",
234
- exc_info=False,
235
- )
236
-
237
- return extracted_responses
238
-
239
-
240
  # --- Main Parsing Function ---
241
  # (No changes needed in the main loop logic, only in the helpers it calls)
242
  def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
 
150
  return item
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  # --- Main Parsing Function ---
154
  # (No changes needed in the main loop logic, only in the helpers it calls)
155
  def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
tests/server/test_openapi.py CHANGED
@@ -73,7 +73,7 @@ def api_client(fastapi_app: FastAPI) -> AsyncClient:
73
 
74
 
75
  @pytest.fixture
76
- async def fastmcp_server(
77
  fastapi_app: FastAPI, api_client: httpx.AsyncClient
78
  ) -> FastMCPOpenAPI:
79
  openapi_spec = fastapi_app.openapi()
@@ -113,11 +113,11 @@ async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
113
 
114
 
115
  class TestTools:
116
- async def test_list_tools(self, fastmcp_server: FastMCPOpenAPI):
117
  """
118
  By default, tools exclude GET methods
119
  """
120
- tools = await fastmcp_server._mcp_list_tools()
121
  assert len(tools) == 2
122
 
123
  assert tools[0].model_dump() == dict(
@@ -148,12 +148,12 @@ class TestTools:
148
  )
149
 
150
  async def test_call_create_user_tool(
151
- self, fastmcp_server: FastMCPOpenAPI, api_client
152
  ):
153
  """
154
  The tool created by the OpenAPI server should be the same as the original
155
  """
156
- tool_response = await fastmcp_server.call_tool(
157
  "create_user_users_post", {"name": "David", "active": False}
158
  )
159
  assert tool_response == User(id=4, name="David", active=False)
@@ -164,19 +164,19 @@ class TestTools:
164
  assert len(response.json()) == 4
165
 
166
  # Check that the user was created via MCP
167
- user_response = await fastmcp_server._mcp_read_resource(
168
  "resource://openapi/get_user_users__user_id__get/4"
169
  )
170
  user = user_response[0].content
171
  assert user == tool_response.model_dump()
172
 
173
  async def test_call_update_user_name_tool(
174
- self, fastmcp_server: FastMCPOpenAPI, api_client
175
  ):
176
  """
177
  The tool created by the OpenAPI server should be the same as the original
178
  """
179
- tool_response = await fastmcp_server.call_tool(
180
  "update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"}
181
  )
182
  assert tool_response == dict(id=1, name="XYZ", active=True)
@@ -186,7 +186,7 @@ class TestTools:
186
  assert dict(id=1, name="XYZ", active=True) in response.json()
187
 
188
  # Check that the user was updated via MCP
189
- user_response = await fastmcp_server._mcp_read_resource(
190
  "resource://openapi/get_user_users__user_id__get/1"
191
  )
192
  user = user_response[0].content
@@ -194,17 +194,20 @@ class TestTools:
194
 
195
 
196
  class TestResources:
197
- async def test_list_resources(self, fastmcp_server: FastMCPOpenAPI):
198
  """
199
  By default, resources exclude GET methods without parameters
200
  """
201
- resources = await fastmcp_server._mcp_list_resources()
202
  assert len(resources) == 1
203
  assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
204
  assert resources[0].name == "get_users_users_get"
205
 
206
  async def test_get_resource(
207
- self, fastmcp_server: FastMCPOpenAPI, api_client, users_db: dict[int, User]
 
 
 
208
  ):
209
  """
210
  The resource created by the OpenAPI server should be the same as the original
@@ -212,7 +215,7 @@ class TestResources:
212
  json_users = TypeAdapter(list[User]).dump_python(
213
  sorted(users_db.values(), key=lambda x: x.id)
214
  )
215
- resource_response = await fastmcp_server._mcp_read_resource(
216
  "resource://openapi/get_users_users_get"
217
  )
218
  resource = resource_response[0].content
@@ -222,11 +225,13 @@ class TestResources:
222
 
223
 
224
  class TestResourceTemplates:
225
- async def test_list_resource_templates(self, fastmcp_server: FastMCPOpenAPI):
 
 
226
  """
227
  By default, resource templates exclude GET methods without parameters
228
  """
229
- resource_templates = await fastmcp_server._mcp_list_resource_templates()
230
  assert len(resource_templates) == 1
231
  assert resource_templates[0].name == "get_user_users__user_id__get"
232
  assert (
@@ -235,13 +240,16 @@ class TestResourceTemplates:
235
  )
236
 
237
  async def test_get_resource_template(
238
- self, fastmcp_server: FastMCPOpenAPI, api_client, users_db: dict[int, User]
 
 
 
239
  ):
240
  """
241
  The resource template created by the OpenAPI server should be the same as the original
242
  """
243
  user_id = 2
244
- resource_response = await fastmcp_server._mcp_read_resource(
245
  f"resource://openapi/get_user_users__user_id__get/{user_id}"
246
  )
247
 
@@ -252,21 +260,23 @@ class TestResourceTemplates:
252
 
253
 
254
  class TestPrompts:
255
- async def test_list_prompts(self, fastmcp_server: FastMCPOpenAPI):
256
  """
257
  By default, there are no prompts.
258
  """
259
- prompts = await fastmcp_server._mcp_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(
@@ -293,10 +303,12 @@ class TestTagTransfer:
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(
@@ -311,11 +323,11 @@ class TestTagTransfer:
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(
@@ -330,11 +342,11 @@ class TestTagTransfer:
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(
@@ -353,3 +365,355 @@ class TestTagTransfer:
353
  assert "users" in resource.tags
354
  assert "detail" in resource.tags
355
  assert len(resource.tags) == 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
 
75
  @pytest.fixture
76
+ async def fastmcp_openapi_server(
77
  fastapi_app: FastAPI, api_client: httpx.AsyncClient
78
  ) -> FastMCPOpenAPI:
79
  openapi_spec = fastapi_app.openapi()
 
113
 
114
 
115
  class TestTools:
116
+ async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
117
  """
118
  By default, tools exclude GET methods
119
  """
120
+ tools = await fastmcp_openapi_server._mcp_list_tools()
121
  assert len(tools) == 2
122
 
123
  assert tools[0].model_dump() == dict(
 
148
  )
149
 
150
  async def test_call_create_user_tool(
151
+ self, fastmcp_openapi_server: FastMCPOpenAPI, api_client
152
  ):
153
  """
154
  The tool created by the OpenAPI server should be the same as the original
155
  """
156
+ tool_response = await fastmcp_openapi_server.call_tool(
157
  "create_user_users_post", {"name": "David", "active": False}
158
  )
159
  assert tool_response == User(id=4, name="David", active=False)
 
164
  assert len(response.json()) == 4
165
 
166
  # Check that the user was created via MCP
167
+ user_response = await fastmcp_openapi_server._mcp_read_resource(
168
  "resource://openapi/get_user_users__user_id__get/4"
169
  )
170
  user = user_response[0].content
171
  assert user == tool_response.model_dump()
172
 
173
  async def test_call_update_user_name_tool(
174
+ self, fastmcp_openapi_server: FastMCPOpenAPI, api_client
175
  ):
176
  """
177
  The tool created by the OpenAPI server should be the same as the original
178
  """
179
+ tool_response = await fastmcp_openapi_server.call_tool(
180
  "update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"}
181
  )
182
  assert tool_response == dict(id=1, name="XYZ", active=True)
 
186
  assert dict(id=1, name="XYZ", active=True) in response.json()
187
 
188
  # Check that the user was updated via MCP
189
+ user_response = await fastmcp_openapi_server._mcp_read_resource(
190
  "resource://openapi/get_user_users__user_id__get/1"
191
  )
192
  user = user_response[0].content
 
194
 
195
 
196
  class TestResources:
197
+ async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
198
  """
199
  By default, resources exclude GET methods without parameters
200
  """
201
+ resources = await fastmcp_openapi_server._mcp_list_resources()
202
  assert len(resources) == 1
203
  assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
204
  assert resources[0].name == "get_users_users_get"
205
 
206
  async def test_get_resource(
207
+ self,
208
+ fastmcp_openapi_server: FastMCPOpenAPI,
209
+ api_client,
210
+ users_db: dict[int, User],
211
  ):
212
  """
213
  The resource created by the OpenAPI server should be the same as the original
 
215
  json_users = TypeAdapter(list[User]).dump_python(
216
  sorted(users_db.values(), key=lambda x: x.id)
217
  )
218
+ resource_response = await fastmcp_openapi_server._mcp_read_resource(
219
  "resource://openapi/get_users_users_get"
220
  )
221
  resource = resource_response[0].content
 
225
 
226
 
227
  class TestResourceTemplates:
228
+ async def test_list_resource_templates(
229
+ self, fastmcp_openapi_server: FastMCPOpenAPI
230
+ ):
231
  """
232
  By default, resource templates exclude GET methods without parameters
233
  """
234
+ resource_templates = await fastmcp_openapi_server._mcp_list_resource_templates()
235
  assert len(resource_templates) == 1
236
  assert resource_templates[0].name == "get_user_users__user_id__get"
237
  assert (
 
240
  )
241
 
242
  async def test_get_resource_template(
243
+ self,
244
+ fastmcp_openapi_server: FastMCPOpenAPI,
245
+ api_client,
246
+ users_db: dict[int, User],
247
  ):
248
  """
249
  The resource template created by the OpenAPI server should be the same as the original
250
  """
251
  user_id = 2
252
+ resource_response = await fastmcp_openapi_server._mcp_read_resource(
253
  f"resource://openapi/get_user_users__user_id__get/{user_id}"
254
  )
255
 
 
260
 
261
 
262
  class TestPrompts:
263
+ async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
264
  """
265
  By default, there are no prompts.
266
  """
267
+ prompts = await fastmcp_openapi_server._mcp_list_prompts()
268
  assert len(prompts) == 0
269
 
270
 
271
  class TestTagTransfer:
272
+ """Tests for transferring tags from OpenAPI routes to MCP objects."""
273
 
274
+ async def test_tags_transferred_to_tools(
275
+ self, fastmcp_openapi_server: FastMCPOpenAPI
276
+ ):
277
  """Test that tags from OpenAPI routes are correctly transferred to Tools."""
278
  # Get internal tools directly (not the public API which returns MCP.Content)
279
+ tools = fastmcp_openapi_server._tool_manager.list_tools()
280
 
281
  # Find the create_user and update_user_name tools
282
  create_user_tool = next(
 
303
  assert "update" in update_user_tool.tags
304
  assert len(update_user_tool.tags) == 2
305
 
306
+ async def test_tags_transferred_to_resources(
307
+ self, fastmcp_openapi_server: FastMCPOpenAPI
308
+ ):
309
  """Test that tags from OpenAPI routes are correctly transferred to Resources."""
310
  # Get internal resources directly
311
+ resources = fastmcp_openapi_server._resource_manager.list_resources()
312
 
313
  # Find the get_users resource
314
  get_users_resource = next(
 
323
  assert len(get_users_resource.tags) == 2
324
 
325
  async def test_tags_transferred_to_resource_templates(
326
+ self, fastmcp_openapi_server: FastMCPOpenAPI
327
  ):
328
  """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
329
  # Get internal resource templates directly
330
+ templates = fastmcp_openapi_server._resource_manager.list_templates()
331
 
332
  # Find the get_user template
333
  get_user_template = next(
 
342
  assert len(get_user_template.tags) == 2
343
 
344
  async def test_tags_preserved_in_resources_created_from_templates(
345
+ self, fastmcp_openapi_server: FastMCPOpenAPI
346
  ):
347
  """Test that tags are preserved when creating resources from templates."""
348
  # Get internal resource templates directly
349
+ templates = fastmcp_openapi_server._resource_manager.list_templates()
350
 
351
  # Find the get_user template
352
  get_user_template = next(
 
365
  assert "users" in resource.tags
366
  assert "detail" in resource.tags
367
  assert len(resource.tags) == 2
368
+
369
+
370
+ class TestOpenAPI30Compatibility:
371
+ """Tests for compatibility with OpenAPI 3.0 specifications."""
372
+
373
+ @pytest.fixture
374
+ def openapi_30_spec(self) -> dict:
375
+ """Fixture that returns a simple OpenAPI 3.0 specification."""
376
+ return {
377
+ "openapi": "3.0.0",
378
+ "info": {"title": "Product API (3.0)", "version": "1.0.0"},
379
+ "paths": {
380
+ "/products": {
381
+ "get": {
382
+ "operationId": "listProducts",
383
+ "summary": "List all products",
384
+ "responses": {"200": {"description": "A list of products"}},
385
+ },
386
+ "post": {
387
+ "operationId": "createProduct",
388
+ "summary": "Create a new product",
389
+ "requestBody": {
390
+ "required": True,
391
+ "content": {
392
+ "application/json": {
393
+ "schema": {
394
+ "type": "object",
395
+ "properties": {
396
+ "name": {"type": "string"},
397
+ "price": {"type": "number"},
398
+ },
399
+ "required": ["name", "price"],
400
+ }
401
+ }
402
+ },
403
+ },
404
+ "responses": {"201": {"description": "Product created"}},
405
+ },
406
+ },
407
+ "/products/{product_id}": {
408
+ "get": {
409
+ "operationId": "getProduct",
410
+ "summary": "Get product by ID",
411
+ "parameters": [
412
+ {
413
+ "name": "product_id",
414
+ "in": "path",
415
+ "required": True,
416
+ "schema": {"type": "string"},
417
+ }
418
+ ],
419
+ "responses": {"200": {"description": "A product"}},
420
+ }
421
+ },
422
+ },
423
+ }
424
+
425
+ @pytest.fixture
426
+ async def mock_30_client(self) -> httpx.AsyncClient:
427
+ """Mock client that returns predefined responses for the 3.0 API."""
428
+
429
+ async def _responder(request):
430
+ if request.url.path == "/products" and request.method == "GET":
431
+ return httpx.Response(
432
+ 200,
433
+ json=[
434
+ {"id": "p1", "name": "Product 1", "price": 19.99},
435
+ {"id": "p2", "name": "Product 2", "price": 29.99},
436
+ ],
437
+ )
438
+ elif request.url.path == "/products" and request.method == "POST":
439
+ import json
440
+
441
+ data = json.loads(request.content)
442
+ return httpx.Response(
443
+ 201, json={"id": "p3", "name": data["name"], "price": data["price"]}
444
+ )
445
+ elif request.url.path.startswith("/products/") and request.method == "GET":
446
+ product_id = request.url.path.split("/")[-1]
447
+ products = {
448
+ "p1": {"id": "p1", "name": "Product 1", "price": 19.99},
449
+ "p2": {"id": "p2", "name": "Product 2", "price": 29.99},
450
+ }
451
+ if product_id in products:
452
+ return httpx.Response(200, json=products[product_id])
453
+ return httpx.Response(404, json={"error": "Product not found"})
454
+ return httpx.Response(404)
455
+
456
+ transport = httpx.MockTransport(_responder)
457
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
458
+
459
+ @pytest.fixture
460
+ async def openapi_30_server(
461
+ self, openapi_30_spec, mock_30_client
462
+ ) -> FastMCPOpenAPI:
463
+ """Create a FastMCPOpenAPI server from the OpenAPI 3.0 spec."""
464
+ return FastMCPOpenAPI(
465
+ openapi_spec=openapi_30_spec, client=mock_30_client, name="Product API 3.0"
466
+ )
467
+
468
+ async def test_server_creation(self, openapi_30_server):
469
+ """Test that a server can be created from an OpenAPI 3.0 spec."""
470
+ assert isinstance(openapi_30_server, FastMCP)
471
+ assert openapi_30_server.name == "Product API 3.0"
472
+
473
+ async def test_resource_discovery(self, openapi_30_server):
474
+ """Test that resources are correctly discovered from an OpenAPI 3.0 spec."""
475
+ resources = await openapi_30_server._mcp_list_resources()
476
+ assert len(resources) == 1
477
+ assert resources[0].uri == AnyUrl("resource://openapi/listProducts")
478
+
479
+ async def test_resource_template_discovery(self, openapi_30_server):
480
+ """Test that resource templates are correctly discovered from an OpenAPI 3.0 spec."""
481
+ templates = await openapi_30_server._mcp_list_resource_templates()
482
+ assert len(templates) == 1
483
+ assert templates[0].name == "getProduct"
484
+ assert templates[0].uriTemplate == r"resource://openapi/getProduct/{product_id}"
485
+
486
+ async def test_tool_discovery(self, openapi_30_server):
487
+ """Test that tools are correctly discovered from an OpenAPI 3.0 spec."""
488
+ tools = await openapi_30_server._mcp_list_tools()
489
+ assert len(tools) == 1
490
+ assert tools[0].name == "createProduct"
491
+ assert "name" in tools[0].inputSchema["properties"]
492
+ assert "price" in tools[0].inputSchema["properties"]
493
+
494
+ async def test_resource_access(self, openapi_30_server):
495
+ """Test reading a resource from an OpenAPI 3.0 server."""
496
+ resource_response = await openapi_30_server._mcp_read_resource(
497
+ "resource://openapi/listProducts"
498
+ )
499
+ content = resource_response[0].content
500
+ assert len(content) == 2
501
+ assert content[0]["name"] == "Product 1"
502
+ assert content[1]["name"] == "Product 2"
503
+
504
+ async def test_resource_template_access(self, openapi_30_server):
505
+ """Test reading a resource from template from an OpenAPI 3.0 server."""
506
+ resource_response = await openapi_30_server._mcp_read_resource(
507
+ "resource://openapi/getProduct/p1"
508
+ )
509
+ content = resource_response[0].content
510
+ assert content["id"] == "p1"
511
+ assert content["name"] == "Product 1"
512
+ assert content["price"] == 19.99
513
+
514
+ async def test_tool_execution(self, openapi_30_server):
515
+ """Test executing a tool from an OpenAPI 3.0 server."""
516
+ tool_response = await openapi_30_server.call_tool(
517
+ "createProduct", {"name": "New Product", "price": 39.99}
518
+ )
519
+ assert tool_response["id"] == "p3"
520
+ assert tool_response["name"] == "New Product"
521
+ assert tool_response["price"] == 39.99
522
+
523
+
524
+ class TestOpenAPI31Compatibility:
525
+ """Tests for compatibility with OpenAPI 3.1 specifications."""
526
+
527
+ @pytest.fixture
528
+ def openapi_31_spec(self) -> dict:
529
+ """Fixture that returns a simple OpenAPI 3.1 specification."""
530
+ return {
531
+ "openapi": "3.1.0",
532
+ "info": {"title": "Order API (3.1)", "version": "1.0.0"},
533
+ "paths": {
534
+ "/orders": {
535
+ "get": {
536
+ "operationId": "listOrders",
537
+ "summary": "List all orders",
538
+ "responses": {"200": {"description": "A list of orders"}},
539
+ },
540
+ "post": {
541
+ "operationId": "createOrder",
542
+ "summary": "Place a new order",
543
+ "requestBody": {
544
+ "required": True,
545
+ "content": {
546
+ "application/json": {
547
+ "schema": {
548
+ "type": "object",
549
+ "properties": {
550
+ "customer": {"type": "string"},
551
+ "items": {
552
+ "type": "array",
553
+ "items": {"type": "string"},
554
+ },
555
+ },
556
+ "required": ["customer", "items"],
557
+ }
558
+ }
559
+ },
560
+ },
561
+ "responses": {"201": {"description": "Order created"}},
562
+ },
563
+ },
564
+ "/orders/{order_id}": {
565
+ "get": {
566
+ "operationId": "getOrder",
567
+ "summary": "Get order by ID",
568
+ "parameters": [
569
+ {
570
+ "name": "order_id",
571
+ "in": "path",
572
+ "required": True,
573
+ "schema": {"type": "string"},
574
+ }
575
+ ],
576
+ "responses": {"200": {"description": "An order"}},
577
+ }
578
+ },
579
+ },
580
+ }
581
+
582
+ @pytest.fixture
583
+ async def mock_31_client(self) -> httpx.AsyncClient:
584
+ """Mock client that returns predefined responses for the 3.1 API."""
585
+
586
+ async def _responder(request):
587
+ if request.url.path == "/orders" and request.method == "GET":
588
+ return httpx.Response(
589
+ 200,
590
+ json=[
591
+ {"id": "o1", "customer": "Alice", "items": ["item1", "item2"]},
592
+ {"id": "o2", "customer": "Bob", "items": ["item3"]},
593
+ ],
594
+ )
595
+ elif request.url.path == "/orders" and request.method == "POST":
596
+ import json
597
+
598
+ data = json.loads(request.content)
599
+ return httpx.Response(
600
+ 201,
601
+ json={
602
+ "id": "o3",
603
+ "customer": data["customer"],
604
+ "items": data["items"],
605
+ },
606
+ )
607
+ elif request.url.path.startswith("/orders/") and request.method == "GET":
608
+ order_id = request.url.path.split("/")[-1]
609
+ orders = {
610
+ "o1": {
611
+ "id": "o1",
612
+ "customer": "Alice",
613
+ "items": ["item1", "item2"],
614
+ },
615
+ "o2": {"id": "o2", "customer": "Bob", "items": ["item3"]},
616
+ }
617
+ if order_id in orders:
618
+ return httpx.Response(200, json=orders[order_id])
619
+ return httpx.Response(404, json={"error": "Order not found"})
620
+ return httpx.Response(404)
621
+
622
+ transport = httpx.MockTransport(_responder)
623
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
624
+
625
+ @pytest.fixture
626
+ async def openapi_31_server(
627
+ self, openapi_31_spec, mock_31_client
628
+ ) -> FastMCPOpenAPI:
629
+ """Create a FastMCPOpenAPI server from the OpenAPI 3.1 spec."""
630
+ return FastMCPOpenAPI(
631
+ openapi_spec=openapi_31_spec, client=mock_31_client, name="Order API 3.1"
632
+ )
633
+
634
+ async def test_server_creation(self, openapi_31_server):
635
+ """Test that a server can be created from an OpenAPI 3.1 spec."""
636
+ assert isinstance(openapi_31_server, FastMCP)
637
+ assert openapi_31_server.name == "Order API 3.1"
638
+
639
+ async def test_resource_discovery(self, openapi_31_server):
640
+ """Test that resources are correctly discovered from an OpenAPI 3.1 spec."""
641
+ resources = await openapi_31_server._mcp_list_resources()
642
+ assert len(resources) == 1
643
+ assert resources[0].uri == AnyUrl("resource://openapi/listOrders")
644
+
645
+ async def test_resource_template_discovery(self, openapi_31_server):
646
+ """Test that resource templates are correctly discovered from an OpenAPI 3.1 spec."""
647
+ templates = await openapi_31_server._mcp_list_resource_templates()
648
+ assert len(templates) == 1
649
+ assert templates[0].name == "getOrder"
650
+ assert templates[0].uriTemplate == r"resource://openapi/getOrder/{order_id}"
651
+
652
+ async def test_tool_discovery(self, openapi_31_server):
653
+ """Test that tools are correctly discovered from an OpenAPI 3.1 spec."""
654
+ tools = await openapi_31_server._mcp_list_tools()
655
+ assert len(tools) == 1
656
+ assert tools[0].name == "createOrder"
657
+ assert "customer" in tools[0].inputSchema["properties"]
658
+ assert "items" in tools[0].inputSchema["properties"]
659
+
660
+ async def test_resource_access(self, openapi_31_server):
661
+ """Test reading a resource from an OpenAPI 3.1 server."""
662
+ resource_response = await openapi_31_server._mcp_read_resource(
663
+ "resource://openapi/listOrders"
664
+ )
665
+ content = resource_response[0].content
666
+ assert len(content) == 2
667
+ assert content[0]["customer"] == "Alice"
668
+ assert content[1]["customer"] == "Bob"
669
+
670
+ async def test_resource_template_access(self, openapi_31_server):
671
+ """Test reading a resource from template from an OpenAPI 3.1 server."""
672
+ resource_response = await openapi_31_server._mcp_read_resource(
673
+ "resource://openapi/getOrder/o1"
674
+ )
675
+ content = resource_response[0].content
676
+ assert content["id"] == "o1"
677
+ assert content["customer"] == "Alice"
678
+ assert content["items"] == ["item1", "item2"]
679
+
680
+ async def test_tool_execution(self, openapi_31_server):
681
+ """Test executing a tool from an OpenAPI 3.1 server."""
682
+ tool_response = await openapi_31_server.call_tool(
683
+ "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
684
+ )
685
+ assert tool_response["id"] == "o3"
686
+ assert tool_response["customer"] == "Charlie"
687
+ assert tool_response["items"] == ["item4", "item5"]
688
+
689
+
690
+ class TestMountFastMCP:
691
+ """Tests for mounting FastMCP servers."""
692
+
693
+ async def test_mount_fastmcp(self, fastmcp_openapi_server: FastMCPOpenAPI):
694
+ """Test mounting an OpenAPI server."""
695
+ mcp = FastMCP("MainApp")
696
+
697
+ mcp.mount("fastapi", fastmcp_openapi_server)
698
+
699
+ resources = await mcp._mcp_list_resources()
700
+ assert len(resources) == 1
701
+ assert resources[0].uri == AnyUrl(
702
+ "fastapi+resource://openapi/get_users_users_get"
703
+ )
704
+
705
+ templates = await mcp._mcp_list_resource_templates()
706
+ assert len(templates) == 1
707
+ assert templates[0].name == "get_user_users__user_id__get"
708
+ assert (
709
+ templates[0].uriTemplate
710
+ == r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}"
711
+ )
712
+
713
+ tools = await mcp._mcp_list_tools()
714
+ assert len(tools) == 2
715
+ assert tools[0].name == "fastapi_create_user_users_post"
716
+ assert tools[1].name == "fastapi_update_user_name_users__user_id__name_patch"
717
+
718
+ prompts = await mcp._mcp_list_prompts()
719
+ assert len(prompts) == 0
uv.lock CHANGED
@@ -254,7 +254,7 @@ wheels = [
254
 
255
  [[package]]
256
  name = "fastmcp"
257
- version = "2.1.1.dev3+4f58d82"
258
  source = { editable = "." }
259
  dependencies = [
260
  { name = "dotenv" },
@@ -1323,4 +1323,4 @@ dependencies = [
1323
  sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 }
1324
  wheels = [
1325
  { url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 },
1326
- ]
 
254
 
255
  [[package]]
256
  name = "fastmcp"
257
+ version = "2.1.2.dev2+c0de75e"
258
  source = { editable = "." }
259
  dependencies = [
260
  { name = "dotenv" },
 
1323
  sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 }
1324
  wheels = [
1325
  { url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 },
1326
+ ]