fastmcp 2.10.2__py3-none-any.whl → 2.10.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/cli.py +102 -225
- fastmcp/cli/install/__init__.py +20 -0
- fastmcp/cli/install/claude_code.py +186 -0
- fastmcp/cli/install/claude_desktop.py +186 -0
- fastmcp/cli/install/cursor.py +196 -0
- fastmcp/cli/install/mcp_config.py +165 -0
- fastmcp/cli/install/shared.py +85 -0
- fastmcp/cli/run.py +13 -4
- fastmcp/client/client.py +230 -124
- fastmcp/client/transports.py +1 -1
- fastmcp/mcp_config.py +282 -0
- fastmcp/prompts/prompt.py +2 -4
- fastmcp/resources/resource.py +2 -2
- fastmcp/resources/template.py +1 -1
- fastmcp/server/openapi.py +40 -9
- fastmcp/server/proxy.py +101 -48
- fastmcp/server/server.py +32 -3
- fastmcp/tools/tool.py +3 -2
- fastmcp/tools/tool_transform.py +5 -6
- fastmcp/utilities/json_schema.py +14 -3
- fastmcp/utilities/openapi.py +92 -0
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/METADATA +4 -3
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/RECORD +26 -20
- fastmcp/utilities/mcp_config.py +0 -103
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/licenses/LICENSE +0 -0
fastmcp/mcp_config.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Canonical MCP Configuration Format.
|
|
2
|
+
|
|
3
|
+
This module defines the standard configuration format for Model Context Protocol (MCP) servers.
|
|
4
|
+
It provides a client-agnostic, extensible format that can be used across all MCP implementations.
|
|
5
|
+
|
|
6
|
+
The configuration format supports both stdio and remote (HTTP/SSE) transports, with comprehensive
|
|
7
|
+
field definitions for server metadata, authentication, and execution parameters.
|
|
8
|
+
|
|
9
|
+
Example configuration:
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"my-server": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "@my/mcp-server"],
|
|
15
|
+
"env": {"API_KEY": "secret"},
|
|
16
|
+
"timeout": 30000,
|
|
17
|
+
"description": "My MCP server"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
30
|
+
from urllib.parse import urlparse
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
from pydantic import AnyUrl, BaseModel, ConfigDict, Field
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from fastmcp.client.transports import (
|
|
37
|
+
SSETransport,
|
|
38
|
+
StdioTransport,
|
|
39
|
+
StreamableHttpTransport,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def infer_transport_type_from_url(
|
|
44
|
+
url: str | AnyUrl,
|
|
45
|
+
) -> Literal["http", "sse"]:
|
|
46
|
+
"""
|
|
47
|
+
Infer the appropriate transport type from the given URL.
|
|
48
|
+
"""
|
|
49
|
+
url = str(url)
|
|
50
|
+
if not url.startswith("http"):
|
|
51
|
+
raise ValueError(f"Invalid URL: {url}")
|
|
52
|
+
|
|
53
|
+
parsed_url = urlparse(url)
|
|
54
|
+
path = parsed_url.path
|
|
55
|
+
|
|
56
|
+
# Match /sse followed by /, ?, &, or end of string
|
|
57
|
+
if re.search(r"/sse(/|\?|&|$)", path):
|
|
58
|
+
return "sse"
|
|
59
|
+
else:
|
|
60
|
+
return "http"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StdioMCPServer(BaseModel):
|
|
64
|
+
"""MCP server configuration for stdio transport.
|
|
65
|
+
|
|
66
|
+
This is the canonical configuration format for MCP servers using stdio transport.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# Required fields
|
|
70
|
+
command: str
|
|
71
|
+
|
|
72
|
+
# Common optional fields
|
|
73
|
+
args: list[str] = Field(default_factory=list)
|
|
74
|
+
env: dict[str, Any] = Field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
# Transport specification
|
|
77
|
+
transport: Literal["stdio"] = "stdio"
|
|
78
|
+
type: Literal["stdio"] | None = None # Alternative transport field name
|
|
79
|
+
|
|
80
|
+
# Execution context
|
|
81
|
+
cwd: str | None = None # Working directory for command execution
|
|
82
|
+
timeout: int | None = None # Maximum response time in milliseconds
|
|
83
|
+
|
|
84
|
+
# Metadata
|
|
85
|
+
description: str | None = None # Human-readable server description
|
|
86
|
+
icon: str | None = None # Icon path or URL for UI display
|
|
87
|
+
|
|
88
|
+
# Authentication configuration
|
|
89
|
+
authentication: dict[str, Any] | None = None # Auth configuration object
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="allow") # Preserve unknown fields
|
|
92
|
+
|
|
93
|
+
def to_transport(self) -> StdioTransport:
|
|
94
|
+
from fastmcp.client.transports import StdioTransport
|
|
95
|
+
|
|
96
|
+
return StdioTransport(
|
|
97
|
+
command=self.command,
|
|
98
|
+
args=self.args,
|
|
99
|
+
env=self.env,
|
|
100
|
+
cwd=self.cwd,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RemoteMCPServer(BaseModel):
|
|
105
|
+
"""MCP server configuration for HTTP/SSE transport.
|
|
106
|
+
|
|
107
|
+
This is the canonical configuration format for MCP servers using remote transports.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# Required fields
|
|
111
|
+
url: str
|
|
112
|
+
|
|
113
|
+
# Transport configuration
|
|
114
|
+
transport: Literal["http", "streamable-http", "sse"] | None = None
|
|
115
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
# Authentication
|
|
118
|
+
auth: Annotated[
|
|
119
|
+
str | Literal["oauth"] | httpx.Auth | None,
|
|
120
|
+
Field(
|
|
121
|
+
description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
|
|
122
|
+
),
|
|
123
|
+
] = None
|
|
124
|
+
|
|
125
|
+
# Timeout configuration
|
|
126
|
+
sse_read_timeout: datetime.timedelta | int | float | None = None
|
|
127
|
+
timeout: int | None = None # Maximum response time in milliseconds
|
|
128
|
+
|
|
129
|
+
# Metadata
|
|
130
|
+
description: str | None = None # Human-readable server description
|
|
131
|
+
icon: str | None = None # Icon path or URL for UI display
|
|
132
|
+
|
|
133
|
+
# Authentication configuration
|
|
134
|
+
authentication: dict[str, Any] | None = None # Auth configuration object
|
|
135
|
+
|
|
136
|
+
model_config = ConfigDict(
|
|
137
|
+
extra="allow", arbitrary_types_allowed=True
|
|
138
|
+
) # Preserve unknown fields
|
|
139
|
+
|
|
140
|
+
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
|
141
|
+
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
142
|
+
|
|
143
|
+
if self.transport is None:
|
|
144
|
+
transport = infer_transport_type_from_url(self.url)
|
|
145
|
+
else:
|
|
146
|
+
transport = self.transport
|
|
147
|
+
|
|
148
|
+
if transport == "sse":
|
|
149
|
+
return SSETransport(
|
|
150
|
+
self.url,
|
|
151
|
+
headers=self.headers,
|
|
152
|
+
auth=self.auth,
|
|
153
|
+
sse_read_timeout=self.sse_read_timeout,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
# Both "http" and "streamable-http" map to StreamableHttpTransport
|
|
157
|
+
return StreamableHttpTransport(
|
|
158
|
+
self.url,
|
|
159
|
+
headers=self.headers,
|
|
160
|
+
auth=self.auth,
|
|
161
|
+
sse_read_timeout=self.sse_read_timeout,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class MCPConfig(BaseModel):
|
|
166
|
+
"""Canonical MCP configuration format.
|
|
167
|
+
|
|
168
|
+
This defines the standard configuration format for Model Context Protocol servers.
|
|
169
|
+
The format is designed to be client-agnostic and extensible for future use cases.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
|
|
178
|
+
"""Parse MCP configuration from dictionary format."""
|
|
179
|
+
# Handle case where config is just the mcpServers object
|
|
180
|
+
if "mcpServers" not in config and any(
|
|
181
|
+
isinstance(v, dict) and ("command" in v or "url" in v)
|
|
182
|
+
for v in config.values()
|
|
183
|
+
):
|
|
184
|
+
# This looks like a bare mcpServers object
|
|
185
|
+
servers_dict = config
|
|
186
|
+
else:
|
|
187
|
+
# Standard format with mcpServers wrapper
|
|
188
|
+
servers_dict = config.get("mcpServers", {})
|
|
189
|
+
|
|
190
|
+
# Parse each server configuration
|
|
191
|
+
parsed_servers = {}
|
|
192
|
+
for name, server_config in servers_dict.items():
|
|
193
|
+
if not isinstance(server_config, dict):
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Determine if this is stdio or remote based on fields
|
|
197
|
+
if "command" in server_config:
|
|
198
|
+
parsed_servers[name] = StdioMCPServer.model_validate(server_config)
|
|
199
|
+
elif "url" in server_config:
|
|
200
|
+
parsed_servers[name] = RemoteMCPServer.model_validate(server_config)
|
|
201
|
+
else:
|
|
202
|
+
# Skip invalid server configs but preserve them as raw dicts
|
|
203
|
+
# This allows for forward compatibility with unknown server types
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Create config with any extra top-level fields preserved
|
|
207
|
+
config_data = {k: v for k, v in config.items() if k != "mcpServers"}
|
|
208
|
+
config_data["mcpServers"] = parsed_servers
|
|
209
|
+
|
|
210
|
+
return cls.model_validate(config_data)
|
|
211
|
+
|
|
212
|
+
def to_dict(self) -> dict[str, Any]:
|
|
213
|
+
"""Convert MCPConfig to dictionary format, preserving all fields."""
|
|
214
|
+
# Start with all extra fields at the top level
|
|
215
|
+
result = self.model_dump(exclude={"mcpServers"}, exclude_none=True)
|
|
216
|
+
|
|
217
|
+
# Add mcpServers with all fields preserved
|
|
218
|
+
result["mcpServers"] = {
|
|
219
|
+
name: server.model_dump(exclude_none=True)
|
|
220
|
+
for name, server in self.mcpServers.items()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
def write_to_file(self, file_path: Path) -> None:
|
|
226
|
+
"""Write configuration to JSON file."""
|
|
227
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
with open(file_path, "w") as f:
|
|
229
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def from_file(cls, file_path: Path) -> MCPConfig:
|
|
233
|
+
"""Load configuration from JSON file."""
|
|
234
|
+
if not file_path.exists():
|
|
235
|
+
return cls(mcpServers={})
|
|
236
|
+
with open(file_path) as f:
|
|
237
|
+
content = f.read().strip()
|
|
238
|
+
if not content:
|
|
239
|
+
return cls(mcpServers={})
|
|
240
|
+
data = json.loads(content)
|
|
241
|
+
return cls.from_dict(data)
|
|
242
|
+
|
|
243
|
+
def add_server(self, name: str, server: StdioMCPServer | RemoteMCPServer) -> None:
|
|
244
|
+
"""Add or update a server in the configuration."""
|
|
245
|
+
self.mcpServers[name] = server
|
|
246
|
+
|
|
247
|
+
def remove_server(self, name: str) -> None:
|
|
248
|
+
"""Remove a server from the configuration."""
|
|
249
|
+
if name in self.mcpServers:
|
|
250
|
+
del self.mcpServers[name]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def update_config_file(
|
|
254
|
+
file_path: Path,
|
|
255
|
+
server_name: str,
|
|
256
|
+
server_config: StdioMCPServer | RemoteMCPServer,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Update MCP configuration file with new server, preserving existing fields."""
|
|
259
|
+
config = MCPConfig.from_file(file_path)
|
|
260
|
+
|
|
261
|
+
# If updating an existing server, merge with existing configuration
|
|
262
|
+
# to preserve any unknown fields
|
|
263
|
+
if server_name in config.mcpServers:
|
|
264
|
+
existing_server = config.mcpServers[server_name]
|
|
265
|
+
# Get the raw dict representation of both servers
|
|
266
|
+
existing_dict = existing_server.model_dump()
|
|
267
|
+
new_dict = server_config.model_dump(exclude_none=True)
|
|
268
|
+
|
|
269
|
+
# Merge, with new values taking precedence
|
|
270
|
+
merged_dict = {**existing_dict, **new_dict}
|
|
271
|
+
|
|
272
|
+
# Create new server instance with merged data
|
|
273
|
+
if "command" in merged_dict:
|
|
274
|
+
merged_server = StdioMCPServer.model_validate(merged_dict)
|
|
275
|
+
else:
|
|
276
|
+
merged_server = RemoteMCPServer.model_validate(merged_dict)
|
|
277
|
+
|
|
278
|
+
config.add_server(server_name, merged_server)
|
|
279
|
+
else:
|
|
280
|
+
config.add_server(server_name, server_config)
|
|
281
|
+
|
|
282
|
+
config.write_to_file(file_path)
|
fastmcp/prompts/prompt.py
CHANGED
|
@@ -329,7 +329,7 @@ class FunctionPrompt(Prompt):
|
|
|
329
329
|
|
|
330
330
|
# Call function and check if result is a coroutine
|
|
331
331
|
result = self.fn(**kwargs)
|
|
332
|
-
if inspect.
|
|
332
|
+
if inspect.isawaitable(result):
|
|
333
333
|
result = await result
|
|
334
334
|
|
|
335
335
|
# Validate messages
|
|
@@ -350,9 +350,7 @@ class FunctionPrompt(Prompt):
|
|
|
350
350
|
)
|
|
351
351
|
)
|
|
352
352
|
else:
|
|
353
|
-
content = pydantic_core.to_json(
|
|
354
|
-
msg, fallback=str, indent=2
|
|
355
|
-
).decode()
|
|
353
|
+
content = pydantic_core.to_json(msg, fallback=str).decode()
|
|
356
354
|
messages.append(
|
|
357
355
|
PromptMessage(
|
|
358
356
|
role="user",
|
fastmcp/resources/resource.py
CHANGED
|
@@ -182,7 +182,7 @@ class FunctionResource(Resource):
|
|
|
182
182
|
kwargs[context_kwarg] = get_context()
|
|
183
183
|
|
|
184
184
|
result = self.fn(**kwargs)
|
|
185
|
-
if inspect.
|
|
185
|
+
if inspect.isawaitable(result):
|
|
186
186
|
result = await result
|
|
187
187
|
|
|
188
188
|
if isinstance(result, Resource):
|
|
@@ -192,4 +192,4 @@ class FunctionResource(Resource):
|
|
|
192
192
|
elif isinstance(result, str):
|
|
193
193
|
return result
|
|
194
194
|
else:
|
|
195
|
-
return pydantic_core.to_json(result, fallback=str
|
|
195
|
+
return pydantic_core.to_json(result, fallback=str).decode()
|
fastmcp/resources/template.py
CHANGED
fastmcp/server/openapi.py
CHANGED
|
@@ -27,6 +27,7 @@ from fastmcp.utilities.logging import get_logger
|
|
|
27
27
|
from fastmcp.utilities.openapi import (
|
|
28
28
|
HTTPRoute,
|
|
29
29
|
_combine_schemas,
|
|
30
|
+
extract_output_schema_from_responses,
|
|
30
31
|
format_array_parameter,
|
|
31
32
|
format_description_with_responses,
|
|
32
33
|
)
|
|
@@ -234,6 +235,7 @@ class OpenAPITool(Tool):
|
|
|
234
235
|
name: str,
|
|
235
236
|
description: str,
|
|
236
237
|
parameters: dict[str, Any],
|
|
238
|
+
output_schema: dict[str, Any] | None = None,
|
|
237
239
|
tags: set[str] | None = None,
|
|
238
240
|
timeout: float | None = None,
|
|
239
241
|
annotations: ToolAnnotations | None = None,
|
|
@@ -243,6 +245,7 @@ class OpenAPITool(Tool):
|
|
|
243
245
|
name=name,
|
|
244
246
|
description=description,
|
|
245
247
|
parameters=parameters,
|
|
248
|
+
output_schema=output_schema,
|
|
246
249
|
tags=tags or set(),
|
|
247
250
|
annotations=annotations,
|
|
248
251
|
serializer=serializer,
|
|
@@ -392,9 +395,22 @@ class OpenAPITool(Tool):
|
|
|
392
395
|
# Try to parse as JSON first
|
|
393
396
|
try:
|
|
394
397
|
result = response.json()
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
+
|
|
399
|
+
# Handle structured content based on output schema, if any
|
|
400
|
+
structured_output = None
|
|
401
|
+
if self.output_schema is not None:
|
|
402
|
+
if self.output_schema.get("x-fastmcp-wrap-result"):
|
|
403
|
+
# Schema says wrap - always wrap in result key
|
|
404
|
+
structured_output = {"result": result}
|
|
405
|
+
else:
|
|
406
|
+
structured_output = result
|
|
407
|
+
# If no output schema, use fallback logic for backward compatibility
|
|
408
|
+
elif not isinstance(result, dict):
|
|
409
|
+
structured_output = {"result": result}
|
|
410
|
+
else:
|
|
411
|
+
structured_output = result
|
|
412
|
+
|
|
413
|
+
return ToolResult(structured_content=structured_output)
|
|
398
414
|
except json.JSONDecodeError:
|
|
399
415
|
return ToolResult(content=response.text)
|
|
400
416
|
|
|
@@ -787,6 +803,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
787
803
|
"""Creates and registers an OpenAPITool with enhanced description."""
|
|
788
804
|
combined_schema = _combine_schemas(route)
|
|
789
805
|
|
|
806
|
+
# Extract output schema from OpenAPI responses
|
|
807
|
+
output_schema = extract_output_schema_from_responses(
|
|
808
|
+
route.responses, route.schema_definitions
|
|
809
|
+
)
|
|
810
|
+
|
|
790
811
|
# Get a unique tool name
|
|
791
812
|
tool_name = self._get_unique_name(name, "tool")
|
|
792
813
|
|
|
@@ -810,6 +831,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
810
831
|
name=tool_name,
|
|
811
832
|
description=enhanced_description,
|
|
812
833
|
parameters=combined_schema,
|
|
834
|
+
output_schema=output_schema,
|
|
813
835
|
tags=set(route.tags or []) | tags,
|
|
814
836
|
timeout=self._timeout,
|
|
815
837
|
)
|
|
@@ -825,10 +847,13 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
825
847
|
f"Using component as-is."
|
|
826
848
|
)
|
|
827
849
|
|
|
850
|
+
# Use the potentially modified tool name as the registration key
|
|
851
|
+
final_tool_name = tool.name
|
|
852
|
+
|
|
828
853
|
# Register the tool by directly assigning to the tools dictionary
|
|
829
|
-
self._tool_manager._tools[
|
|
854
|
+
self._tool_manager._tools[final_tool_name] = tool
|
|
830
855
|
logger.debug(
|
|
831
|
-
f"Registered TOOL: {
|
|
856
|
+
f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
|
832
857
|
)
|
|
833
858
|
|
|
834
859
|
def _create_openapi_resource(
|
|
@@ -875,10 +900,13 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
875
900
|
f"Using component as-is."
|
|
876
901
|
)
|
|
877
902
|
|
|
903
|
+
# Use the potentially modified resource URI as the registration key
|
|
904
|
+
final_resource_uri = str(resource.uri)
|
|
905
|
+
|
|
878
906
|
# Register the resource by directly assigning to the resources dictionary
|
|
879
|
-
self._resource_manager._resources[
|
|
907
|
+
self._resource_manager._resources[final_resource_uri] = resource
|
|
880
908
|
logger.debug(
|
|
881
|
-
f"Registered RESOURCE: {
|
|
909
|
+
f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
882
910
|
)
|
|
883
911
|
|
|
884
912
|
def _create_openapi_template(
|
|
@@ -954,8 +982,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
954
982
|
f"Using component as-is."
|
|
955
983
|
)
|
|
956
984
|
|
|
985
|
+
# Use the potentially modified template URI as the registration key
|
|
986
|
+
final_template_uri = template.uri_template
|
|
987
|
+
|
|
957
988
|
# Register the template by directly assigning to the templates dictionary
|
|
958
|
-
self._resource_manager._templates[
|
|
989
|
+
self._resource_manager._templates[final_template_uri] = template
|
|
959
990
|
logger.debug(
|
|
960
|
-
f"Registered TEMPLATE: {
|
|
991
|
+
f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
961
992
|
)
|