fastmcp 2.12.2__py3-none-any.whl → 2.12.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. fastmcp/cli/claude.py +1 -10
  2. fastmcp/cli/cli.py +45 -25
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +1 -10
  5. fastmcp/cli/install/claude_desktop.py +1 -9
  6. fastmcp/cli/install/cursor.py +2 -18
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +1 -9
  9. fastmcp/cli/run.py +2 -86
  10. fastmcp/client/auth/oauth.py +50 -37
  11. fastmcp/client/client.py +18 -8
  12. fastmcp/client/elicitation.py +6 -1
  13. fastmcp/client/transports.py +1 -1
  14. fastmcp/contrib/component_manager/component_service.py +1 -1
  15. fastmcp/contrib/mcp_mixin/README.md +3 -3
  16. fastmcp/contrib/mcp_mixin/mcp_mixin.py +41 -6
  17. fastmcp/experimental/utilities/openapi/director.py +8 -1
  18. fastmcp/experimental/utilities/openapi/schemas.py +31 -5
  19. fastmcp/prompts/prompt.py +10 -8
  20. fastmcp/resources/resource.py +14 -11
  21. fastmcp/resources/template.py +12 -10
  22. fastmcp/server/auth/auth.py +10 -4
  23. fastmcp/server/auth/oauth_proxy.py +93 -23
  24. fastmcp/server/auth/oidc_proxy.py +348 -0
  25. fastmcp/server/auth/providers/auth0.py +174 -0
  26. fastmcp/server/auth/providers/aws.py +237 -0
  27. fastmcp/server/auth/providers/azure.py +6 -2
  28. fastmcp/server/auth/providers/descope.py +172 -0
  29. fastmcp/server/auth/providers/github.py +6 -2
  30. fastmcp/server/auth/providers/google.py +6 -2
  31. fastmcp/server/auth/providers/workos.py +6 -2
  32. fastmcp/server/context.py +17 -16
  33. fastmcp/server/dependencies.py +18 -5
  34. fastmcp/server/http.py +1 -1
  35. fastmcp/server/middleware/logging.py +147 -116
  36. fastmcp/server/middleware/middleware.py +3 -2
  37. fastmcp/server/openapi.py +5 -1
  38. fastmcp/server/server.py +43 -36
  39. fastmcp/settings.py +42 -6
  40. fastmcp/tools/tool.py +105 -87
  41. fastmcp/tools/tool_transform.py +1 -1
  42. fastmcp/utilities/json_schema.py +18 -1
  43. fastmcp/utilities/logging.py +66 -4
  44. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +4 -39
  45. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -2
  46. fastmcp/utilities/mcp_server_config/v1/schema.json +2 -1
  47. fastmcp/utilities/storage.py +204 -0
  48. fastmcp/utilities/tests.py +8 -6
  49. fastmcp/utilities/types.py +9 -5
  50. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/METADATA +121 -48
  51. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/RECORD +54 -48
  52. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/WHEEL +0 -0
  53. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/entry_points.txt +0 -0
  54. {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import json
5
4
  import webbrowser
6
5
  from asyncio import Future
7
6
  from collections.abc import AsyncGenerator
@@ -29,6 +28,7 @@ from fastmcp.client.oauth_callback import (
29
28
  )
30
29
  from fastmcp.utilities.http import find_available_port
31
30
  from fastmcp.utilities.logging import get_logger
31
+ from fastmcp.utilities.storage import JSONFileStorage
32
32
 
33
33
  __all__ = ["OAuth"]
34
34
 
@@ -62,13 +62,14 @@ class FileTokenStorage(TokenStorage):
62
62
  Implements the mcp.client.auth.TokenStorage protocol.
63
63
 
64
64
  Each instance is tied to a specific server URL for proper token isolation.
65
+ Uses JSONFileStorage internally for consistent file handling.
65
66
  """
66
67
 
67
68
  def __init__(self, server_url: str, cache_dir: Path | None = None):
68
69
  """Initialize storage for a specific server URL."""
69
70
  self.server_url = server_url
70
- self.cache_dir = cache_dir or default_cache_dir()
71
- self.cache_dir.mkdir(exist_ok=True, parents=True)
71
+ # Use JSONFileStorage for actual file operations
72
+ self._storage = JSONFileStorage(cache_dir or default_cache_dir())
72
73
 
73
74
  @staticmethod
74
75
  def get_base_url(url: str) -> str:
@@ -76,28 +77,33 @@ class FileTokenStorage(TokenStorage):
76
77
  parsed = urlparse(url)
77
78
  return f"{parsed.scheme}://{parsed.netloc}"
78
79
 
79
- def get_cache_key(self) -> str:
80
- """Generate a safe filesystem key from the server's base URL."""
80
+ def _get_storage_key(self, file_type: Literal["client_info", "tokens"]) -> str:
81
+ """Get the storage key for the specified data type.
82
+
83
+ JSONFileStorage will handle making the key filesystem-safe.
84
+ """
81
85
  base_url = self.get_base_url(self.server_url)
82
- return (
83
- base_url.replace("://", "_")
84
- .replace(".", "_")
85
- .replace("/", "_")
86
- .replace(":", "_")
87
- )
86
+ return f"{base_url}_{file_type}"
88
87
 
89
88
  def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
90
- """Get the file path for the specified cache file type."""
91
- key = self.get_cache_key()
92
- return self.cache_dir / f"{key}_{file_type}.json"
89
+ """Get the file path for the specified cache file type.
90
+
91
+ This method is kept for backward compatibility with tests that access _get_file_path.
92
+ """
93
+ key = self._get_storage_key(file_type)
94
+ return self._storage._get_file_path(key)
93
95
 
94
96
  async def get_tokens(self) -> OAuthToken | None:
95
97
  """Load tokens from file storage."""
96
- path = self._get_file_path("tokens")
98
+ key = self._get_storage_key("tokens")
99
+ data = await self._storage.get(key)
100
+
101
+ if data is None:
102
+ return None
97
103
 
98
104
  try:
99
- # Parse JSON and validate as StoredToken
100
- stored = stored_token_adapter.validate_json(path.read_text())
105
+ # Parse and validate as StoredToken
106
+ stored = stored_token_adapter.validate_python(data)
101
107
 
102
108
  # Check if token is expired
103
109
  if stored.expires_at is not None:
@@ -117,15 +123,15 @@ class FileTokenStorage(TokenStorage):
117
123
 
118
124
  return stored.token_payload
119
125
 
120
- except (FileNotFoundError, ValidationError) as e:
126
+ except ValidationError as e:
121
127
  logger.debug(
122
- f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
128
+ f"Could not validate tokens for {self.get_base_url(self.server_url)}: {e}"
123
129
  )
124
130
  return None
125
131
 
126
132
  async def set_tokens(self, tokens: OAuthToken) -> None:
127
133
  """Save tokens to file storage."""
128
- path = self._get_file_path("tokens")
134
+ key = self._get_storage_key("tokens")
129
135
 
130
136
  # Calculate absolute expiry time if expires_in is present
131
137
  expires_at = None
@@ -134,19 +140,22 @@ class FileTokenStorage(TokenStorage):
134
140
  seconds=tokens.expires_in
135
141
  )
136
142
 
137
- # Create StoredToken and save using Pydantic serialization
143
+ # Create StoredToken and save using storage
144
+ # Note: JSONFileStorage will wrap this in {"data": ..., "timestamp": ...}
138
145
  stored = StoredToken(token_payload=tokens, expires_at=expires_at)
139
-
140
- path.write_text(stored.model_dump_json(indent=2))
146
+ await self._storage.set(key, stored.model_dump(mode="json"))
141
147
  logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
142
148
 
143
149
  async def get_client_info(self) -> OAuthClientInformationFull | None:
144
150
  """Load client information from file storage."""
145
- path = self._get_file_path("client_info")
151
+ key = self._get_storage_key("client_info")
152
+ data = await self._storage.get(key)
153
+
154
+ if data is None:
155
+ return None
156
+
146
157
  try:
147
- client_info = OAuthClientInformationFull.model_validate_json(
148
- path.read_text()
149
- )
158
+ client_info = OAuthClientInformationFull.model_validate(data)
150
159
  # Check if we have corresponding valid tokens
151
160
  # If no tokens exist, the OAuth flow was incomplete and we should
152
161
  # force a fresh client registration
@@ -157,27 +166,31 @@ class FileTokenStorage(TokenStorage):
157
166
  "OAuth flow may have been incomplete. Clearing client info to force fresh registration."
158
167
  )
159
168
  # Clear the incomplete client info
160
- client_info_path = self._get_file_path("client_info")
161
- client_info_path.unlink(missing_ok=True)
169
+ await self._storage.delete(key)
162
170
  return None
163
171
 
164
172
  return client_info
165
- except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
173
+ except ValidationError as e:
166
174
  logger.debug(
167
- f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
175
+ f"Could not validate client info for {self.get_base_url(self.server_url)}: {e}"
168
176
  )
169
177
  return None
170
178
 
171
179
  async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
172
180
  """Save client information to file storage."""
173
- path = self._get_file_path("client_info")
174
- path.write_text(client_info.model_dump_json(indent=2))
181
+ key = self._get_storage_key("client_info")
182
+ await self._storage.set(key, client_info.model_dump(mode="json"))
175
183
  logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
176
184
 
177
185
  def clear(self) -> None:
178
- """Clear all cached data for this server."""
186
+ """Clear all cached data for this server.
187
+
188
+ Note: This is a synchronous method for backward compatibility.
189
+ Uses direct file operations instead of async storage methods.
190
+ """
179
191
  file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
180
192
  for file_type in file_types:
193
+ # Use the file path directly for synchronous deletion
181
194
  path = self._get_file_path(file_type)
182
195
  path.unlink(missing_ok=True)
183
196
  logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
@@ -318,8 +331,8 @@ class OAuth(OAuthClientProvider):
318
331
  "OAuth client not found - cached credentials may be stale"
319
332
  )
320
333
 
321
- # For any non-redirect response, something is wrong
322
- if response.status_code not in (302, 303, 307, 308):
334
+ # OAuth typically returns redirects, but some providers return 200 with HTML login pages
335
+ if response.status_code not in (200, 302, 303, 307, 308):
323
336
  raise RuntimeError(
324
337
  f"Unexpected authorization response: {response.status_code}"
325
338
  )
@@ -355,7 +368,7 @@ class OAuth(OAuthClientProvider):
355
368
  raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds")
356
369
  finally:
357
370
  server.should_exit = True
358
- await asyncio.sleep(0.1) # Allow server to shutdown gracefully
371
+ await asyncio.sleep(0.1) # Allow server to shut down gracefully
359
372
  tg.cancel_scope.cancel()
360
373
 
361
374
  raise RuntimeError("OAuth callback handler could not be started")
fastmcp/client/client.py CHANGED
@@ -454,7 +454,7 @@ class Client(Generic[ClientTransportT]):
454
454
  if self._session_state.nesting_counter > 0:
455
455
  return
456
456
 
457
- # stop the active seesion
457
+ # stop the active session
458
458
  if self._session_state.session_task is None:
459
459
  return
460
460
  self._session_state.stop_event.set()
@@ -505,7 +505,7 @@ class Client(Generic[ClientTransportT]):
505
505
  ) -> None:
506
506
  """Send a cancellation notification for an in-progress request."""
507
507
  notification = mcp.types.ClientNotification(
508
- mcp.types.CancelledNotification(
508
+ root=mcp.types.CancelledNotification(
509
509
  method="notifications/cancelled",
510
510
  params=mcp.types.CancelledNotificationParams(
511
511
  requestId=request_id,
@@ -743,14 +743,17 @@ class Client(Generic[ClientTransportT]):
743
743
 
744
744
  async def complete_mcp(
745
745
  self,
746
- ref: mcp.types.ResourceReference | mcp.types.PromptReference,
746
+ ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
747
747
  argument: dict[str, str],
748
+ context_arguments: dict[str, Any] | None = None,
748
749
  ) -> mcp.types.CompleteResult:
749
750
  """Send a completion request and return the complete MCP protocol result.
750
751
 
751
752
  Args:
752
- ref (mcp.types.ResourceReference | mcp.types.PromptReference): The reference to complete.
753
+ ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
753
754
  argument (dict[str, str]): Arguments to pass to the completion request.
755
+ context_arguments (dict[str, Any] | None, optional): Optional context arguments to
756
+ include with the completion request. Defaults to None.
754
757
 
755
758
  Returns:
756
759
  mcp.types.CompleteResult: The complete response object from the protocol,
@@ -761,19 +764,24 @@ class Client(Generic[ClientTransportT]):
761
764
  """
762
765
  logger.debug(f"[{self.name}] called complete: {ref}")
763
766
 
764
- result = await self.session.complete(ref=ref, argument=argument)
767
+ result = await self.session.complete(
768
+ ref=ref, argument=argument, context_arguments=context_arguments
769
+ )
765
770
  return result
766
771
 
767
772
  async def complete(
768
773
  self,
769
- ref: mcp.types.ResourceReference | mcp.types.PromptReference,
774
+ ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
770
775
  argument: dict[str, str],
776
+ context_arguments: dict[str, Any] | None = None,
771
777
  ) -> mcp.types.Completion:
772
778
  """Send a completion request to the server.
773
779
 
774
780
  Args:
775
- ref (mcp.types.ResourceReference | mcp.types.PromptReference): The reference to complete.
781
+ ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
776
782
  argument (dict[str, str]): Arguments to pass to the completion request.
783
+ context_arguments (dict[str, Any] | None, optional): Optional context arguments to
784
+ include with the completion request. Defaults to None.
777
785
 
778
786
  Returns:
779
787
  mcp.types.Completion: The completion object.
@@ -781,7 +789,9 @@ class Client(Generic[ClientTransportT]):
781
789
  Raises:
782
790
  RuntimeError: If called while the client is not connected.
783
791
  """
784
- result = await self.complete_mcp(ref=ref, argument=argument)
792
+ result = await self.complete_mcp(
793
+ ref=ref, argument=argument, context_arguments=context_arguments
794
+ )
785
795
  return result.completion
786
796
 
787
797
  # --- Tools ---
@@ -59,7 +59,12 @@ def create_elicitation_callback(
59
59
  "Elicitation responses must be serializable as a JSON object (dict). Received: "
60
60
  f"{result.content!r}"
61
61
  )
62
- return MCPElicitResult(**result.model_dump() | {"content": content})
62
+ return MCPElicitResult(
63
+ _meta=result.meta,
64
+ action=result.action,
65
+ content=content,
66
+ )
67
+
63
68
  except Exception as e:
64
69
  return mcp.types.ErrorData(
65
70
  code=mcp.types.INTERNAL_ERROR,
@@ -609,7 +609,7 @@ class UvStdioTransport(StdioTransport):
609
609
  uv_args: list[str] = []
610
610
 
611
611
  # Check if we need any environment setup
612
- if env_config.needs_uv():
612
+ if env_config._must_run_with_uv():
613
613
  # Use the config to build args, but we need to handle the command differently
614
614
  # since transport has specific needs
615
615
  uv_args = ["run"]
@@ -174,7 +174,7 @@ class ComponentService:
174
174
  key: The key of the prompt to enable
175
175
 
176
176
  Returns:
177
- The prompt that was enable
177
+ The prompt that was enabled
178
178
  """
179
179
  logger.debug("Enabling prompt: %s", key)
180
180
 
@@ -16,7 +16,7 @@ Prompts:
16
16
  * [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts)
17
17
 
18
18
  Resources:
19
- * [enable/disabe](https://gofastmcp.com/servers/resources#disabling-resources)
19
+ * [enable/disable](https://gofastmcp.com/servers/resources#disabling-resources)
20
20
 
21
21
  ## Usage
22
22
 
@@ -91,12 +91,12 @@ class MyComponent(MCPMixin):
91
91
  # prompt
92
92
  @mcp_prompt(name="A prompt")
93
93
  def prompt_method(self, name):
94
- return f"Whats up {name}?"
94
+ return f"What's up {name}?"
95
95
 
96
96
  # disabled prompt
97
97
  @mcp_prompt(name="A prompt", enabled=False)
98
98
  def prompt_method(self, name):
99
- return f"Whats up {name}?"
99
+ return f"What's up {name}?"
100
100
 
101
101
  mcp_server = FastMCP()
102
102
  component = MyComponent()
@@ -8,6 +8,7 @@ from mcp.types import ToolAnnotations
8
8
  from fastmcp.prompts.prompt import Prompt
9
9
  from fastmcp.resources.resource import Resource
10
10
  from fastmcp.tools.tool import Tool
11
+ from fastmcp.utilities.types import get_fn_name
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from fastmcp.server import FastMCP
@@ -34,7 +35,7 @@ def mcp_tool(
34
35
 
35
36
  def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
36
37
  call_args = {
37
- "name": name or func.__name__,
38
+ "name": name or get_fn_name(func),
38
39
  "description": description,
39
40
  "tags": tags,
40
41
  "annotations": annotations,
@@ -63,7 +64,7 @@ def mcp_resource(
63
64
  def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
64
65
  call_args = {
65
66
  "uri": uri,
66
- "name": name or func.__name__,
67
+ "name": name or get_fn_name(func),
67
68
  "description": description,
68
69
  "mime_type": mime_type,
69
70
  "tags": tags,
@@ -88,7 +89,7 @@ def mcp_prompt(
88
89
 
89
90
  def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
90
91
  call_args = {
91
- "name": name or func.__name__,
92
+ "name": name or get_fn_name(func),
92
93
  "description": description,
93
94
  "tags": tags,
94
95
  "enabled": enabled,
@@ -146,7 +147,21 @@ class MCPMixin:
146
147
  registration_info["name"] = (
147
148
  f"{prefix}{separator}{registration_info['name']}"
148
149
  )
149
- tool = Tool.from_function(fn=method, **registration_info)
150
+
151
+ tool = Tool.from_function(
152
+ fn=method,
153
+ name=registration_info.get("name"),
154
+ title=registration_info.get("title"),
155
+ description=registration_info.get("description"),
156
+ tags=registration_info.get("tags"),
157
+ annotations=registration_info.get("annotations"),
158
+ exclude_args=registration_info.get("exclude_args"),
159
+ serializer=registration_info.get("serializer"),
160
+ output_schema=registration_info.get("output_schema"),
161
+ meta=registration_info.get("meta"),
162
+ enabled=registration_info.get("enabled"),
163
+ )
164
+
150
165
  mcp_server.add_tool(tool)
151
166
 
152
167
  def register_resources(
@@ -175,7 +190,19 @@ class MCPMixin:
175
190
  registration_info["uri"] = (
176
191
  f"{prefix}{separator}{registration_info['uri']}"
177
192
  )
178
- resource = Resource.from_function(fn=method, **registration_info)
193
+
194
+ resource = Resource.from_function(
195
+ fn=method,
196
+ uri=registration_info["uri"],
197
+ name=registration_info.get("name"),
198
+ description=registration_info.get("description"),
199
+ mime_type=registration_info.get("mime_type"),
200
+ tags=registration_info.get("tags"),
201
+ enabled=registration_info.get("enabled"),
202
+ annotations=registration_info.get("annotations"),
203
+ meta=registration_info.get("meta"),
204
+ )
205
+
179
206
  mcp_server.add_resource(resource)
180
207
 
181
208
  def register_prompts(
@@ -200,7 +227,15 @@ class MCPMixin:
200
227
  registration_info["name"] = (
201
228
  f"{prefix}{separator}{registration_info['name']}"
202
229
  )
203
- prompt = Prompt.from_function(fn=method, **registration_info)
230
+ prompt = Prompt.from_function(
231
+ fn=method,
232
+ name=registration_info.get("name"),
233
+ title=registration_info.get("title"),
234
+ description=registration_info.get("description"),
235
+ tags=registration_info.get("tags"),
236
+ enabled=registration_info.get("enabled"),
237
+ meta=registration_info.get("meta"),
238
+ )
204
239
  mcp_server.add_prompt(prompt)
205
240
 
206
241
  def register_all(
@@ -69,7 +69,14 @@ class RequestDirector:
69
69
  request_data["content"] = body
70
70
 
71
71
  # Step 5: Create httpx.Request
72
- return httpx.Request(**{k: v for k, v in request_data.items() if v is not None})
72
+ return httpx.Request(
73
+ method=request_data["method"],
74
+ url=request_data["url"],
75
+ params=request_data.get("params"),
76
+ headers=request_data.get("headers"),
77
+ json=request_data.get("json"),
78
+ content=request_data.get("content"),
79
+ )
73
80
 
74
81
  def _unflatten_arguments(
75
82
  self, route: HTTPRoute, flat_args: dict[str, Any]
@@ -79,6 +79,7 @@ def _replace_ref_with_defs(
79
79
 
80
80
  Examples:
81
81
  - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
82
+ - {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/..."}, "properties": {...}}
82
83
  - {"$ref": "#/components/schemas/..."}
83
84
  - {"items": {"$ref": "#/components/schemas/..."}}
84
85
  - {"anyOf": [{"$ref": "#/components/schemas/..."}]}
@@ -117,6 +118,11 @@ def _replace_ref_with_defs(
117
118
  for section in ["anyOf", "allOf", "oneOf"]:
118
119
  for i, item in enumerate(schema.get(section, [])):
119
120
  schema[section][i] = _replace_ref_with_defs(item)
121
+ if additionalProperties := schema.get("additionalProperties"):
122
+ if not isinstance(additionalProperties, bool):
123
+ schema["additionalProperties"] = _replace_ref_with_defs(
124
+ additionalProperties
125
+ )
120
126
  if info.get("description", description) and not schema.get("description"):
121
127
  schema["description"] = description
122
128
  return schema
@@ -297,9 +303,11 @@ def _combine_schemas_and_map_params(
297
303
 
298
304
  # Convert refs if needed
299
305
  if convert_refs:
300
- param_schema = _replace_ref_with_defs(param.schema_)
306
+ param_schema = _replace_ref_with_defs(param.schema_, param.description)
301
307
  else:
302
- param_schema = param.schema_
308
+ param_schema = param.schema_.copy()
309
+ if param.description and not param_schema.get("description"):
310
+ param_schema["description"] = param.description
303
311
  original_desc = param_schema.get("description", "")
304
312
  location_desc = f"({param.location.capitalize()} parameter)"
305
313
  if original_desc:
@@ -324,9 +332,11 @@ def _combine_schemas_and_map_params(
324
332
 
325
333
  # Convert refs if needed
326
334
  if convert_refs:
327
- param_schema = _replace_ref_with_defs(param.schema_)
335
+ param_schema = _replace_ref_with_defs(param.schema_, param.description)
328
336
  else:
329
- param_schema = param.schema_
337
+ param_schema = param.schema_.copy()
338
+ if param.description and not param_schema.get("description"):
339
+ param_schema["description"] = param.description
330
340
 
331
341
  # Don't make optional parameters nullable - they can simply be omitted
332
342
  # The OpenAPI specification doesn't require optional parameters to accept null values
@@ -344,7 +354,7 @@ def _combine_schemas_and_map_params(
344
354
  if route.request_body.required:
345
355
  required.append("body")
346
356
  parameter_map["body"] = {"location": "body", "openapi_name": "body"}
347
- else:
357
+ elif body_props:
348
358
  # Normal case: body has properties
349
359
  for prop_name, prop_schema in body_props.items():
350
360
  properties[prop_name] = prop_schema
@@ -357,6 +367,22 @@ def _combine_schemas_and_map_params(
357
367
 
358
368
  if route.request_body.required:
359
369
  required.extend(body_schema.get("required", []))
370
+ else:
371
+ # Handle direct array/primitive schemas (like list[str] parameters from FastAPI)
372
+ # Use the schema title as parameter name, fall back to generic name
373
+ param_name = body_schema.get("title", "body").lower()
374
+
375
+ # Clean the parameter name to be valid
376
+ import re
377
+
378
+ param_name = re.sub(r"[^a-zA-Z0-9_]", "_", param_name)
379
+ if not param_name or param_name[0].isdigit():
380
+ param_name = "body_data"
381
+
382
+ properties[param_name] = body_schema
383
+ if route.request_body.required:
384
+ required.append(param_name)
385
+ parameter_map[param_name] = {"location": "body", "openapi_name": param_name}
360
386
 
361
387
  result = {
362
388
  "type": "object",
fastmcp/prompts/prompt.py CHANGED
@@ -100,14 +100,16 @@ class Prompt(FastMCPComponent, ABC):
100
100
  )
101
101
  for arg in self.arguments or []
102
102
  ]
103
- kwargs = {
104
- "name": self.name,
105
- "description": self.description,
106
- "arguments": arguments,
107
- "title": self.title,
108
- "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
109
- }
110
- return MCPPrompt(**kwargs | overrides)
103
+
104
+ return MCPPrompt(
105
+ name=overrides.get("name", self.name),
106
+ description=overrides.get("description", self.description),
107
+ arguments=arguments,
108
+ title=overrides.get("title", self.title),
109
+ _meta=overrides.get(
110
+ "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
111
+ ),
112
+ )
111
113
 
112
114
  @staticmethod
113
115
  def from_function(
@@ -24,6 +24,7 @@ from fastmcp.server.dependencies import get_context
24
24
  from fastmcp.utilities.components import FastMCPComponent
25
25
  from fastmcp.utilities.types import (
26
26
  find_kwarg_by_type,
27
+ get_fn_name,
27
28
  )
28
29
 
29
30
  if TYPE_CHECKING:
@@ -122,16 +123,18 @@ class Resource(FastMCPComponent, abc.ABC):
122
123
  **overrides: Any,
123
124
  ) -> MCPResource:
124
125
  """Convert the resource to an MCPResource."""
125
- kwargs = {
126
- "uri": self.uri,
127
- "name": self.name,
128
- "description": self.description,
129
- "mimeType": self.mime_type,
130
- "title": self.title,
131
- "annotations": self.annotations,
132
- "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
133
- }
134
- return MCPResource(**kwargs | overrides)
126
+
127
+ return MCPResource(
128
+ name=overrides.get("name", self.name),
129
+ uri=overrides.get("uri", self.uri),
130
+ description=overrides.get("description", self.description),
131
+ mimeType=overrides.get("mimeType", self.mime_type),
132
+ title=overrides.get("title", self.title),
133
+ annotations=overrides.get("annotations", self.annotations),
134
+ _meta=overrides.get(
135
+ "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
136
+ ),
137
+ )
135
138
 
136
139
  def __repr__(self) -> str:
137
140
  return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
@@ -182,7 +185,7 @@ class FunctionResource(Resource):
182
185
  return cls(
183
186
  fn=fn,
184
187
  uri=uri,
185
- name=name or fn.__name__,
188
+ name=name or get_fn_name(fn),
186
189
  title=title,
187
190
  description=description or inspect.getdoc(fn),
188
191
  mime_type=mime_type or "text/plain",
@@ -154,16 +154,18 @@ class ResourceTemplate(FastMCPComponent):
154
154
  **overrides: Any,
155
155
  ) -> MCPResourceTemplate:
156
156
  """Convert the resource template to an MCPResourceTemplate."""
157
- kwargs = {
158
- "uriTemplate": self.uri_template,
159
- "name": self.name,
160
- "description": self.description,
161
- "mimeType": self.mime_type,
162
- "title": self.title,
163
- "annotations": self.annotations,
164
- "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
165
- }
166
- return MCPResourceTemplate(**kwargs | overrides)
157
+
158
+ return MCPResourceTemplate(
159
+ name=overrides.get("name", self.name),
160
+ uriTemplate=overrides.get("uriTemplate", self.uri_template),
161
+ description=overrides.get("description", self.description),
162
+ mimeType=overrides.get("mimeType", self.mime_type),
163
+ title=overrides.get("title", self.title),
164
+ annotations=overrides.get("annotations", self.annotations),
165
+ _meta=overrides.get(
166
+ "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
167
+ ),
168
+ )
167
169
 
168
170
  @classmethod
169
171
  def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
- from urllib.parse import urljoin
5
4
 
6
5
  from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
7
6
  from mcp.server.auth.middleware.bearer_auth import (
@@ -146,8 +145,9 @@ class AuthProvider(TokenVerifierProtocol):
146
145
  return None
147
146
 
148
147
  if path:
149
- return AnyHttpUrl(urljoin(str(self.base_url), path))
150
-
148
+ prefix = str(self.base_url).rstrip("/")
149
+ suffix = path.lstrip("/")
150
+ return AnyHttpUrl(f"{prefix}/{suffix}")
151
151
  return self.base_url
152
152
 
153
153
 
@@ -353,10 +353,16 @@ class OAuthProvider(
353
353
 
354
354
  # Add protected resource routes if this server is also acting as a resource server
355
355
  if resource_url:
356
+ supported_scopes = (
357
+ self.client_registration_options.valid_scopes
358
+ if self.client_registration_options
359
+ and self.client_registration_options.valid_scopes
360
+ else self.required_scopes
361
+ )
356
362
  protected_routes = create_protected_resource_routes(
357
363
  resource_url=resource_url,
358
364
  authorization_servers=[self.issuer_url],
359
- scopes_supported=self.required_scopes,
365
+ scopes_supported=supported_scopes,
360
366
  )
361
367
  oauth_routes.extend(protected_routes)
362
368