fastmcp 2.3.3__py3-none-any.whl → 2.3.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/client/__init__.py +2 -0
- fastmcp/client/client.py +84 -21
- fastmcp/client/transports.py +53 -28
- fastmcp/exceptions.py +2 -0
- fastmcp/prompts/prompt.py +12 -6
- fastmcp/resources/resource_manager.py +22 -1
- fastmcp/resources/template.py +21 -17
- fastmcp/resources/types.py +25 -27
- fastmcp/server/openapi.py +14 -1
- fastmcp/server/proxy.py +4 -4
- fastmcp/server/server.py +73 -53
- fastmcp/settings.py +55 -29
- fastmcp/tools/tool.py +45 -45
- fastmcp/tools/tool_manager.py +27 -2
- fastmcp/utilities/exceptions.py +49 -0
- fastmcp/utilities/json_schema.py +78 -17
- fastmcp/utilities/logging.py +11 -6
- fastmcp/utilities/openapi.py +122 -7
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.4.dist-info}/METADATA +2 -2
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.4.dist-info}/RECORD +23 -22
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.4.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ from .transports import (
|
|
|
9
9
|
UvxStdioTransport,
|
|
10
10
|
NpxStdioTransport,
|
|
11
11
|
FastMCPTransport,
|
|
12
|
+
StreamableHttpTransport,
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
@@ -22,4 +23,5 @@ __all__ = [
|
|
|
22
23
|
"UvxStdioTransport",
|
|
23
24
|
"NpxStdioTransport",
|
|
24
25
|
"FastMCPTransport",
|
|
26
|
+
"StreamableHttpTransport",
|
|
25
27
|
]
|
fastmcp/client/client.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import datetime
|
|
2
|
-
from contextlib import
|
|
2
|
+
from contextlib import AsyncExitStack
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
import mcp.types
|
|
7
|
+
from exceptiongroup import catch
|
|
7
8
|
from mcp import ClientSession
|
|
8
9
|
from pydantic import AnyUrl
|
|
9
10
|
|
|
@@ -14,8 +15,9 @@ from fastmcp.client.roots import (
|
|
|
14
15
|
create_roots_callback,
|
|
15
16
|
)
|
|
16
17
|
from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
|
|
17
|
-
from fastmcp.exceptions import
|
|
18
|
+
from fastmcp.exceptions import ToolError
|
|
18
19
|
from fastmcp.server import FastMCP
|
|
20
|
+
from fastmcp.utilities.exceptions import get_catch_handlers
|
|
19
21
|
|
|
20
22
|
from .transports import ClientTransport, SessionKwargs, infer_transport
|
|
21
23
|
|
|
@@ -33,8 +35,35 @@ class Client:
|
|
|
33
35
|
"""
|
|
34
36
|
MCP client that delegates connection management to a Transport instance.
|
|
35
37
|
|
|
36
|
-
The Client class is
|
|
37
|
-
|
|
38
|
+
The Client class is responsible for MCP protocol logic, while the Transport
|
|
39
|
+
handles connection establishment and management. Client provides methods
|
|
40
|
+
for working with resources, prompts, tools and other MCP capabilities.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
transport: Connection source specification, which can be:
|
|
44
|
+
- ClientTransport: Direct transport instance
|
|
45
|
+
- FastMCP: In-process FastMCP server
|
|
46
|
+
- AnyUrl | str: URL to connect to
|
|
47
|
+
- Path: File path for local socket
|
|
48
|
+
- dict: Transport configuration
|
|
49
|
+
roots: Optional RootsList or RootsHandler for filesystem access
|
|
50
|
+
sampling_handler: Optional handler for sampling requests
|
|
51
|
+
log_handler: Optional handler for log messages
|
|
52
|
+
message_handler: Optional handler for protocol messages
|
|
53
|
+
timeout: Optional timeout for requests (seconds or timedelta)
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
```python
|
|
57
|
+
# Connect to FastMCP server
|
|
58
|
+
client = Client("http://localhost:8080")
|
|
59
|
+
|
|
60
|
+
async with client:
|
|
61
|
+
# List available resources
|
|
62
|
+
resources = await client.list_resources()
|
|
63
|
+
|
|
64
|
+
# Call a tool
|
|
65
|
+
result = await client.call_tool("my_tool", {"param": "value"})
|
|
66
|
+
```
|
|
38
67
|
"""
|
|
39
68
|
|
|
40
69
|
def __init__(
|
|
@@ -45,19 +74,22 @@ class Client:
|
|
|
45
74
|
sampling_handler: SamplingHandler | None = None,
|
|
46
75
|
log_handler: LogHandler | None = None,
|
|
47
76
|
message_handler: MessageHandler | None = None,
|
|
48
|
-
|
|
77
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
49
78
|
):
|
|
50
79
|
self.transport = infer_transport(transport)
|
|
51
80
|
self._session: ClientSession | None = None
|
|
52
|
-
self.
|
|
81
|
+
self._exit_stack: AsyncExitStack | None = None
|
|
53
82
|
self._nesting_counter: int = 0
|
|
54
83
|
|
|
84
|
+
if isinstance(timeout, int | float):
|
|
85
|
+
timeout = datetime.timedelta(seconds=timeout)
|
|
86
|
+
|
|
55
87
|
self._session_kwargs: SessionKwargs = {
|
|
56
88
|
"sampling_callback": None,
|
|
57
89
|
"list_roots_callback": None,
|
|
58
90
|
"logging_callback": log_handler,
|
|
59
91
|
"message_handler": message_handler,
|
|
60
|
-
"read_timeout_seconds":
|
|
92
|
+
"read_timeout_seconds": timeout,
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
if roots is not None:
|
|
@@ -91,9 +123,23 @@ class Client:
|
|
|
91
123
|
|
|
92
124
|
async def __aenter__(self):
|
|
93
125
|
if self._nesting_counter == 0:
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
126
|
+
# Create exit stack to manage both context managers
|
|
127
|
+
stack = AsyncExitStack()
|
|
128
|
+
await stack.__aenter__()
|
|
129
|
+
|
|
130
|
+
# Add the exception handling context
|
|
131
|
+
stack.enter_context(catch(get_catch_handlers()))
|
|
132
|
+
|
|
133
|
+
# the above catch will only apply once this __aenter__ finishes so
|
|
134
|
+
# we need to wrap the session creation in a new context in case it
|
|
135
|
+
# raises errors itself
|
|
136
|
+
with catch(get_catch_handlers()):
|
|
137
|
+
# Create and enter the transport session using the exit stack
|
|
138
|
+
session_cm = self.transport.connect_session(**self._session_kwargs)
|
|
139
|
+
self._session = await stack.enter_async_context(session_cm)
|
|
140
|
+
|
|
141
|
+
# Store the stack for cleanup in __aexit__
|
|
142
|
+
self._exit_stack = stack
|
|
97
143
|
|
|
98
144
|
self._nesting_counter += 1
|
|
99
145
|
return self
|
|
@@ -101,10 +147,14 @@ class Client:
|
|
|
101
147
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
102
148
|
self._nesting_counter -= 1
|
|
103
149
|
|
|
104
|
-
if self._nesting_counter == 0
|
|
105
|
-
|
|
106
|
-
self.
|
|
107
|
-
|
|
150
|
+
if self._nesting_counter == 0:
|
|
151
|
+
# Exit the stack which will handle cleaning up the session
|
|
152
|
+
if self._exit_stack is not None:
|
|
153
|
+
try:
|
|
154
|
+
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
|
155
|
+
finally:
|
|
156
|
+
self._exit_stack = None
|
|
157
|
+
self._session = None
|
|
108
158
|
|
|
109
159
|
# --- MCP Client Methods ---
|
|
110
160
|
|
|
@@ -377,7 +427,10 @@ class Client:
|
|
|
377
427
|
# --- Call Tool ---
|
|
378
428
|
|
|
379
429
|
async def call_tool_mcp(
|
|
380
|
-
self,
|
|
430
|
+
self,
|
|
431
|
+
name: str,
|
|
432
|
+
arguments: dict[str, Any],
|
|
433
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
381
434
|
) -> mcp.types.CallToolResult:
|
|
382
435
|
"""Send a tools/call request and return the complete MCP protocol result.
|
|
383
436
|
|
|
@@ -387,7 +440,7 @@ class Client:
|
|
|
387
440
|
Args:
|
|
388
441
|
name (str): The name of the tool to call.
|
|
389
442
|
arguments (dict[str, Any]): Arguments to pass to the tool.
|
|
390
|
-
|
|
443
|
+
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
391
444
|
Returns:
|
|
392
445
|
mcp.types.CallToolResult: The complete response object from the protocol,
|
|
393
446
|
containing the tool result and any additional metadata.
|
|
@@ -395,19 +448,25 @@ class Client:
|
|
|
395
448
|
Raises:
|
|
396
449
|
RuntimeError: If called while the client is not connected.
|
|
397
450
|
"""
|
|
398
|
-
|
|
451
|
+
|
|
452
|
+
if isinstance(timeout, int | float):
|
|
453
|
+
timeout = datetime.timedelta(seconds=timeout)
|
|
454
|
+
result = await self.session.call_tool(
|
|
455
|
+
name=name, arguments=arguments, read_timeout_seconds=timeout
|
|
456
|
+
)
|
|
399
457
|
return result
|
|
400
458
|
|
|
401
459
|
async def call_tool(
|
|
402
460
|
self,
|
|
403
461
|
name: str,
|
|
404
462
|
arguments: dict[str, Any] | None = None,
|
|
463
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
405
464
|
) -> list[
|
|
406
465
|
mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
|
|
407
466
|
]:
|
|
408
467
|
"""Call a tool on the server.
|
|
409
468
|
|
|
410
|
-
Unlike call_tool_mcp, this method raises a
|
|
469
|
+
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
|
|
411
470
|
|
|
412
471
|
Args:
|
|
413
472
|
name (str): The name of the tool to call.
|
|
@@ -418,11 +477,15 @@ class Client:
|
|
|
418
477
|
The content returned by the tool.
|
|
419
478
|
|
|
420
479
|
Raises:
|
|
421
|
-
|
|
480
|
+
ToolError: If the tool call results in an error.
|
|
422
481
|
RuntimeError: If called while the client is not connected.
|
|
423
482
|
"""
|
|
424
|
-
result = await self.call_tool_mcp(
|
|
483
|
+
result = await self.call_tool_mcp(
|
|
484
|
+
name=name,
|
|
485
|
+
arguments=arguments or {},
|
|
486
|
+
timeout=timeout,
|
|
487
|
+
)
|
|
425
488
|
if result.isError:
|
|
426
489
|
msg = cast(mcp.types.TextContent, result.content[0]).text
|
|
427
|
-
raise
|
|
490
|
+
raise ToolError(msg)
|
|
428
491
|
return result.content
|
fastmcp/client/transports.py
CHANGED
|
@@ -8,10 +8,9 @@ import sys
|
|
|
8
8
|
import warnings
|
|
9
9
|
from collections.abc import AsyncIterator
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any, TypedDict
|
|
11
|
+
from typing import Any, TypedDict, cast
|
|
12
12
|
|
|
13
|
-
from
|
|
14
|
-
from mcp import ClientSession, McpError, StdioServerParameters
|
|
13
|
+
from mcp import ClientSession, StdioServerParameters
|
|
15
14
|
from mcp.client.session import (
|
|
16
15
|
ListRootsFnT,
|
|
17
16
|
LoggingFnT,
|
|
@@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
26
25
|
from pydantic import AnyUrl
|
|
27
26
|
from typing_extensions import Unpack
|
|
28
27
|
|
|
29
|
-
from fastmcp.exceptions import ClientError
|
|
30
28
|
from fastmcp.server import FastMCP as FastMCPServer
|
|
31
29
|
|
|
32
30
|
|
|
@@ -104,7 +102,12 @@ class WSTransport(ClientTransport):
|
|
|
104
102
|
class SSETransport(ClientTransport):
|
|
105
103
|
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
|
|
106
104
|
|
|
107
|
-
def __init__(
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
url: str | AnyUrl,
|
|
108
|
+
headers: dict[str, str] | None = None,
|
|
109
|
+
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
|
110
|
+
):
|
|
108
111
|
if isinstance(url, AnyUrl):
|
|
109
112
|
url = str(url)
|
|
110
113
|
if not isinstance(url, str) or not url.startswith("http"):
|
|
@@ -112,11 +115,28 @@ class SSETransport(ClientTransport):
|
|
|
112
115
|
self.url = url
|
|
113
116
|
self.headers = headers or {}
|
|
114
117
|
|
|
118
|
+
if isinstance(sse_read_timeout, int | float):
|
|
119
|
+
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
|
120
|
+
self.sse_read_timeout = sse_read_timeout
|
|
121
|
+
|
|
115
122
|
@contextlib.asynccontextmanager
|
|
116
123
|
async def connect_session(
|
|
117
124
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
118
125
|
) -> AsyncIterator[ClientSession]:
|
|
119
|
-
|
|
126
|
+
client_kwargs = {}
|
|
127
|
+
# sse_read_timeout has a default value set, so we can't pass None without overriding it
|
|
128
|
+
# instead we simply leave the kwarg out if it's not provided
|
|
129
|
+
if self.sse_read_timeout is not None:
|
|
130
|
+
client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
|
|
131
|
+
if session_kwargs.get("read_timeout_seconds", None) is not None:
|
|
132
|
+
read_timeout_seconds = cast(
|
|
133
|
+
datetime.timedelta, session_kwargs.get("read_timeout_seconds")
|
|
134
|
+
)
|
|
135
|
+
client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
|
|
136
|
+
|
|
137
|
+
async with sse_client(
|
|
138
|
+
self.url, headers=self.headers, **client_kwargs
|
|
139
|
+
) as transport:
|
|
120
140
|
read_stream, write_stream = transport
|
|
121
141
|
async with ClientSession(
|
|
122
142
|
read_stream, write_stream, **session_kwargs
|
|
@@ -131,7 +151,12 @@ class SSETransport(ClientTransport):
|
|
|
131
151
|
class StreamableHttpTransport(ClientTransport):
|
|
132
152
|
"""Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
|
|
133
153
|
|
|
134
|
-
def __init__(
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
url: str | AnyUrl,
|
|
157
|
+
headers: dict[str, str] | None = None,
|
|
158
|
+
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
|
159
|
+
):
|
|
135
160
|
if isinstance(url, AnyUrl):
|
|
136
161
|
url = str(url)
|
|
137
162
|
if not isinstance(url, str) or not url.startswith("http"):
|
|
@@ -139,11 +164,25 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
139
164
|
self.url = url
|
|
140
165
|
self.headers = headers or {}
|
|
141
166
|
|
|
167
|
+
if isinstance(sse_read_timeout, int | float):
|
|
168
|
+
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
|
169
|
+
self.sse_read_timeout = sse_read_timeout
|
|
170
|
+
|
|
142
171
|
@contextlib.asynccontextmanager
|
|
143
172
|
async def connect_session(
|
|
144
173
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
145
174
|
) -> AsyncIterator[ClientSession]:
|
|
146
|
-
|
|
175
|
+
client_kwargs = {}
|
|
176
|
+
# sse_read_timeout has a default value set, so we can't pass None without overriding it
|
|
177
|
+
# instead we simply leave the kwarg out if it's not provided
|
|
178
|
+
if self.sse_read_timeout is not None:
|
|
179
|
+
client_kwargs["sse_read_timeout"] = self.sse_read_timeout
|
|
180
|
+
if session_kwargs.get("read_timeout_seconds", None) is not None:
|
|
181
|
+
client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
|
|
182
|
+
|
|
183
|
+
async with streamablehttp_client(
|
|
184
|
+
self.url, headers=self.headers, **client_kwargs
|
|
185
|
+
) as transport:
|
|
147
186
|
read_stream, write_stream, _ = transport
|
|
148
187
|
async with ClientSession(
|
|
149
188
|
read_stream, write_stream, **session_kwargs
|
|
@@ -418,26 +457,12 @@ class FastMCPTransport(ClientTransport):
|
|
|
418
457
|
async def connect_session(
|
|
419
458
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
420
459
|
) -> AsyncIterator[ClientSession]:
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def mcperror_handler(excgroup: BaseExceptionGroup):
|
|
428
|
-
for exc in excgroup.exceptions:
|
|
429
|
-
if isinstance(exc, BaseExceptionGroup):
|
|
430
|
-
mcperror_handler(exc)
|
|
431
|
-
raise ClientError(exc)
|
|
432
|
-
|
|
433
|
-
# backport of 3.11's except* syntax
|
|
434
|
-
with catch({McpError: mcperror_handler, Exception: exception_handler}):
|
|
435
|
-
# create_connected_server_and_client_session manages the session lifecycle itself
|
|
436
|
-
async with create_connected_server_and_client_session(
|
|
437
|
-
server=self._fastmcp._mcp_server,
|
|
438
|
-
**session_kwargs,
|
|
439
|
-
) as session:
|
|
440
|
-
yield session
|
|
460
|
+
# create_connected_server_and_client_session manages the session lifecycle itself
|
|
461
|
+
async with create_connected_server_and_client_session(
|
|
462
|
+
server=self._fastmcp._mcp_server,
|
|
463
|
+
**session_kwargs,
|
|
464
|
+
) as session:
|
|
465
|
+
yield session
|
|
441
466
|
|
|
442
467
|
def __repr__(self) -> str:
|
|
443
468
|
return f"<FastMCP(server='{self._fastmcp.name}')>"
|
fastmcp/exceptions.py
CHANGED
fastmcp/prompts/prompt.py
CHANGED
|
@@ -13,7 +13,8 @@ from mcp.types import PromptArgument as MCPPromptArgument
|
|
|
13
13
|
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
|
14
14
|
|
|
15
15
|
from fastmcp.server.dependencies import get_context
|
|
16
|
-
from fastmcp.utilities.json_schema import
|
|
16
|
+
from fastmcp.utilities.json_schema import compress_schema
|
|
17
|
+
from fastmcp.utilities.logging import get_logger
|
|
17
18
|
from fastmcp.utilities.types import (
|
|
18
19
|
_convert_set_defaults,
|
|
19
20
|
find_kwarg_by_type,
|
|
@@ -25,6 +26,8 @@ if TYPE_CHECKING:
|
|
|
25
26
|
|
|
26
27
|
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
|
27
28
|
|
|
29
|
+
logger = get_logger(__name__)
|
|
30
|
+
|
|
28
31
|
|
|
29
32
|
def Message(
|
|
30
33
|
content: str | CONTENT_TYPES, role: Role | None = None, **kwargs: Any
|
|
@@ -112,7 +115,11 @@ class Prompt(BaseModel):
|
|
|
112
115
|
|
|
113
116
|
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
|
114
117
|
if context_kwarg:
|
|
115
|
-
|
|
118
|
+
prune_params = [context_kwarg]
|
|
119
|
+
else:
|
|
120
|
+
prune_params = None
|
|
121
|
+
|
|
122
|
+
parameters = compress_schema(parameters, prune_params=prune_params)
|
|
116
123
|
|
|
117
124
|
# Convert parameters to PromptArguments
|
|
118
125
|
arguments: list[PromptArgument] = []
|
|
@@ -192,13 +199,12 @@ class Prompt(BaseModel):
|
|
|
192
199
|
)
|
|
193
200
|
)
|
|
194
201
|
except Exception:
|
|
195
|
-
raise ValueError(
|
|
196
|
-
f"Could not convert prompt result to message: {msg}"
|
|
197
|
-
)
|
|
202
|
+
raise ValueError("Could not convert prompt result to message.")
|
|
198
203
|
|
|
199
204
|
return messages
|
|
200
205
|
except Exception as e:
|
|
201
|
-
|
|
206
|
+
logger.exception(f"Error rendering prompt {self.name}: {e}")
|
|
207
|
+
raise ValueError(f"Error rendering prompt {self.name}.")
|
|
202
208
|
|
|
203
209
|
def __eq__(self, other: object) -> bool:
|
|
204
210
|
if not isinstance(other, Prompt):
|
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from pydantic import AnyUrl
|
|
8
8
|
|
|
9
|
-
from fastmcp.exceptions import NotFoundError
|
|
9
|
+
from fastmcp.exceptions import NotFoundError, ResourceError
|
|
10
10
|
from fastmcp.resources import FunctionResource
|
|
11
11
|
from fastmcp.resources.resource import Resource
|
|
12
12
|
from fastmcp.resources.template import (
|
|
@@ -244,11 +244,32 @@ class ResourceManager:
|
|
|
244
244
|
uri_str,
|
|
245
245
|
params=params,
|
|
246
246
|
)
|
|
247
|
+
except ResourceError as e:
|
|
248
|
+
logger.error(f"Error creating resource from template: {e}")
|
|
249
|
+
raise e
|
|
247
250
|
except Exception as e:
|
|
251
|
+
logger.error(f"Error creating resource from template: {e}")
|
|
248
252
|
raise ValueError(f"Error creating resource from template: {e}")
|
|
249
253
|
|
|
250
254
|
raise NotFoundError(f"Unknown resource: {uri_str}")
|
|
251
255
|
|
|
256
|
+
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
|
257
|
+
"""Read a resource contents."""
|
|
258
|
+
resource = await self.get_resource(uri)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
return await resource.read()
|
|
262
|
+
|
|
263
|
+
# raise ResourceErrors as-is
|
|
264
|
+
except ResourceError as e:
|
|
265
|
+
logger.error(f"Error reading resource {uri!r}: {e}")
|
|
266
|
+
raise e
|
|
267
|
+
|
|
268
|
+
# raise other exceptions as ResourceErrors without revealing internal details
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"Error reading resource {uri!r}: {e}")
|
|
271
|
+
raise ResourceError(f"Error reading resource {uri!r}") from e
|
|
272
|
+
|
|
252
273
|
def get_resources(self) -> dict[str, Resource]:
|
|
253
274
|
"""Get all registered resources, keyed by URI."""
|
|
254
275
|
return self._resources
|
fastmcp/resources/template.py
CHANGED
|
@@ -21,6 +21,7 @@ from pydantic import (
|
|
|
21
21
|
|
|
22
22
|
from fastmcp.resources.types import FunctionResource, Resource
|
|
23
23
|
from fastmcp.server.dependencies import get_context
|
|
24
|
+
from fastmcp.utilities.json_schema import compress_schema
|
|
24
25
|
from fastmcp.utilities.types import (
|
|
25
26
|
_convert_set_defaults,
|
|
26
27
|
find_kwarg_by_type,
|
|
@@ -150,6 +151,10 @@ class ResourceTemplate(BaseModel):
|
|
|
150
151
|
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
|
151
152
|
parameters = TypeAdapter(fn).json_schema()
|
|
152
153
|
|
|
154
|
+
# compress the schema
|
|
155
|
+
prune_params = [context_kwarg] if context_kwarg else None
|
|
156
|
+
parameters = compress_schema(parameters, prune_params=prune_params)
|
|
157
|
+
|
|
153
158
|
# ensure the arguments are properly cast
|
|
154
159
|
fn = validate_call(fn)
|
|
155
160
|
|
|
@@ -171,28 +176,27 @@ class ResourceTemplate(BaseModel):
|
|
|
171
176
|
"""Create a resource from the template with the given parameters."""
|
|
172
177
|
from fastmcp.server.context import Context
|
|
173
178
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
kwargs[context_kwarg] = get_context()
|
|
179
|
+
# Add context to parameters if needed
|
|
180
|
+
kwargs = params.copy()
|
|
181
|
+
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
182
|
+
if context_kwarg and context_kwarg not in kwargs:
|
|
183
|
+
kwargs[context_kwarg] = get_context()
|
|
180
184
|
|
|
185
|
+
async def resource_read_fn() -> str | bytes:
|
|
181
186
|
# Call function and check if result is a coroutine
|
|
182
187
|
result = self.fn(**kwargs)
|
|
183
188
|
if inspect.iscoroutine(result):
|
|
184
189
|
result = await result
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
raise ValueError(f"Error creating resource from template: {e}")
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
return FunctionResource(
|
|
193
|
+
uri=AnyUrl(uri), # Explicitly convert to AnyUrl
|
|
194
|
+
name=self.name,
|
|
195
|
+
description=self.description,
|
|
196
|
+
mime_type=self.mime_type,
|
|
197
|
+
fn=resource_read_fn,
|
|
198
|
+
tags=self.tags,
|
|
199
|
+
)
|
|
196
200
|
|
|
197
201
|
def __eq__(self, other: object) -> bool:
|
|
198
202
|
if not isinstance(other, ResourceTemplate):
|
fastmcp/resources/types.py
CHANGED
|
@@ -6,7 +6,7 @@ import inspect
|
|
|
6
6
|
import json
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import
|
|
9
|
+
from typing import Any
|
|
10
10
|
|
|
11
11
|
import anyio
|
|
12
12
|
import anyio.to_thread
|
|
@@ -15,12 +15,13 @@ import pydantic.json
|
|
|
15
15
|
import pydantic_core
|
|
16
16
|
from pydantic import Field, ValidationInfo
|
|
17
17
|
|
|
18
|
+
from fastmcp.exceptions import ResourceError
|
|
18
19
|
from fastmcp.resources.resource import Resource
|
|
19
20
|
from fastmcp.server.dependencies import get_context
|
|
21
|
+
from fastmcp.utilities.logging import get_logger
|
|
20
22
|
from fastmcp.utilities.types import find_kwarg_by_type
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
pass
|
|
24
|
+
logger = get_logger(__name__)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
class TextResource(Resource):
|
|
@@ -62,26 +63,23 @@ class FunctionResource(Resource):
|
|
|
62
63
|
"""Read the resource by calling the wrapped function."""
|
|
63
64
|
from fastmcp.server.context import Context
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
|
83
|
-
except Exception as e:
|
|
84
|
-
raise ValueError(f"Error reading resource {self.uri}: {e}")
|
|
66
|
+
kwargs = {}
|
|
67
|
+
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
68
|
+
if context_kwarg is not None:
|
|
69
|
+
kwargs[context_kwarg] = get_context()
|
|
70
|
+
|
|
71
|
+
result = self.fn(**kwargs)
|
|
72
|
+
if inspect.iscoroutinefunction(self.fn):
|
|
73
|
+
result = await result
|
|
74
|
+
|
|
75
|
+
if isinstance(result, Resource):
|
|
76
|
+
return await result.read()
|
|
77
|
+
elif isinstance(result, bytes):
|
|
78
|
+
return result
|
|
79
|
+
elif isinstance(result, str):
|
|
80
|
+
return result
|
|
81
|
+
else:
|
|
82
|
+
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
|
85
83
|
|
|
86
84
|
|
|
87
85
|
class FileResource(Resource):
|
|
@@ -124,7 +122,7 @@ class FileResource(Resource):
|
|
|
124
122
|
return await anyio.to_thread.run_sync(self.path.read_bytes)
|
|
125
123
|
return await anyio.to_thread.run_sync(self.path.read_text)
|
|
126
124
|
except Exception as e:
|
|
127
|
-
raise
|
|
125
|
+
raise ResourceError(f"Error reading file {self.path}") from e
|
|
128
126
|
|
|
129
127
|
|
|
130
128
|
class HttpResource(Resource):
|
|
@@ -185,7 +183,7 @@ class DirectoryResource(Resource):
|
|
|
185
183
|
else list(self.path.rglob("*"))
|
|
186
184
|
)
|
|
187
185
|
except Exception as e:
|
|
188
|
-
raise
|
|
186
|
+
raise ResourceError(f"Error listing directory {self.path}: {e}")
|
|
189
187
|
|
|
190
188
|
async def read(self) -> str: # Always returns JSON string
|
|
191
189
|
"""Read the directory listing."""
|
|
@@ -193,5 +191,5 @@ class DirectoryResource(Resource):
|
|
|
193
191
|
files = await anyio.to_thread.run_sync(self.list_files)
|
|
194
192
|
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
|
|
195
193
|
return json.dumps({"files": file_list}, indent=2)
|
|
196
|
-
except Exception
|
|
197
|
-
raise
|
|
194
|
+
except Exception:
|
|
195
|
+
raise ResourceError(f"Error reading directory {self.path}")
|
fastmcp/server/openapi.py
CHANGED
|
@@ -14,6 +14,7 @@ import httpx
|
|
|
14
14
|
from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
|
|
15
15
|
from pydantic.networks import AnyUrl
|
|
16
16
|
|
|
17
|
+
from fastmcp.exceptions import ToolError
|
|
17
18
|
from fastmcp.resources import Resource, ResourceTemplate
|
|
18
19
|
from fastmcp.server.server import FastMCP
|
|
19
20
|
from fastmcp.tools.tool import Tool, _convert_to_content
|
|
@@ -137,6 +138,10 @@ class OpenAPITool(Tool):
|
|
|
137
138
|
self._route = route
|
|
138
139
|
self._timeout = timeout
|
|
139
140
|
|
|
141
|
+
def __repr__(self) -> str:
|
|
142
|
+
"""Custom representation to prevent recursion errors when printing."""
|
|
143
|
+
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
|
144
|
+
|
|
140
145
|
async def _execute_request(self, *args, **kwargs):
|
|
141
146
|
"""Execute the HTTP request based on the route configuration."""
|
|
142
147
|
context = kwargs.get("context")
|
|
@@ -163,7 +168,7 @@ class OpenAPITool(Tool):
|
|
|
163
168
|
}
|
|
164
169
|
missing_params = required_path_params - path_params.keys()
|
|
165
170
|
if missing_params:
|
|
166
|
-
raise
|
|
171
|
+
raise ToolError(f"Missing required path parameters: {missing_params}")
|
|
167
172
|
|
|
168
173
|
for param_name, param_value in path_params.items():
|
|
169
174
|
path = path.replace(f"{{{param_name}}}", str(param_value))
|
|
@@ -286,6 +291,10 @@ class OpenAPIResource(Resource):
|
|
|
286
291
|
self._route = route
|
|
287
292
|
self._timeout = timeout
|
|
288
293
|
|
|
294
|
+
def __repr__(self) -> str:
|
|
295
|
+
"""Custom representation to prevent recursion errors when printing."""
|
|
296
|
+
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
|
|
297
|
+
|
|
289
298
|
async def read(self) -> str | bytes:
|
|
290
299
|
"""Fetch the resource data by making an HTTP request."""
|
|
291
300
|
try:
|
|
@@ -396,6 +405,10 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|
|
396
405
|
self._route = route
|
|
397
406
|
self._timeout = timeout
|
|
398
407
|
|
|
408
|
+
def __repr__(self) -> str:
|
|
409
|
+
"""Custom representation to prevent recursion errors when printing."""
|
|
410
|
+
return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
|
|
411
|
+
|
|
399
412
|
async def create_resource(
|
|
400
413
|
self,
|
|
401
414
|
uri: str,
|