fastmcp 2.8.1__py3-none-any.whl → 2.9.1__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 (43) hide show
  1. fastmcp/cli/cli.py +99 -1
  2. fastmcp/cli/run.py +1 -3
  3. fastmcp/client/auth/oauth.py +1 -2
  4. fastmcp/client/client.py +23 -7
  5. fastmcp/client/logging.py +1 -2
  6. fastmcp/client/messages.py +126 -0
  7. fastmcp/client/transports.py +17 -2
  8. fastmcp/contrib/mcp_mixin/README.md +79 -2
  9. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  10. fastmcp/prompts/prompt.py +109 -13
  11. fastmcp/prompts/prompt_manager.py +119 -43
  12. fastmcp/resources/resource.py +27 -1
  13. fastmcp/resources/resource_manager.py +249 -76
  14. fastmcp/resources/template.py +44 -2
  15. fastmcp/server/auth/providers/bearer.py +62 -13
  16. fastmcp/server/context.py +113 -10
  17. fastmcp/server/http.py +8 -0
  18. fastmcp/server/low_level.py +35 -0
  19. fastmcp/server/middleware/__init__.py +6 -0
  20. fastmcp/server/middleware/error_handling.py +206 -0
  21. fastmcp/server/middleware/logging.py +165 -0
  22. fastmcp/server/middleware/middleware.py +236 -0
  23. fastmcp/server/middleware/rate_limiting.py +231 -0
  24. fastmcp/server/middleware/timing.py +156 -0
  25. fastmcp/server/proxy.py +250 -140
  26. fastmcp/server/server.py +446 -280
  27. fastmcp/settings.py +2 -2
  28. fastmcp/tools/tool.py +22 -2
  29. fastmcp/tools/tool_manager.py +114 -45
  30. fastmcp/tools/tool_transform.py +42 -16
  31. fastmcp/utilities/components.py +22 -2
  32. fastmcp/utilities/inspect.py +326 -0
  33. fastmcp/utilities/json_schema.py +67 -23
  34. fastmcp/utilities/mcp_config.py +13 -7
  35. fastmcp/utilities/openapi.py +75 -5
  36. fastmcp/utilities/tests.py +1 -1
  37. fastmcp/utilities/types.py +90 -1
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
  39. fastmcp-2.9.1.dist-info/RECORD +78 -0
  40. fastmcp-2.8.1.dist-info/RECORD +0 -69
  41. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
  42. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
  43. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,326 @@
1
+ """Utilities for inspecting FastMCP instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from mcp.server.fastmcp import FastMCP as FastMCP1x
10
+
11
+ import fastmcp
12
+ from fastmcp.server.server import FastMCP
13
+
14
+
15
+ @dataclass
16
+ class ToolInfo:
17
+ """Information about a tool."""
18
+
19
+ key: str
20
+ name: str
21
+ description: str | None
22
+ input_schema: dict[str, Any]
23
+ annotations: dict[str, Any] | None = None
24
+ tags: list[str] | None = None
25
+ enabled: bool | None = None
26
+
27
+
28
+ @dataclass
29
+ class PromptInfo:
30
+ """Information about a prompt."""
31
+
32
+ key: str
33
+ name: str
34
+ description: str | None
35
+ arguments: list[dict[str, Any]] | None = None
36
+ tags: list[str] | None = None
37
+ enabled: bool | None = None
38
+
39
+
40
+ @dataclass
41
+ class ResourceInfo:
42
+ """Information about a resource."""
43
+
44
+ key: str
45
+ uri: str
46
+ name: str | None
47
+ description: str | None
48
+ mime_type: str | None = None
49
+ tags: list[str] | None = None
50
+ enabled: bool | None = None
51
+
52
+
53
+ @dataclass
54
+ class TemplateInfo:
55
+ """Information about a resource template."""
56
+
57
+ key: str
58
+ uri_template: str
59
+ name: str | None
60
+ description: str | None
61
+ mime_type: str | None = None
62
+ tags: list[str] | None = None
63
+ enabled: bool | None = None
64
+
65
+
66
+ @dataclass
67
+ class FastMCPInfo:
68
+ """Information extracted from a FastMCP instance."""
69
+
70
+ name: str
71
+ instructions: str | None
72
+ fastmcp_version: str
73
+ mcp_version: str
74
+ server_version: str
75
+ tools: list[ToolInfo]
76
+ prompts: list[PromptInfo]
77
+ resources: list[ResourceInfo]
78
+ templates: list[TemplateInfo]
79
+ capabilities: dict[str, Any]
80
+
81
+
82
+ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
83
+ """Extract information from a FastMCP v2.x instance.
84
+
85
+ Args:
86
+ mcp: The FastMCP v2.x instance to inspect
87
+
88
+ Returns:
89
+ FastMCPInfo dataclass containing the extracted information
90
+ """
91
+ # Get all the components using FastMCP2's direct methods
92
+ tools_dict = await mcp.get_tools()
93
+ prompts_dict = await mcp.get_prompts()
94
+ resources_dict = await mcp.get_resources()
95
+ templates_dict = await mcp.get_resource_templates()
96
+
97
+ # Extract detailed tool information
98
+ tool_infos = []
99
+ for key, tool in tools_dict.items():
100
+ # Convert to MCP tool to get input schema
101
+ mcp_tool = tool.to_mcp_tool(name=key)
102
+ tool_infos.append(
103
+ ToolInfo(
104
+ key=key,
105
+ name=tool.name or key,
106
+ description=tool.description,
107
+ input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
108
+ annotations=tool.annotations.model_dump() if tool.annotations else None,
109
+ tags=list(tool.tags) if tool.tags else None,
110
+ enabled=tool.enabled,
111
+ )
112
+ )
113
+
114
+ # Extract detailed prompt information
115
+ prompt_infos = []
116
+ for key, prompt in prompts_dict.items():
117
+ prompt_infos.append(
118
+ PromptInfo(
119
+ key=key,
120
+ name=prompt.name or key,
121
+ description=prompt.description,
122
+ arguments=[arg.model_dump() for arg in prompt.arguments]
123
+ if prompt.arguments
124
+ else None,
125
+ tags=list(prompt.tags) if prompt.tags else None,
126
+ enabled=prompt.enabled,
127
+ )
128
+ )
129
+
130
+ # Extract detailed resource information
131
+ resource_infos = []
132
+ for key, resource in resources_dict.items():
133
+ resource_infos.append(
134
+ ResourceInfo(
135
+ key=key,
136
+ uri=key, # For v2, key is the URI
137
+ name=resource.name,
138
+ description=resource.description,
139
+ mime_type=resource.mime_type,
140
+ tags=list(resource.tags) if resource.tags else None,
141
+ enabled=resource.enabled,
142
+ )
143
+ )
144
+
145
+ # Extract detailed template information
146
+ template_infos = []
147
+ for key, template in templates_dict.items():
148
+ template_infos.append(
149
+ TemplateInfo(
150
+ key=key,
151
+ uri_template=key, # For v2, key is the URI template
152
+ name=template.name,
153
+ description=template.description,
154
+ mime_type=template.mime_type,
155
+ tags=list(template.tags) if template.tags else None,
156
+ enabled=template.enabled,
157
+ )
158
+ )
159
+
160
+ # Basic MCP capabilities that FastMCP supports
161
+ capabilities = {
162
+ "tools": {"listChanged": True},
163
+ "resources": {"subscribe": False, "listChanged": False},
164
+ "prompts": {"listChanged": False},
165
+ "logging": {},
166
+ }
167
+
168
+ return FastMCPInfo(
169
+ name=mcp.name,
170
+ instructions=mcp.instructions,
171
+ fastmcp_version=fastmcp.__version__,
172
+ mcp_version=importlib.metadata.version("mcp"),
173
+ server_version=fastmcp.__version__, # v2.x uses FastMCP version
174
+ tools=tool_infos,
175
+ prompts=prompt_infos,
176
+ resources=resource_infos,
177
+ templates=template_infos,
178
+ capabilities=capabilities,
179
+ )
180
+
181
+
182
+ async def inspect_fastmcp_v1(mcp: Any) -> FastMCPInfo:
183
+ """Extract information from a FastMCP v1.x instance using a Client.
184
+
185
+ Args:
186
+ mcp: The FastMCP v1.x instance to inspect
187
+
188
+ Returns:
189
+ FastMCPInfo dataclass containing the extracted information
190
+ """
191
+ from fastmcp import Client
192
+
193
+ # Use a client to interact with the FastMCP1x server
194
+ async with Client(mcp) as client:
195
+ # Get components via client calls (these return MCP objects)
196
+ mcp_tools = await client.list_tools()
197
+ mcp_prompts = await client.list_prompts()
198
+ mcp_resources = await client.list_resources()
199
+
200
+ # Try to get resource templates (FastMCP 1.x does have templates)
201
+ try:
202
+ mcp_templates = await client.list_resource_templates()
203
+ except Exception:
204
+ mcp_templates = []
205
+
206
+ # Extract detailed tool information from MCP Tool objects
207
+ tool_infos = []
208
+ for mcp_tool in mcp_tools:
209
+ # Extract annotations if they exist
210
+ annotations = None
211
+ if hasattr(mcp_tool, "annotations") and mcp_tool.annotations:
212
+ if hasattr(mcp_tool.annotations, "model_dump"):
213
+ annotations = mcp_tool.annotations.model_dump()
214
+ elif isinstance(mcp_tool.annotations, dict):
215
+ annotations = mcp_tool.annotations
216
+ else:
217
+ annotations = None
218
+
219
+ tool_infos.append(
220
+ ToolInfo(
221
+ key=mcp_tool.name, # For 1.x, key and name are the same
222
+ name=mcp_tool.name,
223
+ description=mcp_tool.description,
224
+ input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
225
+ annotations=annotations,
226
+ tags=None, # 1.x doesn't have tags
227
+ enabled=None, # 1.x doesn't have enabled field
228
+ )
229
+ )
230
+
231
+ # Extract detailed prompt information from MCP Prompt objects
232
+ prompt_infos = []
233
+ for mcp_prompt in mcp_prompts:
234
+ # Convert arguments if they exist
235
+ arguments = None
236
+ if hasattr(mcp_prompt, "arguments") and mcp_prompt.arguments:
237
+ arguments = [arg.model_dump() for arg in mcp_prompt.arguments]
238
+
239
+ prompt_infos.append(
240
+ PromptInfo(
241
+ key=mcp_prompt.name, # For 1.x, key and name are the same
242
+ name=mcp_prompt.name,
243
+ description=mcp_prompt.description,
244
+ arguments=arguments,
245
+ tags=None, # 1.x doesn't have tags
246
+ enabled=None, # 1.x doesn't have enabled field
247
+ )
248
+ )
249
+
250
+ # Extract detailed resource information from MCP Resource objects
251
+ resource_infos = []
252
+ for mcp_resource in mcp_resources:
253
+ resource_infos.append(
254
+ ResourceInfo(
255
+ key=str(mcp_resource.uri), # For 1.x, key and uri are the same
256
+ uri=str(mcp_resource.uri),
257
+ name=mcp_resource.name,
258
+ description=mcp_resource.description,
259
+ mime_type=mcp_resource.mimeType,
260
+ tags=None, # 1.x doesn't have tags
261
+ enabled=None, # 1.x doesn't have enabled field
262
+ )
263
+ )
264
+
265
+ # Extract detailed template information from MCP ResourceTemplate objects
266
+ template_infos = []
267
+ for mcp_template in mcp_templates:
268
+ template_infos.append(
269
+ TemplateInfo(
270
+ key=str(
271
+ mcp_template.uriTemplate
272
+ ), # For 1.x, key and uriTemplate are the same
273
+ uri_template=str(mcp_template.uriTemplate),
274
+ name=mcp_template.name,
275
+ description=mcp_template.description,
276
+ mime_type=mcp_template.mimeType,
277
+ tags=None, # 1.x doesn't have tags
278
+ enabled=None, # 1.x doesn't have enabled field
279
+ )
280
+ )
281
+
282
+ # Basic MCP capabilities
283
+ capabilities = {
284
+ "tools": {"listChanged": True},
285
+ "resources": {"subscribe": False, "listChanged": False},
286
+ "prompts": {"listChanged": False},
287
+ "logging": {},
288
+ }
289
+
290
+ return FastMCPInfo(
291
+ name=mcp.name,
292
+ instructions=getattr(mcp, "instructions", None),
293
+ fastmcp_version=fastmcp.__version__, # Report current fastmcp version
294
+ mcp_version=importlib.metadata.version("mcp"),
295
+ server_version="1.0", # FastMCP 1.x version
296
+ tools=tool_infos,
297
+ prompts=prompt_infos,
298
+ resources=resource_infos,
299
+ templates=template_infos, # FastMCP1x does have templates
300
+ capabilities=capabilities,
301
+ )
302
+
303
+
304
+ def _is_fastmcp_v1(mcp: Any) -> bool:
305
+ """Check if the given instance is a FastMCP v1.x instance."""
306
+
307
+ # Check if it's an instance of FastMCP1x and not FastMCP2
308
+ return isinstance(mcp, FastMCP1x) and not isinstance(mcp, FastMCP)
309
+
310
+
311
+ async def inspect_fastmcp(mcp: FastMCP[Any] | Any) -> FastMCPInfo:
312
+ """Extract information from a FastMCP instance into a dataclass.
313
+
314
+ This function automatically detects whether the instance is FastMCP v1.x or v2.x
315
+ and uses the appropriate extraction method.
316
+
317
+ Args:
318
+ mcp: The FastMCP instance to inspect (v1.x or v2.x)
319
+
320
+ Returns:
321
+ FastMCPInfo dataclass containing the extracted information
322
+ """
323
+ if _is_fastmcp_v1(mcp):
324
+ return await inspect_fastmcp_v1(mcp)
325
+ else:
326
+ return await inspect_fastmcp_v2(mcp)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
+ from collections import defaultdict
4
5
 
5
6
 
6
7
  def _prune_param(schema: dict, param: str) -> dict:
@@ -24,25 +25,77 @@ def _prune_param(schema: dict, param: str) -> dict:
24
25
  return schema
25
26
 
26
27
 
28
+ def _prune_unused_defs(schema: dict) -> dict:
29
+ """Walk the schema and prune unused defs."""
30
+
31
+ root_defs: set[str] = set()
32
+ referenced_by: defaultdict[str, list] = defaultdict(list)
33
+
34
+ defs = schema.get("$defs")
35
+ if defs is None:
36
+ return schema
37
+
38
+ def walk(
39
+ node: object, current_def: str | None = None, skip_defs: bool = False
40
+ ) -> None:
41
+ if isinstance(node, dict):
42
+ # Process $ref for definition tracking
43
+ ref = node.get("$ref")
44
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
45
+ def_name = ref.split("/")[-1]
46
+ if current_def:
47
+ referenced_by[def_name].append(current_def)
48
+ else:
49
+ root_defs.add(def_name)
50
+
51
+ # Walk children
52
+ for k, v in node.items():
53
+ if skip_defs and k == "$defs":
54
+ continue
55
+
56
+ walk(v, current_def=current_def)
57
+
58
+ elif isinstance(node, list):
59
+ for v in node:
60
+ walk(v)
61
+
62
+ # Traverse the schema once, skipping the $defs
63
+ walk(schema, skip_defs=True)
64
+
65
+ # Now figure out what defs reference other defs
66
+ for def_name, value in defs.items():
67
+ walk(value, current_def=def_name)
68
+
69
+ # Figure out what defs were referenced directly or recursively
70
+ def def_is_referenced(def_name):
71
+ if def_name in root_defs:
72
+ return True
73
+ references = referenced_by.get(def_name)
74
+ if references:
75
+ for reference in references:
76
+ if def_is_referenced(reference):
77
+ return True
78
+ return False
79
+
80
+ # Remove orphaned definitions if requested
81
+ for def_name in list(defs):
82
+ if not def_is_referenced(def_name):
83
+ defs.pop(def_name)
84
+ if not defs:
85
+ schema.pop("$defs", None)
86
+
87
+ return schema
88
+
89
+
27
90
  def _walk_and_prune(
28
91
  schema: dict,
29
- prune_defs: bool = False,
30
92
  prune_titles: bool = False,
31
93
  prune_additional_properties: bool = False,
32
94
  ) -> dict:
33
- """Walk the schema and optionally prune titles, unused definitions, and additionalProperties: false."""
34
-
35
- # Will only be used if prune_defs is True
36
- used_defs: set[str] = set()
95
+ """Walk the schema and optionally prune titles and additionalProperties: false."""
37
96
 
38
97
  def walk(node: object) -> None:
39
98
  if isinstance(node, dict):
40
- # Process $ref for definition tracking
41
- if prune_defs:
42
- ref = node.get("$ref")
43
- if isinstance(ref, str) and ref.startswith("#/$defs/"):
44
- used_defs.add(ref.split("/")[-1])
45
-
46
99
  # Remove title if requested
47
100
  if prune_titles and "title" in node:
48
101
  node.pop("title")
@@ -62,18 +115,8 @@ def _walk_and_prune(
62
115
  for v in node:
63
116
  walk(v)
64
117
 
65
- # Traverse the schema once
66
118
  walk(schema)
67
119
 
68
- # Remove orphaned definitions if requested
69
- if prune_defs:
70
- defs = schema.get("$defs", {})
71
- for def_name in list(defs):
72
- if def_name not in used_defs:
73
- defs.pop(def_name)
74
- if not defs:
75
- schema.pop("$defs", None)
76
-
77
120
  return schema
78
121
 
79
122
 
@@ -109,12 +152,13 @@ def compress_schema(
109
152
  schema = _prune_param(schema, param=param)
110
153
 
111
154
  # Do a single walk to handle pruning operations
112
- if prune_defs or prune_titles or prune_additional_properties:
155
+ if prune_titles or prune_additional_properties:
113
156
  schema = _walk_and_prune(
114
157
  schema,
115
- prune_defs=prune_defs,
116
158
  prune_titles=prune_titles,
117
159
  prune_additional_properties=prune_additional_properties,
118
160
  )
161
+ if prune_defs:
162
+ schema = _prune_unused_defs(schema)
119
163
 
120
164
  return schema
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from typing import TYPE_CHECKING, Annotated, Any, Literal
4
5
  from urllib.parse import urlparse
5
6
 
6
- from pydantic import AnyUrl, Field
7
+ import httpx
8
+ from pydantic import AnyUrl, ConfigDict, Field
7
9
 
8
10
  from fastmcp.utilities.types import FastMCPBaseModel
9
11
 
@@ -17,7 +19,7 @@ if TYPE_CHECKING:
17
19
 
18
20
  def infer_transport_type_from_url(
19
21
  url: str | AnyUrl,
20
- ) -> Literal["streamable-http", "sse"]:
22
+ ) -> Literal["http", "sse"]:
21
23
  """
22
24
  Infer the appropriate transport type from the given URL.
23
25
  """
@@ -28,10 +30,11 @@ def infer_transport_type_from_url(
28
30
  parsed_url = urlparse(url)
29
31
  path = parsed_url.path
30
32
 
31
- if "/sse/" in path or path.rstrip("/").endswith("/sse"):
33
+ # Match /sse followed by /, ?, &, or end of string
34
+ if re.search(r"/sse(/|\?|&|$)", path):
32
35
  return "sse"
33
36
  else:
34
- return "streamable-http"
37
+ return "http"
35
38
 
36
39
 
37
40
  class StdioMCPServer(FastMCPBaseModel):
@@ -55,14 +58,16 @@ class StdioMCPServer(FastMCPBaseModel):
55
58
  class RemoteMCPServer(FastMCPBaseModel):
56
59
  url: str
57
60
  headers: dict[str, str] = Field(default_factory=dict)
58
- transport: Literal["streamable-http", "sse"] | None = None
61
+ transport: Literal["http", "streamable-http", "sse"] | None = None
59
62
  auth: Annotated[
60
- str | Literal["oauth"] | None,
63
+ str | Literal["oauth"] | httpx.Auth | None,
61
64
  Field(
62
- description='Either a string representing a Bearer token or the literal "oauth" to use OAuth authentication.'
65
+ description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
63
66
  ),
64
67
  ] = None
65
68
 
69
+ model_config = ConfigDict(arbitrary_types_allowed=True)
70
+
66
71
  def to_transport(self) -> StreamableHttpTransport | SSETransport:
67
72
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
68
73
 
@@ -74,6 +79,7 @@ class RemoteMCPServer(FastMCPBaseModel):
74
79
  if transport == "sse":
75
80
  return SSETransport(self.url, headers=self.headers, auth=self.auth)
76
81
  else:
82
+ # Both "http" and "streamable-http" map to StreamableHttpTransport
77
83
  return StreamableHttpTransport(
78
84
  self.url, headers=self.headers, auth=self.auth
79
85
  )
@@ -262,16 +262,24 @@ class OpenAPIParser(
262
262
 
263
263
  if isinstance(resolved_schema, (self.schema_cls)):
264
264
  # Convert schema to dictionary
265
- return resolved_schema.model_dump(
265
+ result = resolved_schema.model_dump(
266
266
  mode="json", by_alias=True, exclude_none=True
267
267
  )
268
268
  elif isinstance(resolved_schema, dict):
269
- return resolved_schema
269
+ result = resolved_schema
270
270
  else:
271
271
  logger.warning(
272
272
  f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
273
273
  )
274
- return {}
274
+ result = {}
275
+
276
+ return _replace_ref_with_defs(result)
277
+ except ValueError as e:
278
+ # Re-raise ValueError for external reference errors and other validation issues
279
+ if "External or non-local reference not supported" in str(e):
280
+ raise
281
+ logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
282
+ return {}
275
283
  except Exception as e:
276
284
  logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
277
285
  return {}
@@ -300,11 +308,17 @@ class OpenAPIParser(
300
308
 
301
309
  # Extract parameter info - handle both 3.0 and 3.1 parameter models
302
310
  param_in = parameter.param_in # Both use param_in
303
- param_location = self._convert_to_parameter_location(param_in)
311
+ # Handle enum or string parameter locations
312
+ from enum import Enum
313
+
314
+ param_in_str = (
315
+ param_in.value if isinstance(param_in, Enum) else param_in
316
+ )
317
+ param_location = self._convert_to_parameter_location(param_in_str)
304
318
  param_schema_obj = parameter.param_schema # Both use param_schema
305
319
 
306
320
  # Skip duplicate parameters (same name and location)
307
- param_key = (parameter.name, param_in)
321
+ param_key = (parameter.name, param_in_str)
308
322
  if param_key in seen_params:
309
323
  continue
310
324
  seen_params[param_key] = True
@@ -398,12 +412,30 @@ class OpenAPIParser(
398
412
  request_body_info.content_schema[media_type_str] = (
399
413
  schema_dict
400
414
  )
415
+ except ValueError as e:
416
+ # Re-raise ValueError for external reference errors
417
+ if "External or non-local reference not supported" in str(
418
+ e
419
+ ):
420
+ raise
421
+ logger.error(
422
+ f"Failed to extract schema for media type '{media_type_str}': {e}"
423
+ )
401
424
  except Exception as e:
402
425
  logger.error(
403
426
  f"Failed to extract schema for media type '{media_type_str}': {e}"
404
427
  )
405
428
 
406
429
  return request_body_info
430
+ except ValueError as e:
431
+ # Re-raise ValueError for external reference errors
432
+ if "External or non-local reference not supported" in str(e):
433
+ raise
434
+ ref_name = getattr(request_body_or_ref, "ref", "unknown")
435
+ logger.error(
436
+ f"Failed to extract request body '{ref_name}': {e}", exc_info=False
437
+ )
438
+ return None
407
439
  except Exception as e:
408
440
  ref_name = getattr(request_body_or_ref, "ref", "unknown")
409
441
  logger.error(
@@ -447,6 +479,17 @@ class OpenAPIParser(
447
479
  media_type_obj.media_type_schema
448
480
  )
449
481
  resp_info.content_schema[media_type_str] = schema_dict
482
+ except ValueError as e:
483
+ # Re-raise ValueError for external reference errors
484
+ if (
485
+ "External or non-local reference not supported"
486
+ in str(e)
487
+ ):
488
+ raise
489
+ logger.error(
490
+ f"Failed to extract schema for media type '{media_type_str}' "
491
+ f"in response {status_code}: {e}"
492
+ )
450
493
  except Exception as e:
451
494
  logger.error(
452
495
  f"Failed to extract schema for media type '{media_type_str}' "
@@ -454,6 +497,16 @@ class OpenAPIParser(
454
497
  )
455
498
 
456
499
  extracted_responses[str(status_code)] = resp_info
500
+ except ValueError as e:
501
+ # Re-raise ValueError for external reference errors
502
+ if "External or non-local reference not supported" in str(e):
503
+ raise
504
+ ref_name = getattr(resp_or_ref, "ref", "unknown")
505
+ logger.error(
506
+ f"Failed to extract response for status code {status_code} "
507
+ f"from reference '{ref_name}': {e}",
508
+ exc_info=False,
509
+ )
457
510
  except Exception as e:
458
511
  ref_name = getattr(resp_or_ref, "ref", "unknown")
459
512
  logger.error(
@@ -554,6 +607,17 @@ class OpenAPIParser(
554
607
  logger.info(
555
608
  f"Successfully extracted route: {method_upper} {path_str}"
556
609
  )
610
+ except ValueError as op_error:
611
+ # Re-raise ValueError for external reference errors
612
+ if "External or non-local reference not supported" in str(
613
+ op_error
614
+ ):
615
+ raise
616
+ op_id = getattr(operation, "operationId", "unknown")
617
+ logger.error(
618
+ f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
619
+ exc_info=True,
620
+ )
557
621
  except Exception as op_error:
558
622
  op_id = getattr(operation, "operationId", "unknown")
559
623
  logger.error(
@@ -899,6 +963,12 @@ def _replace_ref_with_defs(
899
963
  if ref_path.startswith("#/components/schemas/"):
900
964
  schema_name = ref_path.split("/")[-1]
901
965
  schema["$ref"] = f"#/$defs/{schema_name}"
966
+ elif not ref_path.startswith("#/"):
967
+ raise ValueError(
968
+ f"External or non-local reference not supported: {ref_path}. "
969
+ f"FastMCP only supports local schema references starting with '#/'. "
970
+ f"Please include all schema definitions within the OpenAPI document."
971
+ )
902
972
  elif properties := schema.get("properties"):
903
973
  if "$ref" in properties:
904
974
  schema["properties"] = _replace_ref_with_defs(properties)
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
20
20
  @contextmanager
21
21
  def temporary_settings(**kwargs: Any):
22
22
  """
23
- Temporarily override ControlFlow setting values.
23
+ Temporarily override FastMCP setting values.
24
24
 
25
25
  Args:
26
26
  **kwargs: The settings to override, including nested settings.