fastmcp 2.2.6__py3-none-any.whl → 2.2.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/client/client.py +246 -43
- fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py +1 -3
- fastmcp/prompts/__init__.py +7 -2
- fastmcp/prompts/prompt.py +55 -53
- fastmcp/prompts/prompt_manager.py +10 -3
- fastmcp/resources/template.py +29 -18
- fastmcp/resources/types.py +4 -7
- fastmcp/server/context.py +12 -1
- fastmcp/server/openapi.py +28 -12
- fastmcp/server/proxy.py +7 -9
- fastmcp/server/server.py +243 -19
- fastmcp/settings.py +7 -0
- fastmcp/tools/tool.py +79 -62
- fastmcp/tools/tool_manager.py +16 -3
- fastmcp/utilities/http.py +44 -0
- fastmcp/utilities/json_schema.py +59 -0
- fastmcp/utilities/openapi.py +147 -36
- fastmcp/utilities/types.py +66 -1
- fastmcp-2.2.8.dist-info/METADATA +407 -0
- {fastmcp-2.2.6.dist-info → fastmcp-2.2.8.dist-info}/RECORD +23 -22
- fastmcp/utilities/func_metadata.py +0 -229
- fastmcp-2.2.6.dist-info/METADATA +0 -810
- {fastmcp-2.2.6.dist-info → fastmcp-2.2.8.dist-info}/WHEEL +0 -0
- {fastmcp-2.2.6.dist-info → fastmcp-2.2.8.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.2.6.dist-info → fastmcp-2.2.8.dist-info}/licenses/LICENSE +0 -0
fastmcp/resources/types.py
CHANGED
|
@@ -97,15 +97,12 @@ class FunctionResource(Resource):
|
|
|
97
97
|
|
|
98
98
|
if isinstance(result, Resource):
|
|
99
99
|
return await result.read(context=context)
|
|
100
|
-
|
|
100
|
+
elif isinstance(result, bytes):
|
|
101
101
|
return result
|
|
102
|
-
|
|
102
|
+
elif isinstance(result, str):
|
|
103
103
|
return result
|
|
104
|
-
|
|
105
|
-
return
|
|
106
|
-
except (TypeError, pydantic_core.PydanticSerializationError):
|
|
107
|
-
# If JSON serialization fails, try str()
|
|
108
|
-
return str(result)
|
|
104
|
+
else:
|
|
105
|
+
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
|
109
106
|
except Exception as e:
|
|
110
107
|
raise ValueError(f"Error reading resource {self.uri}: {e}")
|
|
111
108
|
|
fastmcp/server/context.py
CHANGED
|
@@ -13,10 +13,12 @@ from mcp.types import (
|
|
|
13
13
|
SamplingMessage,
|
|
14
14
|
TextContent,
|
|
15
15
|
)
|
|
16
|
-
from pydantic import BaseModel
|
|
16
|
+
from pydantic import BaseModel, ConfigDict
|
|
17
17
|
from pydantic.networks import AnyUrl
|
|
18
|
+
from starlette.requests import Request
|
|
18
19
|
|
|
19
20
|
from fastmcp.server.server import FastMCP
|
|
21
|
+
from fastmcp.utilities.http import get_current_starlette_request
|
|
20
22
|
from fastmcp.utilities.logging import get_logger
|
|
21
23
|
|
|
22
24
|
logger = get_logger(__name__)
|
|
@@ -59,6 +61,8 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
|
59
61
|
_request_context: RequestContext[ServerSessionT, LifespanContextT] | None
|
|
60
62
|
_fastmcp: FastMCP | None
|
|
61
63
|
|
|
64
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
65
|
+
|
|
62
66
|
def __init__(
|
|
63
67
|
self,
|
|
64
68
|
*,
|
|
@@ -222,3 +226,10 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
|
222
226
|
)
|
|
223
227
|
|
|
224
228
|
return result.content
|
|
229
|
+
|
|
230
|
+
def get_http_request(self) -> Request:
|
|
231
|
+
"""Get the active starlette request."""
|
|
232
|
+
request = get_current_starlette_request()
|
|
233
|
+
if request is None:
|
|
234
|
+
raise ValueError("Request is not available outside a Starlette request")
|
|
235
|
+
return request
|
fastmcp/server/openapi.py
CHANGED
|
@@ -5,19 +5,19 @@ from __future__ import annotations
|
|
|
5
5
|
import enum
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
|
+
from collections.abc import Callable
|
|
8
9
|
from dataclasses import dataclass
|
|
9
10
|
from re import Pattern
|
|
10
11
|
from typing import TYPE_CHECKING, Any, Literal
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
13
|
-
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
14
|
+
from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
|
|
14
15
|
from pydantic.networks import AnyUrl
|
|
15
16
|
|
|
16
17
|
from fastmcp.resources import Resource, ResourceTemplate
|
|
17
18
|
from fastmcp.server.server import FastMCP
|
|
18
19
|
from fastmcp.tools.tool import Tool, _convert_to_content
|
|
19
20
|
from fastmcp.utilities import openapi
|
|
20
|
-
from fastmcp.utilities.func_metadata import func_metadata
|
|
21
21
|
from fastmcp.utilities.logging import get_logger
|
|
22
22
|
from fastmcp.utilities.openapi import (
|
|
23
23
|
_combine_schemas,
|
|
@@ -122,20 +122,20 @@ class OpenAPITool(Tool):
|
|
|
122
122
|
name: str,
|
|
123
123
|
description: str,
|
|
124
124
|
parameters: dict[str, Any],
|
|
125
|
-
fn_metadata: Any,
|
|
126
|
-
is_async: bool = True,
|
|
127
125
|
tags: set[str] = set(),
|
|
128
126
|
timeout: float | None = None,
|
|
127
|
+
annotations: ToolAnnotations | None = None,
|
|
128
|
+
serializer: Callable[[Any], str] | None = None,
|
|
129
129
|
):
|
|
130
130
|
super().__init__(
|
|
131
131
|
name=name,
|
|
132
132
|
description=description,
|
|
133
133
|
parameters=parameters,
|
|
134
134
|
fn=self._execute_request, # We'll use an instance method instead of a global function
|
|
135
|
-
fn_metadata=fn_metadata,
|
|
136
|
-
is_async=is_async,
|
|
137
135
|
context_kwarg="context", # Default context keyword argument
|
|
138
136
|
tags=tags,
|
|
137
|
+
annotations=annotations,
|
|
138
|
+
serializer=serializer,
|
|
139
139
|
)
|
|
140
140
|
self._client = client
|
|
141
141
|
self._route = route
|
|
@@ -534,10 +534,12 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
534
534
|
or f"Executes {route.method} {route.path}"
|
|
535
535
|
)
|
|
536
536
|
|
|
537
|
-
# Format enhanced description
|
|
537
|
+
# Format enhanced description with parameters and request body
|
|
538
538
|
enhanced_description = format_description_with_responses(
|
|
539
539
|
base_description=base_description,
|
|
540
540
|
responses=route.responses,
|
|
541
|
+
parameters=route.parameters,
|
|
542
|
+
request_body=route.request_body,
|
|
541
543
|
)
|
|
542
544
|
|
|
543
545
|
tool = OpenAPITool(
|
|
@@ -546,8 +548,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
546
548
|
name=tool_name,
|
|
547
549
|
description=enhanced_description,
|
|
548
550
|
parameters=combined_schema,
|
|
549
|
-
fn_metadata=func_metadata(_openapi_passthrough),
|
|
550
|
-
is_async=True,
|
|
551
551
|
tags=set(route.tags or []),
|
|
552
552
|
timeout=self._timeout,
|
|
553
553
|
)
|
|
@@ -565,10 +565,12 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
565
565
|
route.description or route.summary or f"Represents {route.path}"
|
|
566
566
|
)
|
|
567
567
|
|
|
568
|
-
# Format enhanced description
|
|
568
|
+
# Format enhanced description with parameters and request body
|
|
569
569
|
enhanced_description = format_description_with_responses(
|
|
570
570
|
base_description=base_description,
|
|
571
571
|
responses=route.responses,
|
|
572
|
+
parameters=route.parameters,
|
|
573
|
+
request_body=route.request_body,
|
|
572
574
|
)
|
|
573
575
|
|
|
574
576
|
resource = OpenAPIResource(
|
|
@@ -600,16 +602,30 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
600
602
|
route.description or route.summary or f"Template for {route.path}"
|
|
601
603
|
)
|
|
602
604
|
|
|
603
|
-
# Format enhanced description
|
|
605
|
+
# Format enhanced description with parameters and request body
|
|
604
606
|
enhanced_description = format_description_with_responses(
|
|
605
607
|
base_description=base_description,
|
|
606
608
|
responses=route.responses,
|
|
609
|
+
parameters=route.parameters,
|
|
610
|
+
request_body=route.request_body,
|
|
607
611
|
)
|
|
608
612
|
|
|
609
613
|
template_params_schema = {
|
|
610
614
|
"type": "object",
|
|
611
615
|
"properties": {
|
|
612
|
-
p.name:
|
|
616
|
+
p.name: {
|
|
617
|
+
**(p.schema_.copy() if isinstance(p.schema_, dict) else {}),
|
|
618
|
+
**(
|
|
619
|
+
{"description": p.description}
|
|
620
|
+
if p.description
|
|
621
|
+
and not (
|
|
622
|
+
isinstance(p.schema_, dict) and "description" in p.schema_
|
|
623
|
+
)
|
|
624
|
+
else {}
|
|
625
|
+
),
|
|
626
|
+
}
|
|
627
|
+
for p in route.parameters
|
|
628
|
+
if p.location == "path"
|
|
613
629
|
},
|
|
614
630
|
"required": [
|
|
615
631
|
p.name for p in route.parameters if p.location == "path" and p.required
|
fastmcp/server/proxy.py
CHANGED
|
@@ -19,12 +19,11 @@ from pydantic.networks import AnyUrl
|
|
|
19
19
|
|
|
20
20
|
from fastmcp.client import Client
|
|
21
21
|
from fastmcp.exceptions import NotFoundError
|
|
22
|
-
from fastmcp.prompts import
|
|
22
|
+
from fastmcp.prompts import Prompt, PromptMessage
|
|
23
23
|
from fastmcp.resources import Resource, ResourceTemplate
|
|
24
24
|
from fastmcp.server.context import Context
|
|
25
25
|
from fastmcp.server.server import FastMCP
|
|
26
26
|
from fastmcp.tools.tool import Tool
|
|
27
|
-
from fastmcp.utilities.func_metadata import func_metadata
|
|
28
27
|
from fastmcp.utilities.logging import get_logger
|
|
29
28
|
|
|
30
29
|
if TYPE_CHECKING:
|
|
@@ -53,8 +52,6 @@ class ProxyTool(Tool):
|
|
|
53
52
|
description=tool.description,
|
|
54
53
|
parameters=tool.inputSchema,
|
|
55
54
|
fn=_proxy_passthrough,
|
|
56
|
-
fn_metadata=func_metadata(_proxy_passthrough),
|
|
57
|
-
is_async=True,
|
|
58
55
|
)
|
|
59
56
|
|
|
60
57
|
async def run(
|
|
@@ -65,8 +62,9 @@ class ProxyTool(Tool):
|
|
|
65
62
|
# the client context manager will swallow any exceptions inside a TaskGroup
|
|
66
63
|
# so we return the raw result and raise an exception ourselves
|
|
67
64
|
async with self._client:
|
|
68
|
-
result = await self._client.
|
|
69
|
-
self.name,
|
|
65
|
+
result = await self._client.call_tool_mcp(
|
|
66
|
+
name=self.name,
|
|
67
|
+
arguments=arguments,
|
|
70
68
|
)
|
|
71
69
|
if result.isError:
|
|
72
70
|
raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
|
|
@@ -177,10 +175,10 @@ class ProxyPrompt(Prompt):
|
|
|
177
175
|
self,
|
|
178
176
|
arguments: dict[str, Any],
|
|
179
177
|
context: Context[ServerSessionT, LifespanContextT] | None = None,
|
|
180
|
-
) -> list[
|
|
178
|
+
) -> list[PromptMessage]:
|
|
181
179
|
async with self._client:
|
|
182
180
|
result = await self._client.get_prompt(self.name, arguments)
|
|
183
|
-
return
|
|
181
|
+
return result.messages
|
|
184
182
|
|
|
185
183
|
|
|
186
184
|
class FastMCPProxy(FastMCP):
|
|
@@ -293,4 +291,4 @@ class FastMCPProxy(FastMCP):
|
|
|
293
291
|
except NotFoundError:
|
|
294
292
|
async with self.client:
|
|
295
293
|
result = await self.client.get_prompt(name, arguments)
|
|
296
|
-
return
|
|
294
|
+
return result
|
fastmcp/server/server.py
CHANGED
|
@@ -14,8 +14,13 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
|
|
|
14
14
|
|
|
15
15
|
import anyio
|
|
16
16
|
import httpx
|
|
17
|
-
import pydantic_core
|
|
18
17
|
import uvicorn
|
|
18
|
+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
|
19
|
+
from mcp.server.auth.middleware.bearer_auth import (
|
|
20
|
+
BearerAuthBackend,
|
|
21
|
+
RequireAuthMiddleware,
|
|
22
|
+
)
|
|
23
|
+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
|
19
24
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
20
25
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
21
26
|
from mcp.server.lowlevel.server import Server as MCPServer
|
|
@@ -28,6 +33,7 @@ from mcp.types import (
|
|
|
28
33
|
GetPromptResult,
|
|
29
34
|
ImageContent,
|
|
30
35
|
TextContent,
|
|
36
|
+
ToolAnnotations,
|
|
31
37
|
)
|
|
32
38
|
from mcp.types import Prompt as MCPPrompt
|
|
33
39
|
from mcp.types import Resource as MCPResource
|
|
@@ -35,8 +41,12 @@ from mcp.types import ResourceTemplate as MCPResourceTemplate
|
|
|
35
41
|
from mcp.types import Tool as MCPTool
|
|
36
42
|
from pydantic.networks import AnyUrl
|
|
37
43
|
from starlette.applications import Starlette
|
|
44
|
+
from starlette.middleware import Middleware
|
|
45
|
+
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
38
46
|
from starlette.requests import Request
|
|
47
|
+
from starlette.responses import Response
|
|
39
48
|
from starlette.routing import Mount, Route
|
|
49
|
+
from starlette.types import Receive, Scope, Send
|
|
40
50
|
|
|
41
51
|
import fastmcp
|
|
42
52
|
import fastmcp.settings
|
|
@@ -48,6 +58,7 @@ from fastmcp.resources.template import ResourceTemplate
|
|
|
48
58
|
from fastmcp.tools import ToolManager
|
|
49
59
|
from fastmcp.tools.tool import Tool
|
|
50
60
|
from fastmcp.utilities.decorators import DecoratedFunction
|
|
61
|
+
from fastmcp.utilities.http import RequestMiddleware
|
|
51
62
|
from fastmcp.utilities.logging import configure_logging, get_logger
|
|
52
63
|
|
|
53
64
|
if TYPE_CHECKING:
|
|
@@ -183,6 +194,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
183
194
|
self,
|
|
184
195
|
name: str | None = None,
|
|
185
196
|
instructions: str | None = None,
|
|
197
|
+
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
|
198
|
+
| None = None,
|
|
186
199
|
lifespan: (
|
|
187
200
|
Callable[
|
|
188
201
|
[FastMCP[LifespanResultT]],
|
|
@@ -191,6 +204,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
191
204
|
| None
|
|
192
205
|
) = None,
|
|
193
206
|
tags: set[str] | None = None,
|
|
207
|
+
tool_serializer: Callable[[Any], str] | None = None,
|
|
194
208
|
**settings: Any,
|
|
195
209
|
):
|
|
196
210
|
self.tags: set[str] = tags or set()
|
|
@@ -204,7 +218,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
204
218
|
self._mounted_servers: dict[str, MountedServer] = {}
|
|
205
219
|
|
|
206
220
|
if lifespan is None:
|
|
221
|
+
self._has_lifespan = False
|
|
207
222
|
lifespan = default_lifespan
|
|
223
|
+
else:
|
|
224
|
+
self._has_lifespan = True
|
|
208
225
|
|
|
209
226
|
self._mcp_server = MCPServer[LifespanResultT](
|
|
210
227
|
name=name or "FastMCP",
|
|
@@ -212,7 +229,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
212
229
|
lifespan=_lifespan_wrapper(self, lifespan),
|
|
213
230
|
)
|
|
214
231
|
self._tool_manager = ToolManager(
|
|
215
|
-
duplicate_behavior=self.settings.on_duplicate_tools
|
|
232
|
+
duplicate_behavior=self.settings.on_duplicate_tools,
|
|
233
|
+
serializer=tool_serializer,
|
|
216
234
|
)
|
|
217
235
|
self._resource_manager = ResourceManager(
|
|
218
236
|
duplicate_behavior=self.settings.on_duplicate_resources
|
|
@@ -220,6 +238,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
220
238
|
self._prompt_manager = PromptManager(
|
|
221
239
|
duplicate_behavior=self.settings.on_duplicate_prompts
|
|
222
240
|
)
|
|
241
|
+
|
|
242
|
+
if (self.settings.auth is not None) != (auth_server_provider is not None):
|
|
243
|
+
# TODO: after we support separate authorization servers (see
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"settings.auth must be specified if and only if auth_server_provider "
|
|
246
|
+
"is specified"
|
|
247
|
+
)
|
|
248
|
+
self._auth_server_provider = auth_server_provider
|
|
249
|
+
self._custom_starlette_routes: list[Route] = []
|
|
223
250
|
self.dependencies = self.settings.dependencies
|
|
224
251
|
|
|
225
252
|
# Set up MCP protocol handlers
|
|
@@ -339,6 +366,50 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
339
366
|
self._cache.set("prompts", prompts)
|
|
340
367
|
return prompts
|
|
341
368
|
|
|
369
|
+
def custom_route(
|
|
370
|
+
self,
|
|
371
|
+
path: str,
|
|
372
|
+
methods: list[str],
|
|
373
|
+
name: str | None = None,
|
|
374
|
+
include_in_schema: bool = True,
|
|
375
|
+
):
|
|
376
|
+
"""
|
|
377
|
+
Decorator to register a custom HTTP route on the FastMCP server.
|
|
378
|
+
|
|
379
|
+
Allows adding arbitrary HTTP endpoints outside the standard MCP protocol,
|
|
380
|
+
which can be useful for OAuth callbacks, health checks, or admin APIs.
|
|
381
|
+
The handler function must be an async function that accepts a Starlette
|
|
382
|
+
Request and returns a Response.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
path: URL path for the route (e.g., "/oauth/callback")
|
|
386
|
+
methods: List of HTTP methods to support (e.g., ["GET", "POST"])
|
|
387
|
+
name: Optional name for the route (to reference this route with
|
|
388
|
+
Starlette's reverse URL lookup feature)
|
|
389
|
+
include_in_schema: Whether to include in OpenAPI schema, defaults to True
|
|
390
|
+
|
|
391
|
+
Example:
|
|
392
|
+
@server.custom_route("/health", methods=["GET"])
|
|
393
|
+
async def health_check(request: Request) -> Response:
|
|
394
|
+
return JSONResponse({"status": "ok"})
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
def decorator(
|
|
398
|
+
func: Callable[[Request], Awaitable[Response]],
|
|
399
|
+
) -> Callable[[Request], Awaitable[Response]]:
|
|
400
|
+
self._custom_starlette_routes.append(
|
|
401
|
+
Route(
|
|
402
|
+
path,
|
|
403
|
+
endpoint=func,
|
|
404
|
+
methods=methods,
|
|
405
|
+
name=name,
|
|
406
|
+
include_in_schema=include_in_schema,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
return func
|
|
410
|
+
|
|
411
|
+
return decorator
|
|
412
|
+
|
|
342
413
|
async def _mcp_list_tools(self) -> list[MCPTool]:
|
|
343
414
|
"""
|
|
344
415
|
List all available tools, in the format expected by the low-level MCP
|
|
@@ -432,10 +503,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
432
503
|
"""
|
|
433
504
|
if self._prompt_manager.has_prompt(name):
|
|
434
505
|
context = self.get_context()
|
|
435
|
-
|
|
506
|
+
prompt_result = await self._prompt_manager.render_prompt(
|
|
436
507
|
name, arguments=arguments or {}, context=context
|
|
437
508
|
)
|
|
438
|
-
return
|
|
509
|
+
return prompt_result
|
|
439
510
|
else:
|
|
440
511
|
for server in self._mounted_servers.values():
|
|
441
512
|
if server.match_prompt(name):
|
|
@@ -450,6 +521,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
450
521
|
name: str | None = None,
|
|
451
522
|
description: str | None = None,
|
|
452
523
|
tags: set[str] | None = None,
|
|
524
|
+
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
|
453
525
|
) -> None:
|
|
454
526
|
"""Add a tool to the server.
|
|
455
527
|
|
|
@@ -461,9 +533,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
461
533
|
name: Optional name for the tool (defaults to function name)
|
|
462
534
|
description: Optional description of what the tool does
|
|
463
535
|
tags: Optional set of tags for categorizing the tool
|
|
536
|
+
annotations: Optional annotations about the tool's behavior
|
|
464
537
|
"""
|
|
538
|
+
if isinstance(annotations, dict):
|
|
539
|
+
annotations = ToolAnnotations(**annotations)
|
|
540
|
+
|
|
465
541
|
self._tool_manager.add_tool_from_fn(
|
|
466
|
-
fn,
|
|
542
|
+
fn,
|
|
543
|
+
name=name,
|
|
544
|
+
description=description,
|
|
545
|
+
tags=tags,
|
|
546
|
+
annotations=annotations,
|
|
467
547
|
)
|
|
468
548
|
self._cache.clear()
|
|
469
549
|
|
|
@@ -472,6 +552,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
472
552
|
name: str | None = None,
|
|
473
553
|
description: str | None = None,
|
|
474
554
|
tags: set[str] | None = None,
|
|
555
|
+
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
|
475
556
|
) -> Callable[[AnyFunction], AnyFunction]:
|
|
476
557
|
"""Decorator to register a tool.
|
|
477
558
|
|
|
@@ -483,6 +564,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
483
564
|
name: Optional name for the tool (defaults to function name)
|
|
484
565
|
description: Optional description of what the tool does
|
|
485
566
|
tags: Optional set of tags for categorizing the tool
|
|
567
|
+
annotations: Optional annotations about the tool's behavior
|
|
486
568
|
|
|
487
569
|
Example:
|
|
488
570
|
@server.tool()
|
|
@@ -508,7 +590,13 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
508
590
|
)
|
|
509
591
|
|
|
510
592
|
def decorator(fn: AnyFunction) -> AnyFunction:
|
|
511
|
-
self.add_tool(
|
|
593
|
+
self.add_tool(
|
|
594
|
+
fn,
|
|
595
|
+
name=name,
|
|
596
|
+
description=description,
|
|
597
|
+
tags=tags,
|
|
598
|
+
annotations=annotations,
|
|
599
|
+
)
|
|
512
600
|
return fn
|
|
513
601
|
|
|
514
602
|
return decorator
|
|
@@ -732,41 +820,125 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
732
820
|
host: str | None = None,
|
|
733
821
|
port: int | None = None,
|
|
734
822
|
log_level: str | None = None,
|
|
823
|
+
uvicorn_config: dict | None = None,
|
|
735
824
|
) -> None:
|
|
736
825
|
"""Run the server using SSE transport."""
|
|
737
|
-
|
|
826
|
+
uvicorn_config = uvicorn_config or {}
|
|
827
|
+
# the SSE app hangs even when a signal is sent, so we disable the timeout to make it possible to close immediately.
|
|
828
|
+
# see https://github.com/jlowin/fastmcp/issues/296
|
|
829
|
+
uvicorn_config.setdefault("timeout_graceful_shutdown", 0)
|
|
830
|
+
app = RequestMiddleware(self.sse_app())
|
|
738
831
|
|
|
739
832
|
config = uvicorn.Config(
|
|
740
|
-
|
|
833
|
+
app,
|
|
741
834
|
host=host or self.settings.host,
|
|
742
835
|
port=port or self.settings.port,
|
|
743
836
|
log_level=log_level or self.settings.log_level.lower(),
|
|
837
|
+
**uvicorn_config,
|
|
744
838
|
)
|
|
745
839
|
server = uvicorn.Server(config)
|
|
746
840
|
await server.serve()
|
|
747
841
|
|
|
748
842
|
def sse_app(self) -> Starlette:
|
|
749
843
|
"""Return an instance of the SSE server app."""
|
|
844
|
+
from starlette.middleware import Middleware
|
|
845
|
+
from starlette.routing import Mount, Route
|
|
846
|
+
|
|
847
|
+
# Set up auth context and dependencies
|
|
848
|
+
|
|
750
849
|
sse = SseServerTransport(self.settings.message_path)
|
|
751
850
|
|
|
752
|
-
async def handle_sse(
|
|
851
|
+
async def handle_sse(scope: Scope, receive: Receive, send: Send):
|
|
852
|
+
# Add client ID from auth context into request context if available
|
|
853
|
+
|
|
753
854
|
async with sse.connect_sse(
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
855
|
+
scope,
|
|
856
|
+
receive,
|
|
857
|
+
send,
|
|
757
858
|
) as streams:
|
|
758
859
|
await self._mcp_server.run(
|
|
759
860
|
streams[0],
|
|
760
861
|
streams[1],
|
|
761
862
|
self._mcp_server.create_initialization_options(),
|
|
762
863
|
)
|
|
864
|
+
return Response()
|
|
865
|
+
|
|
866
|
+
# Create routes
|
|
867
|
+
routes: list[Route | Mount] = []
|
|
868
|
+
middleware: list[Middleware] = []
|
|
869
|
+
required_scopes = []
|
|
870
|
+
|
|
871
|
+
# Add auth endpoints if auth provider is configured
|
|
872
|
+
if self._auth_server_provider:
|
|
873
|
+
assert self.settings.auth
|
|
874
|
+
from mcp.server.auth.routes import create_auth_routes
|
|
875
|
+
|
|
876
|
+
required_scopes = self.settings.auth.required_scopes or []
|
|
877
|
+
|
|
878
|
+
middleware = [
|
|
879
|
+
# extract auth info from request (but do not require it)
|
|
880
|
+
Middleware(
|
|
881
|
+
AuthenticationMiddleware,
|
|
882
|
+
backend=BearerAuthBackend(
|
|
883
|
+
provider=self._auth_server_provider,
|
|
884
|
+
),
|
|
885
|
+
),
|
|
886
|
+
# Add the auth context middleware to store
|
|
887
|
+
# authenticated user in a contextvar
|
|
888
|
+
Middleware(AuthContextMiddleware),
|
|
889
|
+
]
|
|
890
|
+
routes.extend(
|
|
891
|
+
create_auth_routes(
|
|
892
|
+
provider=self._auth_server_provider,
|
|
893
|
+
issuer_url=self.settings.auth.issuer_url,
|
|
894
|
+
service_documentation_url=self.settings.auth.service_documentation_url,
|
|
895
|
+
client_registration_options=self.settings.auth.client_registration_options,
|
|
896
|
+
revocation_options=self.settings.auth.revocation_options,
|
|
897
|
+
)
|
|
898
|
+
)
|
|
763
899
|
|
|
900
|
+
# When auth is not configured, we shouldn't require auth
|
|
901
|
+
if self._auth_server_provider:
|
|
902
|
+
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
|
|
903
|
+
routes.append(
|
|
904
|
+
Route(
|
|
905
|
+
self.settings.sse_path,
|
|
906
|
+
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
|
|
907
|
+
methods=["GET"],
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
routes.append(
|
|
911
|
+
Mount(
|
|
912
|
+
self.settings.message_path,
|
|
913
|
+
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
else:
|
|
917
|
+
# Auth is disabled, no need for RequireAuthMiddleware
|
|
918
|
+
# Since handle_sse is an ASGI app, we need to create a compatible endpoint
|
|
919
|
+
async def sse_endpoint(request: Request) -> None:
|
|
920
|
+
# Convert the Starlette request to ASGI parameters
|
|
921
|
+
await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
|
|
922
|
+
|
|
923
|
+
routes.append(
|
|
924
|
+
Route(
|
|
925
|
+
self.settings.sse_path,
|
|
926
|
+
endpoint=sse_endpoint,
|
|
927
|
+
methods=["GET"],
|
|
928
|
+
)
|
|
929
|
+
)
|
|
930
|
+
routes.append(
|
|
931
|
+
Mount(
|
|
932
|
+
self.settings.message_path,
|
|
933
|
+
app=sse.handle_post_message,
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
# mount these routes last, so they have the lowest route matching precedence
|
|
937
|
+
routes.extend(self._custom_starlette_routes)
|
|
938
|
+
|
|
939
|
+
# Create Starlette app with routes and middleware
|
|
764
940
|
return Starlette(
|
|
765
|
-
debug=self.settings.debug,
|
|
766
|
-
routes=[
|
|
767
|
-
Route(self.settings.sse_path, endpoint=handle_sse),
|
|
768
|
-
Mount(self.settings.message_path, app=sse.handle_post_message),
|
|
769
|
-
],
|
|
941
|
+
debug=self.settings.debug, routes=routes, middleware=middleware
|
|
770
942
|
)
|
|
771
943
|
|
|
772
944
|
def mount(
|
|
@@ -776,10 +948,62 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
776
948
|
tool_separator: str | None = None,
|
|
777
949
|
resource_separator: str | None = None,
|
|
778
950
|
prompt_separator: str | None = None,
|
|
951
|
+
as_proxy: bool | None = None,
|
|
779
952
|
) -> None:
|
|
953
|
+
"""Mount another FastMCP server on this server with the given prefix.
|
|
954
|
+
|
|
955
|
+
Unlike importing (with import_server), mounting establishes a dynamic connection
|
|
956
|
+
between servers. When a client interacts with a mounted server's objects through
|
|
957
|
+
the parent server, requests are forwarded to the mounted server in real-time.
|
|
958
|
+
This means changes to the mounted server are immediately reflected when accessed
|
|
959
|
+
through the parent.
|
|
960
|
+
|
|
961
|
+
When a server is mounted:
|
|
962
|
+
- Tools from the mounted server are accessible with prefixed names using the tool_separator.
|
|
963
|
+
Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
|
|
964
|
+
- Resources are accessible with prefixed URIs using the resource_separator.
|
|
965
|
+
Example: If server has a resource with URI "weather://forecast", it will be available as
|
|
966
|
+
"prefix+weather://forecast".
|
|
967
|
+
- Templates are accessible with prefixed URI templates using the resource_separator.
|
|
968
|
+
Example: If server has a template with URI "weather://location/{id}", it will be available
|
|
969
|
+
as "prefix+weather://location/{id}".
|
|
970
|
+
- Prompts are accessible with prefixed names using the prompt_separator.
|
|
971
|
+
Example: If server has a prompt named "weather_prompt", it will be available as
|
|
972
|
+
"prefix_weather_prompt".
|
|
973
|
+
|
|
974
|
+
There are two modes for mounting servers:
|
|
975
|
+
1. Direct mounting (default when server has no custom lifespan): The parent server
|
|
976
|
+
directly accesses the mounted server's objects in-memory for better performance.
|
|
977
|
+
In this mode, no client lifecycle events occur on the mounted server, including
|
|
978
|
+
lifespan execution.
|
|
979
|
+
|
|
980
|
+
2. Proxy mounting (default when server has a custom lifespan): The parent server
|
|
981
|
+
treats the mounted server as a separate entity and communicates with it via a
|
|
982
|
+
Client transport. This preserves all client-facing behaviors, including lifespan
|
|
983
|
+
execution, but with slightly higher overhead.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
prefix: Prefix to use for the mounted server's objects.
|
|
987
|
+
server: The FastMCP server to mount.
|
|
988
|
+
tool_separator: Separator character for tool names (defaults to "_").
|
|
989
|
+
resource_separator: Separator character for resource URIs (defaults to "+").
|
|
990
|
+
prompt_separator: Separator character for prompt names (defaults to "_").
|
|
991
|
+
as_proxy: Whether to treat the mounted server as a proxy. If None (default),
|
|
992
|
+
automatically determined based on whether the server has a custom lifespan
|
|
993
|
+
(True if it has a custom lifespan, False otherwise).
|
|
780
994
|
"""
|
|
781
|
-
|
|
782
|
-
|
|
995
|
+
from fastmcp import Client
|
|
996
|
+
from fastmcp.client.transports import FastMCPTransport
|
|
997
|
+
from fastmcp.server.proxy import FastMCPProxy
|
|
998
|
+
|
|
999
|
+
# if as_proxy is not specified and the server has a custom lifespan,
|
|
1000
|
+
# we should treat it as a proxy
|
|
1001
|
+
if as_proxy is None:
|
|
1002
|
+
as_proxy = server._has_lifespan
|
|
1003
|
+
|
|
1004
|
+
if as_proxy and not isinstance(server, FastMCPProxy):
|
|
1005
|
+
server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
|
|
1006
|
+
|
|
783
1007
|
mounted_server = MountedServer(
|
|
784
1008
|
server=server,
|
|
785
1009
|
prefix=prefix,
|
fastmcp/settings.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations as _annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, Literal
|
|
4
4
|
|
|
5
|
+
from mcp.server.auth.settings import AuthSettings
|
|
5
6
|
from pydantic import Field
|
|
6
7
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
8
|
|
|
@@ -20,6 +21,8 @@ class Settings(BaseSettings):
|
|
|
20
21
|
env_prefix="FASTMCP_",
|
|
21
22
|
env_file=".env",
|
|
22
23
|
extra="ignore",
|
|
24
|
+
env_nested_delimiter="__",
|
|
25
|
+
nested_model_default_partial_update=True,
|
|
23
26
|
)
|
|
24
27
|
|
|
25
28
|
test_mode: bool = False
|
|
@@ -37,6 +40,8 @@ class ServerSettings(BaseSettings):
|
|
|
37
40
|
env_prefix="FASTMCP_SERVER_",
|
|
38
41
|
env_file=".env",
|
|
39
42
|
extra="ignore",
|
|
43
|
+
env_nested_delimiter="__",
|
|
44
|
+
nested_model_default_partial_update=True,
|
|
40
45
|
)
|
|
41
46
|
|
|
42
47
|
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
|
|
@@ -65,6 +70,8 @@ class ServerSettings(BaseSettings):
|
|
|
65
70
|
# cache settings (for checking mounted servers)
|
|
66
71
|
cache_expiration_seconds: float = 0
|
|
67
72
|
|
|
73
|
+
auth: AuthSettings | None = None
|
|
74
|
+
|
|
68
75
|
|
|
69
76
|
class ClientSettings(BaseSettings):
|
|
70
77
|
"""FastMCP client settings."""
|