fastmcp 2.2.4__py3-none-any.whl → 2.2.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 +1 -0
- fastmcp/client/base.py +0 -1
- fastmcp/client/client.py +12 -8
- fastmcp/client/logging.py +13 -0
- fastmcp/client/sampling.py +2 -0
- fastmcp/client/transports.py +37 -4
- fastmcp/prompts/prompt.py +43 -4
- fastmcp/prompts/prompt_manager.py +14 -3
- fastmcp/resources/resource.py +12 -2
- fastmcp/resources/resource_manager.py +20 -5
- fastmcp/resources/template.py +43 -4
- fastmcp/resources/types.py +55 -11
- fastmcp/server/context.py +15 -13
- fastmcp/server/openapi.py +86 -31
- fastmcp/server/proxy.py +38 -21
- fastmcp/server/server.py +49 -15
- fastmcp/tools/tool.py +22 -6
- {fastmcp-2.2.4.dist-info → fastmcp-2.2.6.dist-info}/METADATA +2 -2
- {fastmcp-2.2.4.dist-info → fastmcp-2.2.6.dist-info}/RECORD +22 -21
- {fastmcp-2.2.4.dist-info → fastmcp-2.2.6.dist-info}/WHEEL +0 -0
- {fastmcp-2.2.4.dist-info → fastmcp-2.2.6.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.2.4.dist-info → fastmcp-2.2.6.dist-info}/licenses/LICENSE +0 -0
fastmcp/__init__.py
CHANGED
fastmcp/client/base.py
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
fastmcp/client/client.py
CHANGED
|
@@ -5,12 +5,9 @@ from typing import Any, Literal, cast, overload
|
|
|
5
5
|
|
|
6
6
|
import mcp.types
|
|
7
7
|
from mcp import ClientSession
|
|
8
|
-
from mcp.client.session import (
|
|
9
|
-
LoggingFnT,
|
|
10
|
-
MessageHandlerFnT,
|
|
11
|
-
)
|
|
12
8
|
from pydantic import AnyUrl
|
|
13
9
|
|
|
10
|
+
from fastmcp.client.logging import LogHandler, MessageHandler
|
|
14
11
|
from fastmcp.client.roots import (
|
|
15
12
|
RootsHandler,
|
|
16
13
|
RootsList,
|
|
@@ -22,7 +19,14 @@ from fastmcp.server import FastMCP
|
|
|
22
19
|
|
|
23
20
|
from .transports import ClientTransport, SessionKwargs, infer_transport
|
|
24
21
|
|
|
25
|
-
__all__ = [
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"RootsHandler",
|
|
25
|
+
"RootsList",
|
|
26
|
+
"LogHandler",
|
|
27
|
+
"MessageHandler",
|
|
28
|
+
"SamplingHandler",
|
|
29
|
+
]
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
class Client:
|
|
@@ -35,12 +39,12 @@ class Client:
|
|
|
35
39
|
|
|
36
40
|
def __init__(
|
|
37
41
|
self,
|
|
38
|
-
transport: ClientTransport | FastMCP | AnyUrl | Path | str,
|
|
42
|
+
transport: ClientTransport | FastMCP | AnyUrl | Path | dict[str, Any] | str,
|
|
39
43
|
# Common args
|
|
40
44
|
roots: RootsList | RootsHandler | None = None,
|
|
41
45
|
sampling_handler: SamplingHandler | None = None,
|
|
42
|
-
log_handler:
|
|
43
|
-
message_handler:
|
|
46
|
+
log_handler: LogHandler | None = None,
|
|
47
|
+
message_handler: MessageHandler | None = None,
|
|
44
48
|
read_timeout_seconds: datetime.timedelta | None = None,
|
|
45
49
|
):
|
|
46
50
|
self.transport = infer_transport(transport)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import TypeAlias
|
|
2
|
+
|
|
3
|
+
from mcp.client.session import (
|
|
4
|
+
LoggingFnT,
|
|
5
|
+
MessageHandlerFnT,
|
|
6
|
+
)
|
|
7
|
+
from mcp.types import LoggingMessageNotificationParams
|
|
8
|
+
|
|
9
|
+
LogMessage: TypeAlias = LoggingMessageNotificationParams
|
|
10
|
+
LogHandler: TypeAlias = LoggingFnT
|
|
11
|
+
MessageHandler: TypeAlias = MessageHandlerFnT
|
|
12
|
+
|
|
13
|
+
__all__ = ["LogMessage", "LogHandler", "MessageHandler"]
|
fastmcp/client/sampling.py
CHANGED
|
@@ -9,6 +9,8 @@ from mcp.shared.context import LifespanContextT, RequestContext
|
|
|
9
9
|
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
10
10
|
from mcp.types import SamplingMessage
|
|
11
11
|
|
|
12
|
+
__all__ = ["SamplingMessage", "SamplingParams", "MessageResult", "SamplingHandler"]
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
class MessageResult(CreateMessageResult):
|
|
14
16
|
role: mcp.types.Role = "assistant"
|
fastmcp/client/transports.py
CHANGED
|
@@ -6,9 +6,7 @@ import shutil
|
|
|
6
6
|
import sys
|
|
7
7
|
from collections.abc import AsyncIterator
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import
|
|
10
|
-
TypedDict,
|
|
11
|
-
)
|
|
9
|
+
from typing import Any, TypedDict
|
|
12
10
|
|
|
13
11
|
from exceptiongroup import BaseExceptionGroup, catch
|
|
14
12
|
from mcp import ClientSession, McpError, StdioServerParameters
|
|
@@ -416,7 +414,7 @@ class FastMCPTransport(ClientTransport):
|
|
|
416
414
|
|
|
417
415
|
|
|
418
416
|
def infer_transport(
|
|
419
|
-
transport: ClientTransport | FastMCPServer | AnyUrl | Path | str,
|
|
417
|
+
transport: ClientTransport | FastMCPServer | AnyUrl | Path | dict[str, Any] | str,
|
|
420
418
|
) -> ClientTransport:
|
|
421
419
|
"""
|
|
422
420
|
Infer the appropriate transport type from the given transport argument.
|
|
@@ -450,6 +448,41 @@ def infer_transport(
|
|
|
450
448
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
|
|
451
449
|
return WSTransport(url=transport)
|
|
452
450
|
|
|
451
|
+
## if the transport is a config dict
|
|
452
|
+
elif isinstance(transport, dict):
|
|
453
|
+
if "mcpServers" not in transport:
|
|
454
|
+
raise ValueError("Invalid transport dictionary: missing 'mcpServers' key")
|
|
455
|
+
else:
|
|
456
|
+
server = transport["mcpServers"]
|
|
457
|
+
if len(list(server.keys())) > 1:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
"Invalid transport dictionary: multiple servers found - only one expected"
|
|
460
|
+
)
|
|
461
|
+
server_name = list(server.keys())[0]
|
|
462
|
+
# Stdio transport
|
|
463
|
+
if "command" in server[server_name] and "args" in server[server_name]:
|
|
464
|
+
return StdioTransport(
|
|
465
|
+
command=server[server_name]["command"],
|
|
466
|
+
args=server[server_name]["args"],
|
|
467
|
+
env=server[server_name].get("env", None),
|
|
468
|
+
cwd=server[server_name].get("cwd", None),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# HTTP transport
|
|
472
|
+
elif "url" in server:
|
|
473
|
+
return SSETransport(
|
|
474
|
+
url=server["url"],
|
|
475
|
+
headers=server.get("headers", None),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# WebSocket transport
|
|
479
|
+
elif "ws_url" in server:
|
|
480
|
+
return WSTransport(
|
|
481
|
+
url=server["ws_url"],
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
raise ValueError("Cannot determine transport type from dictionary")
|
|
485
|
+
|
|
453
486
|
# the transport is an unknown type
|
|
454
487
|
else:
|
|
455
488
|
raise ValueError(f"Could not infer a valid transport from: {transport}")
|
fastmcp/prompts/prompt.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Base classes for FastMCP prompts."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations as _annotations
|
|
4
|
+
|
|
3
5
|
import inspect
|
|
4
6
|
import json
|
|
5
7
|
from collections.abc import Awaitable, Callable, Sequence
|
|
6
|
-
from typing import Annotated, Any, Literal
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
7
9
|
|
|
8
10
|
import pydantic_core
|
|
9
11
|
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
@@ -13,6 +15,12 @@ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_ca
|
|
|
13
15
|
|
|
14
16
|
from fastmcp.utilities.types import _convert_set_defaults
|
|
15
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from mcp.server.session import ServerSessionT
|
|
20
|
+
from mcp.shared.context import LifespanContextT
|
|
21
|
+
|
|
22
|
+
from fastmcp.server import Context
|
|
23
|
+
|
|
16
24
|
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
|
|
17
25
|
|
|
18
26
|
|
|
@@ -72,6 +80,9 @@ class Prompt(BaseModel):
|
|
|
72
80
|
None, description="Arguments that can be passed to the prompt"
|
|
73
81
|
)
|
|
74
82
|
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
|
|
83
|
+
context_kwarg: str | None = Field(
|
|
84
|
+
None, description="Name of the kwarg that should receive context"
|
|
85
|
+
)
|
|
75
86
|
|
|
76
87
|
@classmethod
|
|
77
88
|
def from_function(
|
|
@@ -80,7 +91,8 @@ class Prompt(BaseModel):
|
|
|
80
91
|
name: str | None = None,
|
|
81
92
|
description: str | None = None,
|
|
82
93
|
tags: set[str] | None = None,
|
|
83
|
-
|
|
94
|
+
context_kwarg: str | None = None,
|
|
95
|
+
) -> Prompt:
|
|
84
96
|
"""Create a Prompt from a function.
|
|
85
97
|
|
|
86
98
|
The function can return:
|
|
@@ -89,11 +101,24 @@ class Prompt(BaseModel):
|
|
|
89
101
|
- A dict (converted to a message)
|
|
90
102
|
- A sequence of any of the above
|
|
91
103
|
"""
|
|
104
|
+
from fastmcp import Context
|
|
105
|
+
|
|
92
106
|
func_name = name or fn.__name__
|
|
93
107
|
|
|
94
108
|
if func_name == "<lambda>":
|
|
95
109
|
raise ValueError("You must provide a name for lambda functions")
|
|
96
110
|
|
|
111
|
+
# Auto-detect context parameter if not provided
|
|
112
|
+
if context_kwarg is None:
|
|
113
|
+
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
|
114
|
+
sig = inspect.signature(fn.__func__)
|
|
115
|
+
else:
|
|
116
|
+
sig = inspect.signature(fn)
|
|
117
|
+
for param_name, param in sig.parameters.items():
|
|
118
|
+
if param.annotation is Context:
|
|
119
|
+
context_kwarg = param_name
|
|
120
|
+
break
|
|
121
|
+
|
|
97
122
|
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
|
98
123
|
parameters = TypeAdapter(fn).json_schema()
|
|
99
124
|
|
|
@@ -101,6 +126,10 @@ class Prompt(BaseModel):
|
|
|
101
126
|
arguments: list[PromptArgument] = []
|
|
102
127
|
if "properties" in parameters:
|
|
103
128
|
for param_name, param in parameters["properties"].items():
|
|
129
|
+
# Skip context parameter
|
|
130
|
+
if param_name == context_kwarg:
|
|
131
|
+
continue
|
|
132
|
+
|
|
104
133
|
required = param_name in parameters.get("required", [])
|
|
105
134
|
arguments.append(
|
|
106
135
|
PromptArgument(
|
|
@@ -119,9 +148,14 @@ class Prompt(BaseModel):
|
|
|
119
148
|
arguments=arguments,
|
|
120
149
|
fn=fn,
|
|
121
150
|
tags=tags or set(),
|
|
151
|
+
context_kwarg=context_kwarg,
|
|
122
152
|
)
|
|
123
153
|
|
|
124
|
-
async def render(
|
|
154
|
+
async def render(
|
|
155
|
+
self,
|
|
156
|
+
arguments: dict[str, Any] | None = None,
|
|
157
|
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
158
|
+
) -> list[Message]:
|
|
125
159
|
"""Render the prompt with arguments."""
|
|
126
160
|
# Validate required arguments
|
|
127
161
|
if self.arguments:
|
|
@@ -132,8 +166,13 @@ class Prompt(BaseModel):
|
|
|
132
166
|
raise ValueError(f"Missing required arguments: {missing}")
|
|
133
167
|
|
|
134
168
|
try:
|
|
169
|
+
# Prepare arguments with context
|
|
170
|
+
kwargs = arguments.copy() if arguments else {}
|
|
171
|
+
if self.context_kwarg is not None and context is not None:
|
|
172
|
+
kwargs[self.context_kwarg] = context
|
|
173
|
+
|
|
135
174
|
# Call function and check if result is a coroutine
|
|
136
|
-
result = self.fn(**
|
|
175
|
+
result = self.fn(**kwargs)
|
|
137
176
|
if inspect.iscoroutine(result):
|
|
138
177
|
result = await result
|
|
139
178
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
"""Prompt management functionality."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations as _annotations
|
|
4
|
+
|
|
3
5
|
from collections.abc import Awaitable, Callable
|
|
4
|
-
from typing import Any
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
5
7
|
|
|
6
8
|
from fastmcp.exceptions import NotFoundError
|
|
7
9
|
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
|
|
8
10
|
from fastmcp.settings import DuplicateBehavior
|
|
9
11
|
from fastmcp.utilities.logging import get_logger
|
|
10
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from mcp.server.session import ServerSessionT
|
|
15
|
+
from mcp.shared.context import LifespanContextT
|
|
16
|
+
|
|
17
|
+
from fastmcp.server import Context
|
|
18
|
+
|
|
11
19
|
logger = get_logger(__name__)
|
|
12
20
|
|
|
13
21
|
|
|
@@ -69,14 +77,17 @@ class PromptManager:
|
|
|
69
77
|
return prompt
|
|
70
78
|
|
|
71
79
|
async def render_prompt(
|
|
72
|
-
self,
|
|
80
|
+
self,
|
|
81
|
+
name: str,
|
|
82
|
+
arguments: dict[str, Any] | None = None,
|
|
83
|
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
73
84
|
) -> list[Message]:
|
|
74
85
|
"""Render a prompt by name with arguments."""
|
|
75
86
|
prompt = self.get_prompt(name)
|
|
76
87
|
if not prompt:
|
|
77
88
|
raise NotFoundError(f"Unknown prompt: {name}")
|
|
78
89
|
|
|
79
|
-
return await prompt.render(arguments)
|
|
90
|
+
return await prompt.render(arguments, context=context)
|
|
80
91
|
|
|
81
92
|
def has_prompt(self, key: str) -> bool:
|
|
82
93
|
"""Check if a prompt exists."""
|
fastmcp/resources/resource.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Base classes and interfaces for FastMCP resources."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import abc
|
|
4
|
-
from typing import Annotated, Any
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
5
7
|
|
|
6
8
|
from mcp.types import Resource as MCPResource
|
|
7
9
|
from pydantic import (
|
|
@@ -17,6 +19,12 @@ from pydantic import (
|
|
|
17
19
|
|
|
18
20
|
from fastmcp.utilities.types import _convert_set_defaults
|
|
19
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from mcp.server.session import ServerSessionT
|
|
24
|
+
from mcp.shared.context import LifespanContextT
|
|
25
|
+
|
|
26
|
+
from fastmcp.server import Context
|
|
27
|
+
|
|
20
28
|
|
|
21
29
|
class Resource(BaseModel, abc.ABC):
|
|
22
30
|
"""Base class for all resources."""
|
|
@@ -58,7 +66,9 @@ class Resource(BaseModel, abc.ABC):
|
|
|
58
66
|
raise ValueError("Either name or uri must be provided")
|
|
59
67
|
|
|
60
68
|
@abc.abstractmethod
|
|
61
|
-
async def read(
|
|
69
|
+
async def read(
|
|
70
|
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
71
|
+
) -> str | bytes:
|
|
62
72
|
"""Read the resource content."""
|
|
63
73
|
pass
|
|
64
74
|
|
|
@@ -61,9 +61,16 @@ class ResourceManager:
|
|
|
61
61
|
The added resource or template. If a resource or template with the same URI already exists,
|
|
62
62
|
returns the existing resource or template.
|
|
63
63
|
"""
|
|
64
|
+
from fastmcp.server.context import Context
|
|
65
|
+
|
|
64
66
|
# Check if this should be a template
|
|
65
67
|
has_uri_params = "{" in uri and "}" in uri
|
|
66
|
-
|
|
68
|
+
# check if the function has any parameters (other than injected context)
|
|
69
|
+
has_func_params = any(
|
|
70
|
+
p
|
|
71
|
+
for p in inspect.signature(fn).parameters.values()
|
|
72
|
+
if p.annotation is not Context
|
|
73
|
+
)
|
|
67
74
|
|
|
68
75
|
if has_uri_params or has_func_params:
|
|
69
76
|
return self.add_template_from_fn(
|
|
@@ -102,12 +109,12 @@ class ResourceManager:
|
|
|
102
109
|
The added resource. If a resource with the same URI already exists,
|
|
103
110
|
returns the existing resource.
|
|
104
111
|
"""
|
|
105
|
-
resource = FunctionResource(
|
|
112
|
+
resource = FunctionResource.from_function(
|
|
113
|
+
fn=fn,
|
|
106
114
|
uri=AnyUrl(uri),
|
|
107
115
|
name=name,
|
|
108
116
|
description=description,
|
|
109
117
|
mime_type=mime_type or "text/plain",
|
|
110
|
-
fn=fn,
|
|
111
118
|
tags=tags or set(),
|
|
112
119
|
)
|
|
113
120
|
return self.add_resource(resource)
|
|
@@ -212,9 +219,13 @@ class ResourceManager:
|
|
|
212
219
|
return True
|
|
213
220
|
return False
|
|
214
221
|
|
|
215
|
-
async def get_resource(self, uri: AnyUrl | str) -> Resource:
|
|
222
|
+
async def get_resource(self, uri: AnyUrl | str, context=None) -> Resource:
|
|
216
223
|
"""Get resource by URI, checking concrete resources first, then templates.
|
|
217
224
|
|
|
225
|
+
Args:
|
|
226
|
+
uri: The URI of the resource to get
|
|
227
|
+
context: Optional context object to pass to template resources
|
|
228
|
+
|
|
218
229
|
Raises:
|
|
219
230
|
NotFoundError: If no resource or template matching the URI is found.
|
|
220
231
|
"""
|
|
@@ -230,7 +241,11 @@ class ResourceManager:
|
|
|
230
241
|
# Try to match against the storage key (which might be a custom key)
|
|
231
242
|
if params := match_uri_template(uri_str, storage_key):
|
|
232
243
|
try:
|
|
233
|
-
return await template.create_resource(
|
|
244
|
+
return await template.create_resource(
|
|
245
|
+
uri_str,
|
|
246
|
+
params=params,
|
|
247
|
+
context=context,
|
|
248
|
+
)
|
|
234
249
|
except Exception as e:
|
|
235
250
|
raise ValueError(f"Error creating resource from template: {e}")
|
|
236
251
|
|
fastmcp/resources/template.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import inspect
|
|
6
6
|
import re
|
|
7
7
|
from collections.abc import Callable
|
|
8
|
-
from typing import Annotated, Any
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
9
9
|
from urllib.parse import unquote
|
|
10
10
|
|
|
11
11
|
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
@@ -22,6 +22,12 @@ from pydantic import (
|
|
|
22
22
|
from fastmcp.resources.types import FunctionResource, Resource
|
|
23
23
|
from fastmcp.utilities.types import _convert_set_defaults
|
|
24
24
|
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from mcp.server.session import ServerSessionT
|
|
27
|
+
from mcp.shared.context import LifespanContextT
|
|
28
|
+
|
|
29
|
+
from fastmcp.server import Context
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
def build_regex(template: str) -> re.Pattern:
|
|
27
33
|
parts = re.split(r"(\{[^}]+\})", template)
|
|
@@ -70,6 +76,9 @@ class ResourceTemplate(BaseModel):
|
|
|
70
76
|
parameters: dict[str, Any] = Field(
|
|
71
77
|
description="JSON schema for function parameters"
|
|
72
78
|
)
|
|
79
|
+
context_kwarg: str | None = Field(
|
|
80
|
+
None, description="Name of the kwarg that should receive context"
|
|
81
|
+
)
|
|
73
82
|
|
|
74
83
|
@field_validator("mime_type", mode="before")
|
|
75
84
|
@classmethod
|
|
@@ -88,18 +97,34 @@ class ResourceTemplate(BaseModel):
|
|
|
88
97
|
description: str | None = None,
|
|
89
98
|
mime_type: str | None = None,
|
|
90
99
|
tags: set[str] | None = None,
|
|
100
|
+
context_kwarg: str | None = None,
|
|
91
101
|
) -> ResourceTemplate:
|
|
92
102
|
"""Create a template from a function."""
|
|
103
|
+
from fastmcp import Context
|
|
104
|
+
|
|
93
105
|
func_name = name or fn.__name__
|
|
94
106
|
if func_name == "<lambda>":
|
|
95
107
|
raise ValueError("You must provide a name for lambda functions")
|
|
96
108
|
|
|
109
|
+
# Auto-detect context parameter if not provided
|
|
110
|
+
if context_kwarg is None:
|
|
111
|
+
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
|
|
112
|
+
sig = inspect.signature(fn.__func__)
|
|
113
|
+
else:
|
|
114
|
+
sig = inspect.signature(fn)
|
|
115
|
+
for param_name, param in sig.parameters.items():
|
|
116
|
+
if param.annotation is Context:
|
|
117
|
+
context_kwarg = param_name
|
|
118
|
+
break
|
|
119
|
+
|
|
97
120
|
# Validate that URI params match function params
|
|
98
121
|
uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
|
|
99
122
|
if not uri_params:
|
|
100
123
|
raise ValueError("URI template must contain at least one parameter")
|
|
101
124
|
|
|
102
125
|
func_params = set(inspect.signature(fn).parameters.keys())
|
|
126
|
+
if context_kwarg:
|
|
127
|
+
func_params.discard(context_kwarg)
|
|
103
128
|
|
|
104
129
|
# get the parameters that are required
|
|
105
130
|
required_params = {
|
|
@@ -107,6 +132,8 @@ class ResourceTemplate(BaseModel):
|
|
|
107
132
|
for p in func_params
|
|
108
133
|
if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
|
|
109
134
|
}
|
|
135
|
+
if context_kwarg and context_kwarg in required_params:
|
|
136
|
+
required_params.discard(context_kwarg)
|
|
110
137
|
|
|
111
138
|
if not required_params.issubset(uri_params):
|
|
112
139
|
raise ValueError(
|
|
@@ -132,17 +159,28 @@ class ResourceTemplate(BaseModel):
|
|
|
132
159
|
fn=fn,
|
|
133
160
|
parameters=parameters,
|
|
134
161
|
tags=tags or set(),
|
|
162
|
+
context_kwarg=context_kwarg,
|
|
135
163
|
)
|
|
136
164
|
|
|
137
165
|
def matches(self, uri: str) -> dict[str, Any] | None:
|
|
138
166
|
"""Check if URI matches template and extract parameters."""
|
|
139
167
|
return match_uri_template(uri, self.uri_template)
|
|
140
168
|
|
|
141
|
-
async def create_resource(
|
|
169
|
+
async def create_resource(
|
|
170
|
+
self,
|
|
171
|
+
uri: str,
|
|
172
|
+
params: dict[str, Any],
|
|
173
|
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
174
|
+
) -> Resource:
|
|
142
175
|
"""Create a resource from the template with the given parameters."""
|
|
143
176
|
try:
|
|
177
|
+
# Add context to parameters if needed
|
|
178
|
+
kwargs = params.copy()
|
|
179
|
+
if self.context_kwarg is not None and context is not None:
|
|
180
|
+
kwargs[self.context_kwarg] = context
|
|
181
|
+
|
|
144
182
|
# Call function and check if result is a coroutine
|
|
145
|
-
result = self.fn(**
|
|
183
|
+
result = self.fn(**kwargs)
|
|
146
184
|
if inspect.iscoroutine(result):
|
|
147
185
|
result = await result
|
|
148
186
|
|
|
@@ -151,8 +189,9 @@ class ResourceTemplate(BaseModel):
|
|
|
151
189
|
name=self.name,
|
|
152
190
|
description=self.description,
|
|
153
191
|
mime_type=self.mime_type,
|
|
154
|
-
fn=lambda: result, # Capture result in closure
|
|
192
|
+
fn=lambda **kwargs: result, # Capture result in closure
|
|
155
193
|
tags=self.tags,
|
|
194
|
+
context_kwarg=self.context_kwarg,
|
|
156
195
|
)
|
|
157
196
|
except Exception as e:
|
|
158
197
|
raise ValueError(f"Error creating resource from template: {e}")
|
fastmcp/resources/types.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Concrete resource implementations."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import inspect
|
|
4
6
|
import json
|
|
5
7
|
from collections.abc import Callable
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
8
10
|
|
|
9
11
|
import anyio
|
|
10
12
|
import anyio.to_thread
|
|
@@ -13,15 +15,24 @@ import pydantic.json
|
|
|
13
15
|
import pydantic_core
|
|
14
16
|
from pydantic import Field, ValidationInfo
|
|
15
17
|
|
|
18
|
+
import fastmcp
|
|
16
19
|
from fastmcp.resources.resource import Resource
|
|
17
20
|
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from mcp.server.session import ServerSessionT
|
|
23
|
+
from mcp.shared.context import LifespanContextT
|
|
24
|
+
|
|
25
|
+
from fastmcp.server import Context
|
|
26
|
+
|
|
18
27
|
|
|
19
28
|
class TextResource(Resource):
|
|
20
29
|
"""A resource that reads from a string."""
|
|
21
30
|
|
|
22
31
|
text: str = Field(description="Text content of the resource")
|
|
23
32
|
|
|
24
|
-
async def read(
|
|
33
|
+
async def read(
|
|
34
|
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
35
|
+
) -> str:
|
|
25
36
|
"""Read the text content."""
|
|
26
37
|
return self.text
|
|
27
38
|
|
|
@@ -31,7 +42,9 @@ class BinaryResource(Resource):
|
|
|
31
42
|
|
|
32
43
|
data: bytes = Field(description="Binary content of the resource")
|
|
33
44
|
|
|
34
|
-
async def read(
|
|
45
|
+
async def read(
|
|
46
|
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
47
|
+
) -> bytes:
|
|
35
48
|
"""Read the binary content."""
|
|
36
49
|
return self.data
|
|
37
50
|
|
|
@@ -50,15 +63,40 @@ class FunctionResource(Resource):
|
|
|
50
63
|
"""
|
|
51
64
|
|
|
52
65
|
fn: Callable[[], Any]
|
|
66
|
+
context_kwarg: str | None = Field(
|
|
67
|
+
default=None, description="Name of the kwarg that should receive context"
|
|
68
|
+
)
|
|
53
69
|
|
|
54
|
-
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_function(
|
|
72
|
+
cls, fn: Callable[[], Any], context_kwarg: str | None = None, **kwargs
|
|
73
|
+
) -> FunctionResource:
|
|
74
|
+
if context_kwarg is None:
|
|
75
|
+
parameters = inspect.signature(fn).parameters
|
|
76
|
+
context_param = next(
|
|
77
|
+
(p for p in parameters.values() if p.annotation is fastmcp.Context),
|
|
78
|
+
None,
|
|
79
|
+
)
|
|
80
|
+
if context_param is not None:
|
|
81
|
+
context_kwarg = context_param.name
|
|
82
|
+
return cls(fn=fn, context_kwarg=context_kwarg, **kwargs)
|
|
83
|
+
|
|
84
|
+
async def read(
|
|
85
|
+
self,
|
|
86
|
+
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
87
|
+
) -> str | bytes:
|
|
55
88
|
"""Read the resource by calling the wrapped function."""
|
|
56
89
|
try:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
90
|
+
kwargs = {}
|
|
91
|
+
if self.context_kwarg is not None:
|
|
92
|
+
kwargs[self.context_kwarg] = context
|
|
93
|
+
|
|
94
|
+
result = self.fn(**kwargs)
|
|
95
|
+
if inspect.iscoroutinefunction(self.fn):
|
|
96
|
+
result = await result
|
|
97
|
+
|
|
60
98
|
if isinstance(result, Resource):
|
|
61
|
-
return await result.read()
|
|
99
|
+
return await result.read(context=context)
|
|
62
100
|
if isinstance(result, bytes):
|
|
63
101
|
return result
|
|
64
102
|
if isinstance(result, str):
|
|
@@ -105,7 +143,9 @@ class FileResource(Resource):
|
|
|
105
143
|
mime_type = info.data.get("mime_type", "text/plain")
|
|
106
144
|
return not mime_type.startswith("text/")
|
|
107
145
|
|
|
108
|
-
async def read(
|
|
146
|
+
async def read(
|
|
147
|
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
148
|
+
) -> str | bytes:
|
|
109
149
|
"""Read the file content."""
|
|
110
150
|
try:
|
|
111
151
|
if self.is_binary:
|
|
@@ -123,7 +163,9 @@ class HttpResource(Resource):
|
|
|
123
163
|
default="application/json", description="MIME type of the resource content"
|
|
124
164
|
)
|
|
125
165
|
|
|
126
|
-
async def read(
|
|
166
|
+
async def read(
|
|
167
|
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
168
|
+
) -> str | bytes:
|
|
127
169
|
"""Read the HTTP content."""
|
|
128
170
|
async with httpx.AsyncClient() as client:
|
|
129
171
|
response = await client.get(self.url)
|
|
@@ -175,7 +217,9 @@ class DirectoryResource(Resource):
|
|
|
175
217
|
except Exception as e:
|
|
176
218
|
raise ValueError(f"Error listing directory {self.path}: {e}")
|
|
177
219
|
|
|
178
|
-
async def read(
|
|
220
|
+
async def read(
|
|
221
|
+
self, context: Context[ServerSessionT, LifespanContextT] | None = None
|
|
222
|
+
) -> str: # Always returns JSON string
|
|
179
223
|
"""Read the directory listing."""
|
|
180
224
|
try:
|
|
181
225
|
files = await anyio.to_thread.run_sync(self.list_files)
|