Use 120 characters instead of 88 (#856)

This commit is contained in:
Marcelo Trylesinski 2025-06-11 02:45:50 -07:00 • committed by GitHub
commit 543961968c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 687 additions and 2142 deletions

View file

@ -2,7 +2,4 @@ from pydantic import ValidationError
def stringify_pydantic_error(validation_error: ValidationError) -> str:
return "\n".join(
f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}"
for e in validation_error.errors()
)
return "\n".join(f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in validation_error.errors())

View file

@ -7,9 +7,7 @@ from starlette.datastructures import FormData, QueryParams
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from mcp.server.auth.errors import (
stringify_pydantic_error,
)
from mcp.server.auth.errors import stringify_pydantic_error
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.provider import (
AuthorizationErrorCode,
@ -18,10 +16,7 @@ from mcp.server.auth.provider import (
OAuthAuthorizationServerProvider,
construct_redirect_uri,
)
from mcp.shared.auth import (
InvalidRedirectUriError,
InvalidScopeError,
)
from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError
logger = logging.getLogger(__name__)
@ -29,23 +24,16 @@ logger = logging.getLogger(__name__)
class AuthorizationRequest(BaseModel):
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
client_id: str = Field(..., description="The client ID")
redirect_uri: AnyUrl | None = Field(
None, description="URL to redirect to after authorization"
)
redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization")
# see OAuthClientMetadata; we only support `code`
response_type: Literal["code"] = Field(
..., description="Must be 'code' for authorization code flow"
)
response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow")
code_challenge: str = Field(..., description="PKCE code challenge")
code_challenge_method: Literal["S256"] = Field(
"S256", description="PKCE code challenge method, must be S256"
)
code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method, must be S256")
state: str | None = Field(None, description="Optional state parameter")
scope: str | None = Field(
None,
description="Optional scope; if specified, should be "
"a space-separated list of scope strings",
description="Optional scope; if specified, should be " "a space-separated list of scope strings",
)
@ -57,9 +45,7 @@ class AuthorizationErrorResponse(BaseModel):
state: str | None = None
def best_effort_extract_string(
key: str, params: None | FormData | QueryParams
) -> str | None:
def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None:
if params is None:
return None
value = params.get(key)
@ -138,9 +124,7 @@ class AuthorizationHandler:
if redirect_uri and client:
return RedirectResponse(
url=construct_redirect_uri(
str(redirect_uri), **error_resp.model_dump(exclude_none=True)
),
url=construct_redirect_uri(str(redirect_uri), **error_resp.model_dump(exclude_none=True)),
status_code=302,
headers={"Cache-Control": "no-store"},
)
@ -172,9 +156,7 @@ class AuthorizationHandler:
if e["loc"] == ("response_type",) and e["type"] == "literal_error":
error = "unsupported_response_type"
break
return await error_response(
error, stringify_pydantic_error(validation_error)
)
return await error_response(error, stringify_pydantic_error(validation_error))
# Get client information
client = await self.provider.get_client(
@ -229,16 +211,9 @@ class AuthorizationHandler:
)
except AuthorizeError as e:
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1
return await error_response(
error=e.error,
error_description=e.error_description,
)
return await error_response(error=e.error, error_description=e.error_description)
except Exception as validation_error:
# Catch-all for unexpected errors
logger.exception(
"Unexpected error in authorization_handler", exc_info=validation_error
)
return await error_response(
error="server_error", error_description="An unexpected error occurred"
)
logger.exception("Unexpected error in authorization_handler", exc_info=validation_error)
return await error_response(error="server_error", error_description="An unexpected error occurred")

View file

@ -10,11 +10,7 @@ from starlette.responses import Response
from mcp.server.auth.errors import stringify_pydantic_error
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.provider import (
OAuthAuthorizationServerProvider,
RegistrationError,
RegistrationErrorCode,
)
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
from mcp.server.auth.settings import ClientRegistrationOptions
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
@ -60,9 +56,7 @@ class RegistrationHandler:
if client_metadata.scope is None and self.options.default_scopes is not None:
client_metadata.scope = " ".join(self.options.default_scopes)
elif (
client_metadata.scope is not None and self.options.valid_scopes is not None
):
elif client_metadata.scope is not None and self.options.valid_scopes is not None:
requested_scopes = set(client_metadata.scope.split())
valid_scopes = set(self.options.valid_scopes)
if not requested_scopes.issubset(valid_scopes):
@ -78,8 +72,7 @@ class RegistrationHandler:
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error="invalid_client_metadata",
error_description="grant_types must be authorization_code "
"and refresh_token",
error_description="grant_types must be authorization_code " "and refresh_token",
),
status_code=400,
)
@ -122,8 +115,6 @@ class RegistrationHandler:
except RegistrationError as e:
# Handle registration errors as defined in RFC 7591 Section 3.2.2
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error=e.error, error_description=e.error_description
),
content=RegistrationErrorResponse(error=e.error, error_description=e.error_description),
status_code=400,
)

View file

@ -10,15 +10,8 @@ from mcp.server.auth.errors import (
stringify_pydantic_error,
)
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.middleware.client_auth import (
AuthenticationError,
ClientAuthenticator,
)
from mcp.server.auth.provider import (
AccessToken,
OAuthAuthorizationServerProvider,
RefreshToken,
)
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken
class RevocationRequest(BaseModel):

View file

@ -7,19 +7,10 @@ from typing import Annotated, Any, Literal
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError
from starlette.requests import Request
from mcp.server.auth.errors import (
stringify_pydantic_error,
)
from mcp.server.auth.errors import stringify_pydantic_error
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.middleware.client_auth import (
AuthenticationError,
ClientAuthenticator,
)
from mcp.server.auth.provider import (
OAuthAuthorizationServerProvider,
TokenError,
TokenErrorCode,
)
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError, TokenErrorCode
from mcp.shared.auth import OAuthToken
@ -27,9 +18,7 @@ class AuthorizationCodeRequest(BaseModel):
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
grant_type: Literal["authorization_code"]
code: str = Field(..., description="The authorization code")
redirect_uri: AnyUrl | None = Field(
None, description="Must be the same as redirect URI provided in /authorize"
)
redirect_uri: AnyUrl | None = Field(None, description="Must be the same as redirect URI provided in /authorize")
client_id: str
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
client_secret: str | None = None
@ -127,8 +116,7 @@ class TokenHandler:
TokenErrorResponse(
error="unsupported_grant_type",
error_description=(
f"Unsupported grant type (supported grant types are "
f"{client_info.grant_types})"
f"Unsupported grant type (supported grant types are " f"{client_info.grant_types})"
),
)
)
@ -137,9 +125,7 @@ class TokenHandler:
match token_request:
case AuthorizationCodeRequest():
auth_code = await self.provider.load_authorization_code(
client_info, token_request.code
)
auth_code = await self.provider.load_authorization_code(client_info, token_request.code)
if auth_code is None or auth_code.client_id != token_request.client_id:
# if code belongs to different client, pretend it doesn't exist
return self.response(
@ -169,18 +155,13 @@ class TokenHandler:
return self.response(
TokenErrorResponse(
error="invalid_request",
error_description=(
"redirect_uri did not match the one "
"used when creating auth code"
),
error_description=("redirect_uri did not match the one " "used when creating auth code"),
)
)
# Verify PKCE code verifier
sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest()
hashed_code_verifier = (
base64.urlsafe_b64encode(sha256).decode().rstrip("=")
)
hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=")
if hashed_code_verifier != auth_code.code_challenge:
# see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
@ -193,9 +174,7 @@ class TokenHandler:
try:
# Exchange authorization code for tokens
tokens = await self.provider.exchange_authorization_code(
client_info, auth_code
)
tokens = await self.provider.exchange_authorization_code(client_info, auth_code)
except TokenError as e:
return self.response(
TokenErrorResponse(
@ -205,13 +184,8 @@ class TokenHandler:
)
case RefreshTokenRequest():
refresh_token = await self.provider.load_refresh_token(
client_info, token_request.refresh_token
)
if (
refresh_token is None
or refresh_token.client_id != token_request.client_id
):
refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token)
if refresh_token is None or refresh_token.client_id != token_request.client_id:
# if token belongs to different client, pretend it doesn't exist
return self.response(
TokenErrorResponse(
@ -230,29 +204,20 @@ class TokenHandler:
)
# Parse scopes if provided
scopes = (
token_request.scope.split(" ")
if token_request.scope
else refresh_token.scopes
)
scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes
for scope in scopes:
if scope not in refresh_token.scopes:
return self.response(
TokenErrorResponse(
error="invalid_scope",
error_description=(
f"cannot request scope `{scope}` "
"not provided by refresh token"
),
error_description=(f"cannot request scope `{scope}` " "not provided by refresh token"),
)
)
try:
# Exchange refresh token for new tokens
tokens = await self.provider.exchange_refresh_token(
client_info, refresh_token, scopes
)
tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes)
except TokenError as e:
return self.response(
TokenErrorResponse(

View file

@ -7,9 +7,7 @@ from mcp.server.auth.provider import AccessToken
# Create a contextvar to store the authenticated user
# The default is None, indicating no authenticated user is present
auth_context_var = contextvars.ContextVar[AuthenticatedUser | None](
"auth_context", default=None
)
auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None)
def get_access_token() -> AccessToken | None:

View file

@ -1,11 +1,7 @@
import time
from typing import Any
from starlette.authentication import (
AuthCredentials,
AuthenticationBackend,
SimpleUser,
)
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
from starlette.exceptions import HTTPException
from starlette.requests import HTTPConnection
from starlette.types import Receive, Scope, Send
@ -35,11 +31,7 @@ class BearerAuthBackend(AuthenticationBackend):
async def authenticate(self, conn: HTTPConnection):
auth_header = next(
(
conn.headers.get(key)
for key in conn.headers
if key.lower() == "authorization"
),
(conn.headers.get(key) for key in conn.headers if key.lower() == "authorization"),
None,
)
if not auth_header or not auth_header.lower().startswith("bearer "):
@ -87,10 +79,7 @@ class RequireAuthMiddleware:
for required_scope in self.required_scopes:
# auth_credentials should always be provided; this is just paranoia
if (
auth_credentials is None
or required_scope not in auth_credentials.scopes
):
if auth_credentials is None or required_scope not in auth_credentials.scopes:
raise HTTPException(status_code=403, detail="Insufficient scope")
await self.app(scope, receive, send)

View file

@ -30,9 +30,7 @@ class ClientAuthenticator:
"""
self.provider = provider
async def authenticate(
self, client_id: str, client_secret: str | None
) -> OAuthClientInformationFull:
async def authenticate(self, client_id: str, client_secret: str | None) -> OAuthClientInformationFull:
# Look up client information
client = await self.provider.get_client(client_id)
if not client:
@ -47,10 +45,7 @@ class ClientAuthenticator:
if client.client_secret != client_secret:
raise AuthenticationError("Invalid client_secret")
if (
client.client_secret_expires_at
and client.client_secret_expires_at < int(time.time())
):
if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()):
raise AuthenticationError("Client secret has expired")
return client

View file

@ -4,10 +4,7 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from pydantic import AnyUrl, BaseModel
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthToken,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
class AuthorizationParams(BaseModel):
@ -96,9 +93,7 @@ RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken)
AccessTokenT = TypeVar("AccessTokenT", bound=AccessToken)
class OAuthAuthorizationServerProvider(
Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]
):
class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]):
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
"""
Retrieves client information by client ID.
@ -129,9 +124,7 @@ class OAuthAuthorizationServerProvider(
"""
...
async def authorize(
self, client: OAuthClientInformationFull, params: AuthorizationParams
) -> str:
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""
Called as part of the /authorize endpoint, and returns a URL that the client
will be redirected to.
@ -207,9 +200,7 @@ class OAuthAuthorizationServerProvider(
"""
...
async def load_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: str
) -> RefreshTokenT | None:
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None:
"""
Loads a RefreshToken by its token string.

View file

@ -31,11 +31,7 @@ def validate_issuer_url(url: AnyHttpUrl):
"""
# RFC 8414 requires HTTPS, but we allow localhost HTTP for testing
if (
url.scheme != "https"
and url.host != "localhost"
and not url.host.startswith("127.0.0.1")
):
if url.scheme != "https" and url.host != "localhost" and not url.host.startswith("127.0.0.1"):
raise ValueError("Issuer URL must be HTTPS")
# No fragments or query parameters allowed
@ -73,9 +69,7 @@ def create_auth_routes(
) -> list[Route]:
validate_issuer_url(issuer_url)
client_registration_options = (
client_registration_options or ClientRegistrationOptions()
)
client_registration_options = client_registration_options or ClientRegistrationOptions()
revocation_options = revocation_options or RevocationOptions()
metadata = build_metadata(
issuer_url,
@ -177,15 +171,11 @@ def build_metadata(
# Add registration endpoint if supported
if client_registration_options.enabled:
metadata.registration_endpoint = AnyHttpUrl(
str(issuer_url).rstrip("/") + REGISTRATION_PATH
)
metadata.registration_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REGISTRATION_PATH)
# Add revocation endpoint if supported
if revocation_options.enabled:
metadata.revocation_endpoint = AnyHttpUrl(
str(issuer_url).rstrip("/") + REVOCATION_PATH
)
metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH)
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
return metadata

View file

@ -15,8 +15,7 @@ class RevocationOptions(BaseModel):
class AuthSettings(BaseModel):
issuer_url: AnyHttpUrl = Field(
...,
description="URL advertised as OAuth issuer; this should be the URL the server "
"is reachable at",
description="URL advertised as OAuth issuer; this should be the URL the server " "is reachable at",
)
service_documentation_url: AnyHttpUrl | None = None
client_registration_options: ClientRegistrationOptions | None = None

View file

@ -42,13 +42,9 @@ class AssistantMessage(Message):
super().__init__(content=content, **kwargs)
message_validator = TypeAdapter[UserMessage | AssistantMessage](
UserMessage | AssistantMessage
)
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
SyncPromptResult = (
str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
)
SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
@ -56,24 +52,16 @@ class PromptArgument(BaseModel):
"""An argument that can be passed to a prompt."""
name: str = Field(description="Name of the argument")
description: str | None = Field(
None, description="Description of what the argument does"
)
required: bool = Field(
default=False, description="Whether the argument is required"
)
description: str | None = Field(None, description="Description of what the argument does")
required: bool = Field(default=False, description="Whether the argument is required")
class Prompt(BaseModel):
"""A prompt template that can be rendered with parameters."""
name: str = Field(description="Name of the prompt")
description: str | None = Field(
None, description="Description of what the prompt does"
)
arguments: list[PromptArgument] | None = Field(
None, description="Arguments that can be passed to the prompt"
)
description: str | None = Field(None, description="Description of what the prompt does")
arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt")
fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
@classmethod
@ -154,14 +142,10 @@ class Prompt(BaseModel):
content = TextContent(type="text", text=msg)
messages.append(UserMessage(content=content))
else:
content = pydantic_core.to_json(
msg, fallback=str, indent=2
).decode()
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
messages.append(Message(role="user", content=content))
except Exception:
raise ValueError(
f"Could not convert prompt result to message: {msg}"
)
raise ValueError(f"Could not convert prompt result to message: {msg}")
return messages
except Exception as e:

View file

@ -39,9 +39,7 @@ class PromptManager:
self._prompts[prompt.name] = prompt
return prompt
async def render_prompt(
self, name: str, arguments: dict[str, Any] | None = None
) -> list[Message]:
async def render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> list[Message]:
"""Render a prompt by name with arguments."""
prompt = self.get_prompt(name)
if not prompt:

View file

@ -19,13 +19,9 @@ class Resource(BaseModel, abc.ABC):
model_config = ConfigDict(validate_default=True)
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
default=..., description="URI of the resource"
)
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource")
name: str | None = Field(description="Name of the resource", default=None)
description: str | None = Field(
description="Description of the resource", default=None
)
description: str | None = Field(description="Description of the resource", default=None)
mime_type: str = Field(
default="text/plain",
description="MIME type of the resource content",

View file

@ -15,18 +15,12 @@ from mcp.server.fastmcp.resources.types import FunctionResource, Resource
class ResourceTemplate(BaseModel):
"""A template for dynamically creating resources."""
uri_template: str = Field(
description="URI template with parameters (e.g. weather://{city}/current)"
)
uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)")
name: str = Field(description="Name of the resource")
description: str | None = Field(description="Description of what the resource does")
mime_type: str = Field(
default="text/plain", description="MIME type of the resource content"
)
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
fn: Callable[..., Any] = Field(exclude=True)
parameters: dict[str, Any] = Field(
description="JSON schema for function parameters"
)
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
@classmethod
def from_function(

View file

@ -54,9 +54,7 @@ class FunctionResource(Resource):
async def read(self) -> str | bytes:
"""Read the resource by calling the wrapped function."""
try:
result = (
await self.fn() if inspect.iscoroutinefunction(self.fn) else self.fn()
)
result = await self.fn() if inspect.iscoroutinefunction(self.fn) else self.fn()
if isinstance(result, Resource):
return await result.read()
elif isinstance(result, bytes):
@ -141,9 +139,7 @@ class HttpResource(Resource):
"""A resource that reads from an HTTP endpoint."""
url: str = Field(description="URL to fetch content from")
mime_type: str = Field(
default="application/json", description="MIME type of the resource content"
)
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
async def read(self) -> str | bytes:
"""Read the HTTP content."""
@ -157,15 +153,9 @@ class DirectoryResource(Resource):
"""A resource that lists files in a directory."""
path: Path = Field(description="Path to the directory")
recursive: bool = Field(
default=False, description="Whether to list files recursively"
)
pattern: str | None = Field(
default=None, description="Optional glob pattern to filter files"
)
mime_type: str = Field(
default="application/json", description="MIME type of the resource content"
)
recursive: bool = Field(default=False, description="Whether to list files recursively")
pattern: str | None = Field(default=None, description="Optional glob pattern to filter files")
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
@pydantic.field_validator("path")
@classmethod
@ -184,16 +174,8 @@ class DirectoryResource(Resource):
try:
if self.pattern:
return (
list(self.path.glob(self.pattern))
if not self.recursive
else list(self.path.rglob(self.pattern))
)
return (
list(self.path.glob("*"))
if not self.recursive
else list(self.path.rglob("*"))
)
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
except Exception as e:
raise ValueError(f"Error listing directory {self.path}: {e}")

View file

@ -97,9 +97,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
# StreamableHTTP settings
json_response: bool = False
stateless_http: bool = (
False # If True, uses true stateless mode (new transport per request)
)
stateless_http: bool = False # If True, uses true stateless mode (new transport per request)
# resource settings
warn_on_duplicate_resources: bool = True
@ -115,9 +113,9 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
description="List of dependencies to install in the server environment",
)
lifespan: (
Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]] | None
) = Field(None, description="Lifespan context manager")
lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]] | None = Field(
None, description="Lifespan context manager"
)
auth: AuthSettings | None = None
@ -125,9 +123,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
def lifespan_wrapper(
app: FastMCP,
lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]],
) -> Callable[
[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[object]
]:
) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[object]]:
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT, Request]) -> AsyncIterator[object]:
async with lifespan(app) as context:
@ -141,8 +137,7 @@ class FastMCP:
self,
name: str | None = None,
instructions: str | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
| None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
event_store: EventStore | None = None,
*,
tools: list[Tool] | None = None,
@ -153,31 +148,18 @@ class FastMCP:
self._mcp_server = MCPServer(
name=name or "FastMCP",
instructions=instructions,
lifespan=(
lifespan_wrapper(self, self.settings.lifespan)
if self.settings.lifespan
else default_lifespan
),
)
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
)
self._prompt_manager = PromptManager(
warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan),
)
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)
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
if (self.settings.auth is not None) != (auth_server_provider is not None):
# TODO: after we support separate authorization servers (see
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/284)
# we should validate that if auth is enabled, we have either an
# auth_server_provider to host our own authorization server,
# OR the URL of a 3rd party authorization server.
raise ValueError(
"settings.auth must be specified if and only if auth_server_provider "
"is specified"
)
raise ValueError("settings.auth must be specified if and only if auth_server_provider " "is specified")
self._auth_server_provider = auth_server_provider
self._event_store = event_store
self._custom_starlette_routes: list[Route] = []
@ -340,9 +322,7 @@ class FastMCP:
description: Optional description of what the tool does
annotations: Optional ToolAnnotations providing additional tool information
"""
self._tool_manager.add_tool(
fn, name=name, description=description, annotations=annotations
)
self._tool_manager.add_tool(fn, name=name, description=description, annotations=annotations)
def tool(
self,
@ -379,14 +359,11 @@ class FastMCP:
# Check if user passed function directly instead of calling decorator
if callable(name):
raise TypeError(
"The @tool decorator was used incorrectly. "
"Did you forget to call it? Use @tool() instead of @tool"
"The @tool decorator was used incorrectly. " "Did you forget to call it? Use @tool() instead of @tool"
)
def decorator(fn: AnyFunction) -> AnyFunction:
self.add_tool(
fn, name=name, description=description, annotations=annotations
)
self.add_tool(fn, name=name, description=description, annotations=annotations)
return fn
return decorator
@ -462,8 +439,7 @@ class FastMCP:
if uri_params != func_params:
raise ValueError(
f"Mismatch between URI parameters {uri_params} "
f"and function parameters {func_params}"
f"Mismatch between URI parameters {uri_params} " f"and function parameters {func_params}"
)
# Register as template
@ -496,9 +472,7 @@ class FastMCP:
"""
self._prompt_manager.add_prompt(prompt)
def prompt(
self, name: str | None = None, description: str | None = None
) -> Callable[[AnyFunction], AnyFunction]:
def prompt(self, name: str | None = None, description: str | None = None) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a prompt.
Args:
@ -665,9 +639,7 @@ class FastMCP:
self.settings.mount_path = mount_path
# Create normalized endpoint considering the mount path
normalized_message_endpoint = self._normalize_path(
self.settings.mount_path, self.settings.message_path
)
normalized_message_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path)
# Set up auth context and dependencies
@ -764,9 +736,7 @@ class FastMCP:
routes.extend(self._custom_starlette_routes)
# Create Starlette app with routes and middleware
return Starlette(
debug=self.settings.debug, routes=routes, middleware=middleware
)
return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware)
def streamable_http_app(self) -> Starlette:
"""Return an instance of the StreamableHTTP server app."""
@ -783,9 +753,7 @@ class FastMCP:
)
# Create the ASGI handler
async def handle_streamable_http(
scope: Scope, receive: Receive, send: Send
) -> None:
async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
await self.session_manager.handle_request(scope, receive, send)
# Create routes
@ -861,9 +829,7 @@ class FastMCP:
for prompt in prompts
]
async def get_prompt(
self, name: str, arguments: dict[str, Any] | None = None
) -> GetPromptResult:
async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult:
"""Get a prompt by name with arguments."""
try:
messages = await self._prompt_manager.render_prompt(name, arguments)
@ -936,9 +902,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
def __init__(
self,
*,
request_context: (
RequestContext[ServerSessionT, LifespanContextT, RequestT] | None
) = None,
request_context: (RequestContext[ServerSessionT, LifespanContextT, RequestT] | None) = None,
fastmcp: FastMCP | None = None,
**kwargs: Any,
):
@ -962,9 +926,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
raise ValueError("Context is not available outside of a request")
return self._request_context
async def report_progress(
self, progress: float, total: float | None = None, message: str | None = None
) -> None:
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for the current operation.
Args:
@ -972,11 +934,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
total: Optional total value e.g. 100
message: Optional message e.g. Starting render...
"""
progress_token = (
self.request_context.meta.progressToken
if self.request_context.meta
else None
)
progress_token = self.request_context.meta.progressToken if self.request_context.meta else None
if progress_token is None:
return
@ -997,9 +955,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
Returns:
The resource content as either text or bytes
"""
assert (
self._fastmcp is not None
), "Context is not available outside of a request"
assert self._fastmcp is not None, "Context is not available outside of a request"
return await self._fastmcp.read_resource(uri)
async def log(
@ -1027,11 +983,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
@property
def client_id(self) -> str | None:
"""Get the client ID if available."""
return (
getattr(self.request_context.meta, "client_id", None)
if self.request_context.meta
else None
)
return getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None
@property
def request_id(self) -> str:

View file

@ -25,16 +25,11 @@ class Tool(BaseModel):
description: str = Field(description="Description of what the tool does")
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
fn_metadata: FuncMetadata = Field(
description="Metadata about the function including a pydantic model for tool"
" arguments"
description="Metadata about the function including a pydantic model for tool" " arguments"
)
is_async: bool = Field(description="Whether the tool is async")
context_kwarg: str | None = Field(
None, description="Name of the kwarg that should receive context"
)
annotations: ToolAnnotations | None = Field(
None, description="Optional annotations for the tool"
)
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
@classmethod
def from_function(
@ -93,9 +88,7 @@ class Tool(BaseModel):
self.fn,
self.is_async,
arguments,
{self.context_kwarg: context}
if self.context_kwarg is not None
else None,
{self.context_kwarg: context} if self.context_kwarg is not None else None,
)
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e

View file

@ -50,9 +50,7 @@ class ToolManager:
annotations: ToolAnnotations | None = None,
) -> Tool:
"""Add a tool to the server."""
tool = Tool.from_function(
fn, name=name, description=description, annotations=annotations
)
tool = Tool.from_function(fn, name=name, description=description, annotations=annotations)
existing = self._tools.get(tool.name)
if existing:
if self.warn_on_duplicate_tools:

View file

@ -102,9 +102,7 @@ class FuncMetadata(BaseModel):
)
def func_metadata(
func: Callable[..., Any], skip_names: Sequence[str] = ()
) -> FuncMetadata:
def func_metadata(func: Callable[..., Any], skip_names: Sequence[str] = ()) -> FuncMetadata:
"""Given a function, return metadata including a pydantic model representing its
signature.
@ -131,9 +129,7 @@ def func_metadata(
globalns = getattr(func, "__globals__", {})
for param in params.values():
if param.name.startswith("_"):
raise InvalidSignature(
f"Parameter {param.name} of {func.__name__} cannot start with '_'"
)
raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'")
if param.name in skip_names:
continue
annotation = param.annotation
@ -142,11 +138,7 @@ def func_metadata(
if annotation is None:
annotation = Annotated[
None,
Field(
default=param.default
if param.default is not inspect.Parameter.empty
else PydanticUndefined
),
Field(default=param.default if param.default is not inspect.Parameter.empty else PydanticUndefined),
]
# Untyped field
@ -160,9 +152,7 @@ def func_metadata(
field_info = FieldInfo.from_annotated_attribute(
_get_typed_annotation(annotation, globalns),
param.default
if param.default is not inspect.Parameter.empty
else PydanticUndefined,
param.default if param.default is not inspect.Parameter.empty else PydanticUndefined,
)
dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
continue
@ -177,9 +167,7 @@ def func_metadata(
def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
def try_eval_type(
value: Any, globalns: dict[str, Any], localns: dict[str, Any]
) -> tuple[Any, bool]:
def try_eval_type(value: Any, globalns: dict[str, Any], localns: dict[str, Any]) -> tuple[Any, bool]:
try:
return eval_type_backport(value, globalns, localns), True
except NameError:

Some files were not shown because too many files have changed in this diff Show more