fastmcp 2.12.5__py3-none-any.whl → 2.14.0__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 (133) hide show
  1. fastmcp/__init__.py +2 -23
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +19 -33
  5. fastmcp/cli/install/claude_code.py +6 -6
  6. fastmcp/cli/install/claude_desktop.py +3 -3
  7. fastmcp/cli/install/cursor.py +18 -12
  8. fastmcp/cli/install/gemini_cli.py +3 -3
  9. fastmcp/cli/install/mcp_json.py +3 -3
  10. fastmcp/cli/install/shared.py +0 -15
  11. fastmcp/cli/run.py +13 -8
  12. fastmcp/cli/tasks.py +110 -0
  13. fastmcp/client/__init__.py +9 -9
  14. fastmcp/client/auth/oauth.py +123 -225
  15. fastmcp/client/client.py +697 -95
  16. fastmcp/client/elicitation.py +11 -5
  17. fastmcp/client/logging.py +18 -14
  18. fastmcp/client/messages.py +7 -5
  19. fastmcp/client/oauth_callback.py +85 -171
  20. fastmcp/client/roots.py +2 -1
  21. fastmcp/client/sampling.py +1 -1
  22. fastmcp/client/tasks.py +614 -0
  23. fastmcp/client/transports.py +117 -30
  24. fastmcp/contrib/component_manager/__init__.py +1 -1
  25. fastmcp/contrib/component_manager/component_manager.py +2 -2
  26. fastmcp/contrib/component_manager/component_service.py +10 -26
  27. fastmcp/contrib/mcp_mixin/README.md +32 -1
  28. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  29. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  30. fastmcp/dependencies.py +25 -0
  31. fastmcp/experimental/sampling/handlers/openai.py +3 -3
  32. fastmcp/experimental/server/openapi/__init__.py +20 -21
  33. fastmcp/experimental/utilities/openapi/__init__.py +16 -47
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +54 -51
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +43 -21
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +161 -61
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -14
  45. fastmcp/server/auth/auth.py +197 -46
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1469 -298
  50. fastmcp/server/auth/oidc_proxy.py +91 -20
  51. fastmcp/server/auth/providers/auth0.py +40 -21
  52. fastmcp/server/auth/providers/aws.py +29 -3
  53. fastmcp/server/auth/providers/azure.py +312 -131
  54. fastmcp/server/auth/providers/debug.py +114 -0
  55. fastmcp/server/auth/providers/descope.py +86 -29
  56. fastmcp/server/auth/providers/discord.py +308 -0
  57. fastmcp/server/auth/providers/github.py +29 -8
  58. fastmcp/server/auth/providers/google.py +48 -9
  59. fastmcp/server/auth/providers/in_memory.py +29 -5
  60. fastmcp/server/auth/providers/introspection.py +281 -0
  61. fastmcp/server/auth/providers/jwt.py +48 -31
  62. fastmcp/server/auth/providers/oci.py +233 -0
  63. fastmcp/server/auth/providers/scalekit.py +238 -0
  64. fastmcp/server/auth/providers/supabase.py +188 -0
  65. fastmcp/server/auth/providers/workos.py +35 -17
  66. fastmcp/server/context.py +236 -116
  67. fastmcp/server/dependencies.py +503 -18
  68. fastmcp/server/elicitation.py +286 -48
  69. fastmcp/server/event_store.py +177 -0
  70. fastmcp/server/http.py +71 -20
  71. fastmcp/server/low_level.py +165 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +50 -39
  76. fastmcp/server/middleware/middleware.py +29 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi/__init__.py +35 -0
  80. fastmcp/{experimental/server → server}/openapi/components.py +15 -10
  81. fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
  82. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  83. fastmcp/server/proxy.py +72 -48
  84. fastmcp/server/server.py +1415 -733
  85. fastmcp/server/tasks/__init__.py +21 -0
  86. fastmcp/server/tasks/capabilities.py +22 -0
  87. fastmcp/server/tasks/config.py +89 -0
  88. fastmcp/server/tasks/converters.py +205 -0
  89. fastmcp/server/tasks/handlers.py +356 -0
  90. fastmcp/server/tasks/keys.py +93 -0
  91. fastmcp/server/tasks/protocol.py +355 -0
  92. fastmcp/server/tasks/subscriptions.py +205 -0
  93. fastmcp/settings.py +125 -113
  94. fastmcp/tools/__init__.py +1 -1
  95. fastmcp/tools/tool.py +138 -55
  96. fastmcp/tools/tool_manager.py +30 -112
  97. fastmcp/tools/tool_transform.py +12 -21
  98. fastmcp/utilities/cli.py +67 -28
  99. fastmcp/utilities/components.py +10 -5
  100. fastmcp/utilities/inspect.py +79 -23
  101. fastmcp/utilities/json_schema.py +4 -4
  102. fastmcp/utilities/json_schema_type.py +8 -8
  103. fastmcp/utilities/logging.py +118 -8
  104. fastmcp/utilities/mcp_config.py +1 -2
  105. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  106. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  107. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  108. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
  109. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  110. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  111. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  112. fastmcp/utilities/openapi/__init__.py +63 -0
  113. fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
  114. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  115. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
  116. fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
  117. fastmcp/utilities/tests.py +92 -5
  118. fastmcp/utilities/types.py +86 -16
  119. fastmcp/utilities/ui.py +626 -0
  120. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
  121. fastmcp-2.14.0.dist-info/RECORD +156 -0
  122. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
  123. fastmcp/cli/claude.py +0 -135
  124. fastmcp/server/auth/providers/bearer.py +0 -25
  125. fastmcp/server/openapi.py +0 -1083
  126. fastmcp/utilities/openapi.py +0 -1568
  127. fastmcp/utilities/storage.py +0 -204
  128. fastmcp-2.12.5.dist-info/RECORD +0 -134
  129. fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  130. fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
  131. fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
  132. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
  133. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
@@ -6,6 +6,7 @@ from typing import Any, Literal, cast
6
6
 
7
7
  from rich.console import Console
8
8
  from rich.logging import RichHandler
9
+ from typing_extensions import override
9
10
 
10
11
  import fastmcp
11
12
 
@@ -19,7 +20,10 @@ def get_logger(name: str) -> logging.Logger:
19
20
  Returns:
20
21
  a configured logger instance
21
22
  """
22
- return logging.getLogger(f"fastmcp.{name}")
23
+ if name.startswith("fastmcp."):
24
+ return logging.getLogger(name=name)
25
+
26
+ return logging.getLogger(name=f"fastmcp.{name}")
23
27
 
24
28
 
25
29
  def configure_logging(
@@ -47,25 +51,52 @@ def configure_logging(
47
51
  if logger is None:
48
52
  logger = logging.getLogger("fastmcp")
49
53
 
50
- # Only configure the FastMCP logger namespace
54
+ formatter = logging.Formatter("%(message)s")
55
+
56
+ # Don't propagate to the root logger
57
+ logger.propagate = False
58
+ logger.setLevel(level)
59
+
60
+ # Configure the handler for normal logs
51
61
  handler = RichHandler(
52
62
  console=Console(stderr=True),
53
- rich_tracebacks=enable_rich_tracebacks,
54
63
  **rich_kwargs,
55
64
  )
56
- formatter = logging.Formatter("%(message)s")
57
65
  handler.setFormatter(formatter)
58
66
 
59
- logger.setLevel(level)
67
+ # filter to exclude tracebacks
68
+ handler.addFilter(lambda record: record.exc_info is None)
69
+
70
+ # Configure the handler for tracebacks, for tracebacks we use a compressed format:
71
+ # no path or level name to maximize width available for the traceback
72
+ # suppress framework frames and limit the number of frames to 3
73
+
74
+ import mcp
75
+ import pydantic
76
+
77
+ # Build traceback kwargs with defaults that can be overridden
78
+ traceback_kwargs = {
79
+ "console": Console(stderr=True),
80
+ "show_path": False,
81
+ "show_level": False,
82
+ "rich_tracebacks": enable_rich_tracebacks,
83
+ "tracebacks_max_frames": 3,
84
+ "tracebacks_suppress": [fastmcp, mcp, pydantic],
85
+ }
86
+ # Override defaults with user-provided values
87
+ traceback_kwargs.update(rich_kwargs)
88
+
89
+ traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type]
90
+ traceback_handler.setFormatter(formatter)
91
+
92
+ traceback_handler.addFilter(lambda record: record.exc_info is not None)
60
93
 
61
94
  # Remove any existing handlers to avoid duplicates on reconfiguration
62
95
  for hdlr in logger.handlers[:]:
63
96
  logger.removeHandler(hdlr)
64
97
 
65
98
  logger.addHandler(handler)
66
-
67
- # Don't propagate to the root logger
68
- logger.propagate = False
99
+ logger.addHandler(traceback_handler)
69
100
 
70
101
 
71
102
  @contextlib.contextmanager
@@ -118,3 +149,82 @@ def temporary_log_level(
118
149
  )
119
150
  else:
120
151
  yield
152
+
153
+
154
+ _level_to_no: dict[
155
+ Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, int | None
156
+ ] = {
157
+ "DEBUG": logging.DEBUG,
158
+ "INFO": logging.INFO,
159
+ "WARNING": logging.WARNING,
160
+ "ERROR": logging.ERROR,
161
+ "CRITICAL": logging.CRITICAL,
162
+ None: None,
163
+ }
164
+
165
+
166
+ class _ClampedLogFilter(logging.Filter):
167
+ min_level: tuple[int, str] | None
168
+ max_level: tuple[int, str] | None
169
+
170
+ def __init__(
171
+ self,
172
+ min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
173
+ | None = None,
174
+ max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
175
+ | None = None,
176
+ ):
177
+ self.min_level = None
178
+ self.max_level = None
179
+
180
+ if min_level_no := _level_to_no.get(min_level):
181
+ self.min_level = (min_level_no, str(min_level))
182
+ if max_level_no := _level_to_no.get(max_level):
183
+ self.max_level = (max_level_no, str(max_level))
184
+
185
+ super().__init__()
186
+
187
+ @override
188
+ def filter(self, record: logging.LogRecord) -> bool:
189
+ if self.max_level:
190
+ max_level_no, max_level_name = self.max_level
191
+
192
+ if record.levelno > max_level_no:
193
+ record.levelno = max_level_no
194
+ record.levelname = max_level_name
195
+ return True
196
+
197
+ if self.min_level:
198
+ min_level_no, min_level_name = self.min_level
199
+ if record.levelno < min_level_no:
200
+ record.levelno = min_level_no
201
+ record.levelname = min_level_name
202
+ return True
203
+
204
+ return True
205
+
206
+
207
+ def _clamp_logger(
208
+ logger: logging.Logger,
209
+ min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
210
+ max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
211
+ ) -> None:
212
+ """Clamp the logger to a minimum and maximum level.
213
+
214
+ If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`.
215
+ If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`.
216
+
217
+ Args:
218
+ min_level: The lower bound of the clamp
219
+ max_level: The upper bound of the clamp
220
+ """
221
+ _unclamp_logger(logger=logger)
222
+
223
+ logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level))
224
+
225
+
226
+ def _unclamp_logger(logger: logging.Logger) -> None:
227
+ """Remove all clamped log filters from the logger."""
228
+ for filter in logger.filters[:]:
229
+ if isinstance(filter, _ClampedLogFilter):
230
+ logger.removeFilter(filter)
@@ -43,8 +43,7 @@ def mcp_server_type_to_servers_and_transports(
43
43
 
44
44
  if isinstance(mcp_server, TransformingRemoteMCPServer | TransformingStdioMCPServer):
45
45
  server, transport = mcp_server._to_server_and_underlying_transport(
46
- server_name=server_name,
47
- client_name=client_name,
46
+ server_name=server_name, client_name=client_name
48
47
  )
49
48
  else:
50
49
  transport = mcp_server.to_transport()
@@ -15,11 +15,11 @@ from fastmcp.utilities.mcp_server_config.v1.sources.base import Source
15
15
  from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
16
16
 
17
17
  __all__ = [
18
- "Source",
19
18
  "Deployment",
20
19
  "Environment",
21
- "UVEnvironment",
22
- "MCPServerConfig",
23
20
  "FileSystemSource",
21
+ "MCPServerConfig",
22
+ "Source",
23
+ "UVEnvironment",
24
24
  "generate_schema",
25
25
  ]
@@ -19,7 +19,6 @@ class Environment(BaseModel, ABC):
19
19
  Returns:
20
20
  Full command ready for subprocess execution
21
21
  """
22
- pass
23
22
 
24
23
  async def prepare(self, output_dir: Path | None = None) -> None:
25
24
  """Prepare the environment (optional, can be no-op).
@@ -27,4 +26,4 @@ class Environment(BaseModel, ABC):
27
26
  Args:
28
27
  output_dir: Directory for persistent environment setup
29
28
  """
30
- pass # Default no-op implementation
29
+ # Default no-op implementation
@@ -28,19 +28,19 @@ class UVEnvironment(Environment):
28
28
  examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
29
29
  )
30
30
 
31
- requirements: str | None = Field(
31
+ requirements: Path | None = Field(
32
32
  default=None,
33
33
  description="Path to requirements.txt file",
34
34
  examples=["requirements.txt", "../requirements/prod.txt"],
35
35
  )
36
36
 
37
- project: str | None = Field(
37
+ project: Path | None = Field(
38
38
  default=None,
39
39
  description="Path to project directory containing pyproject.toml",
40
40
  examples=[".", "../my-project"],
41
41
  )
42
42
 
43
- editable: list[str] | None = Field(
43
+ editable: list[Path] | None = Field(
44
44
  default=None,
45
45
  description="Directories to install in editable mode",
46
46
  examples=[[".", "../my-package"], ["/path/to/package"]],
@@ -64,7 +64,7 @@ class UVEnvironment(Environment):
64
64
 
65
65
  # Add project if specified
66
66
  if self.project:
67
- args.extend(["--project", str(self.project)])
67
+ args.extend(["--project", str(self.project.resolve())])
68
68
 
69
69
  # Add Python version if specified (only if no project, as project has its own Python)
70
70
  if self.python and not self.project:
@@ -78,12 +78,12 @@ class UVEnvironment(Environment):
78
78
 
79
79
  # Add requirements file
80
80
  if self.requirements:
81
- args.extend(["--with-requirements", str(self.requirements)])
81
+ args.extend(["--with-requirements", str(self.requirements.resolve())])
82
82
 
83
83
  # Add editable packages
84
84
  if self.editable:
85
85
  for editable_path in self.editable:
86
- args.extend(["--with-editable", str(editable_path)])
86
+ args.extend(["--with-editable", str(editable_path.resolve())])
87
87
 
88
88
  # Add the command
89
89
  args.extend(command)
@@ -192,7 +192,7 @@ class MCPServerConfig(BaseModel):
192
192
  """
193
193
  if isinstance(v, dict):
194
194
  return FileSystemSource(**v)
195
- return v
195
+ return v # type: ignore[return-value]
196
196
 
197
197
  @field_validator("environment", mode="before")
198
198
  @classmethod
@@ -217,7 +217,7 @@ class MCPServerConfig(BaseModel):
217
217
  """
218
218
  if isinstance(v, dict):
219
219
  return Deployment(**v) # type: ignore[arg-type]
220
- return cast(Deployment, v)
220
+ return cast(Deployment, v) # type: ignore[return-value]
221
221
 
222
222
  @classmethod
223
223
  def from_file(cls, file_path: Path) -> MCPServerConfig:
@@ -291,9 +291,9 @@ class MCPServerConfig(BaseModel):
291
291
  environment = UVEnvironment(
292
292
  python=python,
293
293
  dependencies=dependencies,
294
- requirements=requirements,
295
- project=project,
296
- editable=[editable] if editable else None,
294
+ requirements=Path(requirements) if requirements else None,
295
+ project=Path(project) if project else None,
296
+ editable=[Path(editable)] if editable else None,
297
297
  )
298
298
 
299
299
  # Build deployment config if any deployment args provided
@@ -250,6 +250,7 @@
250
250
  "requirements": {
251
251
  "anyOf": [
252
252
  {
253
+ "format": "path",
253
254
  "type": "string"
254
255
  },
255
256
  {
@@ -267,6 +268,7 @@
267
268
  "project": {
268
269
  "anyOf": [
269
270
  {
271
+ "format": "path",
270
272
  "type": "string"
271
273
  },
272
274
  {
@@ -285,6 +287,7 @@
285
287
  "anyOf": [
286
288
  {
287
289
  "items": {
290
+ "format": "path",
288
291
  "type": "string"
289
292
  },
290
293
  "type": "array"
@@ -17,7 +17,6 @@ class Source(BaseModel, ABC):
17
17
  need preparation (e.g., local files), this is a no-op.
18
18
  """
19
19
  # Default implementation for sources that don't need preparation
20
- pass
21
20
 
22
21
  @abstractmethod
23
22
  async def load_server(self) -> Any:
@@ -1,10 +1,10 @@
1
- # OpenAPI Utilities (New Implementation)
1
+ # OpenAPI Utilities
2
2
 
3
- This directory contains the next-generation OpenAPI integration utilities for FastMCP, designed to replace the legacy `openapi.py` implementation.
3
+ This directory contains the OpenAPI integration utilities for FastMCP.
4
4
 
5
5
  ## Architecture Overview
6
6
 
7
- The new implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance.
7
+ The implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance.
8
8
 
9
9
  ### Core Components
10
10
 
@@ -83,7 +83,7 @@ MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response
83
83
 
84
84
  ## Component Integration
85
85
 
86
- ### Server Components (`/server/openapi_new/`)
86
+ ### Server Components (`/server/openapi/`)
87
87
 
88
88
  1. **`OpenAPITool`** - Simplified tool implementation using RequestDirector
89
89
  2. **`OpenAPIResource`** - Resource implementation with RequestDirector
@@ -104,7 +104,7 @@ All components use the same RequestDirector approach:
104
104
 
105
105
  ```python
106
106
  import httpx
107
- from fastmcp.server.openapi_new import FastMCPOpenAPI
107
+ from fastmcp.server.openapi import FastMCPOpenAPI
108
108
 
109
109
  # OpenAPI spec (can be loaded from file/URL)
110
110
  openapi_spec = {...}
@@ -124,7 +124,7 @@ async with httpx.AsyncClient() as client:
124
124
  ### Direct RequestDirector Usage
125
125
 
126
126
  ```python
127
- from fastmcp.experimental.utilities.openapi.director import RequestDirector
127
+ from fastmcp.utilities.openapi.director import RequestDirector
128
128
  from jsonschema_path import SchemaPath
129
129
 
130
130
  # Create RequestDirector manually
@@ -141,7 +141,7 @@ async with httpx.AsyncClient() as client:
141
141
 
142
142
  ## Testing Strategy
143
143
 
144
- Tests are located in `/tests/server/openapi_new/`:
144
+ Tests are located in `/tests/server/openapi/`:
145
145
 
146
146
  ### Test Categories
147
147
 
@@ -160,34 +160,6 @@ Tests are located in `/tests/server/openapi_new/`:
160
160
  - **Performance Focus**: Test that initialization is fast and stateless
161
161
  - **Behavioral Testing**: Verify OpenAPI compliance without implementation details
162
162
 
163
- ## Migration Guide
164
-
165
- ### From Legacy Implementation
166
-
167
- 1. **Import Changes**:
168
- ```python
169
- # Old
170
- from fastmcp.server.openapi import FastMCPOpenAPI
171
-
172
- # New
173
- from fastmcp.server.openapi_new import FastMCPOpenAPI
174
- ```
175
-
176
- 2. **Constructor**: Same interface, no changes needed
177
-
178
- 3. **Automatic Benefits**:
179
- - Eliminates startup latency (100-200ms improvement)
180
- - Better OpenAPI compliance via openapi-core
181
- - Serverless-friendly performance characteristics
182
- - Simplified architecture without fallback complexity
183
-
184
- ### Performance Improvements
185
-
186
- - **Cold Start**: Zero latency penalty for serverless deployments
187
- - **Memory Usage**: Lower memory footprint without generated client code
188
- - **Reliability**: No dynamic code generation failures
189
- - **Maintainability**: Simpler architecture with fewer moving parts
190
-
191
163
  ## Future Enhancements
192
164
 
193
165
  ### Planned Features
@@ -0,0 +1,63 @@
1
+ """OpenAPI utilities for FastMCP - refactored for better maintainability."""
2
+
3
+ # Import from models
4
+ from .models import (
5
+ HTTPRoute,
6
+ HttpMethod,
7
+ JsonSchema,
8
+ ParameterInfo,
9
+ ParameterLocation,
10
+ RequestBodyInfo,
11
+ ResponseInfo,
12
+ )
13
+
14
+ # Import from parser
15
+ from .parser import parse_openapi_to_http_routes
16
+
17
+ # Import from formatters
18
+ from .formatters import (
19
+ format_array_parameter,
20
+ format_deep_object_parameter,
21
+ format_description_with_responses,
22
+ format_json_for_description,
23
+ format_simple_description,
24
+ generate_example_from_schema,
25
+ )
26
+
27
+ # Import from schemas
28
+ from .schemas import (
29
+ _combine_schemas,
30
+ extract_output_schema_from_responses,
31
+ clean_schema_for_display,
32
+ _make_optional_parameter_nullable,
33
+ )
34
+
35
+ # Import from json_schema_converter
36
+ from .json_schema_converter import (
37
+ convert_openapi_schema_to_json_schema,
38
+ convert_schema_definitions,
39
+ )
40
+
41
+ # Export public symbols - maintaining backward compatibility
42
+ __all__ = [
43
+ "HTTPRoute",
44
+ "HttpMethod",
45
+ "JsonSchema",
46
+ "ParameterInfo",
47
+ "ParameterLocation",
48
+ "RequestBodyInfo",
49
+ "ResponseInfo",
50
+ "_combine_schemas",
51
+ "_make_optional_parameter_nullable",
52
+ "clean_schema_for_display",
53
+ "convert_openapi_schema_to_json_schema",
54
+ "convert_schema_definitions",
55
+ "extract_output_schema_from_responses",
56
+ "format_array_parameter",
57
+ "format_deep_object_parameter",
58
+ "format_description_with_responses",
59
+ "format_json_for_description",
60
+ "format_simple_description",
61
+ "generate_example_from_schema",
62
+ "parse_openapi_to_http_routes",
63
+ ]
@@ -54,28 +54,27 @@ class RequestDirector:
54
54
  url = self._build_url(route.path, path_params, base_url)
55
55
 
56
56
  # Step 3: Prepare request data
57
- request_data = {
58
- "method": route.method.upper(),
59
- "url": url,
60
- "params": query_params if query_params else None,
61
- "headers": header_params if header_params else None,
62
- }
57
+ method: str = route.method.upper()
58
+ params = query_params if query_params else None
59
+ headers = header_params if header_params else None
60
+ json_body: dict[str, Any] | list[Any] | None = None
61
+ content: str | bytes | None = None
63
62
 
64
63
  # Step 4: Handle request body
65
64
  if body is not None:
66
- if isinstance(body, dict) or isinstance(body, list):
67
- request_data["json"] = body
65
+ if isinstance(body, dict | list):
66
+ json_body = body
68
67
  else:
69
- request_data["content"] = body
68
+ content = body
70
69
 
71
70
  # Step 5: Create httpx.Request
72
71
  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"),
72
+ method=method,
73
+ url=url,
74
+ params=params,
75
+ headers=headers,
76
+ json=json_body,
77
+ content=content,
79
78
  )
80
79
 
81
80
  def _unflatten_arguments(
@@ -67,13 +67,13 @@ def format_deep_object_parameter(
67
67
  param_value: dict, parameter_name: str
68
68
  ) -> dict[str, str]:
69
69
  """
70
- Format a dictionary parameter for deepObject style serialization.
70
+ Format a dictionary parameter for deep-object style serialization.
71
71
 
72
72
  According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
73
73
  object properties as separate query parameters with bracket notation.
74
74
 
75
- For example: {"id": "123", "type": "user"} becomes:
76
- param[id]=123&param[type]=user
75
+ For example, `{"id": "123", "type": "user"}` becomes
76
+ `param[id]=123&param[type]=user`.
77
77
 
78
78
  Args:
79
79
  param_value: Dictionary value to format
@@ -84,7 +84,7 @@ def format_deep_object_parameter(
84
84
  """
85
85
  if not isinstance(param_value, dict):
86
86
  logger.warning(
87
- f"deepObject style parameter '{parameter_name}' expected dict, got {type(param_value)}"
87
+ f"Deep-object style parameter '{parameter_name}' expected dict, got {type(param_value)}"
88
88
  )
89
89
  return {}
90
90
 
@@ -181,7 +181,7 @@ def generate_example_from_schema(schema: JsonSchema | None) -> Any:
181
181
 
182
182
 
183
183
  def format_json_for_description(data: Any, indent: int = 2) -> str:
184
- """Formats Python data as a JSON string block for markdown."""
184
+ """Formats Python data as a JSON string block for Markdown."""
185
185
  try:
186
186
  json_str = json.dumps(data, indent=indent)
187
187
  return f"```json\n{json_str}\n```"
@@ -60,7 +60,7 @@ def convert_openapi_schema_to_json_schema(
60
60
  convert_one_of_to_any_of: Whether to convert oneOf to anyOf
61
61
 
62
62
  Returns:
63
- JSON Schema compatible dictionary
63
+ JSON Schema-compatible dictionary
64
64
  """
65
65
  if not isinstance(schema, dict):
66
66
  return schema
@@ -164,10 +164,10 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]:
164
164
  if isinstance(current_type, str):
165
165
  result["type"] = [current_type, "null"]
166
166
  elif isinstance(current_type, list) and "null" not in current_type:
167
- result["type"] = current_type + ["null"]
167
+ result["type"] = [*current_type, "null"]
168
168
  elif "oneOf" in result:
169
169
  # Convert oneOf to anyOf with null
170
- result["anyOf"] = result.pop("oneOf") + [{"type": "null"}]
170
+ result["anyOf"] = [*result.pop("oneOf"), {"type": "null"}]
171
171
  elif "anyOf" in result:
172
172
  # Add null to anyOf if not present
173
173
  if not any(item.get("type") == "null" for item in result["anyOf"]):
@@ -176,6 +176,10 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]:
176
176
  # Wrap allOf in anyOf with null option
177
177
  result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}]
178
178
 
179
+ # Handle enum fields - add null to enum values if present
180
+ if "enum" in result and None not in result["enum"]:
181
+ result["enum"] = result["enum"] + [None]
182
+
179
183
  return result
180
184
 
181
185
 
@@ -178,7 +178,7 @@ class OpenAPIParser(
178
178
  else:
179
179
  # Special handling for components
180
180
  if part == "components" and hasattr(target, "components"):
181
- target = getattr(target, "components")
181
+ target = target.components
182
182
  elif hasattr(target, part): # Fallback check
183
183
  target = getattr(target, part, None)
184
184
  else:
@@ -474,9 +474,22 @@ class OpenAPIParser(
474
474
  and media_type_obj.media_type_schema
475
475
  ):
476
476
  try:
477
- schema_dict = self._extract_schema_as_dict(
478
- media_type_obj.media_type_schema
479
- )
477
+ # Track if this is a top-level $ref before resolution
478
+ top_level_schema_name = None
479
+ media_schema = media_type_obj.media_type_schema
480
+ if isinstance(media_schema, self.reference_cls):
481
+ ref_str = media_schema.ref
482
+ if isinstance(ref_str, str) and ref_str.startswith(
483
+ "#/components/schemas/"
484
+ ):
485
+ top_level_schema_name = ref_str.split("/")[-1]
486
+
487
+ schema_dict = self._extract_schema_as_dict(media_schema)
488
+ # Add marker for top-level schema if it was a ref
489
+ if top_level_schema_name:
490
+ schema_dict["x-fastmcp-top-level-schema"] = (
491
+ top_level_schema_name
492
+ )
480
493
  resp_info.content_schema[media_type_str] = schema_dict
481
494
  except ValueError as e:
482
495
  # Re-raise ValueError for external reference errors
@@ -541,9 +554,7 @@ class OpenAPIParser(
541
554
  if "$ref" in obj and isinstance(obj["$ref"], str):
542
555
  ref = obj["$ref"]
543
556
  # Handle both converted and unconverted refs
544
- if ref.startswith("#/$defs/"):
545
- schema_name = ref.split("/")[-1]
546
- elif ref.startswith("#/components/schemas/"):
557
+ if ref.startswith(("#/$defs/", "#/components/schemas/")):
547
558
  schema_name = ref.split("/")[-1]
548
559
  else:
549
560
  return
@@ -619,18 +630,28 @@ class OpenAPIParser(
619
630
  Returns:
620
631
  Dictionary containing only the schemas needed for outputs
621
632
  """
622
- needed_schemas = set()
633
+ if not responses or not all_schemas:
634
+ return {}
635
+
636
+ needed_schemas: set[str] = set()
623
637
 
624
- # Check responses for schema references
625
638
  for response in responses.values():
626
- if response.content_schema:
627
- for content_schema in response.content_schema.values():
628
- deps = self._extract_schema_dependencies(
629
- content_schema, all_schemas
639
+ if not response.content_schema:
640
+ continue
641
+
642
+ for content_schema in response.content_schema.values():
643
+ deps = self._extract_schema_dependencies(content_schema, all_schemas)
644
+ needed_schemas.update(deps)
645
+
646
+ schema_name = content_schema.get("x-fastmcp-top-level-schema")
647
+ if isinstance(schema_name, str) and schema_name in all_schemas:
648
+ needed_schemas.add(schema_name)
649
+ self._extract_schema_dependencies(
650
+ all_schemas[schema_name],
651
+ all_schemas,
652
+ collected=needed_schemas,
630
653
  )
631
- needed_schemas.update(deps)
632
654
 
633
- # Return only the needed output schemas
634
655
  return {
635
656
  name: all_schemas[name] for name in needed_schemas if name in all_schemas
636
657
  }
@@ -795,6 +816,6 @@ class OpenAPIParser(
795
816
 
796
817
  # Export public symbols
797
818
  __all__ = [
798
- "parse_openapi_to_http_routes",
799
819
  "OpenAPIParser",
820
+ "parse_openapi_to_http_routes",
800
821
  ]