fastmcp 2.10.4__py3-none-any.whl → 2.10.6__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/__init__.py +7 -2
- fastmcp/cli/install/__init__.py +2 -2
- fastmcp/cli/install/claude_code.py +35 -6
- fastmcp/cli/install/{mcp_config.py → mcp_json.py} +10 -7
- fastmcp/prompts/prompt_manager.py +1 -1
- fastmcp/resources/resource_manager.py +2 -2
- fastmcp/server/context.py +3 -0
- fastmcp/server/middleware/__init__.py +0 -8
- fastmcp/server/middleware/middleware.py +8 -34
- fastmcp/server/openapi.py +140 -53
- fastmcp/server/proxy.py +51 -4
- fastmcp/server/server.py +26 -8
- fastmcp/settings.py +10 -12
- fastmcp/tools/tool.py +5 -3
- fastmcp/tools/tool_manager.py +1 -1
- fastmcp/tools/tool_transform.py +10 -3
- fastmcp/utilities/cli.py +6 -6
- fastmcp/utilities/components.py +43 -0
- fastmcp/utilities/openapi.py +201 -20
- {fastmcp-2.10.4.dist-info → fastmcp-2.10.6.dist-info}/METADATA +2 -2
- {fastmcp-2.10.4.dist-info → fastmcp-2.10.6.dist-info}/RECORD +24 -24
- {fastmcp-2.10.4.dist-info → fastmcp-2.10.6.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.4.dist-info → fastmcp-2.10.6.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.4.dist-info → fastmcp-2.10.6.dist-info}/licenses/LICENSE +0 -0
fastmcp/__init__.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"""FastMCP - An ergonomic MCP interface."""
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
|
-
from importlib.metadata import version
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
5
|
from fastmcp.settings import Settings
|
|
6
|
+
from fastmcp.utilities.logging import configure_logging as _configure_logging
|
|
6
7
|
|
|
7
8
|
settings = Settings()
|
|
9
|
+
_configure_logging(
|
|
10
|
+
level=settings.log_level,
|
|
11
|
+
enable_rich_tracebacks=settings.enable_rich_tracebacks,
|
|
12
|
+
)
|
|
8
13
|
|
|
9
14
|
from fastmcp.server.server import FastMCP
|
|
10
15
|
from fastmcp.server.context import Context
|
|
@@ -13,7 +18,7 @@ import fastmcp.server
|
|
|
13
18
|
from fastmcp.client import Client
|
|
14
19
|
from . import client
|
|
15
20
|
|
|
16
|
-
__version__ =
|
|
21
|
+
__version__ = _version("fastmcp")
|
|
17
22
|
|
|
18
23
|
|
|
19
24
|
# ensure deprecation warnings are displayed by default
|
fastmcp/cli/install/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ import cyclopts
|
|
|
5
5
|
from .claude_code import claude_code_command
|
|
6
6
|
from .claude_desktop import claude_desktop_command
|
|
7
7
|
from .cursor import cursor_command
|
|
8
|
-
from .
|
|
8
|
+
from .mcp_json import mcp_json_command
|
|
9
9
|
|
|
10
10
|
# Create a cyclopts app for install subcommands
|
|
11
11
|
install_app = cyclopts.App(
|
|
@@ -17,4 +17,4 @@ install_app = cyclopts.App(
|
|
|
17
17
|
install_app.command(claude_code_command, name="claude-code")
|
|
18
18
|
install_app.command(claude_desktop_command, name="claude-desktop")
|
|
19
19
|
install_app.command(cursor_command, name="cursor")
|
|
20
|
-
install_app.command(
|
|
20
|
+
install_app.command(mcp_json_command, name="mcp-json")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Claude Code integration for FastMCP install using Cyclopts."""
|
|
2
2
|
|
|
3
|
+
import shutil
|
|
3
4
|
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
@@ -16,22 +17,50 @@ logger = get_logger(__name__)
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def find_claude_command() -> str | None:
|
|
19
|
-
"""Find the Claude Code CLI command.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
"""Find the Claude Code CLI command.
|
|
21
|
+
|
|
22
|
+
Checks common installation locations since 'claude' is often a shell alias
|
|
23
|
+
that doesn't work with subprocess calls.
|
|
24
|
+
"""
|
|
25
|
+
# First try shutil.which() in case it's a real executable in PATH
|
|
26
|
+
claude_in_path = shutil.which("claude")
|
|
27
|
+
if claude_in_path:
|
|
23
28
|
try:
|
|
24
29
|
result = subprocess.run(
|
|
25
|
-
[
|
|
30
|
+
[claude_in_path, "--version"],
|
|
26
31
|
check=True,
|
|
27
32
|
capture_output=True,
|
|
28
33
|
text=True,
|
|
29
34
|
)
|
|
30
35
|
if "Claude Code" in result.stdout:
|
|
31
|
-
return
|
|
36
|
+
return claude_in_path
|
|
32
37
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
33
38
|
pass
|
|
34
39
|
|
|
40
|
+
# Check common installation locations (aliases don't work with subprocess)
|
|
41
|
+
potential_paths = [
|
|
42
|
+
# Default Claude Code installation location (after migration)
|
|
43
|
+
Path.home() / ".claude" / "local" / "claude",
|
|
44
|
+
# npm global installation on macOS/Linux (default)
|
|
45
|
+
Path("/usr/local/bin/claude"),
|
|
46
|
+
# npm global installation with custom prefix
|
|
47
|
+
Path.home() / ".npm-global" / "bin" / "claude",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
for path in potential_paths:
|
|
51
|
+
if path.exists():
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[str(path), "--version"],
|
|
55
|
+
check=True,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
)
|
|
59
|
+
if "Claude Code" in result.stdout:
|
|
60
|
+
return str(path)
|
|
61
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
62
|
+
continue
|
|
63
|
+
|
|
35
64
|
return None
|
|
36
65
|
|
|
37
66
|
|
|
@@ -16,7 +16,7 @@ from .shared import process_common_args
|
|
|
16
16
|
logger = get_logger(__name__)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def install_mcp_json(
|
|
20
20
|
file: Path,
|
|
21
21
|
server_object: str | None,
|
|
22
22
|
name: str,
|
|
@@ -65,15 +65,18 @@ def install_mcp_config(
|
|
|
65
65
|
# Add fastmcp run command
|
|
66
66
|
args.extend(["fastmcp", "run", server_spec])
|
|
67
67
|
|
|
68
|
-
# Build MCP server configuration
|
|
69
|
-
|
|
68
|
+
# Build MCP server configuration
|
|
69
|
+
server_config = {
|
|
70
70
|
"command": "uv",
|
|
71
71
|
"args": args,
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
# Add environment variables if provided
|
|
75
75
|
if env_vars:
|
|
76
|
-
|
|
76
|
+
server_config["env"] = env_vars
|
|
77
|
+
|
|
78
|
+
# Wrap with server name as root key
|
|
79
|
+
config = {name: server_config}
|
|
77
80
|
|
|
78
81
|
# Convert to JSON
|
|
79
82
|
json_output = json.dumps(config, indent=2)
|
|
@@ -93,13 +96,13 @@ def install_mcp_config(
|
|
|
93
96
|
return False
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
def
|
|
99
|
+
def mcp_json_command(
|
|
97
100
|
server_spec: str,
|
|
98
101
|
*,
|
|
99
102
|
server_name: Annotated[
|
|
100
103
|
str | None,
|
|
101
104
|
cyclopts.Parameter(
|
|
102
|
-
name=["--
|
|
105
|
+
name=["--name", "-n"],
|
|
103
106
|
help="Custom name for the server in MCP config",
|
|
104
107
|
),
|
|
105
108
|
] = None,
|
|
@@ -151,7 +154,7 @@ def mcp_config_command(
|
|
|
151
154
|
server_spec, server_name, with_packages, env_vars, env_file
|
|
152
155
|
)
|
|
153
156
|
|
|
154
|
-
success =
|
|
157
|
+
success = install_mcp_json(
|
|
155
158
|
file=file,
|
|
156
159
|
server_object=server_object,
|
|
157
160
|
name=name,
|
|
@@ -78,7 +78,7 @@ class PromptManager:
|
|
|
78
78
|
except Exception as e:
|
|
79
79
|
# Skip failed mounts silently, matches existing behavior
|
|
80
80
|
logger.warning(
|
|
81
|
-
f"Failed to get prompts from mounted
|
|
81
|
+
f"Failed to get prompts from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
|
|
82
82
|
)
|
|
83
83
|
continue
|
|
84
84
|
|
|
@@ -109,7 +109,7 @@ class ResourceManager:
|
|
|
109
109
|
except Exception as e:
|
|
110
110
|
# Skip failed mounts silently, matches existing behavior
|
|
111
111
|
logger.warning(
|
|
112
|
-
f"Failed to get resources from mounted
|
|
112
|
+
f"Failed to get resources from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
|
|
113
113
|
)
|
|
114
114
|
continue
|
|
115
115
|
|
|
@@ -157,7 +157,7 @@ class ResourceManager:
|
|
|
157
157
|
except Exception as e:
|
|
158
158
|
# Skip failed mounts silently, matches existing behavior
|
|
159
159
|
logger.warning(
|
|
160
|
-
f"Failed to get templates from mounted
|
|
160
|
+
f"Failed to get templates from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
|
|
161
161
|
)
|
|
162
162
|
continue
|
|
163
163
|
|
fastmcp/server/context.py
CHANGED
|
@@ -16,6 +16,7 @@ from mcp.shared.context import RequestContext
|
|
|
16
16
|
from mcp.types import (
|
|
17
17
|
ContentBlock,
|
|
18
18
|
CreateMessageResult,
|
|
19
|
+
IncludeContext,
|
|
19
20
|
ModelHint,
|
|
20
21
|
ModelPreferences,
|
|
21
22
|
Root,
|
|
@@ -272,6 +273,7 @@ class Context:
|
|
|
272
273
|
self,
|
|
273
274
|
messages: str | list[str | SamplingMessage],
|
|
274
275
|
system_prompt: str | None = None,
|
|
276
|
+
include_context: IncludeContext | None = None,
|
|
275
277
|
temperature: float | None = None,
|
|
276
278
|
max_tokens: int | None = None,
|
|
277
279
|
model_preferences: ModelPreferences | str | list[str] | None = None,
|
|
@@ -304,6 +306,7 @@ class Context:
|
|
|
304
306
|
result: CreateMessageResult = await self.session.create_message(
|
|
305
307
|
messages=sampling_messages,
|
|
306
308
|
system_prompt=system_prompt,
|
|
309
|
+
include_context=include_context,
|
|
307
310
|
temperature=temperature,
|
|
308
311
|
max_tokens=max_tokens,
|
|
309
312
|
model_preferences=self._parse_model_preferences(model_preferences),
|
|
@@ -2,18 +2,10 @@ from .middleware import (
|
|
|
2
2
|
Middleware,
|
|
3
3
|
MiddlewareContext,
|
|
4
4
|
CallNext,
|
|
5
|
-
ListToolsResult,
|
|
6
|
-
ListResourcesResult,
|
|
7
|
-
ListResourceTemplatesResult,
|
|
8
|
-
ListPromptsResult,
|
|
9
5
|
)
|
|
10
6
|
|
|
11
7
|
__all__ = [
|
|
12
8
|
"Middleware",
|
|
13
9
|
"MiddlewareContext",
|
|
14
10
|
"CallNext",
|
|
15
|
-
"ListToolsResult",
|
|
16
|
-
"ListResourcesResult",
|
|
17
|
-
"ListResourceTemplatesResult",
|
|
18
|
-
"ListPromptsResult",
|
|
19
11
|
]
|
|
@@ -29,10 +29,6 @@ __all__ = [
|
|
|
29
29
|
"Middleware",
|
|
30
30
|
"MiddlewareContext",
|
|
31
31
|
"CallNext",
|
|
32
|
-
"ListToolsResult",
|
|
33
|
-
"ListResourcesResult",
|
|
34
|
-
"ListResourceTemplatesResult",
|
|
35
|
-
"ListPromptsResult",
|
|
36
32
|
]
|
|
37
33
|
|
|
38
34
|
logger = logging.getLogger(__name__)
|
|
@@ -62,26 +58,6 @@ ServerResultT = TypeVar(
|
|
|
62
58
|
)
|
|
63
59
|
|
|
64
60
|
|
|
65
|
-
@dataclass(kw_only=True)
|
|
66
|
-
class ListToolsResult:
|
|
67
|
-
tools: dict[str, Tool]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@dataclass(kw_only=True)
|
|
71
|
-
class ListResourcesResult:
|
|
72
|
-
resources: list[Resource]
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@dataclass(kw_only=True)
|
|
76
|
-
class ListResourceTemplatesResult:
|
|
77
|
-
resource_templates: list[ResourceTemplate]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@dataclass(kw_only=True)
|
|
81
|
-
class ListPromptsResult:
|
|
82
|
-
prompts: list[Prompt]
|
|
83
|
-
|
|
84
|
-
|
|
85
61
|
@runtime_checkable
|
|
86
62
|
class ServerResultProtocol(Protocol[ServerResultT]):
|
|
87
63
|
root: ServerResultT
|
|
@@ -212,29 +188,27 @@ class Middleware:
|
|
|
212
188
|
async def on_list_tools(
|
|
213
189
|
self,
|
|
214
190
|
context: MiddlewareContext[mt.ListToolsRequest],
|
|
215
|
-
call_next: CallNext[mt.ListToolsRequest,
|
|
216
|
-
) ->
|
|
191
|
+
call_next: CallNext[mt.ListToolsRequest, list[Tool]],
|
|
192
|
+
) -> list[Tool]:
|
|
217
193
|
return await call_next(context)
|
|
218
194
|
|
|
219
195
|
async def on_list_resources(
|
|
220
196
|
self,
|
|
221
197
|
context: MiddlewareContext[mt.ListResourcesRequest],
|
|
222
|
-
call_next: CallNext[mt.ListResourcesRequest,
|
|
223
|
-
) ->
|
|
198
|
+
call_next: CallNext[mt.ListResourcesRequest, list[Resource]],
|
|
199
|
+
) -> list[Resource]:
|
|
224
200
|
return await call_next(context)
|
|
225
201
|
|
|
226
202
|
async def on_list_resource_templates(
|
|
227
203
|
self,
|
|
228
204
|
context: MiddlewareContext[mt.ListResourceTemplatesRequest],
|
|
229
|
-
call_next: CallNext[
|
|
230
|
-
|
|
231
|
-
],
|
|
232
|
-
) -> ListResourceTemplatesResult:
|
|
205
|
+
call_next: CallNext[mt.ListResourceTemplatesRequest, list[ResourceTemplate]],
|
|
206
|
+
) -> list[ResourceTemplate]:
|
|
233
207
|
return await call_next(context)
|
|
234
208
|
|
|
235
209
|
async def on_list_prompts(
|
|
236
210
|
self,
|
|
237
211
|
context: MiddlewareContext[mt.ListPromptsRequest],
|
|
238
|
-
call_next: CallNext[mt.ListPromptsRequest,
|
|
239
|
-
) ->
|
|
212
|
+
call_next: CallNext[mt.ListPromptsRequest, list[Prompt]],
|
|
213
|
+
) -> list[Prompt]:
|
|
240
214
|
return await call_next(context)
|
fastmcp/server/openapi.py
CHANGED
|
@@ -29,6 +29,7 @@ from fastmcp.utilities.openapi import (
|
|
|
29
29
|
_combine_schemas,
|
|
30
30
|
extract_output_schema_from_responses,
|
|
31
31
|
format_array_parameter,
|
|
32
|
+
format_deep_object_parameter,
|
|
32
33
|
format_description_with_responses,
|
|
33
34
|
)
|
|
34
35
|
|
|
@@ -261,19 +262,45 @@ class OpenAPITool(Tool):
|
|
|
261
262
|
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
262
263
|
"""Execute the HTTP request based on the route configuration."""
|
|
263
264
|
|
|
265
|
+
# Create mapping from suffixed parameter names back to original names and locations
|
|
266
|
+
# This handles parameter collisions where suffixes were added during schema generation
|
|
267
|
+
param_mapping = {} # suffixed_name -> (original_name, location)
|
|
268
|
+
|
|
269
|
+
# First, check if we have request body properties to detect collisions
|
|
270
|
+
body_props = set()
|
|
271
|
+
if self._route.request_body and self._route.request_body.content_schema:
|
|
272
|
+
content_type = next(iter(self._route.request_body.content_schema))
|
|
273
|
+
body_schema = self._route.request_body.content_schema[content_type]
|
|
274
|
+
body_props = set(body_schema.get("properties", {}).keys())
|
|
275
|
+
|
|
276
|
+
# Build parameter mapping for potentially suffixed parameters
|
|
277
|
+
for param in self._route.parameters:
|
|
278
|
+
original_name = param.name
|
|
279
|
+
suffixed_name = f"{param.name}__{param.location}"
|
|
280
|
+
|
|
281
|
+
# If parameter name collides with body property, it would have been suffixed
|
|
282
|
+
if param.name in body_props:
|
|
283
|
+
param_mapping[suffixed_name] = (original_name, param.location)
|
|
284
|
+
# Also map original name for backward compatibility when no collision
|
|
285
|
+
param_mapping[original_name] = (original_name, param.location)
|
|
286
|
+
|
|
264
287
|
# Prepare URL
|
|
265
288
|
path = self._route.path
|
|
266
289
|
|
|
267
|
-
# Replace path parameters with values from
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
p.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
290
|
+
# Replace path parameters with values from arguments
|
|
291
|
+
# Look for both original and suffixed parameter names
|
|
292
|
+
path_params = {}
|
|
293
|
+
for p in self._route.parameters:
|
|
294
|
+
if p.location == "path":
|
|
295
|
+
# Try suffixed name first, then original name
|
|
296
|
+
suffixed_name = f"{p.name}__{p.location}"
|
|
297
|
+
if (
|
|
298
|
+
suffixed_name in arguments
|
|
299
|
+
and arguments.get(suffixed_name) is not None
|
|
300
|
+
):
|
|
301
|
+
path_params[p.name] = arguments[suffixed_name]
|
|
302
|
+
elif p.name in arguments and arguments.get(p.name) is not None:
|
|
303
|
+
path_params[p.name] = arguments[p.name]
|
|
277
304
|
|
|
278
305
|
# Ensure all path parameters are provided
|
|
279
306
|
required_path_params = {
|
|
@@ -312,35 +339,82 @@ class OpenAPITool(Tool):
|
|
|
312
339
|
# Prepare query parameters - filter out None and empty strings
|
|
313
340
|
query_params = {}
|
|
314
341
|
for p in self._route.parameters:
|
|
315
|
-
if
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if explode:
|
|
332
|
-
# When explode=True, we pass the array directly, which HTTPX will serialize
|
|
333
|
-
# as multiple parameters with the same name
|
|
334
|
-
query_params[p.name] = param_value
|
|
335
|
-
else:
|
|
336
|
-
# Format array as comma-separated string when explode=False
|
|
337
|
-
formatted_value = format_array_parameter(
|
|
338
|
-
param_value, p.name, is_query_parameter=True
|
|
339
|
-
)
|
|
340
|
-
query_params[p.name] = formatted_value
|
|
342
|
+
if p.location == "query":
|
|
343
|
+
# Try suffixed name first, then original name
|
|
344
|
+
suffixed_name = f"{p.name}__{p.location}"
|
|
345
|
+
param_value = None
|
|
346
|
+
|
|
347
|
+
suffixed_value = arguments.get(suffixed_name)
|
|
348
|
+
if (
|
|
349
|
+
suffixed_name in arguments
|
|
350
|
+
and suffixed_value is not None
|
|
351
|
+
and suffixed_value != ""
|
|
352
|
+
and not (
|
|
353
|
+
isinstance(suffixed_value, list | dict)
|
|
354
|
+
and len(suffixed_value) == 0
|
|
355
|
+
)
|
|
356
|
+
):
|
|
357
|
+
param_value = arguments[suffixed_name]
|
|
341
358
|
else:
|
|
342
|
-
|
|
343
|
-
|
|
359
|
+
name_value = arguments.get(p.name)
|
|
360
|
+
if (
|
|
361
|
+
p.name in arguments
|
|
362
|
+
and name_value is not None
|
|
363
|
+
and name_value != ""
|
|
364
|
+
and not (
|
|
365
|
+
isinstance(name_value, list | dict) and len(name_value) == 0
|
|
366
|
+
)
|
|
367
|
+
):
|
|
368
|
+
param_value = arguments[p.name]
|
|
369
|
+
|
|
370
|
+
if param_value is not None:
|
|
371
|
+
# Handle different parameter styles and types
|
|
372
|
+
param_style = (
|
|
373
|
+
p.style or "form"
|
|
374
|
+
) # Default style for query parameters is "form"
|
|
375
|
+
param_explode = (
|
|
376
|
+
p.explode if p.explode is not None else True
|
|
377
|
+
) # Default explode for query is True
|
|
378
|
+
|
|
379
|
+
# Handle deepObject style for object parameters
|
|
380
|
+
if (
|
|
381
|
+
param_style == "deepObject"
|
|
382
|
+
and isinstance(param_value, dict)
|
|
383
|
+
and len(param_value) > 0
|
|
384
|
+
):
|
|
385
|
+
if param_explode:
|
|
386
|
+
# deepObject with explode=true: object properties become separate parameters
|
|
387
|
+
# e.g., target[id]=123&target[type]=user
|
|
388
|
+
deep_obj_params = format_deep_object_parameter(
|
|
389
|
+
param_value, p.name
|
|
390
|
+
)
|
|
391
|
+
query_params.update(deep_obj_params)
|
|
392
|
+
else:
|
|
393
|
+
# deepObject with explode=false is not commonly used, fallback to JSON
|
|
394
|
+
logger.warning(
|
|
395
|
+
f"deepObject style with explode=false for parameter '{p.name}' is not standard. "
|
|
396
|
+
f"Using JSON serialization fallback."
|
|
397
|
+
)
|
|
398
|
+
query_params[p.name] = json.dumps(param_value)
|
|
399
|
+
# Handle array parameters with form style (default)
|
|
400
|
+
elif (
|
|
401
|
+
isinstance(param_value, list)
|
|
402
|
+
and p.schema_.get("type") == "array"
|
|
403
|
+
and len(param_value) > 0
|
|
404
|
+
):
|
|
405
|
+
if param_explode:
|
|
406
|
+
# When explode=True, we pass the array directly, which HTTPX will serialize
|
|
407
|
+
# as multiple parameters with the same name
|
|
408
|
+
query_params[p.name] = param_value
|
|
409
|
+
else:
|
|
410
|
+
# Format array as comma-separated string when explode=False
|
|
411
|
+
formatted_value = format_array_parameter(
|
|
412
|
+
param_value, p.name, is_query_parameter=True
|
|
413
|
+
)
|
|
414
|
+
query_params[p.name] = formatted_value
|
|
415
|
+
else:
|
|
416
|
+
# Non-array, non-deepObject parameters are passed as is
|
|
417
|
+
query_params[p.name] = param_value
|
|
344
418
|
|
|
345
419
|
# Prepare headers - fix typing by ensuring all values are strings
|
|
346
420
|
headers = {}
|
|
@@ -348,12 +422,21 @@ class OpenAPITool(Tool):
|
|
|
348
422
|
# Start with OpenAPI-defined header parameters
|
|
349
423
|
openapi_headers = {}
|
|
350
424
|
for p in self._route.parameters:
|
|
351
|
-
if
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
425
|
+
if p.location == "header":
|
|
426
|
+
# Try suffixed name first, then original name
|
|
427
|
+
suffixed_name = f"{p.name}__{p.location}"
|
|
428
|
+
param_value = None
|
|
429
|
+
|
|
430
|
+
if (
|
|
431
|
+
suffixed_name in arguments
|
|
432
|
+
and arguments.get(suffixed_name) is not None
|
|
433
|
+
):
|
|
434
|
+
param_value = arguments[suffixed_name]
|
|
435
|
+
elif p.name in arguments and arguments.get(p.name) is not None:
|
|
436
|
+
param_value = arguments[p.name]
|
|
437
|
+
|
|
438
|
+
if param_value is not None:
|
|
439
|
+
openapi_headers[p.name.lower()] = str(param_value)
|
|
357
440
|
headers.update(openapi_headers)
|
|
358
441
|
|
|
359
442
|
# Add headers from the current MCP client HTTP request (these take precedence)
|
|
@@ -363,16 +446,20 @@ class OpenAPITool(Tool):
|
|
|
363
446
|
# Prepare request body
|
|
364
447
|
json_data = None
|
|
365
448
|
if self._route.request_body and self._route.request_body.content_schema:
|
|
366
|
-
# Extract body parameters
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
449
|
+
# Extract body parameters with collision-aware logic
|
|
450
|
+
# Exclude all parameter names that belong to path/query/header locations
|
|
451
|
+
params_to_exclude = set()
|
|
452
|
+
|
|
453
|
+
for p in self._route.parameters:
|
|
454
|
+
if (
|
|
455
|
+
p.name in body_props
|
|
456
|
+
): # This parameter had a collision, so it was suffixed
|
|
457
|
+
params_to_exclude.add(f"{p.name}__{p.location}")
|
|
458
|
+
else: # No collision, parameter keeps original name but should still be excluded from body
|
|
459
|
+
params_to_exclude.add(p.name)
|
|
460
|
+
|
|
372
461
|
body_params = {
|
|
373
|
-
k: v
|
|
374
|
-
for k, v in arguments.items()
|
|
375
|
-
if k not in path_query_header_params and k != "context"
|
|
462
|
+
k: v for k, v in arguments.items() if k not in params_to_exclude
|
|
376
463
|
}
|
|
377
464
|
|
|
378
465
|
if body_params:
|
fastmcp/server/proxy.py
CHANGED
|
@@ -36,6 +36,7 @@ from fastmcp.server.dependencies import get_context
|
|
|
36
36
|
from fastmcp.server.server import FastMCP
|
|
37
37
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
38
38
|
from fastmcp.tools.tool_manager import ToolManager
|
|
39
|
+
from fastmcp.utilities.components import MirroredComponent
|
|
39
40
|
from fastmcp.utilities.logging import get_logger
|
|
40
41
|
|
|
41
42
|
if TYPE_CHECKING:
|
|
@@ -226,7 +227,7 @@ class ProxyPromptManager(PromptManager):
|
|
|
226
227
|
return result
|
|
227
228
|
|
|
228
229
|
|
|
229
|
-
class ProxyTool(Tool):
|
|
230
|
+
class ProxyTool(Tool, MirroredComponent):
|
|
230
231
|
"""
|
|
231
232
|
A Tool that represents and executes a tool on a remote server.
|
|
232
233
|
"""
|
|
@@ -245,6 +246,7 @@ class ProxyTool(Tool):
|
|
|
245
246
|
parameters=mcp_tool.inputSchema,
|
|
246
247
|
annotations=mcp_tool.annotations,
|
|
247
248
|
output_schema=mcp_tool.outputSchema,
|
|
249
|
+
_mirrored=True,
|
|
248
250
|
)
|
|
249
251
|
|
|
250
252
|
async def run(
|
|
@@ -266,7 +268,7 @@ class ProxyTool(Tool):
|
|
|
266
268
|
)
|
|
267
269
|
|
|
268
270
|
|
|
269
|
-
class ProxyResource(Resource):
|
|
271
|
+
class ProxyResource(Resource, MirroredComponent):
|
|
270
272
|
"""
|
|
271
273
|
A Resource that represents and reads a resource from a remote server.
|
|
272
274
|
"""
|
|
@@ -298,6 +300,7 @@ class ProxyResource(Resource):
|
|
|
298
300
|
name=mcp_resource.name,
|
|
299
301
|
description=mcp_resource.description,
|
|
300
302
|
mime_type=mcp_resource.mimeType or "text/plain",
|
|
303
|
+
_mirrored=True,
|
|
301
304
|
)
|
|
302
305
|
|
|
303
306
|
async def read(self) -> str | bytes:
|
|
@@ -315,7 +318,7 @@ class ProxyResource(Resource):
|
|
|
315
318
|
raise ResourceError(f"Unsupported content type: {type(result[0])}")
|
|
316
319
|
|
|
317
320
|
|
|
318
|
-
class ProxyTemplate(ResourceTemplate):
|
|
321
|
+
class ProxyTemplate(ResourceTemplate, MirroredComponent):
|
|
319
322
|
"""
|
|
320
323
|
A ResourceTemplate that represents and creates resources from a remote server template.
|
|
321
324
|
"""
|
|
@@ -336,6 +339,7 @@ class ProxyTemplate(ResourceTemplate):
|
|
|
336
339
|
description=mcp_template.description,
|
|
337
340
|
mime_type=mcp_template.mimeType or "text/plain",
|
|
338
341
|
parameters={}, # Remote templates don't have local parameters
|
|
342
|
+
_mirrored=True,
|
|
339
343
|
)
|
|
340
344
|
|
|
341
345
|
async def create_resource(
|
|
@@ -371,7 +375,7 @@ class ProxyTemplate(ResourceTemplate):
|
|
|
371
375
|
)
|
|
372
376
|
|
|
373
377
|
|
|
374
|
-
class ProxyPrompt(Prompt):
|
|
378
|
+
class ProxyPrompt(Prompt, MirroredComponent):
|
|
375
379
|
"""
|
|
376
380
|
A Prompt that represents and renders a prompt from a remote server.
|
|
377
381
|
"""
|
|
@@ -400,6 +404,7 @@ class ProxyPrompt(Prompt):
|
|
|
400
404
|
name=mcp_prompt.name,
|
|
401
405
|
description=mcp_prompt.description,
|
|
402
406
|
arguments=arguments,
|
|
407
|
+
_mirrored=True,
|
|
403
408
|
)
|
|
404
409
|
|
|
405
410
|
async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
|
|
@@ -575,3 +580,45 @@ class ProxyClient(Client[ClientTransportT]):
|
|
|
575
580
|
"""
|
|
576
581
|
ctx = get_context()
|
|
577
582
|
await ctx.report_progress(progress, total, message)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class StatefulProxyClient(ProxyClient[ClientTransportT]):
|
|
586
|
+
"""
|
|
587
|
+
A proxy client that provides a stateful client factory for the proxy server.
|
|
588
|
+
|
|
589
|
+
The stateful proxy client bound its copy to the server session.
|
|
590
|
+
And it will be disconnected when the session is exited.
|
|
591
|
+
|
|
592
|
+
This is useful to proxy a stateful mcp server such as the Playwright MCP server.
|
|
593
|
+
Note that it is essential to ensure that the proxy server itself is also stateful.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
597
|
+
"""
|
|
598
|
+
The stateful proxy client will be forced disconnected when the session is exited.
|
|
599
|
+
So we do nothing here.
|
|
600
|
+
"""
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
def new_stateful(self) -> Client[ClientTransportT]:
|
|
604
|
+
"""
|
|
605
|
+
Create a new stateful proxy client instance with the same configuration.
|
|
606
|
+
|
|
607
|
+
Use this method as the client factory for stateful proxy server.
|
|
608
|
+
"""
|
|
609
|
+
session = get_context().session
|
|
610
|
+
proxy_client = session.__dict__.get("_proxy_client", None)
|
|
611
|
+
|
|
612
|
+
if proxy_client is None:
|
|
613
|
+
proxy_client = self.new()
|
|
614
|
+
logger.debug(f"{proxy_client} created for {session}")
|
|
615
|
+
session.__dict__["_proxy_client"] = proxy_client
|
|
616
|
+
|
|
617
|
+
async def _on_session_exit():
|
|
618
|
+
proxy_client: Client = session.__dict__.pop("_proxy_client")
|
|
619
|
+
logger.debug(f"{proxy_client} will be disconnect")
|
|
620
|
+
await proxy_client._disconnect(force=True)
|
|
621
|
+
|
|
622
|
+
session._exit_stack.push_async_callback(_on_session_exit)
|
|
623
|
+
|
|
624
|
+
return proxy_client
|