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/server/proxy.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Callable
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import TYPE_CHECKING, Any, cast
|
|
5
7
|
from urllib.parse import quote
|
|
@@ -16,12 +18,14 @@ from mcp.types import (
|
|
|
16
18
|
)
|
|
17
19
|
from pydantic.networks import AnyUrl
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
import fastmcp
|
|
22
|
+
from fastmcp.client.client import Client, FastMCP1Server
|
|
20
23
|
from fastmcp.client.elicitation import ElicitResult
|
|
21
24
|
from fastmcp.client.logging import LogMessage
|
|
22
25
|
from fastmcp.client.roots import RootsList
|
|
23
26
|
from fastmcp.client.transports import ClientTransportT
|
|
24
27
|
from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
|
|
28
|
+
from fastmcp.mcp_config import MCPConfig
|
|
25
29
|
from fastmcp.prompts import Prompt, PromptMessage
|
|
26
30
|
from fastmcp.prompts.prompt import PromptArgument
|
|
27
31
|
from fastmcp.prompts.prompt_manager import PromptManager
|
|
@@ -33,7 +37,6 @@ from fastmcp.server.server import FastMCP
|
|
|
33
37
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
34
38
|
from fastmcp.tools.tool_manager import ToolManager
|
|
35
39
|
from fastmcp.utilities.logging import get_logger
|
|
36
|
-
from fastmcp.utilities.mcp_config import MCPConfig
|
|
37
40
|
|
|
38
41
|
if TYPE_CHECKING:
|
|
39
42
|
from fastmcp.server import Context
|
|
@@ -44,9 +47,9 @@ logger = get_logger(__name__)
|
|
|
44
47
|
class ProxyToolManager(ToolManager):
|
|
45
48
|
"""A ToolManager that sources its tools from a remote client in addition to local and mounted tools."""
|
|
46
49
|
|
|
47
|
-
def __init__(self,
|
|
50
|
+
def __init__(self, client_factory: Callable[[], Client], **kwargs):
|
|
48
51
|
super().__init__(**kwargs)
|
|
49
|
-
self.
|
|
52
|
+
self.client_factory = client_factory
|
|
50
53
|
|
|
51
54
|
async def get_tools(self) -> dict[str, Tool]:
|
|
52
55
|
"""Gets the unfiltered tool inventory including local, mounted, and proxy tools."""
|
|
@@ -55,13 +58,12 @@ class ProxyToolManager(ToolManager):
|
|
|
55
58
|
|
|
56
59
|
# Then add proxy tools, but don't overwrite existing ones
|
|
57
60
|
try:
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
client = self.client_factory()
|
|
62
|
+
async with client:
|
|
63
|
+
client_tools = await client.list_tools()
|
|
60
64
|
for tool in client_tools:
|
|
61
65
|
if tool.name not in all_tools:
|
|
62
|
-
all_tools[tool.name] = ProxyTool.from_mcp_tool(
|
|
63
|
-
self.client, tool
|
|
64
|
-
)
|
|
66
|
+
all_tools[tool.name] = ProxyTool.from_mcp_tool(client, tool)
|
|
65
67
|
except McpError as e:
|
|
66
68
|
if e.error.code == METHOD_NOT_FOUND:
|
|
67
69
|
pass # No tools available from proxy
|
|
@@ -82,8 +84,9 @@ class ProxyToolManager(ToolManager):
|
|
|
82
84
|
return await super().call_tool(key, arguments)
|
|
83
85
|
except NotFoundError:
|
|
84
86
|
# If not found locally, try proxy
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
client = self.client_factory()
|
|
88
|
+
async with client:
|
|
89
|
+
result = await client.call_tool(key, arguments)
|
|
87
90
|
return ToolResult(
|
|
88
91
|
content=result.content,
|
|
89
92
|
structured_content=result.structured_content,
|
|
@@ -93,9 +96,9 @@ class ProxyToolManager(ToolManager):
|
|
|
93
96
|
class ProxyResourceManager(ResourceManager):
|
|
94
97
|
"""A ResourceManager that sources its resources from a remote client in addition to local and mounted resources."""
|
|
95
98
|
|
|
96
|
-
def __init__(self,
|
|
99
|
+
def __init__(self, client_factory: Callable[[], Client], **kwargs):
|
|
97
100
|
super().__init__(**kwargs)
|
|
98
|
-
self.
|
|
101
|
+
self.client_factory = client_factory
|
|
99
102
|
|
|
100
103
|
async def get_resources(self) -> dict[str, Resource]:
|
|
101
104
|
"""Gets the unfiltered resource inventory including local, mounted, and proxy resources."""
|
|
@@ -104,12 +107,13 @@ class ProxyResourceManager(ResourceManager):
|
|
|
104
107
|
|
|
105
108
|
# Then add proxy resources, but don't overwrite existing ones
|
|
106
109
|
try:
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
client = self.client_factory()
|
|
111
|
+
async with client:
|
|
112
|
+
client_resources = await client.list_resources()
|
|
109
113
|
for resource in client_resources:
|
|
110
114
|
if str(resource.uri) not in all_resources:
|
|
111
115
|
all_resources[str(resource.uri)] = (
|
|
112
|
-
ProxyResource.from_mcp_resource(
|
|
116
|
+
ProxyResource.from_mcp_resource(client, resource)
|
|
113
117
|
)
|
|
114
118
|
except McpError as e:
|
|
115
119
|
if e.error.code == METHOD_NOT_FOUND:
|
|
@@ -126,12 +130,13 @@ class ProxyResourceManager(ResourceManager):
|
|
|
126
130
|
|
|
127
131
|
# Then add proxy templates, but don't overwrite existing ones
|
|
128
132
|
try:
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
client = self.client_factory()
|
|
134
|
+
async with client:
|
|
135
|
+
client_templates = await client.list_resource_templates()
|
|
131
136
|
for template in client_templates:
|
|
132
137
|
if template.uriTemplate not in all_templates:
|
|
133
138
|
all_templates[template.uriTemplate] = (
|
|
134
|
-
ProxyTemplate.from_mcp_template(
|
|
139
|
+
ProxyTemplate.from_mcp_template(client, template)
|
|
135
140
|
)
|
|
136
141
|
except McpError as e:
|
|
137
142
|
if e.error.code == METHOD_NOT_FOUND:
|
|
@@ -158,8 +163,9 @@ class ProxyResourceManager(ResourceManager):
|
|
|
158
163
|
return await super().read_resource(uri)
|
|
159
164
|
except NotFoundError:
|
|
160
165
|
# If not found locally, try proxy
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
client = self.client_factory()
|
|
167
|
+
async with client:
|
|
168
|
+
result = await client.read_resource(uri)
|
|
163
169
|
if isinstance(result[0], TextResourceContents):
|
|
164
170
|
return result[0].text
|
|
165
171
|
elif isinstance(result[0], BlobResourceContents):
|
|
@@ -171,9 +177,9 @@ class ProxyResourceManager(ResourceManager):
|
|
|
171
177
|
class ProxyPromptManager(PromptManager):
|
|
172
178
|
"""A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts."""
|
|
173
179
|
|
|
174
|
-
def __init__(self,
|
|
180
|
+
def __init__(self, client_factory: Callable[[], Client], **kwargs):
|
|
175
181
|
super().__init__(**kwargs)
|
|
176
|
-
self.
|
|
182
|
+
self.client_factory = client_factory
|
|
177
183
|
|
|
178
184
|
async def get_prompts(self) -> dict[str, Prompt]:
|
|
179
185
|
"""Gets the unfiltered prompt inventory including local, mounted, and proxy prompts."""
|
|
@@ -182,12 +188,13 @@ class ProxyPromptManager(PromptManager):
|
|
|
182
188
|
|
|
183
189
|
# Then add proxy prompts, but don't overwrite existing ones
|
|
184
190
|
try:
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
client = self.client_factory()
|
|
192
|
+
async with client:
|
|
193
|
+
client_prompts = await client.list_prompts()
|
|
187
194
|
for prompt in client_prompts:
|
|
188
195
|
if prompt.name not in all_prompts:
|
|
189
196
|
all_prompts[prompt.name] = ProxyPrompt.from_mcp_prompt(
|
|
190
|
-
|
|
197
|
+
client, prompt
|
|
191
198
|
)
|
|
192
199
|
except McpError as e:
|
|
193
200
|
if e.error.code == METHOD_NOT_FOUND:
|
|
@@ -213,8 +220,9 @@ class ProxyPromptManager(PromptManager):
|
|
|
213
220
|
return await super().render_prompt(name, arguments)
|
|
214
221
|
except NotFoundError:
|
|
215
222
|
# If not found locally, try proxy
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
client = self.client_factory()
|
|
224
|
+
async with client:
|
|
225
|
+
result = await client.get_prompt(name, arguments)
|
|
218
226
|
return result
|
|
219
227
|
|
|
220
228
|
|
|
@@ -245,7 +253,6 @@ class ProxyTool(Tool):
|
|
|
245
253
|
context: Context | None = None,
|
|
246
254
|
) -> ToolResult:
|
|
247
255
|
"""Executes the tool by making a call through the client."""
|
|
248
|
-
# This is where the remote execution logic lives.
|
|
249
256
|
async with self._client:
|
|
250
257
|
result = await self._client.call_tool_mcp(
|
|
251
258
|
name=self.name,
|
|
@@ -267,14 +274,22 @@ class ProxyResource(Resource):
|
|
|
267
274
|
_client: Client
|
|
268
275
|
_value: str | bytes | None = None
|
|
269
276
|
|
|
270
|
-
def __init__(
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
client: Client,
|
|
280
|
+
*,
|
|
281
|
+
_value: str | bytes | None = None,
|
|
282
|
+
**kwargs,
|
|
283
|
+
):
|
|
271
284
|
super().__init__(**kwargs)
|
|
272
285
|
self._client = client
|
|
273
286
|
self._value = _value
|
|
274
287
|
|
|
275
288
|
@classmethod
|
|
276
289
|
def from_mcp_resource(
|
|
277
|
-
cls,
|
|
290
|
+
cls,
|
|
291
|
+
client: Client,
|
|
292
|
+
mcp_resource: mcp.types.Resource,
|
|
278
293
|
) -> ProxyResource:
|
|
279
294
|
"""Factory method to create a ProxyResource from a raw MCP resource schema."""
|
|
280
295
|
return cls(
|
|
@@ -397,24 +412,63 @@ class ProxyPrompt(Prompt):
|
|
|
397
412
|
class FastMCPProxy(FastMCP):
|
|
398
413
|
"""
|
|
399
414
|
A FastMCP server that acts as a proxy to a remote MCP-compliant server.
|
|
400
|
-
It uses specialized managers that fulfill requests via
|
|
415
|
+
It uses specialized managers that fulfill requests via a client factory.
|
|
401
416
|
"""
|
|
402
417
|
|
|
403
|
-
def __init__(
|
|
418
|
+
def __init__(
|
|
419
|
+
self,
|
|
420
|
+
client: Client | None = None,
|
|
421
|
+
*,
|
|
422
|
+
client_factory: Callable[[], Client] | None = None,
|
|
423
|
+
**kwargs,
|
|
424
|
+
):
|
|
404
425
|
"""
|
|
405
426
|
Initializes the proxy server.
|
|
406
427
|
|
|
428
|
+
FastMCPProxy requires explicit session management via client_factory.
|
|
429
|
+
Use FastMCP.as_proxy() for convenience with automatic session strategy.
|
|
430
|
+
|
|
407
431
|
Args:
|
|
408
|
-
client:
|
|
432
|
+
client: [DEPRECATED] A Client instance. Use client_factory instead for explicit
|
|
433
|
+
session management. When provided, a client_factory will be automatically
|
|
434
|
+
created that provides session isolation for backwards compatibility.
|
|
435
|
+
client_factory: A callable that returns a Client instance when called.
|
|
436
|
+
This gives you full control over session creation and reuse.
|
|
409
437
|
**kwargs: Additional settings for the FastMCP server.
|
|
410
438
|
"""
|
|
439
|
+
|
|
411
440
|
super().__init__(**kwargs)
|
|
412
|
-
|
|
441
|
+
|
|
442
|
+
# Handle client and client_factory parameters
|
|
443
|
+
if client is not None and client_factory is not None:
|
|
444
|
+
raise ValueError("Cannot specify both 'client' and 'client_factory'")
|
|
445
|
+
|
|
446
|
+
if client is not None:
|
|
447
|
+
# Deprecated in 2.10.3
|
|
448
|
+
if fastmcp.settings.deprecation_warnings:
|
|
449
|
+
warnings.warn(
|
|
450
|
+
"Passing 'client' to FastMCPProxy is deprecated. Use 'client_factory' instead for explicit session management. "
|
|
451
|
+
"For automatic session strategy, use FastMCP.as_proxy().",
|
|
452
|
+
DeprecationWarning,
|
|
453
|
+
stacklevel=2,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Create a factory that provides session isolation for backwards compatibility
|
|
457
|
+
def deprecated_client_factory():
|
|
458
|
+
return client.new()
|
|
459
|
+
|
|
460
|
+
self.client_factory = deprecated_client_factory
|
|
461
|
+
elif client_factory is not None:
|
|
462
|
+
self.client_factory = client_factory
|
|
463
|
+
else:
|
|
464
|
+
raise ValueError("Must specify 'client_factory'")
|
|
413
465
|
|
|
414
466
|
# Replace the default managers with our specialized proxy managers.
|
|
415
|
-
self._tool_manager = ProxyToolManager(
|
|
416
|
-
self._resource_manager = ProxyResourceManager(
|
|
417
|
-
|
|
467
|
+
self._tool_manager = ProxyToolManager(client_factory=self.client_factory)
|
|
468
|
+
self._resource_manager = ProxyResourceManager(
|
|
469
|
+
client_factory=self.client_factory
|
|
470
|
+
)
|
|
471
|
+
self._prompt_manager = ProxyPromptManager(client_factory=self.client_factory)
|
|
418
472
|
|
|
419
473
|
|
|
420
474
|
async def default_proxy_roots_handler(
|
|
@@ -435,15 +489,14 @@ class ProxyClient(Client[ClientTransportT]):
|
|
|
435
489
|
|
|
436
490
|
def __init__(
|
|
437
491
|
self,
|
|
438
|
-
transport:
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
),
|
|
492
|
+
transport: ClientTransportT
|
|
493
|
+
| FastMCP
|
|
494
|
+
| FastMCP1Server
|
|
495
|
+
| AnyUrl
|
|
496
|
+
| Path
|
|
497
|
+
| MCPConfig
|
|
498
|
+
| dict[str, Any]
|
|
499
|
+
| str,
|
|
447
500
|
**kwargs,
|
|
448
501
|
):
|
|
449
502
|
if "roots" not in kwargs:
|
|
@@ -456,7 +509,7 @@ class ProxyClient(Client[ClientTransportT]):
|
|
|
456
509
|
kwargs["log_handler"] = ProxyClient.default_log_handler
|
|
457
510
|
if "progress_handler" not in kwargs:
|
|
458
511
|
kwargs["progress_handler"] = ProxyClient.default_progress_handler
|
|
459
|
-
super().__init__(
|
|
512
|
+
super().__init__(**kwargs | dict(transport=transport))
|
|
460
513
|
|
|
461
514
|
@classmethod
|
|
462
515
|
async def default_sampling_handler(
|
fastmcp/server/server.py
CHANGED
|
@@ -43,6 +43,7 @@ from starlette.routing import BaseRoute, Route
|
|
|
43
43
|
import fastmcp
|
|
44
44
|
import fastmcp.server
|
|
45
45
|
from fastmcp.exceptions import DisabledError, NotFoundError
|
|
46
|
+
from fastmcp.mcp_config import MCPConfig
|
|
46
47
|
from fastmcp.prompts import Prompt, PromptManager
|
|
47
48
|
from fastmcp.prompts.prompt import FunctionPrompt
|
|
48
49
|
from fastmcp.resources import Resource, ResourceManager
|
|
@@ -63,7 +64,6 @@ from fastmcp.utilities.cache import TimedCache
|
|
|
63
64
|
from fastmcp.utilities.cli import log_server_banner
|
|
64
65
|
from fastmcp.utilities.components import FastMCPComponent
|
|
65
66
|
from fastmcp.utilities.logging import get_logger
|
|
66
|
-
from fastmcp.utilities.mcp_config import MCPConfig
|
|
67
67
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
68
68
|
|
|
69
69
|
if TYPE_CHECKING:
|
|
@@ -1931,10 +1931,39 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
1931
1931
|
|
|
1932
1932
|
if isinstance(backend, Client):
|
|
1933
1933
|
client = backend
|
|
1934
|
+
# Session strategy based on client connection state:
|
|
1935
|
+
# - Connected clients: reuse existing session for all requests
|
|
1936
|
+
# - Disconnected clients: create fresh sessions per request for isolation
|
|
1937
|
+
if client.is_connected():
|
|
1938
|
+
from fastmcp.utilities.logging import get_logger
|
|
1939
|
+
|
|
1940
|
+
logger = get_logger(__name__)
|
|
1941
|
+
logger.info(
|
|
1942
|
+
"Proxy detected connected client - reusing existing session for all requests. "
|
|
1943
|
+
"This may cause context mixing in concurrent scenarios."
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
# Reuse sessions - return the same client instance
|
|
1947
|
+
def reuse_client_factory():
|
|
1948
|
+
return client
|
|
1949
|
+
|
|
1950
|
+
client_factory = reuse_client_factory
|
|
1951
|
+
else:
|
|
1952
|
+
# Fresh sessions per request
|
|
1953
|
+
def fresh_client_factory():
|
|
1954
|
+
return client.new()
|
|
1955
|
+
|
|
1956
|
+
client_factory = fresh_client_factory
|
|
1934
1957
|
else:
|
|
1935
|
-
|
|
1958
|
+
base_client = ProxyClient(backend)
|
|
1959
|
+
|
|
1960
|
+
# Fresh client created from transport - use fresh sessions per request
|
|
1961
|
+
def proxy_client_factory():
|
|
1962
|
+
return base_client.new()
|
|
1963
|
+
|
|
1964
|
+
client_factory = proxy_client_factory
|
|
1936
1965
|
|
|
1937
|
-
return FastMCPProxy(
|
|
1966
|
+
return FastMCPProxy(client_factory=client_factory, **settings)
|
|
1938
1967
|
|
|
1939
1968
|
@classmethod
|
|
1940
1969
|
def from_client(
|
fastmcp/tools/tool.py
CHANGED
|
@@ -46,7 +46,7 @@ class _UnserializableType:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def default_serializer(data: Any) -> str:
|
|
49
|
-
return pydantic_core.to_json(data, fallback=str
|
|
49
|
+
return pydantic_core.to_json(data, fallback=str).decode()
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
class ToolResult:
|
|
@@ -434,6 +434,7 @@ def _convert_to_content(
|
|
|
434
434
|
_process_as_single_item: bool = False,
|
|
435
435
|
) -> list[ContentBlock]:
|
|
436
436
|
"""Convert a result to a sequence of content objects."""
|
|
437
|
+
|
|
437
438
|
if result is None:
|
|
438
439
|
return []
|
|
439
440
|
|
|
@@ -467,7 +468,7 @@ def _convert_to_content(
|
|
|
467
468
|
|
|
468
469
|
if other_content:
|
|
469
470
|
other_content = _convert_to_content(
|
|
470
|
-
other_content
|
|
471
|
+
other_content,
|
|
471
472
|
serializer=serializer,
|
|
472
473
|
_process_as_single_item=True,
|
|
473
474
|
)
|
fastmcp/tools/tool_transform.py
CHANGED
|
@@ -9,7 +9,7 @@ from typing import Any, Literal
|
|
|
9
9
|
from mcp.types import ToolAnnotations
|
|
10
10
|
from pydantic import ConfigDict
|
|
11
11
|
|
|
12
|
-
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
|
|
12
|
+
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _convert_to_content
|
|
13
13
|
from fastmcp.utilities.logging import get_logger
|
|
14
14
|
from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
|
|
15
15
|
|
|
@@ -233,7 +233,6 @@ class TransformedTool(Tool):
|
|
|
233
233
|
Returns:
|
|
234
234
|
ToolResult object containing content and optional structured output.
|
|
235
235
|
"""
|
|
236
|
-
from fastmcp.tools.tool import _convert_to_content
|
|
237
236
|
|
|
238
237
|
# Fill in missing arguments with schema defaults to ensure
|
|
239
238
|
# ArgTransform defaults take precedence over function defaults
|
|
@@ -274,7 +273,6 @@ class TransformedTool(Tool):
|
|
|
274
273
|
if isinstance(result, ToolResult):
|
|
275
274
|
if self.output_schema is None:
|
|
276
275
|
# Check if this is from a custom function that returns ToolResult
|
|
277
|
-
import inspect
|
|
278
276
|
|
|
279
277
|
return_annotation = inspect.signature(self.fn).return_annotation
|
|
280
278
|
if return_annotation is ToolResult:
|
|
@@ -298,7 +296,6 @@ class TransformedTool(Tool):
|
|
|
298
296
|
return result
|
|
299
297
|
|
|
300
298
|
# Otherwise convert to content and create ToolResult with proper structured content
|
|
301
|
-
from fastmcp.tools.tool import _convert_to_content
|
|
302
299
|
|
|
303
300
|
unstructured_result = _convert_to_content(
|
|
304
301
|
result, serializer=self.serializer
|
|
@@ -433,8 +430,6 @@ class TransformedTool(Tool):
|
|
|
433
430
|
final_output_schema = parsed_fn.output_schema
|
|
434
431
|
if final_output_schema is None:
|
|
435
432
|
# Check if function returns ToolResult - if so, don't fall back to parent
|
|
436
|
-
import inspect
|
|
437
|
-
|
|
438
433
|
return_annotation = inspect.signature(
|
|
439
434
|
transform_fn
|
|
440
435
|
).return_annotation
|
|
@@ -553,6 +548,7 @@ class TransformedTool(Tool):
|
|
|
553
548
|
"""
|
|
554
549
|
|
|
555
550
|
# Build transformed schema and mapping
|
|
551
|
+
parent_defs = parent_tool.parameters.get("$defs", {})
|
|
556
552
|
parent_props = parent_tool.parameters.get("properties", {}).copy()
|
|
557
553
|
parent_required = set(parent_tool.parameters.get("required", []))
|
|
558
554
|
|
|
@@ -608,6 +604,9 @@ class TransformedTool(Tool):
|
|
|
608
604
|
"required": list(new_required),
|
|
609
605
|
}
|
|
610
606
|
|
|
607
|
+
if parent_defs:
|
|
608
|
+
schema["$defs"] = parent_defs
|
|
609
|
+
|
|
611
610
|
# Create forwarding function that closes over everything it needs
|
|
612
611
|
async def _forward(**kwargs):
|
|
613
612
|
# Validate arguments
|
fastmcp/utilities/json_schema.py
CHANGED
|
@@ -67,13 +67,24 @@ def _prune_unused_defs(schema: dict) -> dict:
|
|
|
67
67
|
walk(value, current_def=def_name)
|
|
68
68
|
|
|
69
69
|
# Figure out what defs were referenced directly or recursively
|
|
70
|
-
def def_is_referenced(def_name):
|
|
70
|
+
def def_is_referenced(def_name, parent_def_names: set[str] | None = None):
|
|
71
71
|
if def_name in root_defs:
|
|
72
72
|
return True
|
|
73
73
|
references = referenced_by.get(def_name)
|
|
74
74
|
if references:
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
if parent_def_names is None:
|
|
76
|
+
parent_def_names = set()
|
|
77
|
+
|
|
78
|
+
# Handle recursion by excluding references already present in parent references
|
|
79
|
+
parent_def_names = parent_def_names | {def_name}
|
|
80
|
+
valid_references = [
|
|
81
|
+
reference
|
|
82
|
+
for reference in references
|
|
83
|
+
if reference not in parent_def_names
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
for reference in valid_references:
|
|
87
|
+
if def_is_referenced(reference, parent_def_names):
|
|
77
88
|
return True
|
|
78
89
|
return False
|
|
79
90
|
|
fastmcp/utilities/openapi.py
CHANGED
|
@@ -152,6 +152,7 @@ __all__ = [
|
|
|
152
152
|
"ParameterLocation",
|
|
153
153
|
"JsonSchema",
|
|
154
154
|
"parse_openapi_to_http_routes",
|
|
155
|
+
"extract_output_schema_from_responses",
|
|
155
156
|
]
|
|
156
157
|
|
|
157
158
|
# Type variables for generic parser
|
|
@@ -1107,3 +1108,94 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1107
1108
|
result = compress_schema(result)
|
|
1108
1109
|
|
|
1109
1110
|
return result
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def extract_output_schema_from_responses(
|
|
1114
|
+
responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None
|
|
1115
|
+
) -> dict[str, Any] | None:
|
|
1116
|
+
"""
|
|
1117
|
+
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
1118
|
+
|
|
1119
|
+
This function finds the first successful response (200, 201, 202, 204) with a
|
|
1120
|
+
JSON-compatible content type and extracts its schema. If the schema is not an
|
|
1121
|
+
object type, it wraps it to comply with MCP requirements.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
responses: Dictionary of ResponseInfo objects keyed by status code
|
|
1125
|
+
schema_definitions: Optional schema definitions to include in the output schema
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
1129
|
+
"""
|
|
1130
|
+
if not responses:
|
|
1131
|
+
return None
|
|
1132
|
+
|
|
1133
|
+
# Priority order for success status codes
|
|
1134
|
+
success_codes = ["200", "201", "202", "204"]
|
|
1135
|
+
|
|
1136
|
+
# Find the first successful response
|
|
1137
|
+
response_info = None
|
|
1138
|
+
for status_code in success_codes:
|
|
1139
|
+
if status_code in responses:
|
|
1140
|
+
response_info = responses[status_code]
|
|
1141
|
+
break
|
|
1142
|
+
|
|
1143
|
+
# If no explicit success codes, try any 2xx response
|
|
1144
|
+
if response_info is None:
|
|
1145
|
+
for status_code, resp_info in responses.items():
|
|
1146
|
+
if status_code.startswith("2"):
|
|
1147
|
+
response_info = resp_info
|
|
1148
|
+
break
|
|
1149
|
+
|
|
1150
|
+
if response_info is None or not response_info.content_schema:
|
|
1151
|
+
return None
|
|
1152
|
+
|
|
1153
|
+
# Prefer application/json, then fall back to other JSON-compatible types
|
|
1154
|
+
json_compatible_types = [
|
|
1155
|
+
"application/json",
|
|
1156
|
+
"application/vnd.api+json",
|
|
1157
|
+
"application/hal+json",
|
|
1158
|
+
"application/ld+json",
|
|
1159
|
+
"text/json",
|
|
1160
|
+
]
|
|
1161
|
+
|
|
1162
|
+
schema = None
|
|
1163
|
+
for content_type in json_compatible_types:
|
|
1164
|
+
if content_type in response_info.content_schema:
|
|
1165
|
+
schema = response_info.content_schema[content_type]
|
|
1166
|
+
break
|
|
1167
|
+
|
|
1168
|
+
# If no JSON-compatible type found, try the first available content type
|
|
1169
|
+
if schema is None and response_info.content_schema:
|
|
1170
|
+
first_content_type = next(iter(response_info.content_schema))
|
|
1171
|
+
schema = response_info.content_schema[first_content_type]
|
|
1172
|
+
logger.debug(
|
|
1173
|
+
f"Using non-JSON content type for output schema: {first_content_type}"
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
if not schema or not isinstance(schema, dict):
|
|
1177
|
+
return None
|
|
1178
|
+
|
|
1179
|
+
# Clean and copy the schema
|
|
1180
|
+
output_schema = schema.copy()
|
|
1181
|
+
|
|
1182
|
+
# MCP requires output schemas to be objects. If this schema is not an object,
|
|
1183
|
+
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
1184
|
+
if output_schema.get("type") != "object":
|
|
1185
|
+
# Create a wrapped schema that contains the original schema under a "result" key
|
|
1186
|
+
wrapped_schema = {
|
|
1187
|
+
"type": "object",
|
|
1188
|
+
"properties": {"result": output_schema},
|
|
1189
|
+
"required": ["result"],
|
|
1190
|
+
"x-fastmcp-wrap-result": True,
|
|
1191
|
+
}
|
|
1192
|
+
output_schema = wrapped_schema
|
|
1193
|
+
|
|
1194
|
+
# Add schema definitions if available
|
|
1195
|
+
if schema_definitions:
|
|
1196
|
+
output_schema["$defs"] = schema_definitions
|
|
1197
|
+
|
|
1198
|
+
# Use compress_schema to remove unused definitions
|
|
1199
|
+
output_schema = compress_schema(output_schema)
|
|
1200
|
+
|
|
1201
|
+
return output_schema
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastmcp
|
|
3
|
-
Version: 2.10.
|
|
4
|
-
Summary: The fast, Pythonic way to build MCP servers.
|
|
3
|
+
Version: 2.10.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
|
|
7
7
|
Project-URL: Documentation, https://gofastmcp.com
|
|
@@ -18,14 +18,15 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Requires-Dist: authlib>=1.5.2
|
|
21
|
+
Requires-Dist: cyclopts>=3.0.0
|
|
21
22
|
Requires-Dist: exceptiongroup>=1.2.2
|
|
22
23
|
Requires-Dist: httpx>=0.28.1
|
|
23
24
|
Requires-Dist: mcp>=1.10.0
|
|
24
25
|
Requires-Dist: openapi-pydantic>=0.5.1
|
|
25
26
|
Requires-Dist: pydantic[email]>=2.11.7
|
|
27
|
+
Requires-Dist: pyperclip>=1.9.0
|
|
26
28
|
Requires-Dist: python-dotenv>=1.1.0
|
|
27
29
|
Requires-Dist: rich>=13.9.4
|
|
28
|
-
Requires-Dist: typer>=0.15.2
|
|
29
30
|
Provides-Extra: websockets
|
|
30
31
|
Requires-Dist: websockets>=15.0.1; extra == 'websockets'
|
|
31
32
|
Description-Content-Type: text/markdown
|