fastmcp 2.14.4__py3-none-any.whl → 3.0.0b1__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/_vendor/__init__.py +1 -0
- fastmcp/_vendor/docket_di/README.md +7 -0
- fastmcp/_vendor/docket_di/__init__.py +163 -0
- fastmcp/cli/cli.py +112 -28
- fastmcp/cli/install/claude_code.py +1 -5
- fastmcp/cli/install/claude_desktop.py +1 -5
- fastmcp/cli/install/cursor.py +1 -5
- fastmcp/cli/install/gemini_cli.py +1 -5
- fastmcp/cli/install/mcp_json.py +1 -6
- fastmcp/cli/run.py +146 -5
- fastmcp/client/__init__.py +7 -9
- fastmcp/client/auth/oauth.py +18 -17
- fastmcp/client/client.py +100 -870
- fastmcp/client/elicitation.py +1 -1
- fastmcp/client/mixins/__init__.py +13 -0
- fastmcp/client/mixins/prompts.py +295 -0
- fastmcp/client/mixins/resources.py +325 -0
- fastmcp/client/mixins/task_management.py +157 -0
- fastmcp/client/mixins/tools.py +397 -0
- fastmcp/client/sampling/handlers/anthropic.py +2 -2
- fastmcp/client/sampling/handlers/openai.py +1 -1
- fastmcp/client/tasks.py +3 -3
- fastmcp/client/telemetry.py +47 -0
- fastmcp/client/transports/__init__.py +38 -0
- fastmcp/client/transports/base.py +82 -0
- fastmcp/client/transports/config.py +170 -0
- fastmcp/client/transports/http.py +145 -0
- fastmcp/client/transports/inference.py +154 -0
- fastmcp/client/transports/memory.py +90 -0
- fastmcp/client/transports/sse.py +89 -0
- fastmcp/client/transports/stdio.py +543 -0
- fastmcp/contrib/component_manager/README.md +4 -10
- fastmcp/contrib/component_manager/__init__.py +1 -2
- fastmcp/contrib/component_manager/component_manager.py +95 -160
- fastmcp/contrib/component_manager/example.py +1 -1
- fastmcp/contrib/mcp_mixin/example.py +4 -4
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
- fastmcp/decorators.py +41 -0
- fastmcp/dependencies.py +12 -1
- fastmcp/exceptions.py +4 -0
- fastmcp/experimental/server/openapi/__init__.py +18 -15
- fastmcp/mcp_config.py +13 -4
- fastmcp/prompts/__init__.py +6 -3
- fastmcp/prompts/function_prompt.py +465 -0
- fastmcp/prompts/prompt.py +321 -271
- fastmcp/resources/__init__.py +5 -3
- fastmcp/resources/function_resource.py +335 -0
- fastmcp/resources/resource.py +325 -115
- fastmcp/resources/template.py +215 -43
- fastmcp/resources/types.py +27 -12
- fastmcp/server/__init__.py +2 -2
- fastmcp/server/auth/__init__.py +14 -0
- fastmcp/server/auth/auth.py +30 -10
- fastmcp/server/auth/authorization.py +190 -0
- fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
- fastmcp/server/auth/oauth_proxy/consent.py +361 -0
- fastmcp/server/auth/oauth_proxy/models.py +178 -0
- fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
- fastmcp/server/auth/oauth_proxy/ui.py +277 -0
- fastmcp/server/auth/oidc_proxy.py +2 -2
- fastmcp/server/auth/providers/auth0.py +24 -94
- fastmcp/server/auth/providers/aws.py +26 -95
- fastmcp/server/auth/providers/azure.py +41 -129
- fastmcp/server/auth/providers/descope.py +18 -49
- fastmcp/server/auth/providers/discord.py +25 -86
- fastmcp/server/auth/providers/github.py +23 -87
- fastmcp/server/auth/providers/google.py +24 -87
- fastmcp/server/auth/providers/introspection.py +60 -79
- fastmcp/server/auth/providers/jwt.py +30 -67
- fastmcp/server/auth/providers/oci.py +47 -110
- fastmcp/server/auth/providers/scalekit.py +23 -61
- fastmcp/server/auth/providers/supabase.py +18 -47
- fastmcp/server/auth/providers/workos.py +34 -127
- fastmcp/server/context.py +372 -419
- fastmcp/server/dependencies.py +541 -251
- fastmcp/server/elicitation.py +20 -18
- fastmcp/server/event_store.py +3 -3
- fastmcp/server/http.py +16 -6
- fastmcp/server/lifespan.py +198 -0
- fastmcp/server/low_level.py +92 -2
- fastmcp/server/middleware/__init__.py +5 -1
- fastmcp/server/middleware/authorization.py +312 -0
- fastmcp/server/middleware/caching.py +101 -54
- fastmcp/server/middleware/middleware.py +6 -9
- fastmcp/server/middleware/ping.py +70 -0
- fastmcp/server/middleware/tool_injection.py +2 -2
- fastmcp/server/mixins/__init__.py +7 -0
- fastmcp/server/mixins/lifespan.py +217 -0
- fastmcp/server/mixins/mcp_operations.py +392 -0
- fastmcp/server/mixins/transport.py +342 -0
- fastmcp/server/openapi/__init__.py +41 -21
- fastmcp/server/openapi/components.py +16 -339
- fastmcp/server/openapi/routing.py +34 -118
- fastmcp/server/openapi/server.py +67 -392
- fastmcp/server/providers/__init__.py +71 -0
- fastmcp/server/providers/aggregate.py +261 -0
- fastmcp/server/providers/base.py +578 -0
- fastmcp/server/providers/fastmcp_provider.py +674 -0
- fastmcp/server/providers/filesystem.py +226 -0
- fastmcp/server/providers/filesystem_discovery.py +327 -0
- fastmcp/server/providers/local_provider/__init__.py +11 -0
- fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
- fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
- fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
- fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
- fastmcp/server/providers/local_provider/local_provider.py +465 -0
- fastmcp/server/providers/openapi/__init__.py +39 -0
- fastmcp/server/providers/openapi/components.py +332 -0
- fastmcp/server/providers/openapi/provider.py +405 -0
- fastmcp/server/providers/openapi/routing.py +109 -0
- fastmcp/server/providers/proxy.py +867 -0
- fastmcp/server/providers/skills/__init__.py +59 -0
- fastmcp/server/providers/skills/_common.py +101 -0
- fastmcp/server/providers/skills/claude_provider.py +44 -0
- fastmcp/server/providers/skills/directory_provider.py +153 -0
- fastmcp/server/providers/skills/skill_provider.py +432 -0
- fastmcp/server/providers/skills/vendor_providers.py +142 -0
- fastmcp/server/providers/wrapped_provider.py +140 -0
- fastmcp/server/proxy.py +34 -700
- fastmcp/server/sampling/run.py +341 -2
- fastmcp/server/sampling/sampling_tool.py +4 -3
- fastmcp/server/server.py +1214 -2171
- fastmcp/server/tasks/__init__.py +2 -1
- fastmcp/server/tasks/capabilities.py +13 -1
- fastmcp/server/tasks/config.py +66 -3
- fastmcp/server/tasks/handlers.py +65 -273
- fastmcp/server/tasks/keys.py +4 -6
- fastmcp/server/tasks/requests.py +474 -0
- fastmcp/server/tasks/routing.py +76 -0
- fastmcp/server/tasks/subscriptions.py +20 -11
- fastmcp/server/telemetry.py +131 -0
- fastmcp/server/transforms/__init__.py +244 -0
- fastmcp/server/transforms/namespace.py +193 -0
- fastmcp/server/transforms/prompts_as_tools.py +175 -0
- fastmcp/server/transforms/resources_as_tools.py +190 -0
- fastmcp/server/transforms/tool_transform.py +96 -0
- fastmcp/server/transforms/version_filter.py +124 -0
- fastmcp/server/transforms/visibility.py +526 -0
- fastmcp/settings.py +34 -96
- fastmcp/telemetry.py +122 -0
- fastmcp/tools/__init__.py +10 -3
- fastmcp/tools/function_parsing.py +201 -0
- fastmcp/tools/function_tool.py +467 -0
- fastmcp/tools/tool.py +215 -362
- fastmcp/tools/tool_transform.py +38 -21
- fastmcp/utilities/async_utils.py +69 -0
- fastmcp/utilities/components.py +152 -91
- fastmcp/utilities/inspect.py +8 -20
- fastmcp/utilities/json_schema.py +12 -5
- fastmcp/utilities/json_schema_type.py +17 -15
- fastmcp/utilities/lifespan.py +56 -0
- fastmcp/utilities/logging.py +12 -4
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/openapi/parser.py +3 -3
- fastmcp/utilities/pagination.py +80 -0
- fastmcp/utilities/skills.py +253 -0
- fastmcp/utilities/tests.py +0 -16
- fastmcp/utilities/timeout.py +47 -0
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/versions.py +285 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
- fastmcp-3.0.0b1.dist-info/RECORD +228 -0
- fastmcp/client/transports.py +0 -1170
- fastmcp/contrib/component_manager/component_service.py +0 -209
- fastmcp/prompts/prompt_manager.py +0 -117
- fastmcp/resources/resource_manager.py +0 -338
- fastmcp/server/tasks/converters.py +0 -206
- fastmcp/server/tasks/protocol.py +0 -359
- fastmcp/tools/tool_manager.py +0 -170
- fastmcp/utilities/mcp_config.py +0 -56
- fastmcp-2.14.4.dist-info/RECORD +0 -161
- /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Client utilities for discovering and downloading skills from MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fastmcp.client import Client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SkillSummary:
|
|
16
|
+
"""Summary information about a skill available on a server."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
uri: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SkillFile:
|
|
25
|
+
"""Information about a file within a skill."""
|
|
26
|
+
|
|
27
|
+
path: str
|
|
28
|
+
size: int
|
|
29
|
+
hash: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SkillManifest:
|
|
34
|
+
"""Full manifest of a skill including all files."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
files: list[SkillFile]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def list_skills(client: Client) -> list[SkillSummary]:
|
|
41
|
+
"""List all available skills from an MCP server.
|
|
42
|
+
|
|
43
|
+
Discovers skills by finding resources with URIs matching the
|
|
44
|
+
`skill://{name}/SKILL.md` pattern.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
client: Connected FastMCP client
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of SkillSummary objects with name, description, and URI
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
```python
|
|
54
|
+
from fastmcp import Client
|
|
55
|
+
from fastmcp.utilities.skills import list_skills
|
|
56
|
+
|
|
57
|
+
async with Client("http://skills-server/mcp") as client:
|
|
58
|
+
skills = await list_skills(client)
|
|
59
|
+
for skill in skills:
|
|
60
|
+
print(f"{skill.name}: {skill.description}")
|
|
61
|
+
```
|
|
62
|
+
"""
|
|
63
|
+
resources = await client.list_resources()
|
|
64
|
+
skills = []
|
|
65
|
+
|
|
66
|
+
for resource in resources:
|
|
67
|
+
uri = str(resource.uri)
|
|
68
|
+
# Match skill://{name}/SKILL.md pattern
|
|
69
|
+
if uri.startswith("skill://") and uri.endswith("/SKILL.md"):
|
|
70
|
+
# Extract skill name from URI
|
|
71
|
+
path_part = uri[len("skill://") :]
|
|
72
|
+
name = path_part.rsplit("/", 1)[0]
|
|
73
|
+
skills.append(
|
|
74
|
+
SkillSummary(
|
|
75
|
+
name=name,
|
|
76
|
+
description=resource.description or "",
|
|
77
|
+
uri=uri,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return skills
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def get_skill_manifest(client: Client, skill_name: str) -> SkillManifest:
|
|
85
|
+
"""Get the manifest for a specific skill.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
client: Connected FastMCP client
|
|
89
|
+
skill_name: Name of the skill
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
SkillManifest with file listing
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If manifest cannot be read or parsed
|
|
96
|
+
"""
|
|
97
|
+
manifest_uri = f"skill://{skill_name}/_manifest"
|
|
98
|
+
result = await client.read_resource(manifest_uri)
|
|
99
|
+
|
|
100
|
+
if not result:
|
|
101
|
+
raise ValueError(f"Could not read manifest for skill: {skill_name}")
|
|
102
|
+
|
|
103
|
+
content = result[0]
|
|
104
|
+
if hasattr(content, "text"):
|
|
105
|
+
try:
|
|
106
|
+
manifest_data = json.loads(content.text)
|
|
107
|
+
except json.JSONDecodeError as e:
|
|
108
|
+
raise ValueError(f"Invalid manifest JSON for skill: {skill_name}") from e
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"Unexpected manifest format for skill: {skill_name}")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return SkillManifest(
|
|
114
|
+
name=manifest_data["skill"],
|
|
115
|
+
files=[
|
|
116
|
+
SkillFile(path=f["path"], size=f["size"], hash=f["hash"])
|
|
117
|
+
for f in manifest_data["files"]
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
except (KeyError, TypeError) as e:
|
|
121
|
+
raise ValueError(f"Invalid manifest format for skill: {skill_name}") from e
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def download_skill(
|
|
125
|
+
client: Client,
|
|
126
|
+
skill_name: str,
|
|
127
|
+
target_dir: str | Path,
|
|
128
|
+
*,
|
|
129
|
+
overwrite: bool = False,
|
|
130
|
+
) -> Path:
|
|
131
|
+
"""Download a skill and all its files to a local directory.
|
|
132
|
+
|
|
133
|
+
Creates a subdirectory named after the skill containing all files.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
client: Connected FastMCP client
|
|
137
|
+
skill_name: Name of the skill to download
|
|
138
|
+
target_dir: Directory where skill folder will be created
|
|
139
|
+
overwrite: If True, overwrite existing skill directory. If False
|
|
140
|
+
(default), raise FileExistsError if directory exists.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Path to the downloaded skill directory
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: If skill cannot be found or downloaded
|
|
147
|
+
FileExistsError: If skill directory exists and overwrite=False
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
```python
|
|
151
|
+
from fastmcp import Client
|
|
152
|
+
from fastmcp.utilities.skills import download_skill
|
|
153
|
+
|
|
154
|
+
async with Client("http://skills-server/mcp") as client:
|
|
155
|
+
skill_path = await download_skill(
|
|
156
|
+
client,
|
|
157
|
+
"pdf-processing",
|
|
158
|
+
"~/.claude/skills"
|
|
159
|
+
)
|
|
160
|
+
print(f"Downloaded to: {skill_path}")
|
|
161
|
+
```
|
|
162
|
+
"""
|
|
163
|
+
target_dir = Path(target_dir).expanduser().resolve()
|
|
164
|
+
skill_dir = target_dir / skill_name
|
|
165
|
+
|
|
166
|
+
# Check if directory exists
|
|
167
|
+
if skill_dir.exists() and not overwrite:
|
|
168
|
+
raise FileExistsError(
|
|
169
|
+
f"Skill directory already exists: {skill_dir}. "
|
|
170
|
+
"Use overwrite=True to replace."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Get manifest to know what files to download
|
|
174
|
+
manifest = await get_skill_manifest(client, skill_name)
|
|
175
|
+
|
|
176
|
+
# Create skill directory
|
|
177
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
# Download each file
|
|
180
|
+
for file_info in manifest.files:
|
|
181
|
+
# Security: reject absolute paths and paths that escape skill_dir
|
|
182
|
+
if Path(file_info.path).is_absolute():
|
|
183
|
+
continue
|
|
184
|
+
file_path = (skill_dir / file_info.path).resolve()
|
|
185
|
+
if not file_path.is_relative_to(skill_dir):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
file_uri = f"skill://{skill_name}/{file_info.path}"
|
|
189
|
+
result = await client.read_resource(file_uri)
|
|
190
|
+
|
|
191
|
+
if not result:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
content = result[0]
|
|
195
|
+
|
|
196
|
+
# Create parent directories if needed
|
|
197
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
|
|
199
|
+
# Write content
|
|
200
|
+
if hasattr(content, "text"):
|
|
201
|
+
file_path.write_text(content.text)
|
|
202
|
+
elif hasattr(content, "blob"):
|
|
203
|
+
# Handle base64-encoded binary content
|
|
204
|
+
import base64
|
|
205
|
+
|
|
206
|
+
file_path.write_bytes(base64.b64decode(content.blob))
|
|
207
|
+
else:
|
|
208
|
+
# Skip unknown content types
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
return skill_dir
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def sync_skills(
|
|
215
|
+
client: Client,
|
|
216
|
+
target_dir: str | Path,
|
|
217
|
+
*,
|
|
218
|
+
overwrite: bool = False,
|
|
219
|
+
) -> list[Path]:
|
|
220
|
+
"""Download all available skills from a server.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
client: Connected FastMCP client
|
|
224
|
+
target_dir: Directory where skill folders will be created
|
|
225
|
+
overwrite: If True, overwrite existing files
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
List of paths to downloaded skill directories
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
```python
|
|
232
|
+
from fastmcp import Client
|
|
233
|
+
from fastmcp.utilities.skills import sync_skills
|
|
234
|
+
|
|
235
|
+
async with Client("http://skills-server/mcp") as client:
|
|
236
|
+
paths = await sync_skills(client, "~/.claude/skills")
|
|
237
|
+
print(f"Downloaded {len(paths)} skills")
|
|
238
|
+
```
|
|
239
|
+
"""
|
|
240
|
+
skills = await list_skills(client)
|
|
241
|
+
downloaded = []
|
|
242
|
+
|
|
243
|
+
for skill in skills:
|
|
244
|
+
try:
|
|
245
|
+
path = await download_skill(
|
|
246
|
+
client, skill.name, target_dir, overwrite=overwrite
|
|
247
|
+
)
|
|
248
|
+
downloaded.append(path)
|
|
249
|
+
except FileExistsError:
|
|
250
|
+
# Skip existing skills when not overwriting
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
return downloaded
|
fastmcp/utilities/tests.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
|
-
import logging
|
|
5
4
|
import multiprocessing
|
|
6
5
|
import socket
|
|
7
6
|
import time
|
|
@@ -12,7 +11,6 @@ from urllib.parse import parse_qs, urlparse
|
|
|
12
11
|
|
|
13
12
|
import httpx
|
|
14
13
|
import uvicorn
|
|
15
|
-
from pytest import LogCaptureFixture
|
|
16
14
|
|
|
17
15
|
from fastmcp import settings
|
|
18
16
|
from fastmcp.client.auth.oauth import OAuth
|
|
@@ -224,20 +222,6 @@ async def run_server_async(
|
|
|
224
222
|
await asyncio.wait_for(server_task, timeout=2.0)
|
|
225
223
|
|
|
226
224
|
|
|
227
|
-
@contextmanager
|
|
228
|
-
def caplog_for_fastmcp(
|
|
229
|
-
caplog: LogCaptureFixture,
|
|
230
|
-
) -> Generator[LogCaptureFixture, None, None]:
|
|
231
|
-
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""
|
|
232
|
-
caplog.clear()
|
|
233
|
-
logger = logging.getLogger("fastmcp")
|
|
234
|
-
logger.addHandler(caplog.handler)
|
|
235
|
-
try:
|
|
236
|
-
yield caplog
|
|
237
|
-
finally:
|
|
238
|
-
logger.removeHandler(caplog.handler)
|
|
239
|
-
|
|
240
|
-
|
|
241
225
|
class HeadlessOAuth(OAuth):
|
|
242
226
|
"""
|
|
243
227
|
OAuth provider that bypasses browser interaction for testing.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Timeout normalization utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_timeout_to_timedelta(
|
|
9
|
+
value: int | float | datetime.timedelta | None,
|
|
10
|
+
) -> datetime.timedelta | None:
|
|
11
|
+
"""Normalize a timeout value to a timedelta.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
value: Timeout value as int/float (seconds), timedelta, or None
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
timedelta if value provided, None otherwise
|
|
18
|
+
"""
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(value, datetime.timedelta):
|
|
22
|
+
return value
|
|
23
|
+
if isinstance(value, int | float):
|
|
24
|
+
return datetime.timedelta(seconds=float(value))
|
|
25
|
+
raise TypeError(f"Invalid timeout type: {type(value)}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_timeout_to_seconds(
|
|
29
|
+
value: int | float | datetime.timedelta | None,
|
|
30
|
+
) -> float | None:
|
|
31
|
+
"""Normalize a timeout value to seconds (float).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
value: Timeout value as int/float (seconds), timedelta, or None.
|
|
35
|
+
Zero values are treated as "disabled" and return None.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
float seconds if value provided and non-zero, None otherwise
|
|
39
|
+
"""
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(value, datetime.timedelta):
|
|
43
|
+
seconds = value.total_seconds()
|
|
44
|
+
return None if seconds == 0 else seconds
|
|
45
|
+
if isinstance(value, int | float):
|
|
46
|
+
return None if value == 0 else float(value)
|
|
47
|
+
raise TypeError(f"Invalid timeout type: {type(value)}")
|
fastmcp/utilities/types.py
CHANGED
|
@@ -480,7 +480,7 @@ def replace_type(type_, type_map: dict[type, type]):
|
|
|
480
480
|
new_args = tuple(replace_type(arg, type_map) for arg in args)
|
|
481
481
|
|
|
482
482
|
if origin is UnionType:
|
|
483
|
-
return Union[new_args] #
|
|
483
|
+
return Union[new_args] # noqa: UP007
|
|
484
484
|
else:
|
|
485
485
|
return origin[new_args]
|
|
486
486
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Version comparison utilities for component versioning.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for comparing component versions. Versions are
|
|
4
|
+
strings that are first attempted to be parsed as PEP 440 versions (using the
|
|
5
|
+
`packaging` library), falling back to lexicographic string comparison.
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
- "1", "2", "10" → parsed as PEP 440, compared semantically (1 < 2 < 10)
|
|
9
|
+
- "1.0", "2.0" → parsed as PEP 440
|
|
10
|
+
- "v1.0" → 'v' prefix stripped, parsed as "1.0"
|
|
11
|
+
- "2025-01-15" → not valid PEP 440, compared as strings
|
|
12
|
+
- None → sorts lowest (unversioned components)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from functools import total_ordering
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from packaging.version import InvalidVersion, Version
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from fastmcp.utilities.components import FastMCPComponent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class VersionSpec:
|
|
29
|
+
"""Specification for filtering components by version.
|
|
30
|
+
|
|
31
|
+
Used by transforms and providers to filter components to a specific
|
|
32
|
+
version or version range. Unversioned components (version=None) always
|
|
33
|
+
match any spec.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
gte: If set, only versions >= this value match.
|
|
37
|
+
lt: If set, only versions < this value match.
|
|
38
|
+
eq: If set, only this exact version matches (gte/lt ignored).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
gte: str | None = None
|
|
42
|
+
lt: str | None = None
|
|
43
|
+
eq: str | None = None
|
|
44
|
+
|
|
45
|
+
def matches(self, version: str | None, *, match_none: bool = True) -> bool:
|
|
46
|
+
"""Check if a version matches this spec.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
version: The version to check, or None for unversioned.
|
|
50
|
+
match_none: Whether unversioned (None) components match. Defaults to True
|
|
51
|
+
for backward compatibility with retrieval operations. Set to False
|
|
52
|
+
when filtering (e.g., enable/disable) to exclude unversioned components
|
|
53
|
+
from version-specific rules.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if the version matches the spec.
|
|
57
|
+
"""
|
|
58
|
+
if version is None:
|
|
59
|
+
return match_none
|
|
60
|
+
|
|
61
|
+
if self.eq is not None:
|
|
62
|
+
return version == self.eq
|
|
63
|
+
|
|
64
|
+
key = parse_version_key(version)
|
|
65
|
+
|
|
66
|
+
if self.gte is not None:
|
|
67
|
+
gte_key = parse_version_key(self.gte)
|
|
68
|
+
if key < gte_key:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
if self.lt is not None:
|
|
72
|
+
lt_key = parse_version_key(self.lt)
|
|
73
|
+
if not key < lt_key:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def intersect(self, other: VersionSpec | None) -> VersionSpec:
|
|
79
|
+
"""Return a spec that satisfies both this spec and other.
|
|
80
|
+
|
|
81
|
+
Used by transforms to combine caller constraints with filter constraints.
|
|
82
|
+
For example, if a VersionFilter has lt="3.0" and caller requests eq="1.0",
|
|
83
|
+
the intersection validates "1.0" is in range and returns the exact spec.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
other: Another spec to intersect with, or None.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A VersionSpec that matches only versions satisfying both specs.
|
|
90
|
+
"""
|
|
91
|
+
if other is None:
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
if self.eq is not None:
|
|
95
|
+
# This spec wants exact - validate against other's range
|
|
96
|
+
if other.matches(self.eq):
|
|
97
|
+
return self
|
|
98
|
+
return VersionSpec(eq="__impossible__")
|
|
99
|
+
|
|
100
|
+
if other.eq is not None:
|
|
101
|
+
# Other wants exact - validate against our range
|
|
102
|
+
if self.matches(other.eq):
|
|
103
|
+
return other
|
|
104
|
+
return VersionSpec(eq="__impossible__")
|
|
105
|
+
|
|
106
|
+
# Both are ranges - take tighter bounds
|
|
107
|
+
return VersionSpec(
|
|
108
|
+
gte=max_version(self.gte, other.gte),
|
|
109
|
+
lt=min_version(self.lt, other.lt),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@total_ordering
|
|
114
|
+
class VersionKey:
|
|
115
|
+
"""A comparable version key that handles None, PEP 440 versions, and strings.
|
|
116
|
+
|
|
117
|
+
Comparison order:
|
|
118
|
+
1. None (unversioned) sorts lowest
|
|
119
|
+
2. PEP 440 versions sort by semantic version order
|
|
120
|
+
3. Invalid versions (strings) sort lexicographically
|
|
121
|
+
4. When comparing PEP 440 vs string, PEP 440 comes first
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
__slots__ = ("_is_none", "_is_pep440", "_parsed", "_raw")
|
|
125
|
+
|
|
126
|
+
def __init__(self, version: str | None) -> None:
|
|
127
|
+
self._raw = version
|
|
128
|
+
self._is_none = version is None
|
|
129
|
+
self._is_pep440 = False
|
|
130
|
+
self._parsed: Version | str | None = None
|
|
131
|
+
|
|
132
|
+
if version is not None:
|
|
133
|
+
# Strip leading 'v' if present (common convention like "v1.0")
|
|
134
|
+
normalized = version.lstrip("v") if version.startswith("v") else version
|
|
135
|
+
try:
|
|
136
|
+
self._parsed = Version(normalized)
|
|
137
|
+
self._is_pep440 = True
|
|
138
|
+
except InvalidVersion:
|
|
139
|
+
# Fall back to string comparison for non-PEP 440 versions
|
|
140
|
+
self._parsed = version
|
|
141
|
+
|
|
142
|
+
def __eq__(self, other: object) -> bool:
|
|
143
|
+
if not isinstance(other, VersionKey):
|
|
144
|
+
return NotImplemented
|
|
145
|
+
if self._is_none and other._is_none:
|
|
146
|
+
return True
|
|
147
|
+
if self._is_none != other._is_none:
|
|
148
|
+
return False
|
|
149
|
+
# Both are not None
|
|
150
|
+
if self._is_pep440 and other._is_pep440:
|
|
151
|
+
return self._parsed == other._parsed
|
|
152
|
+
if not self._is_pep440 and not other._is_pep440:
|
|
153
|
+
return self._parsed == other._parsed
|
|
154
|
+
# One is PEP 440, other is string - never equal
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def __lt__(self, other: object) -> bool:
|
|
158
|
+
if not isinstance(other, VersionKey):
|
|
159
|
+
return NotImplemented
|
|
160
|
+
# None sorts lowest
|
|
161
|
+
if self._is_none and other._is_none:
|
|
162
|
+
return False # Equal
|
|
163
|
+
if self._is_none:
|
|
164
|
+
return True # None < anything
|
|
165
|
+
if other._is_none:
|
|
166
|
+
return False # anything > None
|
|
167
|
+
|
|
168
|
+
# Both are not None
|
|
169
|
+
if self._is_pep440 and other._is_pep440:
|
|
170
|
+
# Both PEP 440 - compare normally
|
|
171
|
+
assert isinstance(self._parsed, Version)
|
|
172
|
+
assert isinstance(other._parsed, Version)
|
|
173
|
+
return self._parsed < other._parsed
|
|
174
|
+
if not self._is_pep440 and not other._is_pep440:
|
|
175
|
+
# Both strings - lexicographic
|
|
176
|
+
assert isinstance(self._parsed, str)
|
|
177
|
+
assert isinstance(other._parsed, str)
|
|
178
|
+
return self._parsed < other._parsed
|
|
179
|
+
# Mixed: PEP 440 sorts before strings
|
|
180
|
+
# (arbitrary but consistent choice)
|
|
181
|
+
return self._is_pep440
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
return f"VersionKey({self._raw!r})"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_version_key(version: str | None) -> VersionKey:
|
|
188
|
+
"""Parse a version string into a sortable key.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
version: The version string, or None for unversioned.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A VersionKey suitable for sorting.
|
|
195
|
+
"""
|
|
196
|
+
return VersionKey(version)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def version_sort_key(component: FastMCPComponent) -> VersionKey:
|
|
200
|
+
"""Get a sort key for a component based on its version.
|
|
201
|
+
|
|
202
|
+
Use with sorted() or max() to order components by version.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
component: The component to get a sort key for.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
A sortable VersionKey.
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
```python
|
|
212
|
+
tools = [tool_v1, tool_v2, tool_unversioned]
|
|
213
|
+
highest = max(tools, key=version_sort_key) # Returns tool_v2
|
|
214
|
+
```
|
|
215
|
+
"""
|
|
216
|
+
return parse_version_key(component.version)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def compare_versions(a: str | None, b: str | None) -> int:
|
|
220
|
+
"""Compare two version strings.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
a: First version string (or None).
|
|
224
|
+
b: Second version string (or None).
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
-1 if a < b, 0 if a == b, 1 if a > b.
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
```python
|
|
231
|
+
compare_versions("1.0", "2.0") # Returns -1
|
|
232
|
+
compare_versions("2.0", "1.0") # Returns 1
|
|
233
|
+
compare_versions(None, "1.0") # Returns -1 (None < any version)
|
|
234
|
+
```
|
|
235
|
+
"""
|
|
236
|
+
key_a = parse_version_key(a)
|
|
237
|
+
key_b = parse_version_key(b)
|
|
238
|
+
return (key_a > key_b) - (key_a < key_b)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def is_version_greater(a: str | None, b: str | None) -> bool:
|
|
242
|
+
"""Check if version a is greater than version b.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
a: First version string (or None).
|
|
246
|
+
b: Second version string (or None).
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
True if a > b, False otherwise.
|
|
250
|
+
"""
|
|
251
|
+
return compare_versions(a, b) > 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def max_version(a: str | None, b: str | None) -> str | None:
|
|
255
|
+
"""Return the greater of two versions.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
a: First version string (or None).
|
|
259
|
+
b: Second version string (or None).
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
The greater version, or None if both are None.
|
|
263
|
+
"""
|
|
264
|
+
if a is None:
|
|
265
|
+
return b
|
|
266
|
+
if b is None:
|
|
267
|
+
return a
|
|
268
|
+
return a if compare_versions(a, b) >= 0 else b
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def min_version(a: str | None, b: str | None) -> str | None:
|
|
272
|
+
"""Return the lesser of two versions.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
a: First version string (or None).
|
|
276
|
+
b: Second version string (or None).
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
The lesser version, or None if both are None.
|
|
280
|
+
"""
|
|
281
|
+
if a is None:
|
|
282
|
+
return b
|
|
283
|
+
if b is None:
|
|
284
|
+
return a
|
|
285
|
+
return a if compare_versions(a, b) <= 0 else b
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastmcp
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0b1
|
|
4
4
|
Summary: The fast, Pythonic way to build MCP servers and clients.
|
|
5
5
|
Project-URL: Homepage, https://gofastmcp.com
|
|
6
6
|
Project-URL: Repository, https://github.com/jlowin/fastmcp
|
|
@@ -26,20 +26,23 @@ Requires-Dist: jsonref>=1.1.0
|
|
|
26
26
|
Requires-Dist: jsonschema-path>=0.3.4
|
|
27
27
|
Requires-Dist: mcp<2.0,>=1.24.0
|
|
28
28
|
Requires-Dist: openapi-pydantic>=0.5.1
|
|
29
|
-
Requires-Dist:
|
|
29
|
+
Requires-Dist: opentelemetry-api>=1.20.0
|
|
30
|
+
Requires-Dist: packaging>=24.0
|
|
30
31
|
Requires-Dist: platformdirs>=4.0.0
|
|
31
32
|
Requires-Dist: py-key-value-aio[disk,keyring,memory]<0.4.0,>=0.3.0
|
|
32
33
|
Requires-Dist: pydantic[email]>=2.11.7
|
|
33
|
-
Requires-Dist: pydocket<0.17.0,>=0.16.6
|
|
34
34
|
Requires-Dist: pyperclip>=1.9.0
|
|
35
35
|
Requires-Dist: python-dotenv>=1.1.0
|
|
36
36
|
Requires-Dist: rich>=13.9.4
|
|
37
37
|
Requires-Dist: uvicorn>=0.35
|
|
38
|
+
Requires-Dist: watchfiles>=1.0.0
|
|
38
39
|
Requires-Dist: websockets>=15.0.1
|
|
39
40
|
Provides-Extra: anthropic
|
|
40
41
|
Requires-Dist: anthropic>=0.40.0; extra == 'anthropic'
|
|
41
42
|
Provides-Extra: openai
|
|
42
43
|
Requires-Dist: openai>=1.102.0; extra == 'openai'
|
|
44
|
+
Provides-Extra: tasks
|
|
45
|
+
Requires-Dist: pydocket>=0.16.4; extra == 'tasks'
|
|
43
46
|
Description-Content-Type: text/markdown
|
|
44
47
|
|
|
45
48
|
<div align="center">
|
|
@@ -78,7 +81,7 @@ Description-Content-Type: text/markdown
|
|
|
78
81
|
> **For production MCP applications, install FastMCP:** `pip install fastmcp`
|
|
79
82
|
|
|
80
83
|
> [!Important]
|
|
81
|
-
> FastMCP 3.0 is in development and may include breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
|
|
84
|
+
> FastMCP 3.0 is in development and may include some breaking changes. To avoid unexpected issues, pin your dependency to v2: `fastmcp<3`
|
|
82
85
|
|
|
83
86
|
---
|
|
84
87
|
|
|
@@ -126,7 +129,7 @@ There are two ways to access the LLM-friendly documentation:
|
|
|
126
129
|
## Table of Contents
|
|
127
130
|
|
|
128
131
|
- [FastMCP v2 🚀](#fastmcp-v2-)
|
|
129
|
-
|
|
132
|
+
- [📚 Documentation](#-documentation)
|
|
130
133
|
- [What is MCP?](#what-is-mcp)
|
|
131
134
|
- [Why FastMCP?](#why-fastmcp)
|
|
132
135
|
- [Installation](#installation)
|