Skip to content

Unpack settings in FastMCP #1198

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 71 additions & 47 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@

import inspect
import re
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import (
AbstractAsyncContextManager,
asynccontextmanager,
)
from collections.abc import AsyncIterator, Awaitable, Callable, Collection, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Generic, Literal

import anyio
import pydantic_core
from pydantic import BaseModel, Field
from pydantic import BaseModel
from pydantic.networks import AnyUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.applications import Starlette
Expand All @@ -25,10 +22,7 @@
from starlette.types import Receive, Scope, Send

from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import (
BearerAuthBackend,
RequireAuthMiddleware,
)
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, elicit_with_validation
Expand All @@ -48,12 +42,7 @@
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
from mcp.types import (
AnyFunction,
ContentBlock,
GetPromptResult,
ToolAnnotations,
)
from mcp.types import AnyFunction, ContentBlock, GetPromptResult, ToolAnnotations
from mcp.types import Prompt as MCPPrompt
from mcp.types import PromptArgument as MCPPromptArgument
from mcp.types import Resource as MCPResource
Expand All @@ -79,58 +68,57 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
)

# Server settings
debug: bool = False
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
debug: bool
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]

# HTTP settings
host: str = "127.0.0.1"
port: int = 8000
mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path)
sse_path: str = "/sse"
message_path: str = "/messages/"
streamable_http_path: str = "/mcp"
host: str
port: int
mount_path: str
sse_path: str
message_path: str
streamable_http_path: str

# StreamableHTTP settings
json_response: bool = False
stateless_http: bool = False # If True, uses true stateless mode (new transport per request)
json_response: bool
stateless_http: bool
"""Define if the server should create a new transport per request."""

# resource settings
warn_on_duplicate_resources: bool = True
warn_on_duplicate_resources: bool

# tool settings
warn_on_duplicate_tools: bool = True
warn_on_duplicate_tools: bool

# prompt settings
warn_on_duplicate_prompts: bool = True
warn_on_duplicate_prompts: bool

dependencies: list[str] = Field(
default_factory=list,
description="List of dependencies to install in the server environment",
)
# TODO(Marcelo): Investigate if this is used. If it is, it's probably a good idea to remove it.
dependencies: list[str]
"""A list of dependencies to install in the server environment."""

lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]] | None = Field(
None, description="Lifespan context manager"
)
lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None
"""A async context manager that will be called when the server is started."""

auth: AuthSettings | None = None
auth: AuthSettings | None

# Transport security settings (DNS rebinding protection)
transport_security: TransportSecuritySettings | None = None
transport_security: TransportSecuritySettings | None


def lifespan_wrapper(
app: FastMCP,
lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]],
) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[object]]:
app: FastMCP[LifespanResultT],
lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]],
) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[LifespanResultT]]:
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT, Request]) -> AsyncIterator[object]:
async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[LifespanResultT]:
async with lifespan(app) as context:
yield context

return wrap


class FastMCP:
class FastMCP(Generic[LifespanResultT]):
def __init__(
self,
name: str | None = None,
Expand All @@ -140,14 +128,50 @@ def __init__(
event_store: EventStore | None = None,
*,
tools: list[Tool] | None = None,
**settings: Any,
debug: bool = False,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
host: str = "127.0.0.1",
port: int = 8000,
mount_path: str = "/",
sse_path: str = "/sse",
message_path: str = "/messages/",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
message_path: str = "/messages/",
message_path: str = "/messages",

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard to trust AI nowadays. 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, actually, the AI didn't make a mistake.

The default on Settings.message_path was added with the /.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea i know :(, is that correct tho?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that it doesn't follow the pattern, no. But also there's some redirect issues that people may be facing.

But should I really change it in this PR? It may affect people.

streamable_http_path: str = "/mcp",
json_response: bool = False,
stateless_http: bool = False,
warn_on_duplicate_resources: bool = True,
warn_on_duplicate_tools: bool = True,
warn_on_duplicate_prompts: bool = True,
dependencies: Collection[str] = (),
lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
auth: AuthSettings | None = None,
transport_security: TransportSecuritySettings | None = None,
Comment on lines +131 to +147
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main change. The rest is just to comply with those types.

Comment on lines +132 to +147
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main worry is that we now keep the defaults in sync between Settings and this. I think ideally, but sadly a BC break would be to just accept settings: Settings = Settings() here. Since I think that would be backwards incompatible. For now, since Settings is only used internally to FastMCP should we remove the defaults from the Settings class, such that we only provide it here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we should remove the defaults from Settings.

):
self.settings = Settings(**settings)
self.settings = Settings(
debug=debug,
log_level=log_level,
host=host,
port=port,
mount_path=mount_path,
sse_path=sse_path,
message_path=message_path,
streamable_http_path=streamable_http_path,
json_response=json_response,
stateless_http=stateless_http,
warn_on_duplicate_resources=warn_on_duplicate_resources,
warn_on_duplicate_tools=warn_on_duplicate_tools,
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
dependencies=list(dependencies),
lifespan=lifespan,
auth=auth,
transport_security=transport_security,
)

self._mcp_server = MCPServer(
name=name or "FastMCP",
instructions=instructions,
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan),
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an FastMCP and Server.
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
)
self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools)
self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources)
Expand Down Expand Up @@ -257,7 +281,7 @@ async def list_tools(self) -> list[MCPTool]:
for info in tools
]

def get_context(self) -> Context[ServerSession, object, Request]:
def get_context(self) -> Context[ServerSession, LifespanResultT, Request]:
"""
Returns a Context object. Note that the context will only be valid
during a request; outside a request, most methods will error.
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async def main():

logger = logging.getLogger(__name__)

LifespanResultT = TypeVar("LifespanResultT")
LifespanResultT = TypeVar("LifespanResultT", default=Any)
RequestT = TypeVar("RequestT", default=Any)

# type aliases for tool call results
Expand All @@ -118,7 +118,7 @@ def __init__(


@asynccontextmanager
async def lifespan(server: Server[LifespanResultT, RequestT]) -> AsyncIterator[object]:
async def lifespan(_: Server[LifespanResultT, RequestT]) -> AsyncIterator[dict[str, Any]]:
"""Default lifespan context manager that does nothing.

Args:
Expand Down
4 changes: 2 additions & 2 deletions tests/shared/test_progress_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async def run_server():
serv_sesh = server_session
async for message in server_session.incoming_messages:
try:
await server._handle_message(message, server_session, ())
await server._handle_message(message, server_session, {})
except Exception as e:
raise e

Expand Down Expand Up @@ -252,7 +252,7 @@ async def run_server():
) as server_session:
async for message in server_session.incoming_messages:
try:
await server._handle_message(message, server_session, ())
await server._handle_message(message, server_session, {})
except Exception as e:
raise e

Expand Down
Loading