fastmcp 2.2.9__py3-none-any.whl → 2.3.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/__init__.py +2 -1
- fastmcp/cli/cli.py +1 -1
- fastmcp/client/client.py +3 -2
- fastmcp/client/transports.py +45 -1
- fastmcp/prompts/prompt.py +10 -15
- fastmcp/prompts/prompt_manager.py +3 -10
- fastmcp/resources/resource.py +2 -7
- fastmcp/resources/resource_manager.py +2 -4
- fastmcp/resources/template.py +11 -24
- fastmcp/resources/types.py +15 -44
- fastmcp/server/__init__.py +1 -0
- fastmcp/server/context.py +50 -38
- fastmcp/server/dependencies.py +35 -0
- fastmcp/server/http.py +308 -0
- fastmcp/server/openapi.py +5 -16
- fastmcp/server/proxy.py +4 -13
- fastmcp/server/server.py +196 -271
- fastmcp/settings.py +20 -0
- fastmcp/tools/tool.py +40 -33
- fastmcp/tools/tool_manager.py +3 -9
- fastmcp/utilities/cache.py +26 -0
- fastmcp/utilities/tests.py +113 -0
- fastmcp/utilities/types.py +4 -7
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0.dist-info}/METADATA +6 -2
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0.dist-info}/RECORD +28 -25
- fastmcp/utilities/http.py +0 -44
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.2.9.dist-info → fastmcp-2.3.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/server.py
CHANGED
|
@@ -16,17 +16,10 @@ import anyio
|
|
|
16
16
|
import httpx
|
|
17
17
|
import pydantic
|
|
18
18
|
import uvicorn
|
|
19
|
-
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
|
20
|
-
from mcp.server.auth.middleware.bearer_auth import (
|
|
21
|
-
BearerAuthBackend,
|
|
22
|
-
RequireAuthMiddleware,
|
|
23
|
-
)
|
|
24
19
|
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
|
25
20
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
26
21
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
27
22
|
from mcp.server.lowlevel.server import Server as MCPServer
|
|
28
|
-
from mcp.server.session import ServerSession
|
|
29
|
-
from mcp.server.sse import SseServerTransport
|
|
30
23
|
from mcp.server.stdio import stdio_server
|
|
31
24
|
from mcp.types import (
|
|
32
25
|
AnyFunction,
|
|
@@ -42,127 +35,31 @@ from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
|
42
35
|
from mcp.types import Tool as MCPTool
|
|
43
36
|
from pydantic import AnyUrl
|
|
44
37
|
from starlette.applications import Starlette
|
|
45
|
-
from starlette.middleware import Middleware
|
|
46
|
-
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
47
38
|
from starlette.requests import Request
|
|
48
39
|
from starlette.responses import Response
|
|
49
|
-
from starlette.routing import
|
|
50
|
-
from starlette.types import Receive, Scope, Send
|
|
40
|
+
from starlette.routing import Route
|
|
51
41
|
|
|
52
|
-
import fastmcp
|
|
42
|
+
import fastmcp.server
|
|
53
43
|
import fastmcp.settings
|
|
54
44
|
from fastmcp.exceptions import NotFoundError, ResourceError
|
|
55
45
|
from fastmcp.prompts import Prompt, PromptManager
|
|
56
46
|
from fastmcp.prompts.prompt import PromptResult
|
|
57
47
|
from fastmcp.resources import Resource, ResourceManager
|
|
58
48
|
from fastmcp.resources.template import ResourceTemplate
|
|
49
|
+
from fastmcp.server.http import create_sse_app
|
|
59
50
|
from fastmcp.tools import ToolManager
|
|
60
51
|
from fastmcp.tools.tool import Tool
|
|
52
|
+
from fastmcp.utilities.cache import TimedCache
|
|
61
53
|
from fastmcp.utilities.decorators import DecoratedFunction
|
|
62
|
-
from fastmcp.utilities.http import RequestMiddleware
|
|
63
54
|
from fastmcp.utilities.logging import configure_logging, get_logger
|
|
64
55
|
|
|
65
56
|
if TYPE_CHECKING:
|
|
66
57
|
from fastmcp.client import Client
|
|
67
|
-
from fastmcp.server.context import Context
|
|
68
58
|
from fastmcp.server.openapi import FastMCPOpenAPI
|
|
69
59
|
from fastmcp.server.proxy import FastMCPProxy
|
|
70
60
|
|
|
71
61
|
logger = get_logger(__name__)
|
|
72
62
|
|
|
73
|
-
NOT_FOUND = object()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
class MountedServer:
|
|
77
|
-
def __init__(
|
|
78
|
-
self,
|
|
79
|
-
prefix: str,
|
|
80
|
-
server: FastMCP,
|
|
81
|
-
tool_separator: str | None = None,
|
|
82
|
-
resource_separator: str | None = None,
|
|
83
|
-
prompt_separator: str | None = None,
|
|
84
|
-
):
|
|
85
|
-
if tool_separator is None:
|
|
86
|
-
tool_separator = "_"
|
|
87
|
-
if resource_separator is None:
|
|
88
|
-
resource_separator = "+"
|
|
89
|
-
if prompt_separator is None:
|
|
90
|
-
prompt_separator = "_"
|
|
91
|
-
|
|
92
|
-
_validate_resource_prefix(f"{prefix}{resource_separator}")
|
|
93
|
-
|
|
94
|
-
self.server = server
|
|
95
|
-
self.prefix = prefix
|
|
96
|
-
self.tool_separator = tool_separator
|
|
97
|
-
self.resource_separator = resource_separator
|
|
98
|
-
self.prompt_separator = prompt_separator
|
|
99
|
-
|
|
100
|
-
async def get_tools(self) -> dict[str, Tool]:
|
|
101
|
-
tools = await self.server.get_tools()
|
|
102
|
-
return {
|
|
103
|
-
f"{self.prefix}{self.tool_separator}{key}": tool
|
|
104
|
-
for key, tool in tools.items()
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async def get_resources(self) -> dict[str, Resource]:
|
|
108
|
-
resources = await self.server.get_resources()
|
|
109
|
-
return {
|
|
110
|
-
f"{self.prefix}{self.resource_separator}{key}": resource
|
|
111
|
-
for key, resource in resources.items()
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
|
115
|
-
templates = await self.server.get_resource_templates()
|
|
116
|
-
return {
|
|
117
|
-
f"{self.prefix}{self.resource_separator}{key}": template
|
|
118
|
-
for key, template in templates.items()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async def get_prompts(self) -> dict[str, Prompt]:
|
|
122
|
-
prompts = await self.server.get_prompts()
|
|
123
|
-
return {
|
|
124
|
-
f"{self.prefix}{self.prompt_separator}{key}": prompt
|
|
125
|
-
for key, prompt in prompts.items()
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
def match_tool(self, key: str) -> bool:
|
|
129
|
-
return key.startswith(f"{self.prefix}{self.tool_separator}")
|
|
130
|
-
|
|
131
|
-
def strip_tool_prefix(self, key: str) -> str:
|
|
132
|
-
return key.removeprefix(f"{self.prefix}{self.tool_separator}")
|
|
133
|
-
|
|
134
|
-
def match_resource(self, key: str) -> bool:
|
|
135
|
-
return key.startswith(f"{self.prefix}{self.resource_separator}")
|
|
136
|
-
|
|
137
|
-
def strip_resource_prefix(self, key: str) -> str:
|
|
138
|
-
return key.removeprefix(f"{self.prefix}{self.resource_separator}")
|
|
139
|
-
|
|
140
|
-
def match_prompt(self, key: str) -> bool:
|
|
141
|
-
return key.startswith(f"{self.prefix}{self.prompt_separator}")
|
|
142
|
-
|
|
143
|
-
def strip_prompt_prefix(self, key: str) -> str:
|
|
144
|
-
return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
class TimedCache:
|
|
148
|
-
def __init__(self, expiration: datetime.timedelta):
|
|
149
|
-
self.expiration = expiration
|
|
150
|
-
self.cache: dict[Any, tuple[Any, datetime.datetime]] = {}
|
|
151
|
-
|
|
152
|
-
def set(self, key: Any, value: Any) -> None:
|
|
153
|
-
expires = datetime.datetime.now() + self.expiration
|
|
154
|
-
self.cache[key] = (value, expires)
|
|
155
|
-
|
|
156
|
-
def get(self, key: Any) -> Any:
|
|
157
|
-
value = self.cache.get(key)
|
|
158
|
-
if value is not None and value[1] > datetime.datetime.now():
|
|
159
|
-
return value[0]
|
|
160
|
-
else:
|
|
161
|
-
return NOT_FOUND
|
|
162
|
-
|
|
163
|
-
def clear(self) -> None:
|
|
164
|
-
self.cache.clear()
|
|
165
|
-
|
|
166
63
|
|
|
167
64
|
@asynccontextmanager
|
|
168
65
|
async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]:
|
|
@@ -249,7 +146,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
249
146
|
"is specified"
|
|
250
147
|
)
|
|
251
148
|
self._auth_server_provider = auth_server_provider
|
|
252
|
-
|
|
149
|
+
|
|
150
|
+
self._additional_http_routes: list[Route] = []
|
|
253
151
|
self.dependencies = self.settings.dependencies
|
|
254
152
|
|
|
255
153
|
# Set up MCP protocol handlers
|
|
@@ -270,30 +168,36 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
270
168
|
return self._mcp_server.instructions
|
|
271
169
|
|
|
272
170
|
async def run_async(
|
|
273
|
-
self,
|
|
171
|
+
self,
|
|
172
|
+
transport: Literal["stdio", "sse", "streamable-http"] | None = None,
|
|
173
|
+
**transport_kwargs: Any,
|
|
274
174
|
) -> None:
|
|
275
175
|
"""Run the FastMCP server asynchronously.
|
|
276
176
|
|
|
277
177
|
Args:
|
|
278
|
-
transport: Transport protocol to use ("stdio" or "
|
|
178
|
+
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
|
|
279
179
|
"""
|
|
280
180
|
if transport is None:
|
|
281
181
|
transport = "stdio"
|
|
282
|
-
if transport not in ["stdio", "sse"]:
|
|
182
|
+
if transport not in ["stdio", "sse", "streamable-http"]:
|
|
283
183
|
raise ValueError(f"Unknown transport: {transport}")
|
|
284
184
|
|
|
285
185
|
if transport == "stdio":
|
|
286
186
|
await self.run_stdio_async(**transport_kwargs)
|
|
287
|
-
|
|
187
|
+
elif transport == "sse":
|
|
288
188
|
await self.run_sse_async(**transport_kwargs)
|
|
189
|
+
else: # transport == "streamable-http"
|
|
190
|
+
await self.run_streamable_http_async(**transport_kwargs)
|
|
289
191
|
|
|
290
192
|
def run(
|
|
291
|
-
self,
|
|
193
|
+
self,
|
|
194
|
+
transport: Literal["stdio", "sse", "streamable-http"] | None = None,
|
|
195
|
+
**transport_kwargs: Any,
|
|
292
196
|
) -> None:
|
|
293
197
|
"""Run the FastMCP server. Note this is a synchronous function.
|
|
294
198
|
|
|
295
199
|
Args:
|
|
296
|
-
transport: Transport protocol to use ("stdio" or "
|
|
200
|
+
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
|
|
297
201
|
"""
|
|
298
202
|
logger.info(f'Starting server "{self.name}"...')
|
|
299
203
|
|
|
@@ -309,23 +213,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
309
213
|
self._mcp_server.get_prompt()(self._mcp_get_prompt)
|
|
310
214
|
self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
|
|
311
215
|
|
|
312
|
-
def get_context(self) -> Context[ServerSession, LifespanResultT]:
|
|
313
|
-
"""
|
|
314
|
-
Returns a Context object. Note that the context will only be valid
|
|
315
|
-
during a request; outside a request, most methods will error.
|
|
316
|
-
"""
|
|
317
|
-
|
|
318
|
-
try:
|
|
319
|
-
request_context = self._mcp_server.request_context
|
|
320
|
-
except LookupError:
|
|
321
|
-
request_context = None
|
|
322
|
-
from fastmcp.server.context import Context
|
|
323
|
-
|
|
324
|
-
return Context(request_context=request_context, fastmcp=self)
|
|
325
|
-
|
|
326
216
|
async def get_tools(self) -> dict[str, Tool]:
|
|
327
217
|
"""Get all registered tools, indexed by registered key."""
|
|
328
|
-
if (tools := self._cache.get("tools")) is NOT_FOUND:
|
|
218
|
+
if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
|
|
329
219
|
tools = {}
|
|
330
220
|
for server in self._mounted_servers.values():
|
|
331
221
|
server_tools = await server.get_tools()
|
|
@@ -336,7 +226,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
336
226
|
|
|
337
227
|
async def get_resources(self) -> dict[str, Resource]:
|
|
338
228
|
"""Get all registered resources, indexed by registered key."""
|
|
339
|
-
if (resources := self._cache.get("resources")) is NOT_FOUND:
|
|
229
|
+
if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
|
|
340
230
|
resources = {}
|
|
341
231
|
for server in self._mounted_servers.values():
|
|
342
232
|
server_resources = await server.get_resources()
|
|
@@ -347,7 +237,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
347
237
|
|
|
348
238
|
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
|
349
239
|
"""Get all registered resource templates, indexed by registered key."""
|
|
350
|
-
if (
|
|
240
|
+
if (
|
|
241
|
+
templates := self._cache.get("resource_templates")
|
|
242
|
+
) is self._cache.NOT_FOUND:
|
|
351
243
|
templates = {}
|
|
352
244
|
for server in self._mounted_servers.values():
|
|
353
245
|
server_templates = await server.get_resource_templates()
|
|
@@ -360,7 +252,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
360
252
|
"""
|
|
361
253
|
List all available prompts.
|
|
362
254
|
"""
|
|
363
|
-
if (prompts := self._cache.get("prompts")) is NOT_FOUND:
|
|
255
|
+
if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
|
|
364
256
|
prompts = {}
|
|
365
257
|
for server in self._mounted_servers.values():
|
|
366
258
|
server_prompts = await server.get_prompts()
|
|
@@ -400,7 +292,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
400
292
|
def decorator(
|
|
401
293
|
func: Callable[[Request], Awaitable[Response]],
|
|
402
294
|
) -> Callable[[Request], Awaitable[Response]]:
|
|
403
|
-
self.
|
|
295
|
+
self._additional_http_routes.append(
|
|
404
296
|
Route(
|
|
405
297
|
path,
|
|
406
298
|
endpoint=func,
|
|
@@ -458,43 +350,46 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
458
350
|
self, key: str, arguments: dict[str, Any]
|
|
459
351
|
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
460
352
|
"""Call a tool by name with arguments."""
|
|
461
|
-
if self._tool_manager.has_tool(key):
|
|
462
|
-
context = self.get_context()
|
|
463
|
-
result = await self._tool_manager.call_tool(key, arguments, context=context)
|
|
464
353
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
result = await server.server._mcp_call_tool(new_key, arguments)
|
|
470
|
-
break
|
|
354
|
+
with fastmcp.server.context.Context(fastmcp=self):
|
|
355
|
+
if self._tool_manager.has_tool(key):
|
|
356
|
+
result = await self._tool_manager.call_tool(key, arguments)
|
|
357
|
+
|
|
471
358
|
else:
|
|
472
|
-
|
|
473
|
-
|
|
359
|
+
for server in self._mounted_servers.values():
|
|
360
|
+
if server.match_tool(key):
|
|
361
|
+
new_key = server.strip_tool_prefix(key)
|
|
362
|
+
result = await server.server._mcp_call_tool(new_key, arguments)
|
|
363
|
+
break
|
|
364
|
+
else:
|
|
365
|
+
raise NotFoundError(f"Unknown tool: {key}")
|
|
366
|
+
return result
|
|
474
367
|
|
|
475
368
|
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
|
476
369
|
"""
|
|
477
370
|
Read a resource by URI, in the format expected by the low-level MCP
|
|
478
371
|
server.
|
|
479
372
|
"""
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if server.match_resource(str(uri)):
|
|
494
|
-
new_uri = server.strip_resource_prefix(str(uri))
|
|
495
|
-
return await server.server._mcp_read_resource(new_uri)
|
|
373
|
+
with fastmcp.server.context.Context(fastmcp=self):
|
|
374
|
+
if self._resource_manager.has_resource(uri):
|
|
375
|
+
resource = await self._resource_manager.get_resource(uri)
|
|
376
|
+
try:
|
|
377
|
+
content = await resource.read()
|
|
378
|
+
return [
|
|
379
|
+
ReadResourceContents(
|
|
380
|
+
content=content, mime_type=resource.mime_type
|
|
381
|
+
)
|
|
382
|
+
]
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.error(f"Error reading resource {uri}: {e}")
|
|
385
|
+
raise ResourceError(str(e))
|
|
496
386
|
else:
|
|
497
|
-
|
|
387
|
+
for server in self._mounted_servers.values():
|
|
388
|
+
if server.match_resource(str(uri)):
|
|
389
|
+
new_uri = server.strip_resource_prefix(str(uri))
|
|
390
|
+
return await server.server._mcp_read_resource(new_uri)
|
|
391
|
+
else:
|
|
392
|
+
raise NotFoundError(f"Unknown resource: {uri}")
|
|
498
393
|
|
|
499
394
|
async def _mcp_get_prompt(
|
|
500
395
|
self, name: str, arguments: dict[str, Any] | None = None
|
|
@@ -504,19 +399,19 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
504
399
|
MCP server.
|
|
505
400
|
|
|
506
401
|
"""
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
else:
|
|
514
|
-
for server in self._mounted_servers.values():
|
|
515
|
-
if server.match_prompt(name):
|
|
516
|
-
new_key = server.strip_prompt_prefix(name)
|
|
517
|
-
return await server.server._mcp_get_prompt(new_key, arguments)
|
|
402
|
+
with fastmcp.server.context.Context(fastmcp=self):
|
|
403
|
+
if self._prompt_manager.has_prompt(name):
|
|
404
|
+
prompt_result = await self._prompt_manager.render_prompt(
|
|
405
|
+
name, arguments=arguments or {}
|
|
406
|
+
)
|
|
407
|
+
return prompt_result
|
|
518
408
|
else:
|
|
519
|
-
|
|
409
|
+
for server in self._mounted_servers.values():
|
|
410
|
+
if server.match_prompt(name):
|
|
411
|
+
new_key = server.strip_prompt_prefix(name)
|
|
412
|
+
return await server.server._mcp_get_prompt(new_key, arguments)
|
|
413
|
+
else:
|
|
414
|
+
raise NotFoundError(f"Unknown prompt: {name}")
|
|
520
415
|
|
|
521
416
|
def add_tool(
|
|
522
417
|
self,
|
|
@@ -823,14 +718,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
823
718
|
host: str | None = None,
|
|
824
719
|
port: int | None = None,
|
|
825
720
|
log_level: str | None = None,
|
|
721
|
+
path: str | None = None,
|
|
722
|
+
message_path: str | None = None,
|
|
826
723
|
uvicorn_config: dict | None = None,
|
|
827
724
|
) -> None:
|
|
828
725
|
"""Run the server using SSE transport."""
|
|
829
726
|
uvicorn_config = uvicorn_config or {}
|
|
830
|
-
# the SSE app hangs even when a signal is sent, so we disable the
|
|
831
|
-
#
|
|
727
|
+
# the SSE app hangs even when a signal is sent, so we disable the
|
|
728
|
+
# timeout to make it possible to close immediately. see
|
|
729
|
+
# https://github.com/jlowin/fastmcp/issues/296
|
|
832
730
|
uvicorn_config.setdefault("timeout_graceful_shutdown", 0)
|
|
833
|
-
app =
|
|
731
|
+
app = self.sse_app(path=path, message_path=message_path)
|
|
834
732
|
|
|
835
733
|
config = uvicorn.Config(
|
|
836
734
|
app,
|
|
@@ -842,107 +740,63 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
842
740
|
server = uvicorn.Server(config)
|
|
843
741
|
await server.serve()
|
|
844
742
|
|
|
845
|
-
def sse_app(
|
|
743
|
+
def sse_app(
|
|
744
|
+
self,
|
|
745
|
+
path: str | None = None,
|
|
746
|
+
message_path: str | None = None,
|
|
747
|
+
) -> Starlette:
|
|
846
748
|
"""Return an instance of the SSE server app."""
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
749
|
+
return create_sse_app(
|
|
750
|
+
server=self,
|
|
751
|
+
message_path=message_path or self.settings.message_path,
|
|
752
|
+
sse_path=path or self.settings.sse_path,
|
|
753
|
+
auth_server_provider=self._auth_server_provider,
|
|
754
|
+
auth_settings=self.settings.auth,
|
|
755
|
+
debug=self.settings.debug,
|
|
756
|
+
additional_routes=self._additional_http_routes,
|
|
757
|
+
)
|
|
856
758
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
required_scopes = []
|
|
873
|
-
|
|
874
|
-
# Add auth endpoints if auth provider is configured
|
|
875
|
-
if self._auth_server_provider:
|
|
876
|
-
assert self.settings.auth
|
|
877
|
-
from mcp.server.auth.routes import create_auth_routes
|
|
878
|
-
|
|
879
|
-
required_scopes = self.settings.auth.required_scopes or []
|
|
880
|
-
|
|
881
|
-
middleware = [
|
|
882
|
-
# extract auth info from request (but do not require it)
|
|
883
|
-
Middleware(
|
|
884
|
-
AuthenticationMiddleware,
|
|
885
|
-
backend=BearerAuthBackend(
|
|
886
|
-
provider=self._auth_server_provider,
|
|
887
|
-
),
|
|
888
|
-
),
|
|
889
|
-
# Add the auth context middleware to store
|
|
890
|
-
# authenticated user in a contextvar
|
|
891
|
-
Middleware(AuthContextMiddleware),
|
|
892
|
-
]
|
|
893
|
-
routes.extend(
|
|
894
|
-
create_auth_routes(
|
|
895
|
-
provider=self._auth_server_provider,
|
|
896
|
-
issuer_url=self.settings.auth.issuer_url,
|
|
897
|
-
service_documentation_url=self.settings.auth.service_documentation_url,
|
|
898
|
-
client_registration_options=self.settings.auth.client_registration_options,
|
|
899
|
-
revocation_options=self.settings.auth.revocation_options,
|
|
900
|
-
)
|
|
901
|
-
)
|
|
759
|
+
def streamable_http_app(self, path: str | None = None) -> Starlette:
|
|
760
|
+
"""Return an instance of the StreamableHTTP server app."""
|
|
761
|
+
from fastmcp.server.http import create_streamable_http_app
|
|
762
|
+
|
|
763
|
+
return create_streamable_http_app(
|
|
764
|
+
server=self,
|
|
765
|
+
streamable_http_path=path or self.settings.streamable_http_path,
|
|
766
|
+
event_store=None,
|
|
767
|
+
auth_server_provider=self._auth_server_provider,
|
|
768
|
+
auth_settings=self.settings.auth,
|
|
769
|
+
json_response=self.settings.json_response,
|
|
770
|
+
stateless_http=self.settings.stateless_http,
|
|
771
|
+
debug=self.settings.debug,
|
|
772
|
+
additional_routes=self._additional_http_routes,
|
|
773
|
+
)
|
|
902
774
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
Mount(
|
|
915
|
-
self.settings.message_path,
|
|
916
|
-
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
|
|
917
|
-
)
|
|
918
|
-
)
|
|
919
|
-
else:
|
|
920
|
-
# Auth is disabled, no need for RequireAuthMiddleware
|
|
921
|
-
# Since handle_sse is an ASGI app, we need to create a compatible endpoint
|
|
922
|
-
async def sse_endpoint(request: Request) -> None:
|
|
923
|
-
# Convert the Starlette request to ASGI parameters
|
|
924
|
-
await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
|
|
775
|
+
async def run_streamable_http_async(
|
|
776
|
+
self,
|
|
777
|
+
host: str | None = None,
|
|
778
|
+
port: int | None = None,
|
|
779
|
+
log_level: str | None = None,
|
|
780
|
+
path: str | None = None,
|
|
781
|
+
uvicorn_config: dict | None = None,
|
|
782
|
+
) -> None:
|
|
783
|
+
"""Run the server using StreamableHTTP transport."""
|
|
784
|
+
uvicorn_config = uvicorn_config or {}
|
|
785
|
+
uvicorn_config.setdefault("timeout_graceful_shutdown", 0)
|
|
925
786
|
|
|
926
|
-
|
|
927
|
-
Route(
|
|
928
|
-
self.settings.sse_path,
|
|
929
|
-
endpoint=sse_endpoint,
|
|
930
|
-
methods=["GET"],
|
|
931
|
-
)
|
|
932
|
-
)
|
|
933
|
-
routes.append(
|
|
934
|
-
Mount(
|
|
935
|
-
self.settings.message_path,
|
|
936
|
-
app=sse.handle_post_message,
|
|
937
|
-
)
|
|
938
|
-
)
|
|
939
|
-
# mount these routes last, so they have the lowest route matching precedence
|
|
940
|
-
routes.extend(self._custom_starlette_routes)
|
|
787
|
+
app = self.streamable_http_app(path=path)
|
|
941
788
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
789
|
+
config = uvicorn.Config(
|
|
790
|
+
app,
|
|
791
|
+
host=host or self.settings.host,
|
|
792
|
+
port=port or self.settings.port,
|
|
793
|
+
log_level=log_level or self.settings.log_level.lower(),
|
|
794
|
+
# lifespan is required for streamable http
|
|
795
|
+
lifespan="on",
|
|
796
|
+
**uvicorn_config,
|
|
945
797
|
)
|
|
798
|
+
server = uvicorn.Server(config)
|
|
799
|
+
await server.serve()
|
|
946
800
|
|
|
947
801
|
def mount(
|
|
948
802
|
self,
|
|
@@ -1145,3 +999,74 @@ def _validate_resource_prefix(prefix: str) -> None:
|
|
|
1145
999
|
raise ValueError(
|
|
1146
1000
|
f"Resource prefix or separator would result in an invalid resource URI: {e}"
|
|
1147
1001
|
)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
class MountedServer:
|
|
1005
|
+
def __init__(
|
|
1006
|
+
self,
|
|
1007
|
+
prefix: str,
|
|
1008
|
+
server: FastMCP,
|
|
1009
|
+
tool_separator: str | None = None,
|
|
1010
|
+
resource_separator: str | None = None,
|
|
1011
|
+
prompt_separator: str | None = None,
|
|
1012
|
+
):
|
|
1013
|
+
if tool_separator is None:
|
|
1014
|
+
tool_separator = "_"
|
|
1015
|
+
if resource_separator is None:
|
|
1016
|
+
resource_separator = "+"
|
|
1017
|
+
if prompt_separator is None:
|
|
1018
|
+
prompt_separator = "_"
|
|
1019
|
+
|
|
1020
|
+
_validate_resource_prefix(f"{prefix}{resource_separator}")
|
|
1021
|
+
|
|
1022
|
+
self.server = server
|
|
1023
|
+
self.prefix = prefix
|
|
1024
|
+
self.tool_separator = tool_separator
|
|
1025
|
+
self.resource_separator = resource_separator
|
|
1026
|
+
self.prompt_separator = prompt_separator
|
|
1027
|
+
|
|
1028
|
+
async def get_tools(self) -> dict[str, Tool]:
|
|
1029
|
+
tools = await self.server.get_tools()
|
|
1030
|
+
return {
|
|
1031
|
+
f"{self.prefix}{self.tool_separator}{key}": tool
|
|
1032
|
+
for key, tool in tools.items()
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async def get_resources(self) -> dict[str, Resource]:
|
|
1036
|
+
resources = await self.server.get_resources()
|
|
1037
|
+
return {
|
|
1038
|
+
f"{self.prefix}{self.resource_separator}{key}": resource
|
|
1039
|
+
for key, resource in resources.items()
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
|
1043
|
+
templates = await self.server.get_resource_templates()
|
|
1044
|
+
return {
|
|
1045
|
+
f"{self.prefix}{self.resource_separator}{key}": template
|
|
1046
|
+
for key, template in templates.items()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async def get_prompts(self) -> dict[str, Prompt]:
|
|
1050
|
+
prompts = await self.server.get_prompts()
|
|
1051
|
+
return {
|
|
1052
|
+
f"{self.prefix}{self.prompt_separator}{key}": prompt
|
|
1053
|
+
for key, prompt in prompts.items()
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
def match_tool(self, key: str) -> bool:
|
|
1057
|
+
return key.startswith(f"{self.prefix}{self.tool_separator}")
|
|
1058
|
+
|
|
1059
|
+
def strip_tool_prefix(self, key: str) -> str:
|
|
1060
|
+
return key.removeprefix(f"{self.prefix}{self.tool_separator}")
|
|
1061
|
+
|
|
1062
|
+
def match_resource(self, key: str) -> bool:
|
|
1063
|
+
return key.startswith(f"{self.prefix}{self.resource_separator}")
|
|
1064
|
+
|
|
1065
|
+
def strip_resource_prefix(self, key: str) -> str:
|
|
1066
|
+
return key.removeprefix(f"{self.prefix}{self.resource_separator}")
|
|
1067
|
+
|
|
1068
|
+
def match_prompt(self, key: str) -> bool:
|
|
1069
|
+
return key.startswith(f"{self.prefix}{self.prompt_separator}")
|
|
1070
|
+
|
|
1071
|
+
def strip_prompt_prefix(self, key: str) -> str:
|
|
1072
|
+
return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
|
fastmcp/settings.py
CHANGED
|
@@ -27,6 +27,16 @@ class Settings(BaseSettings):
|
|
|
27
27
|
|
|
28
28
|
test_mode: bool = False
|
|
29
29
|
log_level: LOG_LEVEL = "INFO"
|
|
30
|
+
tool_attempt_parse_json_args: bool = Field(
|
|
31
|
+
default=False,
|
|
32
|
+
description="""
|
|
33
|
+
Note: this enables a legacy behavior. If True, will attempt to parse
|
|
34
|
+
stringified JSON lists and objects strings in tool arguments before
|
|
35
|
+
passing them to the tool. This is an old behavior that can create
|
|
36
|
+
unexpected type coercion issues, but may be helpful for less powerful
|
|
37
|
+
LLMs that stringify JSON instead of passing actual lists and objects.
|
|
38
|
+
Defaults to False.""",
|
|
39
|
+
)
|
|
30
40
|
|
|
31
41
|
|
|
32
42
|
class ServerSettings(BaseSettings):
|
|
@@ -51,6 +61,7 @@ class ServerSettings(BaseSettings):
|
|
|
51
61
|
port: int = 8000
|
|
52
62
|
sse_path: str = "/sse"
|
|
53
63
|
message_path: str = "/messages/"
|
|
64
|
+
streamable_http_path: str = "/mcp"
|
|
54
65
|
debug: bool = False
|
|
55
66
|
|
|
56
67
|
# resource settings
|
|
@@ -72,6 +83,12 @@ class ServerSettings(BaseSettings):
|
|
|
72
83
|
|
|
73
84
|
auth: AuthSettings | None = None
|
|
74
85
|
|
|
86
|
+
# StreamableHTTP settings
|
|
87
|
+
json_response: bool = False
|
|
88
|
+
stateless_http: bool = (
|
|
89
|
+
False # If True, uses true stateless mode (new transport per request)
|
|
90
|
+
)
|
|
91
|
+
|
|
75
92
|
|
|
76
93
|
class ClientSettings(BaseSettings):
|
|
77
94
|
"""FastMCP client settings."""
|
|
@@ -83,3 +100,6 @@ class ClientSettings(BaseSettings):
|
|
|
83
100
|
)
|
|
84
101
|
|
|
85
102
|
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
settings = Settings()
|