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.
- fastmcp/cli/claude.py +1 -10
- fastmcp/cli/cli.py +45 -25
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +1 -10
- fastmcp/cli/install/claude_desktop.py +1 -9
- fastmcp/cli/install/cursor.py +2 -18
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +1 -9
- fastmcp/cli/run.py +2 -86
- fastmcp/client/auth/oauth.py +50 -37
- fastmcp/client/client.py +18 -8
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/transports.py +1 -1
- fastmcp/contrib/component_manager/component_service.py +1 -1
- fastmcp/contrib/mcp_mixin/README.md +3 -3
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +41 -6
- fastmcp/experimental/utilities/openapi/director.py +8 -1
- fastmcp/experimental/utilities/openapi/schemas.py +31 -5
- fastmcp/prompts/prompt.py +10 -8
- fastmcp/resources/resource.py +14 -11
- fastmcp/resources/template.py +12 -10
- fastmcp/server/auth/auth.py +10 -4
- fastmcp/server/auth/oauth_proxy.py +93 -23
- fastmcp/server/auth/oidc_proxy.py +348 -0
- fastmcp/server/auth/providers/auth0.py +174 -0
- fastmcp/server/auth/providers/aws.py +237 -0
- fastmcp/server/auth/providers/azure.py +6 -2
- fastmcp/server/auth/providers/descope.py +172 -0
- fastmcp/server/auth/providers/github.py +6 -2
- fastmcp/server/auth/providers/google.py +6 -2
- fastmcp/server/auth/providers/workos.py +6 -2
- fastmcp/server/context.py +17 -16
- fastmcp/server/dependencies.py +18 -5
- fastmcp/server/http.py +1 -1
- fastmcp/server/middleware/logging.py +147 -116
- fastmcp/server/middleware/middleware.py +3 -2
- fastmcp/server/openapi.py +5 -1
- fastmcp/server/server.py +43 -36
- fastmcp/settings.py +42 -6
- fastmcp/tools/tool.py +105 -87
- fastmcp/tools/tool_transform.py +1 -1
- fastmcp/utilities/json_schema.py +18 -1
- fastmcp/utilities/logging.py +66 -4
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +4 -39
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -2
- fastmcp/utilities/mcp_server_config/v1/schema.json +2 -1
- fastmcp/utilities/storage.py +204 -0
- fastmcp/utilities/tests.py +8 -6
- fastmcp/utilities/types.py +9 -5
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/METADATA +121 -48
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/RECORD +54 -48
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.2.dist-info → fastmcp-2.12.4.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -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
|
-
|
|
71
|
-
self.cache_dir
|
|
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
|
|
80
|
-
"""
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
stored = stored_token_adapter.
|
|
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
|
|
126
|
+
except ValidationError as e:
|
|
121
127
|
logger.debug(
|
|
122
|
-
f"Could not
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
173
|
+
except ValidationError as e:
|
|
166
174
|
logger.debug(
|
|
167
|
-
f"Could not
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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(
|
|
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 ---
|
fastmcp/client/elicitation.py
CHANGED
|
@@ -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(
|
|
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,
|
fastmcp/client/transports.py
CHANGED
|
@@ -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.
|
|
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"]
|
|
@@ -16,7 +16,7 @@ Prompts:
|
|
|
16
16
|
* [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts)
|
|
17
17
|
|
|
18
18
|
Resources:
|
|
19
|
-
* [enable/
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
|
|
108
|
-
"
|
|
109
|
-
|
|
110
|
-
|
|
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(
|
fastmcp/resources/resource.py
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
"name"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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",
|
fastmcp/resources/template.py
CHANGED
|
@@ -154,16 +154,18 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
154
154
|
**overrides: Any,
|
|
155
155
|
) -> MCPResourceTemplate:
|
|
156
156
|
"""Convert the resource template to an MCPResourceTemplate."""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"name"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
|
|
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:
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
365
|
+
scopes_supported=supported_scopes,
|
|
360
366
|
)
|
|
361
367
|
oauth_routes.extend(protected_routes)
|
|
362
368
|
|