fastmcp 2.1.1__py3-none-any.whl → 2.2.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.
- fastmcp/cli/cli.py +32 -0
- fastmcp/client/client.py +16 -15
- fastmcp/client/transports.py +28 -7
- fastmcp/exceptions.py +8 -0
- fastmcp/prompts/prompt.py +20 -9
- fastmcp/prompts/prompt_manager.py +37 -45
- fastmcp/resources/resource.py +19 -8
- fastmcp/resources/resource_manager.py +83 -115
- fastmcp/resources/template.py +82 -17
- fastmcp/server/openapi.py +10 -16
- fastmcp/server/proxy.py +102 -76
- fastmcp/server/server.py +319 -256
- fastmcp/settings.py +8 -11
- fastmcp/tools/tool.py +66 -14
- fastmcp/tools/tool_manager.py +46 -44
- fastmcp/utilities/logging.py +14 -6
- fastmcp/utilities/openapi.py +0 -87
- {fastmcp-2.1.1.dist-info → fastmcp-2.2.0.dist-info}/METADATA +20 -7
- fastmcp-2.2.0.dist-info/RECORD +40 -0
- fastmcp-2.1.1.dist-info/RECORD +0 -40
- {fastmcp-2.1.1.dist-info → fastmcp-2.2.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.1.1.dist-info → fastmcp-2.2.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.1.1.dist-info → fastmcp-2.2.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/server.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""FastMCP - A more ergonomic interface for MCP servers."""
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import datetime
|
|
4
4
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
5
5
|
from contextlib import (
|
|
6
6
|
AbstractAsyncContextManager,
|
|
@@ -13,7 +13,6 @@ import anyio
|
|
|
13
13
|
import httpx
|
|
14
14
|
import pydantic_core
|
|
15
15
|
import uvicorn
|
|
16
|
-
from fastapi import FastAPI
|
|
17
16
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
18
17
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
19
18
|
from mcp.server.lowlevel.server import Server as MCPServer
|
|
@@ -28,7 +27,6 @@ from mcp.types import (
|
|
|
28
27
|
TextContent,
|
|
29
28
|
)
|
|
30
29
|
from mcp.types import Prompt as MCPPrompt
|
|
31
|
-
from mcp.types import PromptArgument as MCPPromptArgument
|
|
32
30
|
from mcp.types import Resource as MCPResource
|
|
33
31
|
from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
34
32
|
from mcp.types import Tool as MCPTool
|
|
@@ -39,24 +37,115 @@ from starlette.routing import Mount, Route
|
|
|
39
37
|
|
|
40
38
|
import fastmcp
|
|
41
39
|
import fastmcp.settings
|
|
42
|
-
from fastmcp.exceptions import ResourceError
|
|
40
|
+
from fastmcp.exceptions import NotFoundError, ResourceError
|
|
43
41
|
from fastmcp.prompts import Prompt, PromptManager
|
|
44
|
-
from fastmcp.prompts.prompt import
|
|
42
|
+
from fastmcp.prompts.prompt import PromptResult
|
|
45
43
|
from fastmcp.resources import Resource, ResourceManager
|
|
46
44
|
from fastmcp.resources.template import ResourceTemplate
|
|
47
45
|
from fastmcp.tools import ToolManager
|
|
48
46
|
from fastmcp.tools.tool import Tool
|
|
49
47
|
from fastmcp.utilities.decorators import DecoratedFunction
|
|
50
48
|
from fastmcp.utilities.logging import configure_logging, get_logger
|
|
51
|
-
from fastmcp.utilities.types import Image
|
|
52
49
|
|
|
53
50
|
if TYPE_CHECKING:
|
|
54
51
|
from fastmcp.client import Client
|
|
55
52
|
from fastmcp.server.context import Context
|
|
56
53
|
from fastmcp.server.openapi import FastMCPOpenAPI
|
|
57
54
|
from fastmcp.server.proxy import FastMCPProxy
|
|
55
|
+
|
|
58
56
|
logger = get_logger(__name__)
|
|
59
57
|
|
|
58
|
+
NOT_FOUND = object()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MountedServer:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
prefix: str,
|
|
65
|
+
server: "FastMCP",
|
|
66
|
+
tool_separator: str | None = None,
|
|
67
|
+
resource_separator: str | None = None,
|
|
68
|
+
prompt_separator: str | None = None,
|
|
69
|
+
):
|
|
70
|
+
if tool_separator is None:
|
|
71
|
+
tool_separator = "_"
|
|
72
|
+
if resource_separator is None:
|
|
73
|
+
resource_separator = "+"
|
|
74
|
+
if prompt_separator is None:
|
|
75
|
+
prompt_separator = "_"
|
|
76
|
+
|
|
77
|
+
self.server = server
|
|
78
|
+
self.prefix = prefix
|
|
79
|
+
self.tool_separator = tool_separator
|
|
80
|
+
self.resource_separator = resource_separator
|
|
81
|
+
self.prompt_separator = prompt_separator
|
|
82
|
+
|
|
83
|
+
async def get_tools(self) -> dict[str, Tool]:
|
|
84
|
+
tools = await self.server.get_tools()
|
|
85
|
+
return {
|
|
86
|
+
f"{self.prefix}{self.tool_separator}{key}": tool
|
|
87
|
+
for key, tool in tools.items()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async def get_resources(self) -> dict[str, Resource]:
|
|
91
|
+
resources = await self.server.get_resources()
|
|
92
|
+
return {
|
|
93
|
+
f"{self.prefix}{self.resource_separator}{key}": resource
|
|
94
|
+
for key, resource in resources.items()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
|
98
|
+
templates = await self.server.get_resource_templates()
|
|
99
|
+
return {
|
|
100
|
+
f"{self.prefix}{self.resource_separator}{key}": template
|
|
101
|
+
for key, template in templates.items()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async def get_prompts(self) -> dict[str, Prompt]:
|
|
105
|
+
prompts = await self.server.get_prompts()
|
|
106
|
+
return {
|
|
107
|
+
f"{self.prefix}{self.prompt_separator}{key}": prompt
|
|
108
|
+
for key, prompt in prompts.items()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def match_tool(self, key: str) -> bool:
|
|
112
|
+
return key.startswith(f"{self.prefix}{self.tool_separator}")
|
|
113
|
+
|
|
114
|
+
def strip_tool_prefix(self, key: str) -> str:
|
|
115
|
+
return key.removeprefix(f"{self.prefix}{self.tool_separator}")
|
|
116
|
+
|
|
117
|
+
def match_resource(self, key: str) -> bool:
|
|
118
|
+
return key.startswith(f"{self.prefix}{self.resource_separator}")
|
|
119
|
+
|
|
120
|
+
def strip_resource_prefix(self, key: str) -> str:
|
|
121
|
+
return key.removeprefix(f"{self.prefix}{self.resource_separator}")
|
|
122
|
+
|
|
123
|
+
def match_prompt(self, key: str) -> bool:
|
|
124
|
+
return key.startswith(f"{self.prefix}{self.prompt_separator}")
|
|
125
|
+
|
|
126
|
+
def strip_prompt_prefix(self, key: str) -> str:
|
|
127
|
+
return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TimedCache:
|
|
131
|
+
def __init__(self, expiration: datetime.timedelta):
|
|
132
|
+
self.expiration = expiration
|
|
133
|
+
self.cache: dict[Any, tuple[Any, datetime.datetime]] = {}
|
|
134
|
+
|
|
135
|
+
def set(self, key: Any, value: Any) -> None:
|
|
136
|
+
expires = datetime.datetime.now() + self.expiration
|
|
137
|
+
self.cache[key] = (value, expires)
|
|
138
|
+
|
|
139
|
+
def get(self, key: Any) -> Any:
|
|
140
|
+
value = self.cache.get(key)
|
|
141
|
+
if value is not None and value[1] > datetime.datetime.now():
|
|
142
|
+
return value[0]
|
|
143
|
+
else:
|
|
144
|
+
return NOT_FOUND
|
|
145
|
+
|
|
146
|
+
def clear(self) -> None:
|
|
147
|
+
self.cache.clear()
|
|
148
|
+
|
|
60
149
|
|
|
61
150
|
@asynccontextmanager
|
|
62
151
|
async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
|
|
@@ -71,7 +160,7 @@ async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
|
|
|
71
160
|
yield {}
|
|
72
161
|
|
|
73
162
|
|
|
74
|
-
def
|
|
163
|
+
def _lifespan_wrapper(
|
|
75
164
|
app: "FastMCP",
|
|
76
165
|
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
|
|
77
166
|
) -> Callable[
|
|
@@ -80,17 +169,7 @@ def lifespan_wrapper(
|
|
|
80
169
|
@asynccontextmanager
|
|
81
170
|
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
|
|
82
171
|
async with AsyncExitStack() as stack:
|
|
83
|
-
# enter main app's lifespan
|
|
84
172
|
context = await stack.enter_async_context(lifespan(app))
|
|
85
|
-
|
|
86
|
-
# Enter all mounted app lifespans
|
|
87
|
-
for prefix, mounted_app in app._mounted_apps.items():
|
|
88
|
-
mounted_context = mounted_app._mcp_server.lifespan(
|
|
89
|
-
mounted_app._mcp_server
|
|
90
|
-
)
|
|
91
|
-
await stack.enter_async_context(mounted_context)
|
|
92
|
-
logger.debug(f"Prepared lifespan for mounted app '{prefix}'")
|
|
93
|
-
|
|
94
173
|
yield context
|
|
95
174
|
|
|
96
175
|
return wrap
|
|
@@ -109,9 +188,13 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
109
188
|
):
|
|
110
189
|
self.tags: set[str] = tags or set()
|
|
111
190
|
self.settings = fastmcp.settings.ServerSettings(**settings)
|
|
191
|
+
self._cache = TimedCache(
|
|
192
|
+
expiration=datetime.timedelta(
|
|
193
|
+
seconds=self.settings.cache_expiration_seconds
|
|
194
|
+
)
|
|
195
|
+
)
|
|
112
196
|
|
|
113
|
-
|
|
114
|
-
self._mounted_apps: dict[str, FastMCP] = {}
|
|
197
|
+
self._mounted_servers: dict[str, MountedServer] = {}
|
|
115
198
|
|
|
116
199
|
if lifespan is None:
|
|
117
200
|
lifespan = default_lifespan
|
|
@@ -119,7 +202,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
119
202
|
self._mcp_server = MCPServer[LifespanResultT](
|
|
120
203
|
name=name or "FastMCP",
|
|
121
204
|
instructions=instructions,
|
|
122
|
-
lifespan=
|
|
205
|
+
lifespan=_lifespan_wrapper(self, lifespan),
|
|
123
206
|
)
|
|
124
207
|
self._tool_manager = ToolManager(
|
|
125
208
|
duplicate_behavior=self.settings.on_duplicate_tools
|
|
@@ -138,6 +221,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
138
221
|
# Configure logging
|
|
139
222
|
configure_logging(self.settings.log_level)
|
|
140
223
|
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
return f"{type(self).__name__}({self.name!r})"
|
|
226
|
+
|
|
141
227
|
@property
|
|
142
228
|
def name(self) -> str:
|
|
143
229
|
return self._mcp_server.name
|
|
@@ -146,7 +232,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
146
232
|
def instructions(self) -> str | None:
|
|
147
233
|
return self._mcp_server.instructions
|
|
148
234
|
|
|
149
|
-
async def run_async(
|
|
235
|
+
async def run_async(
|
|
236
|
+
self, transport: Literal["stdio", "sse"] | None = None, **transport_kwargs: Any
|
|
237
|
+
) -> None:
|
|
150
238
|
"""Run the FastMCP server asynchronously.
|
|
151
239
|
|
|
152
240
|
Args:
|
|
@@ -158,51 +246,31 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
158
246
|
raise ValueError(f"Unknown transport: {transport}")
|
|
159
247
|
|
|
160
248
|
if transport == "stdio":
|
|
161
|
-
await self.run_stdio_async()
|
|
249
|
+
await self.run_stdio_async(**transport_kwargs)
|
|
162
250
|
else: # transport == "sse"
|
|
163
|
-
await self.run_sse_async()
|
|
251
|
+
await self.run_sse_async(**transport_kwargs)
|
|
164
252
|
|
|
165
|
-
def run(
|
|
253
|
+
def run(
|
|
254
|
+
self, transport: Literal["stdio", "sse"] | None = None, **transport_kwargs: Any
|
|
255
|
+
) -> None:
|
|
166
256
|
"""Run the FastMCP server. Note this is a synchronous function.
|
|
167
257
|
|
|
168
258
|
Args:
|
|
169
259
|
transport: Transport protocol to use ("stdio" or "sse")
|
|
170
260
|
"""
|
|
171
261
|
logger.info(f'Starting server "{self.name}"...')
|
|
172
|
-
anyio.run(self.run_async, transport)
|
|
262
|
+
anyio.run(self.run_async, transport, **transport_kwargs)
|
|
173
263
|
|
|
174
264
|
def _setup_handlers(self) -> None:
|
|
175
265
|
"""Set up core MCP protocol handlers."""
|
|
176
266
|
self._mcp_server.list_tools()(self._mcp_list_tools)
|
|
177
|
-
self._mcp_server.call_tool()(self.
|
|
267
|
+
self._mcp_server.call_tool()(self._mcp_call_tool)
|
|
178
268
|
self._mcp_server.list_resources()(self._mcp_list_resources)
|
|
179
269
|
self._mcp_server.read_resource()(self._mcp_read_resource)
|
|
180
270
|
self._mcp_server.list_prompts()(self._mcp_list_prompts)
|
|
181
271
|
self._mcp_server.get_prompt()(self._mcp_get_prompt)
|
|
182
272
|
self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
|
|
183
273
|
|
|
184
|
-
def list_tools(self) -> list[Tool]:
|
|
185
|
-
return self._tool_manager.list_tools()
|
|
186
|
-
|
|
187
|
-
async def _mcp_list_tools(self) -> list[MCPTool]:
|
|
188
|
-
"""
|
|
189
|
-
List all available tools, in the format expected by the low-level MCP
|
|
190
|
-
server.
|
|
191
|
-
|
|
192
|
-
See `list_tools` for a more ergonomic way to list tools.
|
|
193
|
-
"""
|
|
194
|
-
|
|
195
|
-
tools = self.list_tools()
|
|
196
|
-
|
|
197
|
-
return [
|
|
198
|
-
MCPTool(
|
|
199
|
-
name=info.name,
|
|
200
|
-
description=info.description,
|
|
201
|
-
inputSchema=info.parameters,
|
|
202
|
-
)
|
|
203
|
-
for info in tools
|
|
204
|
-
]
|
|
205
|
-
|
|
206
274
|
def get_context(self) -> "Context[ServerSession, LifespanResultT]":
|
|
207
275
|
"""
|
|
208
276
|
Returns a Context object. Note that the context will only be valid
|
|
@@ -217,83 +285,152 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
217
285
|
|
|
218
286
|
return Context(request_context=request_context, fastmcp=self)
|
|
219
287
|
|
|
220
|
-
async def
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
288
|
+
async def get_tools(self) -> dict[str, Tool]:
|
|
289
|
+
"""Get all registered tools, indexed by registered key."""
|
|
290
|
+
if (tools := self._cache.get("tools")) is NOT_FOUND:
|
|
291
|
+
tools = {}
|
|
292
|
+
for server in self._mounted_servers.values():
|
|
293
|
+
server_tools = await server.get_tools()
|
|
294
|
+
tools.update(server_tools)
|
|
295
|
+
tools.update(self._tool_manager.get_tools())
|
|
296
|
+
self._cache.set("tools", tools)
|
|
297
|
+
return tools
|
|
298
|
+
|
|
299
|
+
async def get_resources(self) -> dict[str, Resource]:
|
|
300
|
+
"""Get all registered resources, indexed by registered key."""
|
|
301
|
+
if (resources := self._cache.get("resources")) is NOT_FOUND:
|
|
302
|
+
resources = {}
|
|
303
|
+
for server in self._mounted_servers.values():
|
|
304
|
+
server_resources = await server.get_resources()
|
|
305
|
+
resources.update(server_resources)
|
|
306
|
+
resources.update(self._resource_manager.get_resources())
|
|
307
|
+
self._cache.set("resources", resources)
|
|
308
|
+
return resources
|
|
309
|
+
|
|
310
|
+
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
|
311
|
+
"""Get all registered resource templates, indexed by registered key."""
|
|
312
|
+
if (templates := self._cache.get("resource_templates")) is NOT_FOUND:
|
|
313
|
+
templates = {}
|
|
314
|
+
for server in self._mounted_servers.values():
|
|
315
|
+
server_templates = await server.get_resource_templates()
|
|
316
|
+
templates.update(server_templates)
|
|
317
|
+
templates.update(self._resource_manager.get_templates())
|
|
318
|
+
self._cache.set("resource_templates", templates)
|
|
319
|
+
return templates
|
|
320
|
+
|
|
321
|
+
async def get_prompts(self) -> dict[str, Prompt]:
|
|
322
|
+
"""
|
|
323
|
+
List all available prompts.
|
|
324
|
+
"""
|
|
325
|
+
if (prompts := self._cache.get("prompts")) is NOT_FOUND:
|
|
326
|
+
prompts = {}
|
|
327
|
+
for server in self._mounted_servers.values():
|
|
328
|
+
server_prompts = await server.get_prompts()
|
|
329
|
+
prompts.update(server_prompts)
|
|
330
|
+
prompts.update(self._prompt_manager.get_prompts())
|
|
331
|
+
self._cache.set("prompts", prompts)
|
|
332
|
+
return prompts
|
|
228
333
|
|
|
229
|
-
def
|
|
230
|
-
|
|
334
|
+
async def _mcp_list_tools(self) -> list[MCPTool]:
|
|
335
|
+
"""
|
|
336
|
+
List all available tools, in the format expected by the low-level MCP
|
|
337
|
+
server.
|
|
338
|
+
|
|
339
|
+
"""
|
|
340
|
+
tools = await self.get_tools()
|
|
341
|
+
return [tool.to_mcp_tool(name=key) for key, tool in tools.items()]
|
|
231
342
|
|
|
232
343
|
async def _mcp_list_resources(self) -> list[MCPResource]:
|
|
233
344
|
"""
|
|
234
345
|
List all available resources, in the format expected by the low-level MCP
|
|
235
346
|
server.
|
|
236
347
|
|
|
237
|
-
See `list_resources` for a more ergonomic way to list resources.
|
|
238
348
|
"""
|
|
239
|
-
|
|
240
|
-
resources = self.list_resources()
|
|
349
|
+
resources = await self.get_resources()
|
|
241
350
|
return [
|
|
242
|
-
|
|
243
|
-
uri=resource.uri,
|
|
244
|
-
name=resource.name or "",
|
|
245
|
-
description=resource.description,
|
|
246
|
-
mimeType=resource.mime_type,
|
|
247
|
-
)
|
|
248
|
-
for resource in resources
|
|
351
|
+
resource.to_mcp_resource(uri=key) for key, resource in resources.items()
|
|
249
352
|
]
|
|
250
353
|
|
|
251
|
-
def list_resource_templates(self) -> list[ResourceTemplate]:
|
|
252
|
-
return self._resource_manager.list_templates()
|
|
253
|
-
|
|
254
354
|
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
|
255
355
|
"""
|
|
256
356
|
List all available resource templates, in the format expected by the low-level
|
|
257
357
|
MCP server.
|
|
258
358
|
|
|
259
|
-
See `list_resource_templates` for a more ergonomic way to list resource
|
|
260
|
-
templates.
|
|
261
359
|
"""
|
|
262
|
-
templates = self.
|
|
360
|
+
templates = await self.get_resource_templates()
|
|
263
361
|
return [
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
name=template.name,
|
|
267
|
-
description=template.description,
|
|
268
|
-
)
|
|
269
|
-
for template in templates
|
|
362
|
+
template.to_mcp_template(uriTemplate=key)
|
|
363
|
+
for key, template in templates.items()
|
|
270
364
|
]
|
|
271
365
|
|
|
272
|
-
async def
|
|
273
|
-
"""
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
366
|
+
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
|
367
|
+
"""
|
|
368
|
+
List all available prompts, in the format expected by the low-level MCP
|
|
369
|
+
server.
|
|
370
|
+
|
|
371
|
+
"""
|
|
372
|
+
prompts = await self.get_prompts()
|
|
373
|
+
return [prompt.to_mcp_prompt(name=key) for key, prompt in prompts.items()]
|
|
374
|
+
|
|
375
|
+
async def _mcp_call_tool(
|
|
376
|
+
self, key: str, arguments: dict[str, Any]
|
|
377
|
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
378
|
+
"""Call a tool by name with arguments."""
|
|
379
|
+
if self._tool_manager.has_tool(key):
|
|
380
|
+
context = self.get_context()
|
|
381
|
+
result = await self._tool_manager.call_tool(key, arguments, context=context)
|
|
382
|
+
|
|
383
|
+
else:
|
|
384
|
+
for server in self._mounted_servers.values():
|
|
385
|
+
if server.match_tool(key):
|
|
386
|
+
new_key = server.strip_tool_prefix(key)
|
|
387
|
+
result = await server.server._mcp_call_tool(new_key, arguments)
|
|
388
|
+
break
|
|
389
|
+
else:
|
|
390
|
+
raise NotFoundError(f"Unknown tool: {key}")
|
|
391
|
+
return result
|
|
278
392
|
|
|
279
393
|
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
|
280
394
|
"""
|
|
281
395
|
Read a resource by URI, in the format expected by the low-level MCP
|
|
282
396
|
server.
|
|
283
|
-
|
|
284
|
-
See `read_resource` for a more ergonomic way to read resources.
|
|
285
397
|
"""
|
|
398
|
+
if self._resource_manager.has_resource(uri):
|
|
399
|
+
resource = await self._resource_manager.get_resource(uri)
|
|
400
|
+
try:
|
|
401
|
+
content = await resource.read()
|
|
402
|
+
return [
|
|
403
|
+
ReadResourceContents(content=content, mime_type=resource.mime_type)
|
|
404
|
+
]
|
|
405
|
+
except Exception as e:
|
|
406
|
+
logger.error(f"Error reading resource {uri}: {e}")
|
|
407
|
+
raise ResourceError(str(e))
|
|
408
|
+
else:
|
|
409
|
+
for server in self._mounted_servers.values():
|
|
410
|
+
if server.match_resource(str(uri)):
|
|
411
|
+
new_uri = server.strip_resource_prefix(str(uri))
|
|
412
|
+
return await server.server._mcp_read_resource(new_uri)
|
|
413
|
+
else:
|
|
414
|
+
raise NotFoundError(f"Unknown resource: {uri}")
|
|
286
415
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
416
|
+
async def _mcp_get_prompt(
|
|
417
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
418
|
+
) -> GetPromptResult:
|
|
419
|
+
"""
|
|
420
|
+
Get a prompt by name with arguments, in the format expected by the low-level
|
|
421
|
+
MCP server.
|
|
290
422
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
423
|
+
"""
|
|
424
|
+
if self._prompt_manager.has_prompt(name):
|
|
425
|
+
messages = await self._prompt_manager.render_prompt(name, arguments)
|
|
426
|
+
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
|
427
|
+
else:
|
|
428
|
+
for server in self._mounted_servers.values():
|
|
429
|
+
if server.match_prompt(name):
|
|
430
|
+
new_key = server.strip_prompt_prefix(name)
|
|
431
|
+
return await server.server._mcp_get_prompt(new_key, arguments)
|
|
432
|
+
else:
|
|
433
|
+
raise NotFoundError(f"Unknown prompt: {name}")
|
|
297
434
|
|
|
298
435
|
def add_tool(
|
|
299
436
|
self,
|
|
@@ -316,6 +453,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
316
453
|
self._tool_manager.add_tool_from_fn(
|
|
317
454
|
fn, name=name, description=description, tags=tags
|
|
318
455
|
)
|
|
456
|
+
self._cache.clear()
|
|
319
457
|
|
|
320
458
|
def tool(
|
|
321
459
|
self,
|
|
@@ -359,18 +497,19 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
359
497
|
|
|
360
498
|
def decorator(fn: AnyFunction) -> AnyFunction:
|
|
361
499
|
self.add_tool(fn, name=name, description=description, tags=tags)
|
|
362
|
-
return
|
|
500
|
+
return fn
|
|
363
501
|
|
|
364
502
|
return decorator
|
|
365
503
|
|
|
366
|
-
def add_resource(self, resource: Resource) -> None:
|
|
504
|
+
def add_resource(self, resource: Resource, key: str | None = None) -> None:
|
|
367
505
|
"""Add a resource to the server.
|
|
368
506
|
|
|
369
507
|
Args:
|
|
370
508
|
resource: A Resource instance to add
|
|
371
509
|
"""
|
|
372
510
|
|
|
373
|
-
self._resource_manager.add_resource(resource)
|
|
511
|
+
self._resource_manager.add_resource(resource, key=key)
|
|
512
|
+
self._cache.clear()
|
|
374
513
|
|
|
375
514
|
def add_resource_fn(
|
|
376
515
|
self,
|
|
@@ -402,6 +541,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
402
541
|
mime_type=mime_type,
|
|
403
542
|
tags=tags,
|
|
404
543
|
)
|
|
544
|
+
self._cache.clear()
|
|
405
545
|
|
|
406
546
|
def resource(
|
|
407
547
|
self,
|
|
@@ -457,7 +597,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
457
597
|
)
|
|
458
598
|
|
|
459
599
|
def decorator(fn: AnyFunction) -> AnyFunction:
|
|
460
|
-
self.
|
|
600
|
+
self.add_resource_fn(
|
|
461
601
|
fn=fn,
|
|
462
602
|
uri=uri,
|
|
463
603
|
name=name,
|
|
@@ -465,7 +605,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
465
605
|
mime_type=mime_type,
|
|
466
606
|
tags=tags,
|
|
467
607
|
)
|
|
468
|
-
return
|
|
608
|
+
return fn
|
|
469
609
|
|
|
470
610
|
return decorator
|
|
471
611
|
|
|
@@ -487,6 +627,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
487
627
|
description=description,
|
|
488
628
|
tags=tags,
|
|
489
629
|
)
|
|
630
|
+
self._cache.clear()
|
|
490
631
|
|
|
491
632
|
def prompt(
|
|
492
633
|
self,
|
|
@@ -592,87 +733,72 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
592
733
|
],
|
|
593
734
|
)
|
|
594
735
|
|
|
595
|
-
def
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
""
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
List all available prompts, in the format expected by the low-level MCP
|
|
604
|
-
server.
|
|
605
|
-
|
|
606
|
-
See `list_prompts` for a more ergonomic way to list prompts.
|
|
736
|
+
def mount(
|
|
737
|
+
self,
|
|
738
|
+
prefix: str,
|
|
739
|
+
server: "FastMCP",
|
|
740
|
+
tool_separator: str | None = None,
|
|
741
|
+
resource_separator: str | None = None,
|
|
742
|
+
prompt_separator: str | None = None,
|
|
743
|
+
) -> None:
|
|
607
744
|
"""
|
|
608
|
-
|
|
609
|
-
return [
|
|
610
|
-
MCPPrompt(
|
|
611
|
-
name=prompt.name,
|
|
612
|
-
description=prompt.description,
|
|
613
|
-
arguments=[
|
|
614
|
-
MCPPromptArgument(
|
|
615
|
-
name=arg.name,
|
|
616
|
-
description=arg.description,
|
|
617
|
-
required=arg.required,
|
|
618
|
-
)
|
|
619
|
-
for arg in (prompt.arguments or [])
|
|
620
|
-
],
|
|
621
|
-
)
|
|
622
|
-
for prompt in prompts
|
|
623
|
-
]
|
|
624
|
-
|
|
625
|
-
async def get_prompt(
|
|
626
|
-
self, name: str, arguments: dict[str, Any] | None = None
|
|
627
|
-
) -> list[Message]:
|
|
628
|
-
"""Get a prompt by name with arguments."""
|
|
629
|
-
return await self._prompt_manager.render_prompt(name, arguments)
|
|
630
|
-
|
|
631
|
-
async def _mcp_get_prompt(
|
|
632
|
-
self, name: str, arguments: dict[str, Any] | None = None
|
|
633
|
-
) -> GetPromptResult:
|
|
745
|
+
Mount another FastMCP server on a given prefix.
|
|
634
746
|
"""
|
|
635
|
-
|
|
636
|
-
|
|
747
|
+
mounted_server = MountedServer(
|
|
748
|
+
server=server,
|
|
749
|
+
prefix=prefix,
|
|
750
|
+
tool_separator=tool_separator,
|
|
751
|
+
resource_separator=resource_separator,
|
|
752
|
+
prompt_separator=prompt_separator,
|
|
753
|
+
)
|
|
754
|
+
self._mounted_servers[prefix] = mounted_server
|
|
755
|
+
self._cache.clear()
|
|
637
756
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
messages = await self.get_prompt(name, arguments)
|
|
757
|
+
def unmount(self, prefix: str) -> None:
|
|
758
|
+
self._mounted_servers.pop(prefix)
|
|
759
|
+
self._cache.clear()
|
|
642
760
|
|
|
643
|
-
|
|
644
|
-
except Exception as e:
|
|
645
|
-
logger.error(f"Error getting prompt {name}: {e}")
|
|
646
|
-
raise ValueError(str(e))
|
|
647
|
-
|
|
648
|
-
def mount(
|
|
761
|
+
async def import_server(
|
|
649
762
|
self,
|
|
650
763
|
prefix: str,
|
|
651
|
-
|
|
764
|
+
server: "FastMCP",
|
|
652
765
|
tool_separator: str | None = None,
|
|
653
766
|
resource_separator: str | None = None,
|
|
654
767
|
prompt_separator: str | None = None,
|
|
655
768
|
) -> None:
|
|
656
|
-
"""
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
- The
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
769
|
+
"""
|
|
770
|
+
Import the MCP objects from another FastMCP server into this one,
|
|
771
|
+
optionally with a given prefix.
|
|
772
|
+
|
|
773
|
+
Note that when a server is *imported*, its objects are immediately
|
|
774
|
+
registered to the importing server. This is a one-time operation and
|
|
775
|
+
future changes to the imported server will not be reflected in the
|
|
776
|
+
importing server. Server-level configurations and lifespans are not imported.
|
|
777
|
+
|
|
778
|
+
When an server is mounted: - The tools are imported with prefixed names
|
|
779
|
+
using the tool_separator
|
|
780
|
+
Example: If server has a tool named "get_weather", it will be
|
|
781
|
+
available as "weatherget_weather"
|
|
782
|
+
- The resources are imported with prefixed URIs using the
|
|
783
|
+
resource_separator Example: If server has a resource with URI
|
|
784
|
+
"weather://forecast", it will be available as
|
|
785
|
+
"weather+weather://forecast"
|
|
786
|
+
- The templates are imported with prefixed URI templates using the
|
|
787
|
+
resource_separator Example: If server has a template with URI
|
|
788
|
+
"weather://location/{id}", it will be available as
|
|
789
|
+
"weather+weather://location/{id}"
|
|
790
|
+
- The prompts are imported with prefixed names using the
|
|
791
|
+
prompt_separator Example: If server has a prompt named
|
|
792
|
+
"weather_prompt", it will be available as "weather_weather_prompt"
|
|
793
|
+
- The mounted server's lifespan will be executed when the parent
|
|
794
|
+
server's lifespan runs, ensuring that any setup needed by the mounted
|
|
795
|
+
server is performed
|
|
669
796
|
|
|
670
797
|
Args:
|
|
671
|
-
prefix: The prefix to use for the mounted
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
prompt_separator: Separator for prompt names (defaults to "_")
|
|
798
|
+
prefix: The prefix to use for the mounted server server: The FastMCP
|
|
799
|
+
server to mount tool_separator: Separator for tool names (defaults
|
|
800
|
+
to "_") resource_separator: Separator for resource URIs (defaults to
|
|
801
|
+
"+") prompt_separator: Separator for prompt names (defaults to "_")
|
|
676
802
|
"""
|
|
677
803
|
if tool_separator is None:
|
|
678
804
|
tool_separator = "_"
|
|
@@ -681,58 +807,30 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
681
807
|
if prompt_separator is None:
|
|
682
808
|
prompt_separator = "_"
|
|
683
809
|
|
|
684
|
-
#
|
|
685
|
-
self._mounted_apps[prefix] = app
|
|
686
|
-
|
|
687
|
-
# Import tools from the mounted app
|
|
810
|
+
# Import tools from the mounted server
|
|
688
811
|
tool_prefix = f"{prefix}{tool_separator}"
|
|
689
|
-
|
|
812
|
+
for key, tool in (await server.get_tools()).items():
|
|
813
|
+
self._tool_manager.add_tool(tool, key=f"{tool_prefix}{key}")
|
|
690
814
|
|
|
691
|
-
# Import resources and templates from the mounted
|
|
815
|
+
# Import resources and templates from the mounted server
|
|
692
816
|
resource_prefix = f"{prefix}{resource_separator}"
|
|
693
|
-
|
|
694
|
-
|
|
817
|
+
for key, resource in (await server.get_resources()).items():
|
|
818
|
+
self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
|
|
819
|
+
for key, template in (await server.get_resource_templates()).items():
|
|
820
|
+
self._resource_manager.add_template(template, key=f"{resource_prefix}{key}")
|
|
695
821
|
|
|
696
|
-
# Import prompts from the mounted
|
|
822
|
+
# Import prompts from the mounted server
|
|
697
823
|
prompt_prefix = f"{prefix}{prompt_separator}"
|
|
698
|
-
|
|
824
|
+
for key, prompt in (await server.get_prompts()).items():
|
|
825
|
+
self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}")
|
|
699
826
|
|
|
700
|
-
logger.info(f"
|
|
827
|
+
logger.info(f"Imported server {server.name} with prefix '{prefix}'")
|
|
701
828
|
logger.debug(f"Imported tools with prefix '{tool_prefix}'")
|
|
702
829
|
logger.debug(f"Imported resources with prefix '{resource_prefix}'")
|
|
703
830
|
logger.debug(f"Imported templates with prefix '{resource_prefix}'")
|
|
704
831
|
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
|
|
705
832
|
|
|
706
|
-
|
|
707
|
-
async def as_proxy(
|
|
708
|
-
cls, client: "Client | FastMCP", **settings: Any
|
|
709
|
-
) -> "FastMCPProxy":
|
|
710
|
-
"""
|
|
711
|
-
Create a FastMCP proxy server from a client.
|
|
712
|
-
|
|
713
|
-
This method creates a new FastMCP server instance that proxies requests to the provided client.
|
|
714
|
-
It discovers the client's tools, resources, prompts, and templates, and creates corresponding
|
|
715
|
-
components in the server that forward requests to the client.
|
|
716
|
-
|
|
717
|
-
Args:
|
|
718
|
-
client: The client to proxy requests to
|
|
719
|
-
**settings: Additional settings for the FastMCP server
|
|
720
|
-
|
|
721
|
-
Returns:
|
|
722
|
-
A FastMCP server that proxies requests to the client
|
|
723
|
-
"""
|
|
724
|
-
from fastmcp.client import Client
|
|
725
|
-
|
|
726
|
-
from .proxy import FastMCPProxy
|
|
727
|
-
|
|
728
|
-
if isinstance(client, Client):
|
|
729
|
-
return await FastMCPProxy.from_client(client=client, **settings)
|
|
730
|
-
|
|
731
|
-
elif isinstance(client, FastMCP):
|
|
732
|
-
return await FastMCPProxy.from_server(server=client, **settings)
|
|
733
|
-
|
|
734
|
-
else:
|
|
735
|
-
raise ValueError(f"Unknown client type: {type(client)}")
|
|
833
|
+
self._cache.clear()
|
|
736
834
|
|
|
737
835
|
@classmethod
|
|
738
836
|
def from_openapi(
|
|
@@ -747,11 +845,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
747
845
|
|
|
748
846
|
@classmethod
|
|
749
847
|
def from_fastapi(
|
|
750
|
-
cls, app:
|
|
848
|
+
cls, app: "Any", name: str | None = None, **settings: Any
|
|
751
849
|
) -> "FastMCPOpenAPI":
|
|
752
850
|
"""
|
|
753
851
|
Create a FastMCP server from a FastAPI application.
|
|
754
852
|
"""
|
|
853
|
+
|
|
755
854
|
from .openapi import FastMCPOpenAPI
|
|
756
855
|
|
|
757
856
|
client = httpx.AsyncClient(
|
|
@@ -764,47 +863,11 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
764
863
|
openapi_spec=app.openapi(), client=client, name=name, **settings
|
|
765
864
|
)
|
|
766
865
|
|
|
866
|
+
@classmethod
|
|
867
|
+
def from_client(cls, client: "Client", **settings: Any) -> "FastMCPProxy":
|
|
868
|
+
"""
|
|
869
|
+
Create a FastMCP proxy server from a FastMCP client.
|
|
870
|
+
"""
|
|
871
|
+
from fastmcp.server.proxy import FastMCPProxy
|
|
767
872
|
|
|
768
|
-
|
|
769
|
-
result: Any,
|
|
770
|
-
_process_as_single_item: bool = False,
|
|
771
|
-
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
772
|
-
"""Convert a result to a sequence of content objects."""
|
|
773
|
-
if result is None:
|
|
774
|
-
return []
|
|
775
|
-
|
|
776
|
-
if isinstance(result, TextContent | ImageContent | EmbeddedResource):
|
|
777
|
-
return [result]
|
|
778
|
-
|
|
779
|
-
if isinstance(result, Image):
|
|
780
|
-
return [result.to_image_content()]
|
|
781
|
-
|
|
782
|
-
if isinstance(result, list | tuple) and not _process_as_single_item:
|
|
783
|
-
# if the result is a list, then it could either be a list of MCP types,
|
|
784
|
-
# or a "regular" list that the tool is returning, or a mix of both.
|
|
785
|
-
#
|
|
786
|
-
# so we extract all the MCP types / images and convert them as individual content elements,
|
|
787
|
-
# and aggregate the rest as a single content element
|
|
788
|
-
|
|
789
|
-
mcp_types = []
|
|
790
|
-
other_content = []
|
|
791
|
-
|
|
792
|
-
for item in result:
|
|
793
|
-
if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image):
|
|
794
|
-
mcp_types.append(_convert_to_content(item)[0])
|
|
795
|
-
else:
|
|
796
|
-
other_content.append(item)
|
|
797
|
-
if other_content:
|
|
798
|
-
other_content = _convert_to_content(
|
|
799
|
-
other_content, _process_as_single_item=True
|
|
800
|
-
)
|
|
801
|
-
|
|
802
|
-
return other_content + mcp_types
|
|
803
|
-
|
|
804
|
-
if not isinstance(result, str):
|
|
805
|
-
try:
|
|
806
|
-
result = json.dumps(pydantic_core.to_jsonable_python(result))
|
|
807
|
-
except Exception:
|
|
808
|
-
result = str(result)
|
|
809
|
-
|
|
810
|
-
return [TextContent(type="text", text=result)]
|
|
873
|
+
return FastMCPProxy(client=client, **settings)
|