fastmcp 1.0__py3-none-any.whl → 2.0.0__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.
@@ -1,6 +1,7 @@
1
1
  """Resource manager functionality."""
2
2
 
3
- from typing import Callable, Dict, Optional, Union
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from pydantic import AnyUrl
6
7
 
@@ -15,8 +16,8 @@ class ResourceManager:
15
16
  """Manages FastMCP resources."""
16
17
 
17
18
  def __init__(self, warn_on_duplicate_resources: bool = True):
18
- self._resources: Dict[str, Resource] = {}
19
- self._templates: Dict[str, ResourceTemplate] = {}
19
+ self._resources: dict[str, Resource] = {}
20
+ self._templates: dict[str, ResourceTemplate] = {}
20
21
  self.warn_on_duplicate_resources = warn_on_duplicate_resources
21
22
 
22
23
  def add_resource(self, resource: Resource) -> Resource:
@@ -34,7 +35,7 @@ class ResourceManager:
34
35
  extra={
35
36
  "uri": resource.uri,
36
37
  "type": type(resource).__name__,
37
- "name": resource.name,
38
+ "resource_name": resource.name,
38
39
  },
39
40
  )
40
41
  existing = self._resources.get(str(resource.uri))
@@ -47,11 +48,11 @@ class ResourceManager:
47
48
 
48
49
  def add_template(
49
50
  self,
50
- fn: Callable,
51
+ fn: Callable[..., Any],
51
52
  uri_template: str,
52
- name: Optional[str] = None,
53
- description: Optional[str] = None,
54
- mime_type: Optional[str] = None,
53
+ name: str | None = None,
54
+ description: str | None = None,
55
+ mime_type: str | None = None,
55
56
  ) -> ResourceTemplate:
56
57
  """Add a template from a function."""
57
58
  template = ResourceTemplate.from_function(
@@ -64,7 +65,7 @@ class ResourceManager:
64
65
  self._templates[template.uri_template] = template
65
66
  return template
66
67
 
67
- async def get_resource(self, uri: Union[AnyUrl, str]) -> Optional[Resource]:
68
+ async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
68
69
  """Get resource by URI, checking concrete resources first, then templates."""
69
70
  uri_str = str(uri)
70
71
  logger.debug("Getting resource", extra={"uri": uri_str})
@@ -92,3 +93,59 @@ class ResourceManager:
92
93
  """List all registered templates."""
93
94
  logger.debug("Listing templates", extra={"count": len(self._templates)})
94
95
  return list(self._templates.values())
96
+
97
+ def import_resources(
98
+ self, manager: "ResourceManager", prefix: str | None = None
99
+ ) -> None:
100
+ """Import resources from another resource manager.
101
+
102
+ Resources are imported with a prefixed URI if a prefix is provided. For example,
103
+ if a resource has URI "data://users" and you import it with prefix "app+", the
104
+ imported resource will have URI "app+data://users". If no prefix is provided,
105
+ the original URI is used.
106
+
107
+ Args:
108
+ manager: The ResourceManager to import from
109
+ prefix: A prefix to apply to the resource URIs, including the delimiter.
110
+ For example, "app+" would result in URIs like "app+data://users".
111
+ If None, the original URI is used.
112
+ """
113
+ for uri, resource in manager._resources.items():
114
+ # Create prefixed URI and copy the resource with the new URI
115
+ prefixed_uri = f"{prefix}{uri}" if prefix else uri
116
+
117
+ # Log the import
118
+ logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
119
+
120
+ # Store directly in resources dictionary
121
+ self._resources[prefixed_uri] = resource
122
+
123
+ def import_templates(
124
+ self, manager: "ResourceManager", prefix: str | None = None
125
+ ) -> None:
126
+ """Import resource templates from another resource manager.
127
+
128
+ Templates are imported with a prefixed URI template if a prefix is provided.
129
+ For example, if a template has URI template "data://users/{id}" and you import
130
+ it with prefix "app+", the imported template will have URI template
131
+ "app+data://users/{id}". If no prefix is provided, the original URI template is used.
132
+
133
+ Args:
134
+ manager: The ResourceManager to import templates from
135
+ prefix: A prefix to apply to the template URIs, including the delimiter.
136
+ For example, "app+" would result in URI templates like "app+data://users/{id}".
137
+ If None, the original URI template is used.
138
+ """
139
+ for uri_template, template in manager._templates.items():
140
+ # Create prefixed URI template and copy the template with the new URI template
141
+ prefixed_uri_template = (
142
+ f"{prefix}{uri_template}" if prefix else uri_template
143
+ )
144
+
145
+ # Log the import
146
+ logger.debug(
147
+ f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
148
+ )
149
+
150
+ # Store directly in templates dictionary
151
+ self._templates[prefixed_uri_template] = template
@@ -1,8 +1,11 @@
1
1
  """Resource template functionality."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import inspect
4
6
  import re
5
- from typing import Any, Callable, Dict, Optional
7
+ from collections.abc import Callable
8
+ from typing import Any
6
9
 
7
10
  from pydantic import BaseModel, Field, TypeAdapter, validate_call
8
11
 
@@ -20,18 +23,20 @@ class ResourceTemplate(BaseModel):
20
23
  mime_type: str = Field(
21
24
  default="text/plain", description="MIME type of the resource content"
22
25
  )
23
- fn: Callable = Field(exclude=True)
24
- parameters: dict = Field(description="JSON schema for function parameters")
26
+ fn: Callable[..., Any] = Field(exclude=True)
27
+ parameters: dict[str, Any] = Field(
28
+ description="JSON schema for function parameters"
29
+ )
25
30
 
26
31
  @classmethod
27
32
  def from_function(
28
33
  cls,
29
- fn: Callable,
34
+ fn: Callable[..., Any],
30
35
  uri_template: str,
31
- name: Optional[str] = None,
32
- description: Optional[str] = None,
33
- mime_type: Optional[str] = None,
34
- ) -> "ResourceTemplate":
36
+ name: str | None = None,
37
+ description: str | None = None,
38
+ mime_type: str | None = None,
39
+ ) -> ResourceTemplate:
35
40
  """Create a template from a function."""
36
41
  func_name = name or fn.__name__
37
42
  if func_name == "<lambda>":
@@ -52,7 +57,7 @@ class ResourceTemplate(BaseModel):
52
57
  parameters=parameters,
53
58
  )
54
59
 
55
- def matches(self, uri: str) -> Optional[Dict[str, Any]]:
60
+ def matches(self, uri: str) -> dict[str, Any] | None:
56
61
  """Check if URI matches template and extract parameters."""
57
62
  # Convert template to regex pattern
58
63
  pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
@@ -61,7 +66,7 @@ class ResourceTemplate(BaseModel):
61
66
  return match.groupdict()
62
67
  return None
63
68
 
64
- async def create_resource(self, uri: str, params: Dict[str, Any]) -> Resource:
69
+ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
65
70
  """Create a resource from the template with the given parameters."""
66
71
  try:
67
72
  # Call function and check if result is a coroutine
@@ -1,10 +1,13 @@
1
1
  """Concrete resource implementations."""
2
2
 
3
- import asyncio
3
+ import inspect
4
4
  import json
5
+ from collections.abc import Callable
5
6
  from pathlib import Path
6
- from typing import Any, Callable, Union
7
+ from typing import Any
7
8
 
9
+ import anyio
10
+ import anyio.to_thread
8
11
  import httpx
9
12
  import pydantic.json
10
13
  import pydantic_core
@@ -48,10 +51,12 @@ class FunctionResource(Resource):
48
51
 
49
52
  fn: Callable[[], Any] = Field(exclude=True)
50
53
 
51
- async def read(self) -> Union[str, bytes]:
54
+ async def read(self) -> str | bytes:
52
55
  """Read the resource by calling the wrapped function."""
53
56
  try:
54
- result = self.fn()
57
+ result = (
58
+ await self.fn() if inspect.iscoroutinefunction(self.fn) else self.fn()
59
+ )
55
60
  if isinstance(result, Resource):
56
61
  return await result.read()
57
62
  if isinstance(result, bytes):
@@ -100,12 +105,12 @@ class FileResource(Resource):
100
105
  mime_type = info.data.get("mime_type", "text/plain")
101
106
  return not mime_type.startswith("text/")
102
107
 
103
- async def read(self) -> Union[str, bytes]:
108
+ async def read(self) -> str | bytes:
104
109
  """Read the file content."""
105
110
  try:
106
111
  if self.is_binary:
107
- return await asyncio.to_thread(self.path.read_bytes)
108
- return await asyncio.to_thread(self.path.read_text)
112
+ return await anyio.to_thread.run_sync(self.path.read_bytes)
113
+ return await anyio.to_thread.run_sync(self.path.read_text)
109
114
  except Exception as e:
110
115
  raise ValueError(f"Error reading file {self.path}: {e}")
111
116
 
@@ -114,11 +119,11 @@ class HttpResource(Resource):
114
119
  """A resource that reads from an HTTP endpoint."""
115
120
 
116
121
  url: str = Field(description="URL to fetch content from")
117
- mime_type: str | None = Field(
122
+ mime_type: str = Field(
118
123
  default="application/json", description="MIME type of the resource content"
119
124
  )
120
125
 
121
- async def read(self) -> Union[str, bytes]:
126
+ async def read(self) -> str | bytes:
122
127
  """Read the HTTP content."""
123
128
  async with httpx.AsyncClient() as client:
124
129
  response = await client.get(self.url)
@@ -136,7 +141,7 @@ class DirectoryResource(Resource):
136
141
  pattern: str | None = Field(
137
142
  default=None, description="Optional glob pattern to filter files"
138
143
  )
139
- mime_type: str | None = Field(
144
+ mime_type: str = Field(
140
145
  default="application/json", description="MIME type of the resource content"
141
146
  )
142
147
 
@@ -173,7 +178,7 @@ class DirectoryResource(Resource):
173
178
  async def read(self) -> str: # Always returns JSON string
174
179
  """Read the directory listing."""
175
180
  try:
176
- files = await asyncio.to_thread(self.list_files)
181
+ files = await anyio.to_thread.run_sync(self.list_files)
177
182
  file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
178
183
  return json.dumps({"files": file_list}, indent=2)
179
184
  except Exception as e:
@@ -0,0 +1,5 @@
1
+ from .server import FastMCP
2
+ from .context import Context
3
+
4
+
5
+ __all__ = ["FastMCP", "Context"]
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations as _annotations
2
+
3
+ from typing import Any, Generic, Literal
4
+
5
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
6
+ from mcp.server.session import ServerSessionT
7
+ from mcp.shared.context import LifespanContextT, RequestContext
8
+ from mcp.types import (
9
+ CreateMessageResult,
10
+ ImageContent,
11
+ Root,
12
+ SamplingMessage,
13
+ TextContent,
14
+ )
15
+ from pydantic import BaseModel
16
+ from pydantic.networks import AnyUrl
17
+
18
+ from fastmcp.server.server import FastMCP
19
+ from fastmcp.utilities.logging import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
25
+ """Context object providing access to MCP capabilities.
26
+
27
+ This provides a cleaner interface to MCP's RequestContext functionality.
28
+ It gets injected into tool and resource functions that request it via type hints.
29
+
30
+ To use context in a tool function, add a parameter with the Context type annotation:
31
+
32
+ ```python
33
+ @server.tool()
34
+ def my_tool(x: int, ctx: Context) -> str:
35
+ # Log messages to the client
36
+ ctx.info(f"Processing {x}")
37
+ ctx.debug("Debug info")
38
+ ctx.warning("Warning message")
39
+ ctx.error("Error message")
40
+
41
+ # Report progress
42
+ ctx.report_progress(50, 100)
43
+
44
+ # Access resources
45
+ data = ctx.read_resource("resource://data")
46
+
47
+ # Get request info
48
+ request_id = ctx.request_id
49
+ client_id = ctx.client_id
50
+
51
+ return str(x)
52
+ ```
53
+
54
+ The context parameter name can be anything as long as it's annotated with Context.
55
+ The context is optional - tools that don't need it can omit the parameter.
56
+ """
57
+
58
+ _request_context: RequestContext[ServerSessionT, LifespanContextT] | None
59
+ _fastmcp: FastMCP | None
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None,
65
+ fastmcp: FastMCP | None = None,
66
+ **kwargs: Any,
67
+ ):
68
+ super().__init__(**kwargs)
69
+ self._request_context = request_context
70
+ self._fastmcp = fastmcp
71
+
72
+ @property
73
+ def fastmcp(self) -> FastMCP:
74
+ """Access to the FastMCP server."""
75
+ if self._fastmcp is None:
76
+ raise ValueError("Context is not available outside of a request")
77
+ return self._fastmcp
78
+
79
+ @property
80
+ def request_context(self) -> RequestContext[ServerSessionT, LifespanContextT]:
81
+ """Access to the underlying request context."""
82
+ if self._request_context is None:
83
+ raise ValueError("Context is not available outside of a request")
84
+ return self._request_context
85
+
86
+ async def report_progress(
87
+ self, progress: float, total: float | None = None
88
+ ) -> None:
89
+ """Report progress for the current operation.
90
+
91
+ Args:
92
+ progress: Current progress value e.g. 24
93
+ total: Optional total value e.g. 100
94
+ """
95
+
96
+ progress_token = (
97
+ self.request_context.meta.progressToken
98
+ if self.request_context.meta
99
+ else None
100
+ )
101
+
102
+ if progress_token is None:
103
+ return
104
+
105
+ await self.request_context.session.send_progress_notification(
106
+ progress_token=progress_token, progress=progress, total=total
107
+ )
108
+
109
+ async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
110
+ """Read a resource by URI.
111
+
112
+ Args:
113
+ uri: Resource URI to read
114
+
115
+ Returns:
116
+ The resource content as either text or bytes
117
+ """
118
+ assert self._fastmcp is not None, (
119
+ "Context is not available outside of a request"
120
+ )
121
+ return await self._fastmcp.read_resource(uri)
122
+
123
+ async def log(
124
+ self,
125
+ level: Literal["debug", "info", "warning", "error"],
126
+ message: str,
127
+ *,
128
+ logger_name: str | None = None,
129
+ ) -> None:
130
+ """Send a log message to the client.
131
+
132
+ Args:
133
+ level: Log level (debug, info, warning, error)
134
+ message: Log message
135
+ logger_name: Optional logger name
136
+ **extra: Additional structured data to include
137
+ """
138
+ await self.request_context.session.send_log_message(
139
+ level=level, data=message, logger=logger_name
140
+ )
141
+
142
+ @property
143
+ def client_id(self) -> str | None:
144
+ """Get the client ID if available."""
145
+ return (
146
+ getattr(self.request_context.meta, "client_id", None)
147
+ if self.request_context.meta
148
+ else None
149
+ )
150
+
151
+ @property
152
+ def request_id(self) -> str:
153
+ """Get the unique ID for this request."""
154
+ return str(self.request_context.request_id)
155
+
156
+ @property
157
+ def session(self):
158
+ """Access to the underlying session for advanced usage."""
159
+ return self.request_context.session
160
+
161
+ # Convenience methods for common log levels
162
+ async def debug(self, message: str, **extra: Any) -> None:
163
+ """Send a debug log message."""
164
+ await self.log("debug", message, **extra)
165
+
166
+ async def info(self, message: str, **extra: Any) -> None:
167
+ """Send an info log message."""
168
+ await self.log("info", message, **extra)
169
+
170
+ async def warning(self, message: str, **extra: Any) -> None:
171
+ """Send a warning log message."""
172
+ await self.log("warning", message, **extra)
173
+
174
+ async def error(self, message: str, **extra: Any) -> None:
175
+ """Send an error log message."""
176
+ await self.log("error", message, **extra)
177
+
178
+ async def list_roots(self) -> list[Root]:
179
+ """List the roots available to the server, as indicated by the client."""
180
+ result = await self.request_context.session.list_roots()
181
+ return result.roots
182
+
183
+ async def sample(
184
+ self,
185
+ messages: str | list[str | SamplingMessage],
186
+ system_prompt: str | None = None,
187
+ temperature: float | None = None,
188
+ max_tokens: int | None = None,
189
+ ) -> TextContent | ImageContent:
190
+ """
191
+ Send a sampling request to the client and await the response.
192
+
193
+ Call this method at any time to have the server request an LLM
194
+ completion from the client. The client must be appropriately configured,
195
+ or the request will error.
196
+ """
197
+
198
+ if max_tokens is None:
199
+ max_tokens = 512
200
+
201
+ if isinstance(messages, str):
202
+ sampling_messages = [
203
+ SamplingMessage(
204
+ content=TextContent(text=messages, type="text"), role="user"
205
+ )
206
+ ]
207
+ elif isinstance(messages, list):
208
+ sampling_messages = [
209
+ SamplingMessage(content=TextContent(text=m, type="text"), role="user")
210
+ if isinstance(m, str)
211
+ else m
212
+ for m in messages
213
+ ]
214
+
215
+ result: CreateMessageResult = await self.request_context.session.create_message(
216
+ messages=sampling_messages,
217
+ system_prompt=system_prompt,
218
+ temperature=temperature,
219
+ max_tokens=max_tokens,
220
+ )
221
+
222
+ return result.content